diff --git a/build.gradle.kts b/build.gradle.kts index e87a520c..5649c034 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,9 +99,9 @@ tasks.withType { } } -tasks.withType { +tasks.withType().configureEach { useJUnitPlatform() - maxHeapSize = "4096m" + maxHeapSize = "4g" jvmArgs("-Dfile.encoding=UTF-8") } diff --git a/docs/20260529_메인_홈_추천_API/plan-task.md b/docs/20260529_메인_홈_추천_API/plan-task.md index b3a0d57e..8f6ce095 100644 --- a/docs/20260529_메인_홈_추천_API/plan-task.md +++ b/docs/20260529_메인_홈_추천_API/plan-task.md @@ -29,6 +29,9 @@ - 저장소에는 DB migration 디렉터리가 없으므로 신규 스냅샷/조회 이력 엔티티 추가 시 운영 DB DDL 반영은 배포 절차에서 별도 수행한다. 코드 구현 task에는 JPA 엔티티/리포지토리와 통합 테스트를 포함하고, Phase 7 완료 후 신규 엔티티 테이블 생성 SQL을 문서 산출물로 작성한다. - 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. 단순 조회/상세 조립/대상 활성 조건은 JPA 또는 QueryDSL로 표현하고, CTE/window function/`union all`/DB-side exact scoring처럼 SQL 고급 기능이 필요한 추천 산정에만 native SQL을 사용한다. native SQL 사용 시에는 H2 MySQL mode와 Kotlin 정책 산식 parity를 포함한 repository 통합 테스트를 반드시 둔다. - 이번 범위에서는 기존 홈/콘텐츠 홈/라이브/AI 캐릭터 API의 공개 스키마를 변경하지 않고, 앱 다국어 문구 번역, ML 개인화, A/B 테스트 플랫폼, 관리자 화면, 추천 결과 수동 편집 기능은 구현하지 않는다. 응답 enum은 앱 다국어 처리를 위해 안정적인 영문 code로 유지한다. +- 방금 활동한 크리에이터 item은 활동을 등록한 `Member.id`를 non-null `creatorId`로 항상 제공한다. +- `LIVE` 활동의 `targetId`는 `live_room.is_active = true`이면 `live_room.id`, `false`이면 `null`로 제공한다. +- 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입은 추가하지 않고 기존 활동 시간·정렬과 비 LIVE `targetId` 의미를 유지한다. --- @@ -691,12 +694,185 @@ --- +### Phase 11: 최근 활동 라이브 이동 대상 분기 + +**Phase 결과:** `recentlyActiveCreators[]`가 모든 활동의 `creatorId`를 제공하고, 진행 중 라이브는 라이브 방으로, 종료된 라이브는 크리에이터 채널로 이동할 수 있는 식별자 계약을 제공한다. + +**선행조건:** Phase 10 완료와 `docs/20260529_메인_홈_추천_API/prd.md` Feature D의 2026-07-30 확정 계약. + +**Phase 완료 조건:** `P11-T1`, `P11-T2`, `P11-GATE`의 체크박스와 완료 증거가 모두 충족되고 검증 결과가 이 문서의 Verification Log에 누적된다. + +**리뷰 후속 조건(2026-07-30):** 완료된 `P11-T1`, `P11-T2`, `P11-GATE`는 되돌리지 않고, `REV-P11-001`의 신규 회귀 Goal `P11-R1`을 완료한 뒤 Phase 11 리뷰를 종료한다. + +#### Task 11.1: 최근 활동 조회 record와 LIVE target id 분기 + +**Goal 실행 `P11-T1`:** 최근 활동 조회 결과가 크리에이터 id를 항상 포함하고 라이브 활성 상태에 따라 라이브 방 id 또는 `null`을 반환한다. + +- **시작 조건:** Phase 10 완료와 PRD Feature D Response Contract 확정. +- **완료 증거:** 아래 체크박스 완료, repository focused test 통과, 내부 record와 native query row 매핑 일치. +- **범위 밖:** 공개 API DTO와 facade 매핑, 신규 활동 타입·이동 타입·상태 필드 추가. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + +**Interfaces:** + +- Consumes: `LiveRoom.isActive`, `LiveRoom.id`, `LiveRoom.member.id`, 기존 `findRecentlyActiveCreators(limit, memberId, includeAdultActivities)`. +- Produces: + +```kotlin +data class RecentlyActiveCreatorRecord( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val activityType: CreatorActivityType, + val activityAt: LocalDateTime, + val targetId: Long? +) +``` + +- [x] **RED:** `shouldFindOneLatestActivityPerCreatorWithActivityType`에서 모든 활동의 `creatorId`를 검증하고 진행 중 LIVE의 `targetId`가 `live_room.id`인지 검증한다. `shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators`에서는 종료된 LIVE의 `creatorId`와 `targetId = null`을 검증한다. +- [x] **RED 확인:** 아래 focused test를 실행해 `creatorId` 미구현 컴파일 실패 또는 진행 중 LIVE `targetId`의 `null` assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators +``` + +- [x] **GREEN:** `RecentlyActiveCreatorRecord`에 non-null `creatorId`를 추가한다. native SQL outer select에 `ranked.creator_id`를 포함하고 LIVE branch의 `target_id`를 아래 식으로 변경한 뒤 row index를 새 select 순서에 맞춘다. `HomeRecommendationQueryServiceTest`의 기존 record fixture에는 해당 크리에이터 id만 추가한다. + +```sql +case when lr.is_active = true then lr.id else null end as target_id +``` + +- [x] **GREEN 확인:** RED focused test를 다시 실행해 진행 중 LIVE는 `targetId = live_room.id`, 종료된 LIVE는 `targetId = null`, 모든 활동은 올바른 `creatorId`를 반환하는지 확인한다. +- [x] **REFACTOR:** 비 LIVE 활동의 `targetId`, `activityAt`, 크리에이터별 최신 활동 선정, 차단·성인·비활성 회원 제외 조건을 변경하지 않았는지 repository 테스트 클래스 전체로 회귀 확인하고 결과를 Progress에 기록한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest \ + --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest +``` + +#### Task 11.2: 최근 활동 공개 응답에 creatorId 노출 + +**Goal 실행 `P11-T2`:** 홈 통합 API가 내부 최근 활동 record의 `creatorId`와 상태별 `targetId`를 최종 JSON에 그대로 노출한다. + +- **시작 조건:** `P11-T1` 완료. +- **완료 증거:** 아래 체크박스 완료, 홈 통합 API 통합 테스트 통과, additive schema와 상태별 JSON 계약 확인. +- **범위 밖:** 앱 네비게이션 코드 구현, 라이브 입장 실패 처리, 기존 endpoint URL과 다른 추천 item 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + +**Interfaces:** + +- Consumes: `P11-T1`의 `RecentlyActiveCreatorRecord.creatorId`와 상태별 nullable `targetId`. +- Produces: + +```kotlin +data class HomeActiveCreatorItem( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String, + val activityType: String, + val activityAt: String, + val targetId: Long? +) +``` + +- [x] **RED:** `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`를 추가한다. 서로 다른 크리에이터의 진행 중 LIVE와 종료된 LIVE를 저장하고 홈 통합 API 응답에서 두 item의 `creatorId`, 진행 중 LIVE의 `targetId = live_room.id`, 종료된 LIVE의 명시적 `targetId = null`을 검증한다. +- [x] **RED 확인:** 아래 focused test를 실행해 `creatorId` JSON path 미존재로 실패하는지 확인한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators +``` + +- [x] **GREEN:** `HomeActiveCreatorItem`에 non-null `creatorId`를 추가하고 `HomeRecommendationFacade.RecentlyActiveCreatorRecord.toItem()`에서 `creatorId = creatorId`를 매핑한다. controller 테스트의 `saveLiveRoom` fixture는 종료된 라이브를 만들 수 있도록 `isActive: Boolean = true`만 추가한다. +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 최종 JSON의 진행 중/종료 LIVE 이동 식별자 계약을 확인한다. +- [x] **REFACTOR:** `isOnAir`, `targetType`, 종료 전용 활동 타입을 추가하지 않고 기존 프로필 이미지·활동 타입·UTC 시간 변환을 유지한다. 홈 API 테스트 클래스 전체를 실행해 공개 응답 회귀를 확인하고 결과를 Progress에 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest +``` + +#### Phase 11 Gate + +**Goal 실행 `P11-GATE`:** Phase 11의 내부 조회·공개 응답 계약과 변경 범위 품질을 최종 판정한다. + +- **시작 조건:** `P11-T1`, `P11-T2` 완료. +- **완료 증거:** 아래 focused/영향 범위 회귀, lint, 문서 검증이 모두 통과하고 실제 결과가 Verification Log에 기록됨. +- **범위 밖:** Gate 통과를 위한 테스트 삭제·완화, 전체 라이브/추천 구조 리팩터링, 신규 dependency 추가. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest \ + --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \ + --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 명령이 0 exit code로 통과하고, 진행 중 LIVE는 라이브 방 id, 종료된 LIVE는 `null`, 모든 최근 활동 item은 non-null 크리에이터 id를 반환하며 비 LIVE 활동 계약은 유지된다. + +- 전체 `./gradlew test`는 공통 인증·설정·다중 도메인을 변경하지 않는 국소 조회/DTO 변경이므로 기본 생략한다. focused/영향 범위 회귀에서 범위를 판단할 수 없는 실패가 발생하면 전체 테스트로 확장하고 결과를 기록한다. + +**기존 구현 실행 순서:** `P11-T1 → P11-T2 → P11-GATE` + +#### Task 11.3: 종료 LIVE targetId의 명시적 null JSON 계약 검증 보강 + +**Goal 실행 `P11-R1`:** `REV-P11-001`에 따라 종료 LIVE 응답이 `targetId` 필드를 생략하지 않고 명시적 `null`로 제공하는 계약을 회귀 테스트로 고정한다. + +- **시작 조건:** `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`의 `REV-P11-001` 확정. +- **완료 증거:** JSON path 존재와 null 값을 각각 검증하는 focused test, 홈 API 테스트 회귀, lint·문서 검증, review/Verification Log 기록. +- **범위 밖:** production DTO·facade·query 변경, nullable 정책 변경, 신규 Jackson 전역 설정. +- **TDD 예외 사유:** 현재 production 구현은 nullable DTO와 기본 Jackson 설정으로 명시적 null을 직렬화하며, 확정 항목은 누락과 null을 구분하지 못하는 기존 assertion의 판별력 공백이다. production 동작을 변경하지 않는 테스트 보강이므로 별도 실패 구현을 만들지 않는다. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` +- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md` +- Modify: `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md` + +- [x] 종료 LIVE의 `$.data.recentlyActiveCreators[1].targetId`에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`를 함께 사용해 필드 존재와 null 값을 모두 검증한다. +- [x] 아래 focused test를 실행해 명시적 null JSON 계약이 통과하는지 확인한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators +``` + +- [x] 홈 API 테스트 클래스와 Phase 11 문서 검증을 실행한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +- [x] 실제 실행 결과를 이 문서 Verification Log와 `phase-11-review.md`의 수정 후 검증 기록에 누적하고 `REV-P11-001`을 `수정 완료`로 갱신한다. + +**후속 실행 순서:** `P11-R1` + +--- + ## PRD Coverage Check - Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다. - Feature B: Task 3.1, Task 6.3에서 라이브 최신순/전체보기/비활성 회원 제외와 크리에이터 닉네임/프로필 이미지/라이브 번호 노출 필드를 검증한다. - Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다. -- Feature D: Task 1.3, Task 3.1, Task 10.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable, `COMMUNITY` 활동의 `creator_community.id` 이동 대상 id를 검증한다. +- Feature D: Task 1.3, Task 3.1, Task 10.1, Task 11.1, Task 11.2, Task 11.3에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 id/프로필 이미지/닉네임, UTC 시간, `COMMUNITY` 활동의 `creator_community.id`, 진행 중 LIVE의 `live_room.id`, 종료된 LIVE의 명시적 nullable 이동 대상과 크리에이터 채널 fallback 식별자를 검증한다. - Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다. - Feature F: Task 1.1, Task 3.2, Task 6.3, Task 9.1에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외, native query Boolean 계산 컬럼 매핑을 검증한다. - Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다. @@ -705,12 +881,17 @@ - Feature J: Task 1.1, Task 2.2, Task 2.3.1, Task 2.4, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 5.1, Task 5.2에서 최근 응원 점수/스냅샷 조회, 스냅샷 일 배치 클러스터 단일 실행, 8명 limit, 크리에이터 프로필 이미지/닉네임 노출, `CHANNEL_DONATION` 기준 후원 금액/후원 수, 팬 Talk 수, 최근 7일 집계, 데뷔일 기준 신규 부스트, DB-side exact scoring, 해당 섹션의 동시 팔로우를 검증한다. - Feature K: Task 1.1, Task 2.2, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 7.1에서 인기 커뮤니티 점수/조건/홈 통합 응답 노출 필드(크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 내용)/댓글 불가 게시글 댓글 수 0점 계산, 데뷔일 기준 신규 부스트, 최근 7일 집계, DB-side exact scoring을 검증한다. - Metrics: Task 7.2에서 메인 홈 API 성공률/응답 시간, 섹션별 빈 응답 비율, 전체보기 API 조회 수, 추천 섹션별 클릭률, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공률, 일 배치 집계 성공/실패 수와 스냅샷 생성 소요 시간의 로그 또는 metric 기록 지점을 검증한다. -- Technical Constraints/Non-Goals: Phase 1~7과 Phase 9에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, 기존 공개 스키마 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서 검증한다. +- Technical Constraints/Non-Goals: Phase 1~7, Phase 9, Phase 11에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, additive `creatorId`, 기존 필드 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서, 최근 활동의 신규 상태·이동 타입 미추가는 Task 11.2에서 검증한다. --- ## Verification Log +- 2026-07-30: Phase 11의 `P11-R1` 반영 상태를 Gradle 재실행 없이 2차 정적 리뷰했다. PRD Feature D와 native query/row mapping, `RecentlyActiveCreatorRecord`, facade, `HomeActiveCreatorItem`, repository/API 테스트를 다시 대조했고, 모든 활동의 non-null `creatorId`, 진행 중 LIVE의 `live_room.id`, 종료 LIVE의 명시적 null, 비 LIVE `targetId` 유지가 일치함을 확인했다. record·DTO 생성자와 조회 호출 지점, null 제외 설정, 변경 Kotlin 라인 길이, `git diff --check`를 점검했으며 신규 확정 발견 사항은 없었다. 기존 `REV-P11-001`은 수정 완료 상태를 유지하고 추가 회귀 Task/Goal은 만들지 않았다. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았으며 상세 결과는 `reviews/phase-11-review.md`의 2차 정적 재점검 절에 기록했다. +- 2026-07-30: P11-R1을 완료했다. `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`의 종료 LIVE `targetId` assertion에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`를 유지해 JSON path 존재와 명시적 null 값을 함께 검증하도록 보강했다. focused test는 최초 120000ms timeout에 도달해 240000ms로 재실행했고 `BUILD SUCCESSFUL`로 통과했다. 추가 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 무출력 통과했다. production DTO·facade·query는 변경하지 않았고, `REV-P11-001`은 수정 완료로 갱신했다. +- 2026-07-30: Phase 11 구현을 테스트 재실행 없이 정적 리뷰했다. record/native query의 `creatorId` select와 row index, LIVE `targetId` 분기, DTO/facade 매핑은 PRD Feature D와 일치했다. `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`의 종료 LIVE assertion이 `doesNotExist()`만 사용해 JSON path 누락과 null을 구분하지 못하는 테스트 공백 `REV-P11-001`을 Low로 확정하고, 완료된 Task와 Gate를 되돌리지 않은 채 신규 회귀 Task 11.3/Goal `P11-R1`을 추가했다. 사용자 지시에 따라 Gradle 컴파일과 테스트는 실행하지 않았으며, 상세 근거는 `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`에 기록했다. +- 2026-07-30: Phase 11 구현을 완료했다. P11-T1 RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators` focused 실행이 `RecentlyActiveCreatorRecord.creatorId` 미구현으로 `compileTestKotlin` 실패하는 것을 확인했다. GREEN에서 내부 record에 non-null `creatorId`를 추가하고 최근 활동 native SQL outer select/row mapping에 `ranked.creator_id`를 포함했으며, LIVE `target_id`를 `case when lr.is_active = true then lr.id else null end`로 변경했다. focused 재실행과 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다. P11-T2 RED에서는 `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`가 `$.data.recentlyActiveCreators[0].creatorId` `PathNotFoundException`으로 실패했고, GREEN에서 `HomeActiveCreatorItem.creatorId`와 facade 매핑을 추가해 focused test와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`가 `BUILD SUCCESSFUL`로 통과했다. P11-GATE로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 모두 통과했다. 추가 광역 회귀로 `./gradlew test`를 실행했으나 300000ms와 600000ms timeout에 각각 도달해 완료 결과를 얻지 못했고, 동일 timeout 2회 후 추가 재시도하지 않았다. +- 2026-07-30: 사용자 승인에 따라 방금 활동한 크리에이터의 모든 item에 non-null `creatorId`를 추가하고, 진행 중 LIVE는 `targetId = live_room.id`, 종료된 LIVE는 `targetId = null`로 분기하는 요구사항을 PRD Feature D와 plan-task Phase 11에 반영했다. `P11-T1`은 내부 record/native query, `P11-T2`는 공개 DTO/facade/JSON, `P11-GATE`는 focused 회귀와 lint를 각각 소유하도록 분리했다. 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입과 기존 정렬 변경은 범위에서 제외했다. 문서 자체 검토로 Phase 11의 PRD coverage, 타입·테스트명·파일 경로 일치, placeholder와 상충 문구 부재를 확인했고, `git diff --check`와 `./gradlew tasks --all`이 통과했다. 이 단계에서는 제품 코드를 변경하거나 테스트를 실행하지 않았다. - 2026-07-10: 사용자 피드백에 따라 홈 추천 최근 활동 크리에이터의 `COMMUNITY` 활동 `targetId`를 기존 크리에이터 id에서 `creator_community.id`로 변경했다. PRD Feature D와 plan-task Phase 10을 보강했고, RED/GREEN으로 repository 테스트를 갱신했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다. - 2026-06-27: Phase 9 코드 리뷰 및 검증을 진행했다. 변경 범위가 첫 오디오 콘텐츠 native query row 매핑의 Boolean 변환 보정과 운영 회귀 테스트/문서 보강에 한정되어 있는지 확인했고, `isPointAvailable`, `isAdult`, `isOriginalSeries`가 `Boolean` 또는 `Number(0/1)` 모두에서 명시적으로 Boolean으로 변환되는지 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `git diff --check --cached`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다. - 2026-06-23: Phase 8 코드 리뷰 및 검증을 진행했다. 변경 범위가 `creatorId` additive schema 추가에 한정되어 있는지 확인했고, `HomeAiCharacterRecommendationRecord.creatorId` → `HomeAiCharacterItem.creatorId` 매핑, `ChatCharacter.creatorMember` inner join과 활성/CREATOR/AI_CHARACTER 필터, 홈 통합/AI 캐릭터 전체보기 JSON 응답 검증 테스트를 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다. diff --git a/docs/20260529_메인_홈_추천_API/prd.md b/docs/20260529_메인_홈_추천_API/prd.md index b084cb83..51532800 100644 --- a/docs/20260529_메인_홈_추천_API/prd.md +++ b/docs/20260529_메인_홈_추천_API/prd.md @@ -23,6 +23,7 @@ - 시간 응답은 UTC 기준으로 내려주고 앱에서 표시 포맷과 다국어를 처리한다. - 장르 기반 크리에이터 추천을 위해 콘텐츠 조회 이력 기록 방식을 도입한다. - 여러 크리에이터를 동시에 팔로우하는 API를 제공한다. +- 방금 활동한 크리에이터의 라이브가 진행 중이면 라이브로, 종료됐으면 해당 크리에이터 채널로 이동할 수 있는 식별자를 제공한다. --- @@ -33,6 +34,8 @@ - 추천 산식의 머신러닝 모델화, 개인화 가중치 학습, A/B 테스트 플랫폼은 이번 범위에 포함하지 않는다. - 관리자 화면 신규 개발은 포함하지 않는다. - 추천 결과 수동 편집 기능은 포함하지 않는다. +- 방금 활동한 크리에이터 응답에 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입을 추가하지 않는다. +- 라이브 종료 시각을 새로 저장하거나 방금 활동한 크리에이터의 기존 활동 시간·정렬 기준을 변경하지 않는다. --- @@ -46,7 +49,7 @@ ## 6. User Stories - 사용자는 메인 홈 진입 시 라이브 중인 방송 20개를 최신순으로 보고 싶다. - 사용자는 홈 배너를 최대 20개까지 정해진 노출 순서대로 보고 싶다. -- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하고 싶다. +- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하며, 라이브 활동은 진행 중이면 라이브로, 종료됐으면 해당 크리에이터 채널로 이동하고 싶다. - 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다. - 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다. - 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다. @@ -115,15 +118,36 @@ - 활동 타입 후보는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`로 한다. - 오디오는 콘텐츠를 업로드한 경우를 의미한다. - 커뮤니티는 커뮤니티 게시글을 등록한 경우를 의미한다. -- 라이브는 라이브 진행 후 종료한 경우를 의미한다. +- 라이브는 `live_room.channel_name`이 존재하고 빈 값이 아닌 진행 중 또는 종료된 라이브를 의미한다. - 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다. -- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다. -- 라이브 활동은 별도 이동 대상 id가 필요하지 않다. +- 노출 정보는 크리에이터 id, 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다. +- `creatorId`는 모든 활동 item에 non-null로 제공하며, 활동을 등록한 `Member.id`를 사용한다. +- 라이브 활동은 `live_room.is_active = true`이면 `targetId`로 `live_room.id`를 내려주고, `live_room.is_active = false`이면 `targetId`를 `null`로 내려준다. - 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 `creator_community.id`를 내려준다. +- 앱 클라이언트는 `activityType = LIVE`이면서 `targetId != null`이면 라이브로 이동하고, `targetId = null`이면 `creatorId`를 사용해 크리에이터 채널로 이동한다. - 크리에이터당 최신 활동 1개만 노출한다. +- 라이브의 활동 시간은 기존과 같이 `live_room.begin_date_time`을 사용하며, 기존 최신 활동 선정과 정렬 기준을 변경하지 않는다. #### Edge Cases - `다시듣기` 콘텐츠는 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류한다. +- 응답 조회 후 라이브 입장 전에 방송이 종료되어 라이브 진입에 실패하면 앱 클라이언트는 같은 item의 `creatorId`를 사용해 크리에이터 채널로 이동할 수 있다. + +#### Response Contract + +| 활동 상태 | `activityType` | `creatorId` | `targetId` | 이동 대상 | +|---|---|---|---|---| +| 진행 중 라이브 | `LIVE` | 크리에이터 `Member.id` | `live_room.id` | 라이브 | +| 종료된 라이브 | `LIVE` | 크리에이터 `Member.id` | `null` | 크리에이터 채널 | +| 오디오 | `AUDIO` | 크리에이터 `Member.id` | `content.id` | 오디오 콘텐츠 | +| 라이브 다시듣기 | `LIVE_REPLAY` | 크리에이터 `Member.id` | `content.id` | 라이브 다시듣기 콘텐츠 | +| 커뮤니티 | `COMMUNITY` | 크리에이터 `Member.id` | `creator_community.id` | 커뮤니티 게시글 | + +#### Acceptance Criteria +- 모든 `recentlyActiveCreators[]` item은 non-null `creatorId`를 반환한다. +- 진행 중 라이브 item은 `activityType = LIVE`, `targetId = live_room.id`를 반환한다. +- 종료된 라이브 item은 `activityType = LIVE`, `targetId = null`을 반환한다. +- 오디오, 라이브 다시듣기, 커뮤니티의 `activityType`과 `targetId` 의미는 변경하지 않는다. +- `creatorId` 추가는 additive schema 변경으로 처리하고, `isOnAir`, `targetType`, 신규 활동 타입은 추가하지 않는다. ### Feature E. 최근 데뷔한 크리에이터 @@ -267,6 +291,8 @@ - Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다. - `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다. - 홈 추천 AI 캐릭터 응답의 `creatorId` 추가는 기존 `characterId` 의미를 변경하지 않는 additive schema 변경으로만 처리한다. +- 방금 활동한 크리에이터 응답의 `creatorId` 추가는 기존 필드를 제거하거나 이름을 변경하지 않는 additive schema 변경으로 처리한다. +- 방금 활동한 크리에이터의 `LIVE` `targetId`는 `live_room.is_active`에 따라 `live_room.id` 또는 `null`로 결정하고, 다른 활동 타입의 `targetId` 의미는 유지한다. - 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다. - `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다. - 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다. @@ -306,6 +332,7 @@ - 실제 데뷔일을 계산할 첫 공개 콘텐츠와 첫 라이브가 모두 없는 크리에이터는 Phase 2 스냅샷 후보에서 제외한다. - Phase 2 점수 기반 스냅샷은 DB-side exact scoring으로 계산한다. service는 기준 시각 계산과 snapshot replace만 담당하고, 최종 점수 산식/정렬/limit은 repository query에서 처리한다. - 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. native SQL은 SQL 고급 기능이 필요한 추천/랭킹/스냅샷 산정에 한정하고, 단순 상세 조회와 대상 활성 조건은 가능하면 QueryDSL/JPA 조건으로 표현한다. +- 2026-07-30: 방금 활동한 크리에이터 item은 `creatorId`를 항상 제공한다. `LIVE`의 `targetId`는 진행 중이면 `live_room.id`, 종료됐으면 `null`로 제공하며, 종료된 라이브는 `creatorId`로 크리에이터 채널에 이동한다. 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입은 추가하지 않는다. --- diff --git a/docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md b/docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md new file mode 100644 index 00000000..575e4d3f --- /dev/null +++ b/docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md @@ -0,0 +1,193 @@ +# Phase 11 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 11 / Task 11.1~11.2 / `P11-GATE` | +| 기준 commit 또는 working tree | `b30447f0` 기준 working tree 변경 | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260529_메인_홈_추천_API/plan-task.md` | +| 리뷰 상태 | 수정 검증 완료 / 2차 정적 재점검 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- Phase 11 구현이 PRD Feature D의 `creatorId`와 상태별 LIVE `targetId` 계약을 충족하는지 정적으로 확인한다. +- 완료된 Task·Gate 기록과 실제 코드·테스트 변경이 일치하는지 확인한다. + +### 포함 범위 + +- 코드: `RecentlyActiveCreatorRecord`, 최근 활동 native query/row mapping, `HomeActiveCreatorItem`, facade 변환 +- 테스트: 최근 활동 repository 테스트, 홈 통합 API JSON 테스트, service fixture +- 문서: PRD Feature D, plan-task Phase 11과 Verification Log +- 수동 검증: working tree diff, 호출 흐름, Spring JSON path matcher 의미 대조 + +### 제외 범위 + +- Phase 1~10과 Phase 11 외 기능 +- 앱 클라이언트 네비게이션 및 라이브 입장 실패 fallback 구현 +- 사용자 지시에 따른 Gradle 컴파일·테스트 재실행 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 핵심 흐름 불능, 보안·데이터 손실 또는 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항·공개 API 계약 위반 또는 주요 회귀 | +| Medium | 제한된 조건의 기능·복구 문제 | +| Low | 테스트 판별력, 유지보수성 또는 문서 정합성 문제 | + +| 상태 | 의미 | +|---|---| +| 후보 | 근거 확인 전 | +| 확정 | 코드·테스트·문서 근거로 확인됨 | +| 오탐 | 요구사항 또는 코드 근거상 문제 아님 | +| 보류 | 외부 결정·환경 필요 | +| 수정 완료 | 수정과 검증 완료 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: PRD Feature D Requirements, Response Contract, Acceptance Criteria +- 계획: `P11-T1`, `P11-T2`, `P11-GATE` +- 내부 조회: `DefaultHomeRecommendationQueryRepository.kt:130-208` +- 내부 record: `HomeRecommendationQueryPort.kt:105-112` +- 공개 응답: `HomeRecommendationResponse.kt:38-45` +- facade: `HomeRecommendationFacade.kt:247-254` +- repository 테스트: `DefaultHomeRecommendationQueryRepositoryTest.kt:405-467` +- API 테스트: `HomeRecommendationControllerTest.kt:527-545` + +### 실행 환경 + +```text +검토 방식: working tree 정적 리뷰 +기준 commit: b30447f0 +컴파일·테스트: 사용자 지시에 따라 실행하지 않음 +민감정보: 조회·기록하지 않음 +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `git status --short`, `git diff --name-status` | 성공 | Phase 11 계획의 코드·테스트 7개 파일과 문서 변경 확인 | +| Phase 11 관련 `git diff` 및 호출 흐름 대조 | 성공 | query select/row index, record/DTO/facade 필드가 계약과 일치 | +| Spring 5.3.29 `JsonPathResultMatchers` 로컬 source jar 확인 | 성공 | `doesNotExist()`가 누락 path와 null 값 모두 허용함을 확인 | +| Jackson null 제외 설정 검색 | 성공 | 대상 DTO와 전역 설정에 `NON_NULL` 적용이 없음을 확인 | +| Gradle 컴파일·테스트 | 미실행 | 사용자가 현재 통과 상태를 제공하고 재실행을 금지함 | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P11-001` | Low | 수정 완료 | 종료 LIVE 테스트가 targetId 누락과 null을 구분하지 못함 | Task 11.3 | `P11-R1` | + +구현 코드에서 확정된 기능 결함은 발견하지 않았다. + +## 6. 발견 사항 상세 + +### REV-P11-001 — 종료 LIVE 테스트가 targetId 누락과 null을 구분하지 못함 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** PRD Feature D Acceptance Criteria +- **관련 계약:** 종료된 LIVE는 `targetId = null` +- **소유 Task:** Task 11.3 / `P11-R1` + +**관찰 내용** + +`HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`는 종료 LIVE의 `targetId`를 `doesNotExist()`로 검증한다. Spring 5.3.29에서 이 matcher는 JSON path가 없을 때와 값이 null일 때 모두 통과하므로, 필드를 생략하는 회귀를 탐지하지 못한다. + +**근거** + +- 코드: `HomeRecommendationControllerTest.kt:545` +- 문서: PRD Feature D의 Response Contract와 Acceptance Criteria +- 라이브러리: Spring Test 5.3.29 `JsonPathResultMatchers.doesNotExist()` 및 `JsonPathExpectationsHelper.doesNotExist(...)` + +**재현 또는 검증 절차** + +1. 종료 LIVE JSON assertion이 `doesNotExist()`만 사용하는지 확인했다. +2. 로컬 Spring Test 5.3.29 source jar에서 `doesNotExist()` 구현을 확인했다. +3. 해당 구현은 path 평가 실패를 정상 반환하고, path가 있으면 값이 null일 때 성공한다. +4. 따라서 현재 assertion은 `targetId` 누락과 명시적 null을 구분하지 않는다. + +**영향** + +현재 production 구현은 nullable DTO를 사용하고 null 제외 설정이 없어 계약과 일치하는 구조다. 그러나 이후 Jackson null 제외 설정이나 DTO annotation이 추가되어 `targetId`가 생략돼도 이 테스트는 통과하므로 공개 응답 계약 회귀를 차단하지 못한다. + +**권장 조치** + +같은 JSON path에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`와 함께 검증한다. production 코드는 변경하지 않는다. + +**판정 기록** + +- 2026-07-30 — Spring 5.3.29 matcher 구현과 PRD 계약을 대조해 Low 확정. +- 2026-07-30 — `hasJsonPath()`와 `doesNotExist()` 조합으로 JSON path 존재와 null 값을 모두 검증하도록 수정하고 focused/API 회귀/lint/문서 검증을 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +### 신규 회귀 수정 Task + +- Task 11.3: 종료 LIVE `targetId`의 명시적 null JSON 계약 검증 보강 +- Goal: `P11-R1` +- 변경 범위: `HomeRecommendationControllerTest`, plan-task, 이 review 문서 +- production 코드 변경: 없음 + +### create_goal objective 초안 + +```text +[P11-R1]의 확정 review 항목 REV-P11-001을 수정하고 회귀를 방지한다. +plan-task.md의 Task 11.3만 수행한다. +종료 LIVE targetId의 JSON path 존재와 null 값을 함께 검증하고 focused test, 홈 API 회귀, lint, 문서 검증과 기록이 모두 끝나기 전에는 complete로 표시하지 않는다. +production DTO·facade·query 변경과 관련 없는 리팩터링은 범위 밖이다. +``` + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 11 문서·코드·테스트 diff 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P11-001` Low 확정 | +| 확정 항목 plan 반영 | 충족 | Task 11.3 / `P11-R1` 추가 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, Gradle 미실행 사유 명시 | + +**최종 결론:** 수정 완료 + +**남은 항목:** 없음 + +## 9. 수정 후 검증 기록 + +- 2026-07-30: `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`에 종료 LIVE `targetId` `hasJsonPath()` assertion을 추가해 명시적 null JSON 계약을 고정했다. focused test는 120000ms timeout 후 240000ms로 재실행해 `BUILD SUCCESSFUL`로 통과했다. 이어서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 무출력 통과했다. production 코드는 변경하지 않았다. + +## 10. 2차 정적 재점검 — 2026-07-30 + +### 범위와 방법 + +- 기준: `b30447f0` 기준 현재 working tree와 `P11-R1` 반영 상태 +- 문서: PRD Feature D, plan-task Phase 11·PRD Coverage Check·Verification Log +- 코드 흐름: native query/row mapping → `RecentlyActiveCreatorRecord` → facade → `HomeActiveCreatorItem` +- 테스트: repository의 전체 활동 타입·LIVE 상태 분기와 홈 API JSON 계약 assertion +- 제외: 사용자 지시에 따라 Gradle 컴파일·테스트 재실행 + +### 정적 검증 결과 + +| 검증 | 결과 | 근거 | +|---|---|---| +| `git diff`로 Phase 11 변경 전체 대조 | 충족 | `creatorId` select/row index/DTO/facade 매핑과 LIVE `targetId` 분기가 PRD 계약과 일치 | +| `rg`로 record·DTO 생성자와 조회 호출 지점 확인 | 충족 | 변경 타입의 모든 생성·매핑 지점이 신규 non-null `creatorId`를 반영 | +| nullable JSON 설정과 assertion 대조 | 충족 | 대상 DTO·전역 설정에 null 제외가 없고 `hasJsonPath()` + `doesNotExist()`가 명시적 null 계약을 고정 | +| 비 LIVE 회귀와 LIVE 상태 분기 테스트 대조 | 충족 | AUDIO·LIVE_REPLAY·COMMUNITY의 기존 `targetId`, 진행 중/종료 LIVE, 모든 활동의 `creatorId`를 검증 | +| 변경 Kotlin 라인 길이와 `git diff --check` | 충족 | 130자 초과 신규 Kotlin 라인 없음, whitespace 오류 없음 | +| 과설계·범위 확장 점검 | 충족 | 신규 dependency·상태 타입·추상화 없이 기존 record/DTO/query만 최소 변경 | + +### 발견 사항과 종료 판정 + +- 신규 후보·확정·보류 항목 없음. +- 기존 `REV-P11-001`은 Task 11.3 / `P11-R1`에서 수정 완료 상태를 유지한다. +- 추가로 `plan-task.md`에 전환할 회귀 수정 Task/Goal 없음. +- **최종 결론:** Phase 11 2차 정적 리뷰 완료, 확정 발견 사항 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/plan-task.md b/docs/20260612_크리에이터_채널_홈_API/plan-task.md index 7dfd21da..c34dbbac 100644 --- a/docs/20260612_크리에이터_채널_홈_API/plan-task.md +++ b/docs/20260612_크리에이터_채널_홈_API/plan-task.md @@ -21,10 +21,30 @@ - 스케줄 성인 노출 정책: repository query에서 조회자의 성인 노출 정책을 먼저 반영하고, service 최종 조합에서도 내부 스케줄 후보의 `isAdult`로 한 번 더 보정한다. 공개 스케줄 응답에는 `isAdult`를 노출하지 않는다. - 현재 라이브와 예약 라이브 스케줄은 기존 라이브 목록과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다. application service는 조회자의 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 `effectiveViewerGender`를 산출해 query port에 넘긴다. - 신규 오디오 콘텐츠와 오디오 목록은 중복 노출하지 않는다. `latestAudioContent`로 내려간 가장 최신 콘텐츠를 오디오 목록에서 제외한다. +- `latestAudioContent`는 상단 고정 여부와 관계없이 공개 시각 최신순을 유지한다. +- `audioContents`는 활성 `PinContent`를 `updatedAt desc`로 먼저 배치하고, 나머지는 `AudioContent.releaseDate desc`, `AudioContent.id desc`로 배치한 뒤 최대 9개를 내려준다. +- 크리에이터별 활성 오디오 상단 고정 한도는 9개다. 10번째 고정은 기존처럼 `PinContent.updatedAt`이 가장 오래된 활성 고정을 교체한다. - 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다. 응답 메시지는 기본 문구를 조합하지 않고 후원자가 입력한 추가 메시지만 내려준다. - 오리지널 시리즈 여부는 `Series.isOriginal == true`로 판단한다. - 화보와 상단 탭별 전체보기 API는 이번 범위에서 제외한다. +### 0.1 2026-07-30 후속 변경 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1~2 | 완료 | `3/3` | 없음 | 기존 검증 기록 유지 | +| 3 | 완료 | `17/17` | 없음 | 없음 | +| 4 | 완료 | `5/5` | 없음 | 없음 | +| 5 | 완료 | `3/3` | 없음 | 없음 | +| 6 | 완료 | `4/4` | 없음 | 없음 | +| 7 | 완료 | `5/5` | 없음 | 없음 | + +- 동시에 하나의 미완료 Goal만 진행한다. +- 완료된 Task 체크박스와 기존 검증 기록은 되돌리지 않는다. +- 2026-07-30 1차 정적 리뷰 후속 실행 순서 `P7-R1` → `P4-R1` → `P5-R1` → `P6-R1` 완료. +- 2026-07-30 2차 정적 리뷰 후속 실행 순서 `P3-R1` → `P4-R2` → `P4-R3` → `P7-R2` 완료. +- 2026-07-31 3차 정적 리뷰 후속 실행 순서 `P7-R3` 완료. + --- ## 1. 파일 구조 계획 @@ -54,6 +74,7 @@ - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt` ### 문서 산출물 +- Modify: `docs/20260612_크리에이터_채널_홈_API/prd.md` - Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md` --- @@ -515,6 +536,20 @@ data class CreatorChannelSnsResponse( - REFACTOR: 좋아요/댓글/구매 여부 조회를 `leftJoin` 하나로 합치지 않고, 현재의 id 목록 기반 bulk 조회 구조를 유지한다. - 기대 결과: 구매자는 삭제된 유료 게시글도 기존 전체보기 의미와 동일하게 접근할 수 있고, 비구매자는 삭제된 게시글을 조회하지 못한다. +- [x] **Task 3.17: 홈 채널 후원자의 삭제 닉네임 prefix 제거** + + **Goal 실행 `P3-R1`:** 홈 채널 후원도 기존 채널 후원 목록과 동일하게 삭제 회원 닉네임의 `deleted_` prefix를 공개 응답에서 제거한다. + + - **시작 조건:** `REV-P3-001` 확정, Task 3.1~3.16 완료. + - **완료 증거:** 삭제 회원 후원자 RED/GREEN, service focused test, 기존 후원 조회 회귀, 검증 기록 누적. + - **범위 밖:** 비밀 후원 노출 정책, 후원 메시지, projection 컬럼, 공개 응답 스키마 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` + - [x] **RED:** `nickname = "deleted_donor"`인 후원 record가 홈 domain에서 `nickname = "donor"`로 조립되는 테스트를 추가한다. + - [x] **GREEN:** 기존 채널 후원 목록과 전용 v2 후원 탭이 사용하는 `removeDeletedNicknamePrefix()`를 홈 후원 domain 변환 경계에도 적용한다. + - [x] **REFACTOR/GATE:** 닉네임 변환 외 repository 조회·후원 메시지·JSON 계약은 변경하지 않고 후원 관련 focused test 결과를 누적한다. + --- ### Phase 4: application service 조립 @@ -546,6 +581,50 @@ data class CreatorChannelSnsResponse( - REFACTOR: 차단 예외 메시지 조합에 `SodaMessageSource`가 필요하면 기존 `ExplorerService.getCreatorDetail` 패턴을 따른다. - 기대 결과: 신규 API 접근 정책이 구버전 채널 정책과 맞는다. +- [x] **Task 4.3: 조회자 콘텐츠 선호 조회를 1회로 통합** + + **Goal 실행 `P4-R1`:** 홈 조회에서 한 번 가져온 `ViewerContentPreference`로 성인 노출 여부와 콘텐츠 타입을 모두 결정한다. + + - **시작 조건:** `REV-P4-001` 확정, Task 4.1~4.2 완료. + - **완료 증거:** service RED/GREEN, focused test, Phase 4 직접 영향 회귀, 검증 기록 누적. + - **범위 밖:** 선호 기본값·국가별 성인 판정·쿼리 필터 정책 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` + - [x] **RED:** `getStoredPreference(viewer)`가 반환한 `isAdult`가 모든 성인 필터 인자에 전달되고, service가 `canViewAdultContent(viewer)`를 별도 호출하지 않는 것을 검증한다. + - [x] **GREEN:** 이미 조회한 `preference.isAdult`를 `canViewAdultContent`로 사용해 중복 `REQUIRES_NEW` 조회를 제거한다. + - [x] **REFACTOR/GATE:** 서비스 조립·성인 필터·`contentType` 회귀를 확인하고 `plan-task.md` 검증 기록에 결과를 누적한다. + +- [x] **Task 4.4: 라이브 크리에이터 입장 제한에 조회자 role 전달** + + **Goal 실행 `P4-R2`:** 조회 대상과의 동일인 여부가 아니라 조회자의 `MemberRole.CREATOR` 여부로 현재/예약 라이브의 크리에이터 입장 제한을 적용한다. + + - **시작 조건:** `REV-P4-002` 확정, `P3-R1` 완료. + - **완료 증거:** 다른 크리에이터 조회 RED/GREEN, 현재 라이브·예약 스케줄 service 회귀, 검증 기록 누적. + - **범위 밖:** 성별·성인 필터, 자기 라이브 예외, query port의 공개 API 스키마 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` + - [x] **RED:** `viewer.role == CREATOR`이고 `viewer.id != creatorId`인 경우 `findCurrentLive`와 `findSchedules`에 `isViewerCreator = true`가 전달되는 테스트를 추가한다. 일반 회원은 `false`, 대상 본인인 크리에이터는 `true`인 기존 의미도 함께 고정한다. + - [x] **GREEN:** `isViewerCreator`를 `viewer.role == MemberRole.CREATOR`로 계산해 기존 라이브 목록의 `isAvailableJoinCreator` 정책과 정렬한다. + - [x] **REFACTOR/GATE:** repository의 자기 라이브 예외 조건은 유지하고 service focused test 결과를 누적한다. + +- [x] **Task 4.5: 홈 조회 기본 시각을 UTC로 고정** + + **Goal 실행 `P4-R3`:** JVM 기본 timezone과 무관하게 홈 조회의 공개/예약 경계와 KST 월 범위 계산에 UTC `LocalDateTime`을 전달한다. + + - **시작 조건:** `REV-P4-003` 확정, `P4-R2` 완료. + - **완료 증거:** 비 UTC JVM timezone RED/GREEN, facade→service `now` 전달 회귀, 검증 기록 누적. + - **범위 밖:** DB 컬럼 타입, API 시간 문자열 형식, 클라이언트 timezone 파라미터 추가. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt` + - Modify if needed: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` + - Test if needed: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` + - [x] **RED:** JVM 기본 timezone을 `Asia/Seoul`로 바꿔도 인자 없는 홈 조회가 UTC 기준 `now`를 service/query port에 전달하는 테스트를 추가하고, 테스트 종료 시 원 timezone을 복구한다. + - [x] **GREEN:** 기본 `now` 생성 지점을 `LocalDateTime.now(ZoneOffset.UTC)` 또는 같은 의미의 UTC clock으로 고정한다. 명시적으로 전달된 `now`는 그대로 사용한다. + - [x] **REFACTOR/GATE:** 별도 시간 추상화가 필요하지 않으면 추가하지 않고, 공개/예약 비교와 KST 월 경계의 직접 영향 회귀만 확인한다. + --- ### Phase 5: web API와 응답 계약 @@ -588,6 +667,21 @@ data class CreatorChannelSnsResponse( - REFACTOR: nullable 섹션은 단건이면 `null`, 목록이면 빈 배열로 일관되게 내려준다. - 기대 결과: 클라이언트가 사용할 JSON 스키마가 테스트로 고정된다. +- [x] **Task 5.3: 빈 홈 응답의 null/빈 배열 JSON 계약 고정** + + **Goal 실행 `P5-R1`:** 데이터가 없는 홈 응답에서 단건 섹션은 `null`, 목록 섹션은 빈 배열로 직렬화되는 공개 계약을 회귀 테스트로 고정한다. + + - **시작 조건:** `REV-P5-001` 확정, `P4-R1` 완료. + - **완료 증거:** MockMvc RED/GREEN, controller focused test, 검증 기록 누적. + - **범위 밖:** 공개 필드 이름·endpoint·`ApiResponse` 구조 변경. + - Files: + - Modify if needed: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` + - **TDD 예외 사유:** 확정 항목은 실행 결함이 아니라 빈 응답 회귀 증거 부재이므로, 신규 계약 테스트가 첫 실행에서 바로 통과할 수 있다. + - [x] **계약 검증:** `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`이 명시적 `null`이고, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`가 빈 배열인 fixture를 응답해 각 JSON 경로의 존재와 값을 검증한다. + - [x] **GREEN:** 최초 계약 검증이 실제 직렬화 문제를 드러낼 때만 DTO mapping/annotation을 최소 수정한다. + - [x] **REFACTOR/GATE:** 채워진 응답 계약 테스트와 빈 응답 계약 테스트를 같이 실행하고 결과를 누적한다. + --- ### Phase 6: 통합 회귀와 문서 갱신 @@ -636,6 +730,141 @@ data class CreatorChannelSnsResponse( - REFACTOR: 실패한 검증이 있으면 해당 phase/task로 돌아가 plan-task 체크박스를 완료 처리하지 않는다. - 기대 결과: 구현 완료 시 어떤 검증으로 완료 판단했는지 문서에 남는다. +- [x] **Task 6.4: 단일 HTTP 요청 기준 홈 전체 조립 통합 회귀 보강** + + **Goal 실행 `P6-R1`:** 실제 controller→facade→service→repository/공용 서비스 경로가 한 번의 홈 API 요청에서 전체 섹션을 조립하는지 검증한다. + + - **시작 조건:** `REV-P6-001` 확정, `P5-R1` 완료. + - **완료 증거:** 통합 시나리오 검증, 실제 bean 경로의 전체 섹션 JSON assertion, Phase 6 직접 영향 회귀, 검증 기록 누적. + - **범위 밖:** 홈 응답 스키마·조회 정책 변경, 테스트 편의용 운영 API 추가. + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` + - **TDD 예외 사유:** 확정 항목은 계층 연결의 실행 결함이 아니라 단일 요청 통합 증거 부재이므로, 신규 E2E가 첫 실행에서 바로 통과할 수 있다. + - [x] **통합 검증:** 현재 리포지토리 테스트의 각 조회 호출과 mock facade controller 테스트로는 증명되지 않는 단일 요청 시나리오를 작성한다. 최소한 creator/current live/latest audio/donation/notice/schedule/audio list/series/community/fan Talk/activity/SNS를 한 fixture에 구성하고 실제 bean과 JSON을 검증한다. + - [x] **GREEN:** 통합 테스트가 드러낸 mapping·bean wiring·쿼리 누락만 최소 수정한다. 최초 통합 검증이 바로 통과하면 생산 코드는 변경하지 않는다. + - [x] **REFACTOR/GATE:** fixture helper는 해당 테스트 범위에만 두고, 새 E2E와 기존 service/repository/controller focused test를 함께 실행해 결과를 누적한다. + +--- + +### Phase 7: 오디오 상단 고정 한도와 홈 목록 정렬 보정 + +**Phase 결과:** 크리에이터가 오디오 콘텐츠를 최대 9개까지 상단 고정할 수 있고, 홈 `audioContents`에서 고정 콘텐츠가 최근 고정순으로 먼저 노출된다. + +**선행조건:** PRD Feature D/H와 `DEC-001` 확정. + +**Phase 완료 조건:** 기존 `P7-T1`, `P7-T2`, `P7-GATE`, `P7-R1`, `P7-R2` 완료 이력을 유지하고, 3차 리뷰 후속 `P7-R3`의 실제 검증 결과까지 누적. + +- [x] **Task 7.1: 오디오 상단 고정 최대 개수를 9개로 보정** + + **Goal 실행 `P7-T1`:** 상세 응답의 고정 가능 여부와 상단 고정 등록이 같은 9개 상한을 사용하도록 고정한다. + + - **시작 조건:** PRD Feature H의 고정 상한과 초과 교체 정책 확정. + - **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, `AudioContentServiceTest` focused test 통과, 검증 기록 누적. + - **범위 밖:** 고정/해제 endpoint, 오류 키, `PinContent` 테이블 스키마 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Interfaces: + - Consumes: `PinContentRepository.getPinContentList(memberId, active)`의 기존 오래된 고정 우선 반환 순서. + - Produces: `MAX_PIN_CONTENT_COUNT = 9`, `isAvailablePin == (activePinCount < 9)`, 10번째 고정 시 가장 오래된 활성 고정 교체 동작. + - [x] **RED:** 활성 고정이 8개일 때 상세 `isAvailablePin == true`, 9개일 때 `false`임을 검증하고, 9개 상태의 새 고정이 리포지토리 목록의 가장 오래된 항목을 재사용하는 테스트를 추가한다. + - [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`을 실행해 기존 3개 상한 때문에 8개 상태의 `isAvailablePin` 또는 교체 대상 assertion이 실패함을 확인한다. + - [x] **GREEN:** `AudioContentService`의 상세 고정 가능 판정과 `pinToTheTop`이 공통 `MAX_PIN_CONTENT_COUNT = 9`를 사용하도록 최소 수정한다. + - [x] **GREEN 확인:** 동일 `AudioContentServiceTest` 명령을 다시 실행해 통과를 확인한다. + - [x] **REFACTOR:** 상한 숫자 중복만 제거하고 고정 초과 시 최근 고정을 거부하는 새 예외·설정·DB 제약은 추가하지 않는다. focused test와 `./gradlew ktlintCheck --no-daemon`를 재실행한다. + +- [x] **Task 7.2: 홈 오디오 목록을 활성 고정 우선으로 정렬** + + **Goal 실행 `P7-T2`:** `latestAudioContent`의 최신 공개 정책을 유지하면서 `audioContents`만 활성 고정 우선순으로 반환한다. + + - **시작 조건:** `P7-T1` 완료. + - **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, `DefaultCreatorChannelHomeQueryRepositoryTest` focused test 통과, 검증 기록 누적. + - **범위 밖:** `latestAudioContent` 선정 정책, `audioContents` 최대 9개, 공개 응답 DTO/스키마 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` + - Interfaces: + - Consumes: `PinContent.member`, `PinContent.content`, `PinContent.isActive`, `PinContent.updatedAt` 및 기존 `findLatestAudioContent`/`findAudioContents` port 계약. + - Produces: `findAudioContents`의 활성 고정 우선 정렬과 `findLatestAudioContent`의 기존 `releaseDate desc`, `id desc` 정렬 유지. + - [x] **RED:** 최신 공개 콘텐츠, 최근에 고정한 이전 공개 콘텐츠, 먼저 고정한 이전 공개 콘텐츠, 비활성 고정, 일반 콘텐츠 fixture를 구성해 다음을 한 테스트에서 검증한다. + - `latestAudioContent`는 상단 고정 여부와 관계없이 실제 최신 공개 콘텐츠다. + - `audioContents`는 `PinContent.member.id == creatorId && isActive == true`인 콘텐츠를 `PinContent.updatedAt desc`로 먼저 반환한다. + - 비활성 고정은 일반 콘텐츠로 취급하고, 고정 이후 나머지는 `releaseDate desc`, `id desc`다. + - `latestAudioContent`는 `audioContents`에 중복되지 않고, 고정 우선 정렬 후 최대 9개만 반환한다. + - [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`을 실행해 기존 최신순 전용 정렬 때문에 고정 우선 기대 순서 assertion이 실패함을 확인한다. + - [x] **GREEN:** `findAudioContentRows`의 최신 단건/목록 용도를 구분해 목록 조회에만 크리에이터의 활성 `PinContent`를 left join하고 `PinContent` 존재 여부 desc, `PinContent.updatedAt desc`, `AudioContent.releaseDate desc`, `AudioContent.id desc`를 적용한다. + - [x] **GREEN 확인:** 동일 repository focused test를 다시 실행해 통과를 확인한다. + - [x] **REFACTOR:** 공개 조건·projection·bulk 조립은 기존 구조를 유지하고, 정렬 구분을 위한 최소 변경만 남긴다. repository focused test, `CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck --no-daemon`를 실행한다. + +#### Phase 7 Gate + +**Goal 실행 `P7-GATE`:** 고정 한도와 홈 오디오 정렬의 확정 요구사항, 직접 영향 회귀, 문서 정합성을 최종 판정한다. + +- **시작 조건:** `P7-T1`, `P7-T2` 완료. +- **완료 증거:** 아래 명령 전체 성공과 검증 기록 누적. +- **범위 밖:** 전체 회귀 실패와 무관한 기존 문제 수정. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon +./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon +./gradlew ktlintCheck --no-daemon +./gradlew tasks --all --no-daemon +git diff --check +``` + +**Expected:** 활성 고정 상한 9개, 10번째 고정의 최고령 고정 교체, `latestAudioContent` 최신순 유지, `audioContents` 활성 고정 우선/일반 최신순/최대 9개가 검증되고 Kotlin 포맷과 문서 명령이 성공한다. + +전체 `./gradlew test`는 공개 API 스키마·공통 인증·예외·설정을 변경하지 않고 두 focused test 범위로 직접 영향을 판정할 수 있으므로 기본 생략한다. Gate 실행 중 targeted test로 영향 범위를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장한다. + +**실행 순서:** `P7-T1` → `P7-T2` → `P7-GATE`(기존 완료) → `P7-R1`(기존 완료) → `P7-R2`(기존 완료) → `P7-R3` + +- [x] **Task 7.3: 동시 상단 고정에서도 크리에이터별 활성 9개 상한 보장** + + **Goal 실행 `P7-R1`:** 같은 크리에이터의 상단 고정 변경을 직렬화해 동시 요청에서도 활성 고정과 콘텐츠 중복이 생기지 않고 9개 상한을 지킨다. + + - **시작 조건:** `REV-P7-001` 확정, 기존 `P7-GATE` 완료. + - **완료 증거:** 동시성 RED/GREEN, 홈 목록 9개 상한 assertion, Phase 7 Gate 재실행, 검증 기록 누적. + - **범위 밖:** endpoint·오류 키·노출 정렬 계약·DB DDL 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt` if needed + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` + - [x] **RED:** `AiCharacterAdminCommunityPostConcurrencyTest` 패턴을 참고해 실제 transaction 2개를 사용한다. 활성 고정 8개 상태에서 같은 크리에이터의 서로 다른 콘텐츠 고정 요청을 동시 실행해 최종 활성 고정 수가 9를 넘지 않고 콘텐츠가 중복되지 않아야 함을 재현한다. 홈 오디오 fixture도 9개를 초과해 반환 개수 9를 명시적으로 검증한다. + - [x] **GREEN:** 기존 `MemberRepository.findByIdForUpdate` 패턴 등 최소의 크리에이터 단위 pessimistic lock을 고정 상태 조회 전에 적용한다. 같은 콘텐츠의 재요청·9개 초과 교체 의미는 유지한다. + - [x] **REFACTOR/GATE:** lock 범위를 고정 변경 transaction에만 두고, `AudioContentServiceTest`, home repository/service focused test, `ktlintCheck`, `git diff --check`의 결과를 누적한다. + +- [x] **Task 7.4: 상단 고정 transaction의 첫 DB 조회에서 크리에이터 lock 획득** + + **Goal 실행 `P7-R2`:** MySQL InnoDB 기본 `REPEATABLE READ`에서도 고정 상태를 읽기 전에 크리에이터 lock을 획득해 대기 transaction이 최신 고정 상태를 기준으로 판단하게 한다. + + - **시작 조건:** `REV-P7-002` 확정, `P4-R3` 완료. + - **완료 증거:** DB 호출 순서 RED/GREEN, 기존 동시성·9개 상한 회귀, Phase 7 직접 영향 Gate, 검증 기록 누적. + - **범위 밖:** DB 격리수준 설정, `PinContent` DDL/unique constraint, 새 테스트 의존성, endpoint·오류 키 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt` + - [x] **RED:** `memberRepository.findByIdForUpdate`가 `repository.findByIdAndCreatorId`, `findByContentIdAndMemberId`, `getPinContentList`보다 먼저 호출되는지를 하나의 `inOrder` 검증으로 고정한다. 현재 구현은 콘텐츠 일반 조회가 먼저라 실패해야 한다. + - [x] **GREEN:** `pinToTheTop`의 첫 DB 접근에서 크리에이터 member row를 잠그고, 이후 콘텐츠와 활성 고정 상태를 조회한다. 필요하면 반환된 locked member를 후속 저장에 사용한다. + - [x] **REFACTOR/GATE:** 기존 동시 요청 결과 검증과 10번째 교체·비활성 재활성화 회귀를 유지하고, 격리수준 변경이나 추가 lock/DDL 없이 최소 호출 순서 변경만 남긴다. + +- [x] **Task 7.5: 상단 고정 해제도 크리에이터 lock으로 직렬화** + + **Goal 실행 `P7-R3`:** 같은 크리에이터의 고정과 해제가 동일한 member row lock을 공유하게 해, 최고령 `PinContent` 행 재사용과 이전 콘텐츠 해제가 겹쳐 새 고정이 소실되지 않도록 한다. + + - **시작 조건:** `REV-P7-003` 확정, `P7-R2` 완료. + - **완료 증거:** 해제 lock 순서 RED/GREEN, 기존 동시 고정·10번째 교체·비활성 재활성화 회귀, Phase 7 직접 영향 Gate, 검증 기록 누적. + - **범위 밖:** `PinContent` DDL/unique constraint, endpoint·오류 키·홈 정렬 계약, 고정 행 재사용 정책 변경. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + - Test if needed: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt` + - [x] **RED:** `unpinAtTheTop`이 `memberRepository.findByIdForUpdate(member.id)`로 크리에이터 lock을 획득한 뒤 `findByContentIdAndMemberId`를 호출하는 순서를 `inOrder`로 먼저 고정한다. 현재 구현은 lock 호출이 없어 실패해야 한다. + - [x] **GREEN:** `unpinAtTheTop`의 첫 DB 접근에서 `pinToTheTop`과 같은 member row lock을 획득하고, 잠긴 creator id로 해제할 `PinContent`를 조회해 비활성화한다. + - [x] **REFACTOR/GATE:** 별도 lock·격리수준·DDL을 추가하지 않고 공용 member lock만 재사용한다. 해제 focused test와 기존 `AudioContentServiceTest`, `AudioContentPinConcurrencyTest`, 홈 repository/service 회귀, `ktlintCheck`, `git diff --check` 결과를 누적한다. + --- ## 구현 중 주의사항 @@ -645,6 +874,7 @@ data class CreatorChannelSnsResponse( - 공개 시간은 UTC ISO-8601 문자열로 내려주고, 앱 표시 포맷은 서버에서 조합하지 않는다. - 목록 섹션은 데이터가 없으면 빈 배열, 단건 섹션은 없으면 `null`로 내려준다. - 신규 API 공개 스키마 변경은 이 문서의 task 범위 안에서만 수행한다. +- Phase 7은 공개 응답 DTO를 변경하지 않고, `PinContent` 고정 상한과 홈 `audioContents` 조회 순서만 보정한다. --- @@ -703,3 +933,27 @@ data class CreatorChannelSnsResponse( - 2026-06-13: Phase 6 Task 6.1 통합 시나리오 검증 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 현실적인 단일 크리에이터 fixture로 creator/currentLive/latestAudioContent/channelDonations/notices/schedules/audioContents/series/communities/fanTalk/activity/sns 후보 조회를 모두 검증하는 `shouldFindCreatorChannelHomeIntegratedSections`를 추가했다. 기존 구현에서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests '*shouldFindCreatorChannelHomeIntegratedSections' --no-daemon` 통과. MockMvc 응답 표면은 `CreatorChannelHomeControllerTest`에 schedule 내부 `isAdult`와 channelDonation 내부 `donationId`/`memberId`/`isSecret` 비노출 assertion을 보강했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과. - 2026-06-13: Phase 6 Task 6.2 추천 페이지 enum rename 회귀 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon` 통과. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과 없음. - 2026-06-13: Phase 6 Task 6.3 전체 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check`, `./gradlew test --no-daemon` 통과. 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsTestKotlin` 삭제 경합이 한 번 발생했으나 동일 repository 테스트를 단독 재실행해 통과를 확인했다. +- 2026-07-30: Phase 7 후속 변경 문서화 - 활성 오디오 상단 고정 한도 9개, 10번째 고정의 최고령 고정 교체, `latestAudioContent` 최신순 유지, `audioContents` 활성 고정 `PinContent.updatedAt desc` 우선·일반 콘텐츠 `releaseDate desc`, `id desc`·최대 9개 정책을 PRD Feature D/H와 `DEC-001`에 확정했다. 기존 완료 Task는 유지하고 `P7-T1` → `P7-T2` → `P7-GATE` TDD 계획을 추가했다. `git diff --check` 통과. `./gradlew tasks --all --no-daemon`은 최초 샌드박스의 `~/.gradle` lock 파일 접근 제한으로 실패했고, 승인된 Gradle 캐시 접근으로 동일 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 사용자 요청이 관련 문서 반영이므로 생산 코드와 테스트는 아직 변경·실행하지 않았다. +- 2026-07-30: Phase 7 Task 7.1 RED 확인 - `AudioContentServiceTest`에 활성 고정 8개/9개 상세 `isAvailablePin` 계약과 10번째 고정의 최고령 활성 고정 재사용 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon` 실행 시 기존 3개 상한 때문에 `shouldExposeAvailablePinByNineActivePinLimit`가 실패하는 것을 확인했다. +- 2026-07-30: Phase 7 Task 7.1 GREEN/REFACTOR 확인 - `AudioContentService`의 상세 고정 가능 판정과 `pinToTheTop` 교체 기준을 `MAX_PIN_CONTENT_COUNT = 9`로 통일했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과. +- 2026-07-30: Phase 7 Task 7.2 RED 확인 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 최신 오디오, 최근/이전 활성 고정, 비활성 고정, 일반 오디오 fixture를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 실행 시 기존 최신순 전용 정렬 때문에 `shouldSortHomeAudioContentsByActivePinBeforeReleaseDate`가 실패하는 것을 확인했다. +- 2026-07-30: Phase 7 Task 7.2 GREEN/REFACTOR 확인 - `findAudioContentRows`를 최신 단건과 목록 용도로 구분하고, 목록 조회에만 활성 `PinContent` left join 및 `pinContent.isActive desc`, `pinContent.updatedAt desc`, `AudioContent.releaseDate desc`, `AudioContent.id desc` 정렬을 적용했다. `pinContent.id.isNotNull.desc()`는 HQL syntax 오류를 내 기존 repository 패턴인 `pinContent.isActive.desc()`로 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과. +- 2026-07-30: Phase 7 Gate 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon`, `git diff --check` 모두 통과. +- 2026-07-30: Phase 7 reviewer gate 보정 RED/GREEN 확인 - 리뷰어가 `pinToTheTop`이 비활성 `PinContent`까지 포함한 전체 목록 기준으로 동작하면 `9 active + inactive` 상태에서 활성 고정이 10개가 될 수 있음을 차단 이슈로 지적했다. `shouldKeepNineActivePinsWhenReactivatingInactivePin` 테스트 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`에서 해당 테스트 실패를 확인했고, `pinToTheTop`이 `getPinContentList(memberId, active = true)` 기준으로 최고령 활성 고정을 내린 뒤 비활성 기존 고정을 재활성화하도록 보정했다. 보정 후 같은 `AudioContentServiceTest` 통과. +- 2026-07-30: Phase 7 reviewer gate 보정 후 Gate 재확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon`, `git diff --check` 통과. +- 2026-07-30: Phase 7 reviewer gate 최종 확인 - 보정 delta 재검토 결과 차단 findings 없음. +- 2026-07-30: Phase 1~7 정적 리뷰 - `docs/20260612_크리에이터_채널_홈_API/reviews/phase-1-review.md`~`phase-7-review.md`를 작성했다. Phase 1~3은 확정 발견 사항 없음으로 판정했고, `REV-P4-001`(선호 중복 조회), `REV-P5-001`(빈 응답 계약 테스트 누락), `REV-P6-001`(단일 요청 통합 증거 누락), `REV-P7-001`(동시 고정 상한/중복 경쟁)을 확정해 Task 4.3·5.3·6.4·7.3과 `P4-R1`·`P5-R1`·`P6-R1`·`P7-R1`로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았고, `rg`·`sed`·`git diff`·`git log`·`git status`를 사용한 정적 검토와 `git diff --check` 성공만 기록한다. +- 2026-07-30: Phase 7 Task 7.3 RED/GREEN 확인 - `AudioContentServiceTest`에 `MemberRepository.findByIdForUpdate`가 `PinContent` 조회 전에 호출되는 순서를 먼저 고정했고, `AudioContentPinConcurrencyTest`에 활성 고정 8개 상태의 동시 고정 요청 최종 활성 9개/콘텐츠 중복 없음 통합 검증을 추가했다. RED는 production service 생성자에 lock 의존성이 없어 컴파일 실패하는 것으로 확인했고, `AudioContentService.pinToTheTop`이 고정 상태 조회 전 `MemberRepository.findByIdForUpdate(member.id!!)`를 호출하도록 보정했다. `DefaultCreatorChannelHomeQueryRepositoryTest`에는 활성 고정 후보가 9개를 넘어도 `findAudioContents(..., limit = 9)`가 9개만 반환하는 assertion을 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과. +- 2026-07-30: Phase 4 Task 4.3 RED/GREEN 확인 - `CreatorChannelHomeQueryServiceTest`에 `getStoredPreference(viewer)` 결과의 `isAdult`가 service/커뮤니티 성인 필터에 전달되고 `canViewAdultContent(viewer)`가 별도 호출되지 않는 테스트를 추가했다. RED는 `NeverWantedButInvoked`로 확인했고, service가 `preference.isAdult`를 재사용하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon` 통과. +- 2026-07-30: Phase 5 Task 5.3 계약 검증 - `CreatorChannelHomeControllerTest`에 빈 홈 응답 fixture를 추가해 `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`은 명시적 `null`, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`는 빈 배열로 직렬화됨을 검증했다. 문서상 TDD 예외 항목이며 생산 DTO 변경 없이 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과. +- 2026-07-30: Phase 6 Task 6.4 통합 검증 - `CreatorChannelHomeEndToEndTest`를 추가해 실제 Spring bean과 DB fixture로 인증된 `GET /api/v2/creator-channels/{creatorId}/home` 단일 요청이 creator/current live/latest audio/donation/notice/schedule/audio list/series/community/fan Talk/activity/SNS 대표 필드를 조립하는지 검증했다. fixture 필수 series genre와 `audioContentCount` 기대값을 실제 정책에 맞게 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --no-daemon` 통과. +- 2026-07-30: 리뷰 후속 통합 Gate 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. +- 2026-07-30: Phase 1~7 2차 정적 리뷰 - 기존 Phase별 리뷰 문서에 2차 리뷰 결과를 추가했다. Phase 1·2·5·6은 신규 확정 발견 사항 없음으로 판정했다. `REV-P3-001`(삭제 회원 후원자 닉네임 prefix), `REV-P4-002`(다른 크리에이터 조회 시 라이브 입장 제한 누락), `REV-P4-003`(기본 `now`의 JVM timezone 의존), `REV-P7-002`(member lock 전 일반 조회로 인한 MySQL snapshot 위험)를 확정해 Task 3.17·4.4·4.5·7.4와 `P3-R1`·`P4-R2`·`P4-R3`·`P7-R2`로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았으며 코드·테스트·문서와 MySQL 공식 격리수준 문서를 정적으로 대조했다. `git diff --check`, 리뷰 문서별 2차 섹션 단일 존재, finding ID와 미완료 Task 연결, trailing whitespace 부재를 정적 확인했다. +- 2026-07-30: Phase 3 Task 3.17 RED/GREEN 확인 - `CreatorChannelHomeQueryServiceTest`에 삭제 회원 후원자 `deleted_donor`가 홈 domain에서 `donor`로 조립되는 테스트를 추가했다. RED는 `AssertionFailedError`로 확인했고, `CreatorChannelDonationRecord.toDomain()`에서 `removeDeletedNicknamePrefix()`를 적용한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests '*shouldRemoveDeletedNicknamePrefixFromChannelDonation' --no-daemon` 통과. +- 2026-07-30: Phase 4 Task 4.4 RED/GREEN 확인 - `viewer.role == CREATOR`이고 `viewer.id != creatorId`인 조회자의 `isViewerCreator` 전달 테스트를 추가해 RED를 확인했다. 이후 `CreatorChannelHomeQueryService`의 `isViewerCreator` 계산을 `viewer.role == MemberRole.CREATOR`로 보정하고 일반 회원 false, 대상 본인 크리에이터 true 회귀와 함께 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests '*shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator' --tests '*shouldPassNonCreatorFlagWhenViewerIsUser' --tests '*shouldPassViewerCreatorFlagToLivePolicyQueries' --no-daemon` 통과. +- 2026-07-30: Phase 4 Task 4.5 RED/GREEN 확인 - `CreatorChannelHomeFacadeTest`에 JVM 기본 timezone을 `Asia/Seoul`로 바꾼 상태에서 인자 없는 홈 조회가 UTC 기준 `now`를 service에 전달하는 테스트를 추가했다. RED는 UTC 범위 assertion 실패로 확인했고, facade와 service의 기본 `now`를 `LocalDateTime.now(ZoneOffset.UTC)`로 고정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --no-daemon` 통과. +- 2026-07-30: Phase 7 Task 7.4 RED/GREEN 확인 - `AudioContentServiceTest`의 10번째 고정 테스트에 `memberRepository.findByIdForUpdate`가 `repository.findByIdAndCreatorId`와 `PinContent` 조회보다 먼저 호출되는 `inOrder` 검증을 추가했다. RED는 `VerificationInOrderFailure`로 확인했고, `AudioContentService.pinToTheTop`이 첫 DB 접근에서 locked creator를 가져와 이후 콘텐츠/고정 조회와 저장에 사용하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests '*shouldReuseOldestActivePinWhenPinningTenthContent' --tests '*shouldKeepNineActivePinsWhenReactivatingInactivePin' --no-daemon` 통과. +- 2026-07-31: 2차 리뷰 후속 최종 Gate 확인 - Reviewer gate에서 `P7-R2` 순서 테스트가 `getPinContentList` 호출까지 고정하지 않은 blocker를 확인해 같은 `inOrder`에 추가했고, 재리뷰 PASS를 받았다. 전체 `./gradlew test --no-daemon`은 첫 실행에서 UTC 기본 `now` 변경 영향으로 `CreatorChannelHomeEndToEndTest`의 local-time fixture current live가 `null`이 되어 실패했고, fixture 기준 시각을 `LocalDateTime.now(ZoneOffset.UTC)`로 보정했다. 보정 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --no-daemon`, `./gradlew test --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. +- 2026-07-31: Phase 1~7 3차 정적 리뷰 - 기존 Phase별 리뷰 문서에 3차 결과를 누적했다. Phase 1~6은 신규 확정 발견 사항 없음으로 판정했고, `REV-P7-003`(고정 해제가 creator lock을 공유하지 않아 최고령 `PinContent` 행 재사용과 겹치면 새 고정이 소실될 수 있음)을 확정해 Task 7.5 / `P7-R3`으로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았다. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`과 문서 trailing whitespace 검색은 결과 없음, finding ID와 미완료 Task/Goal 연결 확인, `git diff --check -- docs/20260612_크리에이터_채널_홈_API/plan-task.md` 통과. +- 2026-07-31: Phase 7 Task 7.5 RED/GREEN 확인 - `AudioContentServiceTest`에 `unpinAtTheTop`이 `memberRepository.findByIdForUpdate`로 크리에이터 lock을 획득한 뒤 `findByContentIdAndMemberId`를 호출하는 `inOrder` 테스트를 추가했다. RED는 `WantedButNotInvoked`로 확인했고, `AudioContentService.unpinAtTheTop`이 첫 DB 접근에서 creator member row lock을 획득한 뒤 잠긴 creator id로 `PinContent`를 조회하도록 최소 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests '*shouldLockCreatorBeforeFindingPinWhenUnpinningContent' --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. 사용자 지시에 따라 전체 테스트는 실행하지 않았다. +- 2026-07-31: Phase 1~7 4차 정적 리뷰 - 기존 Phase별 리뷰 문서에 4차 결과를 각각 누적했다. PRD·계획·구조 정렬 후속 계약과 현재 domain/port/repository/service/facade/controller, 기존 테스트를 정적 대조한 결과 모든 Phase에서 신규 확정 발견 사항이 없어 후속 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았다. `rg`로 이전 enum 잔존 여부와 Phase별 4차 절의 단일 존재를 확인했고, trailing whitespace 검색과 `git diff --check`가 통과했다. 문서 가이드에 따른 `./gradlew tasks --all --no-daemon`은 최초 샌드박스의 Gradle lock 접근 제한으로 실패했으나 승인된 캐시 접근으로 재실행해 `BUILD SUCCESSFUL`을 확인했다. diff --git a/docs/20260612_크리에이터_채널_홈_API/prd.md b/docs/20260612_크리에이터_채널_홈_API/prd.md index 2c5f3798..2c68e152 100644 --- a/docs/20260612_크리에이터_채널_홈_API/prd.md +++ b/docs/20260612_크리에이터_채널_홈_API/prd.md @@ -22,6 +22,7 @@ - 공지, 커뮤니티 게시글은 홈 노출에 필요한 게시글 요약 필드를 제공한다. - 채널 후원은 최신순 8개를 내려준다. - 오디오 콘텐츠는 최근 업로드 기준 최대 9개를 내려주고, 예약 업로드 전 콘텐츠는 일반 오디오 목록에는 포함하지 않는다. +- 크리에이터가 상단 고정한 오디오 콘텐츠는 최대 9개까지 관리하고, 홈 오디오 목록에서 일반 콘텐츠보다 먼저 노출한다. - 시리즈는 최대 8개를 내려주고, 해당 시리즈에 속한 콘텐츠의 최신 공개일 기준으로 정렬한다. - 팬 Talk는 가장 최근에 남긴 팬 Talk 1개와 전체 팬 Talk 개수를 함께 내려준다. - 활동 지수와 SNS는 `ExplorerService.getCreatorDetail`의 계산/필드 의미를 기준으로 확장한다. @@ -181,6 +182,13 @@ #### Requirements - 최근 업로드된 오디오 콘텐츠를 최대 9개 내려준다. - 신규 오디오 콘텐츠 영역과 오디오 목록 영역의 첫 번째 항목이 겹치지 않도록, 오디오 목록에서는 Feature D의 `latestAudioContent`로 내려간 가장 최신 콘텐츠를 제외한다. +- `latestAudioContent`는 상단 고정 여부와 관계없이 기존처럼 공개 시각 기준 최신 콘텐츠를 내려준다. +- `audioContents`는 `PinContent.member.id == creatorId && PinContent.isActive == true`인 콘텐츠를 일반 콘텐츠보다 먼저 내려준다. +- 상단 고정 콘텐츠 사이의 정렬은 `PinContent.updatedAt desc`다. +- 고정되지 않은 콘텐츠 사이의 정렬은 기존 `AudioContent.releaseDate desc`, `AudioContent.id desc`를 유지한다. +- 상단 고정 우선순위를 적용한 뒤 전체 `audioContents`를 최대 9개로 제한한다. +- 크리에이터별 활성 상단 고정 콘텐츠는 최대 9개다. 9개가 활성인 상태에서 새 콘텐츠를 고정하면 기존 동작처럼 `PinContent.updatedAt`이 가장 오래된 활성 고정을 교체한다. +- 오디오 상세의 `isAvailablePin`은 요청자가 해당 콘텐츠의 크리에이터이고 활성 고정 개수가 9개 미만일 때만 `true`다. - 예약 업로드 전 콘텐츠는 포함하지 않는다. - `releaseDate == null`인 오디오 콘텐츠는 목록, 최신 콘텐츠, 첫 콘텐츠 판정에서 제외한다. - 응답에는 다음 값을 포함한다. @@ -201,6 +209,8 @@ #### Edge Cases - 시리즈에 속하지 않은 콘텐츠는 시리즈 관련 필드를 `null`로 내려준다. - 오디오 콘텐츠가 없으면 빈 배열을 내려준다. +- `latestAudioContent`가 상단 고정 콘텐츠여도 `audioContents`에 중복 노출하지 않는다. +- 비활성 `PinContent`는 고정 우선 정렬에 사용하지 않는다. ### Feature I. 시리즈 @@ -332,3 +342,11 @@ ## 11. Open Questions - 없음. + +--- + +## 12. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 범위 | +|---|---|---|---|---|---| +| 2026-07-30 | `DEC-001` | 확정 | 오디오 상단 고정 한도를 3개에서 9개로 늘리고, `audioContents`에서 활성 고정을 `PinContent.updatedAt desc`로 먼저 노출한 뒤 일반 콘텐츠를 기존 최신순으로 노출한다. `latestAudioContent`의 최신 공개 정책과 공개 API 스키마는 유지한다. | 2026-07-30 `deep-interview` 확정 결과와 기존 `PinContent` 교체 동작 | Feature D, Feature H, `P7-T1`, `P7-T2`, `P7-GATE` | diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-1-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-1-review.md new file mode 100644 index 00000000..f052d327 --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-1-review.md @@ -0,0 +1,66 @@ +# Phase 1 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / Task 1.1 | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- `RecommendedActivityType`의 공용 `CreatorActivityType` 이동과 추천 기능 회귀 범위를 코드·테스트·검증 기록과 정적 대조했다. +- 후속 패키지 정렬 문서로 승인된 경로 변경은 결함에서 제외했다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았고, 기존 성공 기록은 참고 증거로만 사용했다. + +## 3. 검토 근거 + +- `CreatorActivityType.kt`는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`와 각 name 기반 `code`를 제공한다. +- 추천 service/port/repository와 대응 테스트의 import와 type은 공용 enum으로 정렬되어 있다. +- `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`으로 이전 타입 잔존 여부를 정적 확인했다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +- **대상:** 1차 리뷰 후속 변경이 공용 `CreatorActivityType`과 추천 기능에 만든 영향. +- **방법:** 현재 working tree의 import/type 사용처와 추천 회귀 테스트 코드를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 8. 3차 리뷰 — 2026-07-31 + +- **대상:** 현재 working tree의 `CreatorActivityType` 정의와 추천·크리에이터 채널 홈 사용처. +- **방법:** enum 값·`code`, import/type 사용처, 대응 테스트와 `plan-task.md` Task 1.1을 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** 공용 `CreatorActivityType` 정의와 추천·홈 스케줄의 현재 사용 경계. +- **방법:** enum 값·`code`, 추천 service/port/repository 및 홈 domain/DTO import, 이전 `RecommendedActivityType` 잔존 여부를 Task 1.1과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-2-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-2-review.md new file mode 100644 index 00000000..e8592cba --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-2-review.md @@ -0,0 +1,66 @@ +# Phase 2 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 2 / Task 2.1~2.2 | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, 후속 홈 API 구조 정렬 문서, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- 홈 domain/response가 PRD의 13개 상위 섹션을 유지하는지, 순수 정책이 스케줄 제한·정렬·성인 보정·최신 오디오 중복 제거를 보장하는지 정적 검토했다. +- 최초 오디오 판정을 repository 계층으로 이동한 후속 정렬은 해당 문서와 현재 repository 테스트를 함께 대조했다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- `CreatorChannelHome.kt`, `CreatorChannelHomeResponse.kt`, `CreatorChannelHomeQueryPolicy.kt` +- `CreatorChannelHomeQueryPolicyTest.kt`, `CreatorChannelHomeQueryServiceTest.kt`, `DefaultCreatorChannelHomeQueryRepositoryTest.kt` +- 최신 오디오 제외와 스케줄 경계값·동시각 LIVE 우선·성인 노출 정책의 테스트 존재를 `rg`로 확인했다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 2 모델·정책·테스트 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +- **대상:** 1차 리뷰 후속 변경 이후 domain/response/policy의 정렬·제한·null/빈 목록 계약. +- **방법:** 모델 변환과 policy 호출부, 대응 단위 테스트를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 8. 3차 리뷰 — 2026-07-31 + +- **대상:** 홈 13개 상위 섹션 domain/response와 스케줄·최신 오디오 제외 순수 정책. +- **방법:** `CreatorChannelHome`, `CreatorChannelHomeResponse`, `CreatorChannelHomeQueryPolicy`와 service/controller 테스트를 PRD Feature A~N 및 Task 2.1~2.2와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** 홈 domain/response 13개 섹션과 스케줄 제한·동시각 정렬·성인 보정·최신 오디오 제외 정책. +- **방법:** domain/response factory, `CreatorChannelHomeQueryPolicy`, service 호출부와 대응 단위·응답 계약 테스트를 Task 2.1~2.2와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-3-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-3-review.md new file mode 100644 index 00000000..bf881aa2 --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-3-review.md @@ -0,0 +1,88 @@ +# Phase 3 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 3 / Task 3.1~3.16 | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, 커뮤니티 좋아요·홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- 현재 query port/persistence adapter와 공용 커뮤니티 조회 경계가 creator, 차단, 라이브, 예약, 오디오, 후원, 시리즈, 팬 Talk, 활동, SNS 정책을 PRD와 같은 의미로 구현하는지 정적 검토했다. +- 후속 문서로 승인된 `isOwned`/`isRented`, `isLiked`, 패키지 분리는 원 계획과의 단순 차이로 결함 판정하지 않았다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- `CreatorChannelHomeQueryPort.kt`, `DefaultCreatorChannelHomeQueryRepository.kt` +- `DefaultCreatorChannelHomeQueryRepositoryTest.kt`의 공개 시각/null, 성인, 성별·크리에이터 입장, 구매·비밀 후원, 삭제된 유료 게시물, 시리즈 경계, 데뷔일, KST 월 경계 테스트 +- 주요 쿼리의 대량 조립이 id 목록 기반 bulk 조회를 유지하고, 추가 N+1을 만들지 않는지 확인했다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 3 코드·테스트·후속 계약 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +### 7.1 리뷰 범위와 방법 + +- 1차 리뷰 후속 변경이 반영된 홈 후원 record/domain 변환을 PRD의 “기존 채널 후원 목록과 동일” 계약과 대조했다. +- 기존 `ChannelDonationService`와 전용 v2 후원 탭의 삭제 회원 닉네임 처리도 함께 확인했다. +- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. + +### 7.2 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P3-001` | Low | 완료 | 홈 후원에 삭제 회원 닉네임 prefix가 노출된다 | Task 3.17 | `P3-R1` | + +#### REV-P3-001 — 홈 후원에 삭제 회원 닉네임 prefix가 노출된다 + +- **관련 요구사항:** PRD Feature E, Task 3.5·3.15 +- **관찰:** 홈 repository는 후원자의 저장 닉네임을 그대로 projection하고, `CreatorChannelHomeQueryService.toDomain()`도 그대로 복사한다. 기존 `ChannelDonationService`와 `CreatorChannelDonationQueryService`는 `removeDeletedNicknamePrefix()`를 적용한다. +- **영향:** 탈퇴한 후원자의 내부 저장 형식인 `deleted_...`가 홈 API에서만 공개되어 기존 채널 후원 목록과 표시 의미가 달라진다. +- **권장 조치:** 홈 후원 domain 변환 경계에 기존 extension을 적용하고 삭제 회원 후원자 회귀 테스트를 추가한다. +- **판정 기록:** 코드 경로 3개를 정적 대조해 확정했다. +- **완료 기록:** 2026-07-30 — `CreatorChannelHomeQueryServiceTest` RED/GREEN으로 확인하고 service 변환 경계에 `removeDeletedNicknamePrefix()`를 적용했다. + +### 7.3 plan·goal 전환과 종료 판정 + +- `plan-task.md` Phase 3에 Task 3.17 / `P3-R1`을 추가했다. +- **최종 결론:** Low 1건 수정 완료. +- **남은 항목:** 없음. + +## 8. 3차 리뷰 — 2026-07-31 + +- **대상:** 현재 query port/persistence adapter와 공용 커뮤니티 조회 경계의 creator, 라이브·스케줄, 오디오, 후원, 공지·커뮤니티, 시리즈, 팬 Talk, 활동, SNS 정책. +- **방법:** projection·bulk 조립, 공개/예약·성인·차단 조건, KST 월 경계, 삭제 회원 닉네임 보정과 repository/service 테스트를 PRD 및 Task 3.1~3.17과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** `REV-P3-001` 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 3 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** query port/persistence adapter와 공용 커뮤니티 service의 creator·라이브·스케줄·오디오·후원·게시글·시리즈·팬 Talk·활동·SNS 조회 정책. +- **방법:** projection/bulk 조회, 공개·예약·성인·성별·차단·구매·KST 월 경계 조건과 repository/service 테스트를 PRD Feature A~N 및 Task 3.1~3.17과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 기존 `REV-P3-001` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 3 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-4-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-4-review.md new file mode 100644 index 00000000..6bd0063a --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-4-review.md @@ -0,0 +1,122 @@ +# Phase 4 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 4 / Task 4.1~4.2 | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- service의 접근 검증, 조회자 context, 섹션 조립, 최종 정책 보정을 PRD와 대조했다. +- `CreatorChannelHomeQueryService.kt`, `MemberContentPreferenceService.kt`, 대응 service 테스트를 포함했다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- `CreatorChannelHomeQueryService.kt:70-71`은 `getStoredPreference(viewer)` 후 `canViewAdultContent(viewer)`를 이어서 호출한다. +- `MemberContentPreferenceService.kt:144-157`에서 `canViewAdultContent`ub294 `getStoredPreference(member).isAdult`를 다시 호출한다. +- 두 호출은 같은 요청에서 같은 `ViewerContentPreference`를 사용할 수 있으며, `getStoredPreference`는 `REQUIRES_NEW` transaction이다. + +## 4. 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P4-001` | Low | 완료 | 홈 조회가 콘텐츠 선호를 같은 요청에서 두 번 조회한다 | Task 4.3 | `P4-R1` | + +### REV-P4-001 — 홈 조회가 콘텐츠 선호를 같은 요청에서 두 번 조회한다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** Phase 4 조회자 성인 노출·콘텐츠 타입 context 조립 +- **소유 Task:** Task 4.3 / `P4-R1` + +**관찰 내용** + +service가 이미 받은 `preference.isAdult`를 사용할 수 있지만 `canViewAdultContent(viewer)`를 다시 호출한다. 실제 bean에서는 이 메서드가 `getStoredPreference` 전체 경로를 반복한다. + +**영향** + +공개 응답은 바뀌지 않지만, 홈 조회마다 독립 transaction·선호·국가 context 조회가 중복된다. + +**권장 조치** + +`preference.isAdult`를 성인 필터에 재사용하고, service 테스트에서 별도 `canViewAdultContent` 호출이 없음을 고정한다. + +**판정 기록** + +- 2026-07-30 — 두 method의 정적 호출 관계와 transaction annotation으로 확정. +- 2026-07-30 — `CreatorChannelHomeQueryServiceTest` RED/GREEN으로 `preference.isAdult` 재사용과 `canViewAdultContent(viewer)` 미호출을 검증하고 완료. + +## 5. plan·goal 전환 + +- `plan-task.md` Phase 4에 Task 4.3 / `P4-R1`을 추가했다. +- 실행 objective: `REV-P4-001`을 수정하고 service 조립 회귀를 방지한다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | service·선호 service·테스트 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 4.3 / `P4-R1` | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 완료. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +### 7.1 리뷰 범위와 방법 + +- service/facade의 조회자 context 조립과 기본 `now` 생성 지점을 기존 라이브 목록, 홈 Following, UTC 응답·KST 월 경계 계약과 대조했다. +- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. + +### 7.2 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P4-002` | Medium | 완료 | 다른 크리에이터가 조회하면 입장 제한이 적용되지 않는다 | Task 4.4 | `P4-R2` | +| `REV-P4-003` | Medium | 완료 | 홈 기본 시각이 JVM timezone에 따라 달라진다 | Task 4.5 | `P4-R3` | + +#### REV-P4-002 — 다른 크리에이터가 조회하면 입장 제한이 적용되지 않는다 + +- **관련 요구사항:** PRD Feature B/F의 기존 라이브 목록 동일 정책, Task 4.1 +- **관찰:** service는 `isViewerCreator = viewerId == creatorId`로 계산한다. 반면 기존 라이브 목록과 `HomeFollowingQueryService`는 조회자의 `MemberRole.CREATOR` 여부를 사용하며, repository는 이 값이 true일 때 `isAvailableJoinCreator` 또는 자기 라이브 예외를 적용한다. +- **영향:** 크리에이터가 다른 크리에이터 채널을 볼 때 `isAvailableJoinCreator == false`인 현재/예약 라이브가 홈에 노출될 수 있다. +- **권장 조치:** 조회자의 role로 flag를 산출하고 다른 크리에이터 조회 회귀 테스트를 추가한다. +- **완료 기록:** 2026-07-30 — 다른 크리에이터/일반 회원/대상 본인 focused test로 `viewer.role == MemberRole.CREATOR` 계산을 검증했다. + +#### REV-P4-003 — 홈 기본 시각이 JVM timezone에 따라 달라진다 + +- **관련 요구사항:** UTC 시간 계약, KST 기준 이번 달 후원, Task 4.1·6.4 +- **관찰:** facade와 service의 기본값은 `LocalDateTime.now()`인 반면 repository는 입력 `now`를 UTC로 간주해 KST 월 경계로 변환하고 DTO도 UTC offset을 붙인다. JVM timezone을 고정하는 설정은 확인되지 않았고, 인접 `HomeFollowingQueryService`는 UTC clock을 명시한다. +- **영향:** JVM이 UTC가 아니면 공개/예약 콘텐츠 경계와 이번 달 후원 범위가 timezone offset만큼 이동하고, 응답 문자열도 실제 instant와 다를 수 있다. 현재 E2E fixture도 같은 `LocalDateTime.now()`를 사용해 이 조건을 드러내지 못한다. +- **권장 조치:** 인자 없는 조회의 `now`를 UTC로 생성하고 비 UTC JVM timezone 회귀 테스트를 추가한다. +- **완료 기록:** 2026-07-30 — facade 기본 호출을 `Asia/Seoul` JVM timezone에서 검증하고 facade/service 기본 `now`를 UTC로 고정했다. + +### 7.3 plan·goal 전환과 종료 판정 + +- `plan-task.md` Phase 4에 Task 4.4 / `P4-R2`, Task 4.5 / `P4-R3`을 추가했다. +- **최종 결론:** Medium 2건 수정 완료. +- **남은 항목:** 없음. + +## 8. 3차 리뷰 — 2026-07-31 + +- **대상:** 접근 검증 순서, 콘텐츠 선호 단일 조회, 조회자 role/effective gender 전달, facade·service 기본 UTC 시각과 전체 섹션 조립. +- **방법:** `CreatorChannelHomeQueryService`, `CreatorChannelHomeFacade`와 대응 service/facade 테스트를 Task 4.1~4.5 및 기존 라이브 목록 정책과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** `REV-P4-001`~`REV-P4-003` 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 4 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** 접근 예외 순서, 콘텐츠 선호 단일 조회, 조회자 role/effective gender, UTC `now` 전달과 전체 섹션 조립. +- **방법:** query service/facade의 실제 호출 흐름과 service/facade 테스트를 기존 라이브 목록 정책 및 Task 4.1~4.5와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 기존 `REV-P4-001`~`REV-P4-003` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 4 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-5-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-5-review.md new file mode 100644 index 00000000..9a3523e7 --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-5-review.md @@ -0,0 +1,92 @@ +# Phase 5 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 5 / Task 5.1~5.2 | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- endpoint·인증·`ApiResponse`·DTO mapping·JSON 필드 계약을 controller/DTO 테스트와 정적 대조했다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- `CreatorChannelHomeControllerTest.kt:90-148`은 facade가 반환한 모든 섹션이 채워진 응답의 최상위·boolean·비노출 필드를 검증한다. +- `plan-task.md` Task 5.2 REFACTOR와 주의사항은 단건이 없으면 `null`, 목록이 없으면 빈 배열을 내려주도록 명시한다. +- 현재 controller 테스트에는 모든 nullable/목록 섹션이 빈 fixture의 JSON 직렬화 assertion이 없다. + +## 4. 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P5-001` | Low | 완료 | 빈 홈 응답의 null/빈 배열 계약이 테스트로 고정되지 않았다 | Task 5.3 | `P5-R1` | + +### REV-P5-001 — 빈 홈 응답의 null/빈 배열 계약이 테스트로 고정되지 않았다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** Task 5.2 REFACTOR, 구현 중 주의사항의 빈 섹션 계약 +- **소유 Task:** Task 5.3 / `P5-R1` + +**관찰 내용** + +DTO 타입은 nullability와 `List` 구조를 갖추고 있지만, 실제 Jackson 응답에서 null 필드가 존재하고 목록이 `[]`로 나가는지 검증하는 빈 응답 시나리오가 없다. + +**영향** + +현재 실행 결함을 확인한 것은 아니지만, Jackson 설정·DTO annotation·mapping 변경 시 클라이언트의 빈 화면 계약이 회귀해도 감지하지 못한다. + +**권장 조치** + +MockMvc에 최소 빈 fixture 한 개를 추가해 `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`과 목록 6개의 JSON 값을 명시적으로 고정한다. + +**판정 기록** + +- 2026-07-30 — 계획의 명시적 계약과 현재 controller 테스트 범위를 대조해 확정. +- 2026-07-30 — `CreatorChannelHomeControllerTest`에 빈 홈 응답 null/빈 배열 JSON 계약 테스트를 추가하고 focused test 통과로 완료. + +## 5. plan·goal 전환 + +- `plan-task.md` Phase 5에 Task 5.3 / `P5-R1`을 추가했다. +- RED가 바로 통과하면 생산 DTO는 변경하지 않고 회귀 테스트만 남기도록 범위를 제한했다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | controller·DTO·테스트 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P5-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 5.3 / `P5-R1` | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 완료. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +- **대상:** 빈 홈 응답 보강 이후 controller 인증·endpoint·JSON 표면 계약. +- **방법:** controller, response factory, 채워진/빈 응답 MockMvc assertion을 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 8. 3차 리뷰 — 2026-07-31 + +- **대상:** 인증 controller, facade 응답 변환, Boolean 필드명, 내부 필드 비노출과 null/빈 배열 JSON 계약. +- **방법:** controller/response DTO와 채워진·빈 응답 MockMvc assertion을 Task 5.1~5.3과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** 인증 endpoint, `ApiResponse.ok(...)`, facade 응답 변환, Boolean 이름과 null/빈 배열 JSON 계약. +- **방법:** controller/response DTO, 채워진·빈 응답 MockMvc assertion과 구조 정렬 후 공용 오디오 응답 필드를 Task 5.1~5.3과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 기존 `REV-P5-001` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-6-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-6-review.md new file mode 100644 index 00000000..3414c5fb --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-6-review.md @@ -0,0 +1,94 @@ +# Phase 6 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 6 / Task 6.1~6.3 | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- Task 6.1이 요구한 “PRD의 홈 전체 섹션이 한 요청에서 조립” 증거와 실제 테스트 계층/빈 경로를 대조했다. +- 추천 enum 회귀와 기존 검증 기록의 존재도 정적 확인했다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- `CreatorChannelHomeControllerTest.kt:46-52`는 `@WebMvcTest`와 mock `CreatorChannelHomeFacade`를 사용한다. +- `DefaultCreatorChannelHomeQueryRepositoryTest.kt:178-275`의 `shouldFindCreatorChannelHomeIntegratedSections`는 동일 fixture로 repository method를 각각 직접 호출한다. +- 해당 repository 통합 시나리오는 후속 구조 정렬에서 공용 커뮤니티 service로 이동한 notices/communities를 포함하지 않는다. +- `CreatorChannelHomeEndToEndTest` 또는 같은 역할의 실제 홈 endpoint 통합 테스트는 존재하지 않는다. + +## 4. 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P6-001` | Low | 완료 | 한 HTTP 요청의 홈 전체 조립을 증명하는 통합 테스트가 없다 | Task 6.4 | `P6-R1` | + +### REV-P6-001 — 한 HTTP 요청의 홈 전체 조립을 증명하는 통합 테스트가 없다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** Task 6.1 기대 결과 +- **소유 Task:** Task 6.4 / `P6-R1` + +**관찰 내용** + +현재 증거는 “mock facade가 만든 domain을 controller가 JSON으로 변환”하는 테스트와 “한 fixture에서 repository method를 각각 호출”하는 테스트로 분리되어 있다. 따라서 controller→facade→service→repository/공용 서비스→JSON이 한 요청에서 연결되는지는 직접 증명되지 않는다. + +**영향** + +개별 계층 테스트가 통과해도 bean wiring, facade의 커뮤니티 조회 호출, 시간/context 전달, 전체 mapping 중 누락을 한 번에 검출하지 못한다. 이는 실행 결함 확정이 아니라 Task 6.1 완료 증거의 공백이다. + +**권장 조치** + +실제 Spring bean과 DB fixture를 사용해 인증된 단일 홈 요청을 보내고, 13개 상위 섹션의 대표 필드를 검증하는 통합 테스트를 추가한다. + +**판정 기록** + +- 2026-07-30 — 테스트 annotation, mock 경계, repository 호출 범위, E2E 파일 부재를 정적 대조해 확정. +- 2026-07-30 — `CreatorChannelHomeEndToEndTest`를 추가해 실제 bean 단일 HTTP 요청으로 전체 대표 섹션 JSON을 검증하고 완료. + +## 5. plan·goal 전환 + +- `plan-task.md` Phase 6에 Task 6.4 / `P6-R1`을 추가했다. +- 통합 테스트가 바로 통과하면 생산 코드는 변경하지 않도록 범위를 제한했다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | controller·repository 통합 증거 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R1` | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 완료. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +- **대상:** 신규 `CreatorChannelHomeEndToEndTest`가 실제 bean 경로와 13개 섹션의 대표 응답을 연결하는지 확인했다. +- **방법:** fixture 생성 시각, HTTP 호출, JSON assertion과 기존 focused test 경계를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** Phase 6 자체의 신규 확정 발견 사항은 없다. E2E가 JVM 기본 timezone과 같은 `LocalDateTime.now()`를 사용해 UTC 오류를 가릴 수 있는 점은 원인 소유 Phase 4의 `REV-P4-003` / Task 4.5로 전환했으며 중복 Task를 만들지 않았다. +- **남은 항목:** Phase 6 없음. Task 4.5 완료 후 E2E 직접 영향만 재확인한다. + +## 8. 3차 리뷰 — 2026-07-31 + +- **대상:** 실제 controller→facade→service→repository/공용 community 경로의 단일 HTTP 요청 통합 증거와 추천 enum 회귀 범위. +- **방법:** `CreatorChannelHomeEndToEndTest`의 UTC fixture·대표 13개 섹션 assertion, repository/service/controller 테스트 경계와 Task 6.1~6.4를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** `REV-P6-001`과 Phase 4 UTC 후속 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 6 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** 단일 인증 HTTP 요청의 실제 bean 경로, UTC fixture, 홈 13개 섹션 대표 assertion과 추천 enum 회귀 증거. +- **방법:** E2E·repository·service·controller 테스트의 계층 경계와 현재 생산 코드 wiring을 Task 6.1~6.4와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 기존 `REV-P6-001` 및 UTC 보정이 유지되며 신규 확정 발견 사항 없음. Phase 6 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260612_크리에이터_채널_홈_API/reviews/phase-7-review.md b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-7-review.md new file mode 100644 index 00000000..f637b93b --- /dev/null +++ b/docs/20260612_크리에이터_채널_홈_API/reviews/phase-7-review.md @@ -0,0 +1,172 @@ +# Phase 7 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 7 / Task 7.1~7.2 / P7-GATE | +| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- 활성 고정 9개 상한, 10번째 고정의 최고령 교체, 비활성 재활성화, 홈 오디오 고정 우선 정렬, 최신 오디오 제외을 코드·테스트·문서와 정적 대조했다. +- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았고, Phase 7의 기존 Gate 성공 기록은 참고 증거로만 사용했다. + +## 3. 검토 근거 + +- `AudioContentService.kt:1230-1261`은 transaction 안에서 현재 고정→활성 목록 순으로 읽고 추가·재활성화하지만 크리에이터 단위 lock을 취하지 않는다. +- `PinContent.kt:11-19`에는 `(member_id, content_id)` unique constraint가 없고, `PinContentRepository.kt:15-43`의 조회에도 pessimistic lock이 없다. +- `MemberRepository.kt:35-37`에는 이미 회원 행을 잠그는 `findByIdForUpdate` 패턴이 있다. +- `AudioContentServiceTest.kt:454-530`은 8/9개, 10번째 교체, 비활성 재활성화를 순차 mock 요청으로 검증하며 동시 요청은 다루지 않는다. + +## 4. 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P7-001` | Medium | 완료 | 동시 고정 요청이 활성 9개 상한을 넘거나 중복 행을 만들 수 있다 | Task 7.3 | `P7-R1` | + +### REV-P7-001 — 동시 고정 요청이 활성 9개 상한을 넘거나 중복 행을 만들 수 있다 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Feature D/H, `DEC-001`, Task 7.1 +- **소유 Task:** Task 7.3 / `P7-R1` + +**관찰 내용** + +활성 고정이 8개일 때 같은 크리에이터의 두 transaction이 동시에 목록을 읽으면 둘 다 `size < 9`로 판단해 서로 다른 행을 추가할 수 있다. 같은 콘텐츠에 대한 두 요청도 둘 다 `findByContentIdAndMemberId == null`을 관찰한 후 중복 행을 추가할 수 있다. + +**정적 재현 절차** + +1. 활성 고정 8개를 준비한다. +2. transaction A/B가 각각 서로 다른 콘텐츠에 대해 기존 고정이 없음과 활성 목록 8개를 읽는다. +3. A/B가 각각 새 `PinContent`를 저장하면 최종 활성 수는 10개가 된다. +4. 현재 코드·lock·constraint 중 이 interleaving을 차단하는 장치가 없다. + +**영향** + +동시 요청이라는 제한 조건에서 크리에이터별 활성 고정 9개 계약이 깨지고, 홈 정렬 join에 중복 행이 생기면 응답 개수·순서도 오염될 수 있다. + +**권장 조치** + +고정 상태를 읽기 전 크리에이터 행을 pessimistic write lock으로 직렬화하고, 새 DDL 없이 동시성 통합 테스트로 9개 상한과 콘텐츠 유일성을 고정한다. 홈 repository 테스트에는 9개 초과 fixture에서 반환 개수가 9임을 명시적으로 추가한다. + +**판정 기록** + +- 2026-07-30 — transaction 내 읽기-판단-쓰기 순서, lock 부재, unique constraint 부재를 정적 대조해 확정. +- 2026-07-30 — `AudioContentService.pinToTheTop`에 creator member row lock을 추가하고 `AudioContentPinConcurrencyTest`/`AudioContentServiceTest` 통과로 완료. Reviewer gate에서 요구한 홈 목록 9개 상한 assertion은 `DefaultCreatorChannelHomeQueryRepositoryTest`에 추가해 통과 확인. + +## 5. plan·goal 전환 + +- `plan-task.md` Phase 7에 Task 7.3 / `P7-R1`을 추가했다. +- 기존 Task 7.1~7.2과 P7-GATE의 완료 이력은 유지하고 리뷰 후속 goal만 추가했다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | 고정 service·entity·repository·테스트 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P7-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 7.3 / `P7-R1` | +| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 완료. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +### 7.1 리뷰 범위와 방법 + +- Task 7.3의 member row lock 호출 순서와 동시성 테스트가 MySQL 운영 격리수준에서도 직렬화를 보장하는지 정적 검토했다. +- MySQL 공식 InnoDB `REPEATABLE READ`의 consistent read/locking read 의미를 근거로 대조했다. +- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. + +### 7.2 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P7-002` | Medium | 완료 | 크리에이터 lock 전에 일반 조회가 snapshot을 만들 수 있다 | Task 7.4 | `P7-R2` | + +#### REV-P7-002 — 크리에이터 lock 전에 일반 조회가 snapshot을 만들 수 있다 + +- **관련 요구사항:** PRD Feature D/H, Task 7.3 +- **관찰:** `pinToTheTop`은 `repository.findByIdAndCreatorId` 일반 조회 후 `memberRepository.findByIdForUpdate`를 호출하고, 그 뒤 `PinContent`를 일반 조회한다. MySQL InnoDB 기본 `REPEATABLE READ`에서는 첫 consistent read가 snapshot을 정하고 locking read는 최신 행을 읽으므로 두 방식을 섞으면 후속 일반 조회가 lock 대기 전 snapshot을 계속 사용할 수 있다. [MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html) +- **영향:** 두 번째 transaction이 member lock을 기다린 뒤에도 첫 transaction의 최신 고정 변경을 보지 못해 9개 상한이나 콘텐츠 중복 방지 판단이 stale 상태를 기준으로 수행될 수 있다. +- **검증 공백:** 현재 단위 테스트는 member lock이 `PinContent` 조회보다 빠른지만 확인하고 콘텐츠 일반 조회는 순서 검증에 포함하지 않는다. 동시성 테스트의 시작 latch도 두 transaction의 고정 목록 읽기 시점을 강제하지 않아 이 interleaving을 보장하지 않는다. +- **권장 조치:** transaction의 첫 DB 접근에서 member row lock을 잡고 모든 일반 조회를 그 뒤로 옮기며, 호출 순서를 단위 테스트로 고정한다. +- **완료 기록:** 2026-07-30 — `AudioContentServiceTest`의 `inOrder` RED/GREEN으로 lock이 콘텐츠/고정 조회보다 먼저 호출됨을 고정했다. + +### 7.3 plan·goal 전환과 종료 판정 + +- `plan-task.md` Phase 7에 Task 7.4 / `P7-R2`를 추가했다. +- **최종 결론:** Medium 1건 수정 완료. +- **남은 항목:** 없음. + +## 8. 3차 리뷰 — 2026-07-31 + +### 8.1 리뷰 범위와 방법 + +- Task 7.3~7.4 후속 구현이 같은 크리에이터의 모든 상단 고정 변경을 실제로 직렬화하는지 `pinToTheTop`, `unpinAtTheTop`, member lock, `PinContent` 행 재사용 흐름을 정적 검토했다. +- 기존 단위·동시성 테스트가 고정과 해제의 경쟁을 포함하는지도 함께 확인했다. +- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. + +### 8.2 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P7-003` | Medium | 수정 완료 | 고정 해제가 creator lock을 공유하지 않아 새 고정을 소실할 수 있다 | Task 7.5 | `P7-R3` | + +#### REV-P7-003 — 고정 해제가 creator lock을 공유하지 않아 새 고정을 소실할 수 있다 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** PRD Feature H, Task 7.1·7.3·7.4 +- **소유 Task:** Task 7.5 / `P7-R3` + +**관찰 내용** + +`pinToTheTop`은 첫 DB 접근에서 `MemberRepository.findByIdForUpdate`로 크리에이터를 잠근 뒤, 활성 고정이 9개이면 가장 오래된 `PinContent` 행의 `content`를 새 콘텐츠로 바꿔 재사용한다. 반면 `unpinAtTheTop`은 같은 member lock 없이 이전 콘텐츠로 `PinContent`를 조회하고 `isActive = false`로 변경한다. + +**정적 재현 절차** + +1. 활성 고정 9개에서 가장 오래된 고정 콘텐츠를 A, 새 고정 콘텐츠를 B로 둔다. +2. 해제 transaction이 A의 `PinContent`를 먼저 읽은 뒤 commit 전 대기한다. +3. 고정 transaction이 member lock을 얻고 같은 행을 B의 활성 고정으로 재사용해 commit한다. +4. 해제 transaction이 늦게 commit하면 이전 A를 가리키던 stale entity update가 재사용된 행을 다시 비활성화하거나 이전 상태로 덮을 수 있다. + +**근거** + +- 코드: `AudioContentService.pinToTheTop`은 member lock과 최고령 `PinContent` 행 재사용을 수행한다. +- 코드: `AudioContentService.unpinAtTheTop`은 member lock 없이 `PinContent`를 조회·비활성화한다. +- 테스트: `AudioContentPinConcurrencyTest`는 고정 요청 2개의 경쟁만 검증하고 고정/해제 경쟁은 다루지 않는다. + +**영향** + +겹친 두 요청의 순서에 따라 성공한 새 고정 B가 홈 `audioContents`에서 사라지거나 재사용 행 상태가 요청 완료 순서와 다르게 남을 수 있다. 활성 9개 상한 자체를 초과하지는 않지만 상단 고정 상태의 일관성이 깨진다. + +**권장 조치** + +`unpinAtTheTop`도 첫 DB 접근에서 `pinToTheTop`과 같은 member row lock을 획득한 뒤 해제 대상을 조회한다. 최소 회귀 테스트로 member lock이 `PinContent` 조회보다 먼저 호출되는 순서를 고정하고 기존 동시 고정·10번째 교체 테스트를 함께 유지한다. + +**판정 기록** + +- 2026-07-31 — 고정 행 재사용, 해제의 lock 부재, 현재 동시성 테스트 범위를 정적 대조해 확정했다. +- 2026-07-31 — `unpinAtTheTop`의 creator lock 선행 획득을 RED/GREEN으로 보정하고, Phase 7 직접 영향 단위 테스트와 `ktlintCheck`, `git diff --check` 통과를 확인했다. + +### 8.3 plan·goal 전환과 종료 판정 + +- `plan-task.md` Phase 7에 Task 7.5 / `P7-R3`을 추가했다. +- **최종 결론:** Medium 1건 수정 완료. +- **남은 항목:** 없음. + +## 9. 4차 리뷰 — 2026-07-31 + +- **대상:** 활성 고정 9개 상한, 10번째·비활성 재고정, 홈 고정 우선 정렬, 최신 오디오 제외와 고정/해제 creator lock 직렬화. +- **방법:** `AudioContentService`, member pessimistic lock, `PinContent` 조회 순서, 홈 repository 정렬과 단위·동시성·repository 테스트를 Task 7.1~7.5 및 기존 `REV-P7-001`~`REV-P7-003`과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다. +- **결과:** 기존 동시성 보정이 유지되며 신규 확정 발견 사항 없음. Phase 7 후속 Task를 추가하지 않는다. +- **남은 항목:** 없음. diff --git a/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md b/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md index 015faf73..a733bd38 100644 --- a/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md +++ b/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md @@ -36,7 +36,7 @@ - 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%. - 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다. - 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다. -- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다. +- 무료 오디오와 포인트 오디오는 가격 조건으로 분리하며, 무료/추천 또는 포인트/추천 섹션 사이의 중복만 허용한다. - `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다. - 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다. @@ -357,7 +357,7 @@ interface AudioRecommendationQueryPort { - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` - - RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. + - RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. 이 문장은 2026-06-23 당시 기준이며 2026-07-31 후속 요구사항 정정에서 두 섹션을 가격 조건으로 분리한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest` - GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다. - REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다. @@ -721,3 +721,10 @@ interface AudioRecommendationQueryPort { - 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다. - 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다. + +## 2026-07-31 후속 요구사항 정정 + +- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다. +- 포인트 오디오를 `isPointAvailable == true`만으로 조회하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다. +- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다. +- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다. diff --git a/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md b/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md index 3a6c4ba6..08ac4f5c 100644 --- a/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md +++ b/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md @@ -72,7 +72,7 @@ - `mostCommentedAudios`: 최대 5개 - `recommendedAudios`: 최대 20개 - 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다. -- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다. +- 무료 오디오와 포인트 오디오는 가격 조건상 중복 노출하지 않는다. 무료/추천 또는 포인트/추천 섹션 사이의 중복은 허용한다. #### Edge Cases - 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다. @@ -134,7 +134,7 @@ #### Requirements - 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다. -- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다. +- 포인트 오디오는 `isPointAvailable = true`이면서 `price > 0`인 공개 오디오로 정의한다. - Response는 공통 오디오 카드 응답을 사용한다. ### Feature H. 최근 댓글이 많은 오디오 @@ -285,7 +285,8 @@ data class CommentedAudioResponse( - 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다. - New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다. - 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다. -- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다. +- 무료 오디오와 포인트 오디오는 각각 `price == 0`, `isPointAvailable == true && price > 0` 조건으로 분리한다. +- 무료/추천 또는 포인트/추천처럼 그 밖의 추천 섹션 간 중복은 서버에서 제거하지 않는다. - `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다. --- @@ -302,3 +303,14 @@ data class CommentedAudioResponse( ## 12. Open Questions - 없음 + +--- + +## 13. 후속 요구사항 정정 + +| 날짜 | 상태 | 정정 내용 | 기준 문서 | +|---|---|---|---| +| 2026-07-31 | 확정 | 무료 콘텐츠와 포인트 결제 가능 콘텐츠를 분리하고 `pointAudios`에서 `price == 0`을 제외한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` | + +- 공통 `AudioCardResponse.isPointAvailable`은 저장값을 그대로 노출하지 않고 `storedIsPointAvailable && price > 0`으로 응답한다. +- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다. diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md index 9547423d..bca4133e 100644 --- a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md @@ -27,7 +27,7 @@ - `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다. - `type=AUDIO`는 `price > 0`인 유료 공개 오디오만 조회한다. - `type=FREE`는 `price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다. -- `type=POINT`는 `isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다. +- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하고 목록과 count에 동일하게 적용한다. - 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다. - `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다. - `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다. @@ -493,7 +493,7 @@ interface MainContentAllQueryPort { - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt` - RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다. - - RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. + - RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. 이 문장은 2026-06-25 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다. - RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` - GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다. @@ -587,7 +587,7 @@ interface MainContentAllQueryPort { - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` - RED: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다. - - RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. + - RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. 이 문장은 2026-07-10 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다. - RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다. - 실패 확인: - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` @@ -667,3 +667,11 @@ interface MainContentAllQueryPort { - GREEN: `./gradlew test` 성공. - GREEN: `./gradlew ktlintCheck` 성공. - GREEN: `git diff --check` 성공. + +## 2026-07-31 후속 요구사항 정정 + +- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다. +- `type=POINT`가 `isPointAvailable == true`만 사용하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다. +- POINT 목록, count와 `hasNext` 후보는 같은 보정 조건을 사용하고 무료 오디오는 제외한다. +- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다. +- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다. diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md b/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md index ac416961..7cd454be 100644 --- a/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md @@ -176,9 +176,9 @@ #### Requirements - `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다. -- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다. +- 포인트 오디오는 `isPointAvailable == true`이면서 `price > 0`인 공개 오디오로 정의한다. - 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다. -- `type=POINT`는 `type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다. +- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하고 목록과 전체 개수에 동일하게 적용한다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. #### Edge Cases @@ -317,7 +317,7 @@ data class MainContentSeriesResponse( ### 구현 주의사항 - `type=AUDIO`는 `price > 0` 조건을 적용하고, `type=FREE`는 `price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다. -- `type=POINT`는 `isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다. +- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하며, 목록과 count가 같은 조건 함수를 공유한다. - 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다. - 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다. - 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다. @@ -339,3 +339,14 @@ data class MainContentSeriesResponse( ## 12. Open Questions - 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다. + +--- + +## 13. 후속 요구사항 정정 + +| 날짜 | 상태 | 정정 내용 | 기준 문서 | +|---|---|---|---| +| 2026-07-31 | 확정 | `type=POINT`에서 무료 콘텐츠를 제외하고 목록·`totalCount`·`hasNext` 후보에 같은 조건을 적용한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` | + +- `MainContentAudioResponse.isPointAvailable`은 `storedIsPointAvailable && price > 0`으로 응답한다. +- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md index 3f055e85..144200fd 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md @@ -10,6 +10,23 @@ --- +## 현재 후속 작업 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1~2 | 완료 | 기존 Task 전체 | 없음 | 없음 | +| 3 | 완료 | 기존 `6/6`, 회귀 `1/1` | 없음 | 없음 | +| 4 | 완료 | 기존 `5/5`, 회귀 `2/2` | 없음 | 없음 | +| 5~5.5 | 완료 | 기존 Task 전체 | 없음 | 없음 | +| 6 | 완료 | 기존 `2/2`, 회귀 `2/2` | 없음 | 없음 | +| 7 | 완료 | 기존 `1/1`, 회귀 `2/2` | 없음 | 없음 | + +- 2026-07-30 1차 Phase별 리뷰의 확정 항목은 `P3-R1` → `P3-R-GATE` → `P7-R1` → `P7-R2` → `P7-R-GATE` 순서로 실행 완료했다. +- 2026-07-30 2차 정적 리뷰의 후속 순서 `P4-R1` → `P4-R-GATE` → `P6-R1` → `P6-R-GATE`는 실행 완료했다. +- 2026-07-30 3차 정적 리뷰의 후속 순서 `P4-R2` → `P4-R2-GATE` → `P6-R2` → `P6-R2-GATE`는 실행 완료했다. + +--- + ## 0. 확정 사항 - API endpoint: `GET /api/v2/home/following` @@ -30,6 +47,11 @@ - 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다. - `CREATOR_RANKING`은 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다. - `CONTENT_RANKING`은 `contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다. +- `CREATOR_RANKING`은 현재 시점에 공개된 최신 `WEEKLY`, `DONE` 크리에이터 랭킹 job 기준 배치만 최근 소식에 표시한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 표시하지 않는다. +- 최신 공개 배치에 포함되지 않은 팔로잉 크리에이터의 과거 `CREATOR_RANKING`은 보충하지 않는다. +- `CONTENT_RANKING`은 같은 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 표시한다. +- 랭킹 배치 필터와 콘텐츠 중복 제거를 먼저 적용한 뒤 전체 최근 소식 최대 30개를 조회하며, 제외 후 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다. +- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다. - `AUDIO_CONTENT`, `PHOTO_CONTENT`는 각각 `audioContent`/`photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. - `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다. - `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다. @@ -90,6 +112,11 @@ - Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql` - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` +### Phase 7 후속 보완 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` +- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + --- ## 2. Response data class 초안 @@ -565,6 +592,48 @@ data class HomeFollowingNewsInboxRecord( - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다. +- [x] **Task 3.7: 라이브 성별·크리에이터 입장 제한 회귀 수정** + + **Goal 실행 `P3-R1`:** `REV-P3-001`에 따라 On Air와 라이브 스케줄에서 기존 라이브 입장 제한을 동일하게 적용한다. + + - **시작 조건:** `reviews/phase-3-review.md`의 `REV-P3-001` 확정, 기존 Task 3.1~3.6 완료. + - **완료 증거:** 제한 불일치 재현 테스트의 RED 확인, 최소 구현 후 focused test와 E2E 통과, 검증 기록 누적. + - **범위 밖:** 공개 응답 스키마 변경, 오디오 스케줄 정책 변경, 라이브 입장 정책 자체의 재정의. + - **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + - [x] **RED:** 회원 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부가 query port에 전달되는 테스트를 추가하고 실패를 확인한다. + - [x] **RED:** 성별 제한이 맞지 않는 라이브와 `isAvailableJoinCreator=false`인 타 크리에이터 라이브가 On Air 및 라이브 스케줄에서 제외되는 repository 테스트를 추가하고 실패를 확인한다. + - [x] **GREEN:** 기존 `LiveRoomQueryRepositoryImpl`/`DefaultCreatorChannelHomeQueryRepository`의 + `genderRestriction` 및 크리에이터 입장 제한 조건을 재사용 가능한 최소 QueryDSL 조건으로 적용한다. + - [x] **GREEN 확인:** 아래 focused test를 실행해 신규 회귀와 기존 섹션 조회가 통과하는지 확인한다. + + ```bash + ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" + ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" + ``` + + - [x] **REFACTOR:** live 조건만 정리하고 공개 DTO와 최근 소식 로직은 변경하지 않는다. + +#### Phase 3 리뷰 회귀 Gate + +**Goal 실행 `P3-R-GATE`:** Phase 3의 라이브 입장 정책 수정과 팔로잉 탭 조립 회귀를 최종 판정한다. + +- **시작 조건:** `P3-R1` 완료. +- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-3-review.md`에 누적됨. +- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화. + +```bash +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" +./gradlew --no-daemon ktlintCheck +``` + ### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결 - [x] **Task 4.1: sourceKey 생성 정책 구현** @@ -631,6 +700,96 @@ data class HomeFollowingNewsInboxRecord( - 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인. - REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다. +- [x] **Task 4.6: 언팔로우·재팔로우와 최근 소식 발행 동시성 보장** + + **Goal 실행 `P4-R1`:** `REV-P4-001`에 따라 follower 판정부터 inbox insert까지의 경계를 팔로우 상태 변경과 직렬화해, 언팔로우 이전 이벤트가 재팔로우 후 새 소식으로 노출되지 않게 한다. + + - **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-001` 확정, 기존 Task 4.1~4.5 완료. + - **완료 증거:** 두 트랜잭션을 제어한 회귀 테스트의 RED 확인, 최소 구현 후 focused test 통과, 실제 결과를 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적. + - **범위 밖:** 외부 MQ/outbox/worker 도입, inbox 공개 API·DDL 변경, 팔로우 알림 정책 변경, 관련 없는 publish 경로 리팩터링. + - **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + - [x] **RED:** publish가 active follower를 읽은 뒤 insert하기 전에 같은 회원이 언팔로우하는 순서를 두 트랜잭션과 latch로 고정한다. 언팔로우 완료 후 publish insert가 늦게 완료되고 다시 팔로우하면 언팔로우 이전 이벤트가 조회되는 현재 실패를 재현한다. + - [x] **GREEN:** follower 판정과 inbox insert를 하나의 트랜잭션 경계로 묶고 해당 `creator_following` row를 팔로우/언팔로우 상태 변경과 동일한 잠금 순서로 직렬화한다. publish가 먼저 완료되면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 완료되면 publish가 해당 회원 row를 생성하지 않아야 한다. + - [x] **GREEN:** 재팔로우는 기존 비활성 inbox를 복구하지 않으며, 재팔로우 이후 발생한 새 이벤트만 active row로 생성되는 기존 정책을 유지한다. + - [x] **GREEN 확인:** 아래 focused test를 실행해 동시성 회귀와 기존 중복 방지·발행·언팔로우 테스트가 모두 통과하는지 확인한다. + + ```bash + ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest" + ``` + + - [x] **REFACTOR:** H2/MySQL에서 검증 가능한 기존 JPA 경로와 공개 port 범위를 유지하고, 동시성 보장에 필요하지 않은 계층·설정·DDL을 추가하지 않는다. + +#### Phase 4 Review Gate + +**Goal 실행 `P4-R-GATE`:** `P4-R1`의 동시성 보장과 기존 최근 소식 발행·조회 계약을 최종 판정한다. + +- **시작 조건:** `P4-R1` 완료. +- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적됨. +- **범위 밖:** 테스트 삭제·완화, 공개 API·DDL 확장, 이번 회귀와 무관한 코드 수정. + +```bash +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest" +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" +./gradlew --no-daemon ktlintCheck +``` + +**Expected:** publish와 언팔로우의 완료 순서와 무관하게 최종 언팔로우 상태에서는 해당 creator의 active inbox가 없고, 재팔로우 후에는 재팔로우 이후 이벤트만 노출된다. + +- 전체 `./gradlew test`는 follower fan-out과 팔로우 상태 변경 경계만 보완하는 국소 수정이므로 기본 Gate에서 생략한다. focused test 또는 E2E가 공유 트랜잭션 경계의 회귀를 충분히 판정하지 못하면 전체 회귀로 확장하고 근거와 결과를 기록한다. + +- [x] **Task 4.7: 통합 팔로우 API의 `isActive=false` 경로에서 inbox 비활성화** + + **Goal 실행 `P4-R2`:** `REV-P4-002`에 따라 `creatorFollow(..., isActive=false)`가 새 active 팔로우를 만들지 않고, 기존 관계의 active inbox를 비활성화해 재팔로우 후 과거 소식이 다시 노출되지 않게 한다. + + - **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-002` 확정, `P4-R1`과 `P4-R-GATE` 완료. + - **완료 증거:** 통합 팔로우 API 경로의 회귀 테스트 RED 확인, 최소 구현 후 `MemberServiceTest` 통과, Phase 4 회귀 Gate와 검증 기록 누적. + - **범위 밖:** `CreatorFollowRequest`·controller 공개 스키마 변경, 알림 설정 정책 변경, 외부 MQ/outbox 도입, 전용 `creatorUnFollow(...)` 경로 리팩터링. + - **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt` + - **Interfaces:** + - Consumes: `MemberService.creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long)`, `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long)`. + - Produces: 기존 public method·request 계약을 바꾸지 않고 `isActive=false`일 때 전용 언팔로우 경로와 같은 inbox 최종 상태. + - [x] **RED:** 관계가 없는 회원이 `creatorFollow(..., isActive=false)`를 호출해도 새 active 팔로우가 생성되지 않는지 검증한다. + - [x] **RED:** active 팔로우와 active inbox를 준비한 뒤 `creatorFollow(..., isActive=false)`를 호출하고 관계와 inbox가 모두 inactive인지 검증한다. 이어 `creatorFollow(..., isActive=true)`를 호출해 기존 inbox가 inactive로 유지되는지 검증한다. + - [x] **RED 확인:** 아래 단일 테스트를 실행해 `creatorFollow(..., isActive=false)` 직후 inbox가 여전히 active인 assertion 실패를 확인한다. + + ```bash + ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest" + ``` + + - [x] **GREEN:** 관계가 없고 `isActive=false`면 전용 `creatorUnFollow(...)`와 동일하게 새 active 관계를 만들지 않는다. 기존 `creator_following` row에 `isActive=false`를 반영하는 같은 트랜잭션에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다. `isActive=true`인 알림 변경·재팔로우 경로에서는 기존 비활성 inbox를 복구하지 않는다. + - [x] **GREEN 확인:** 같은 `MemberServiceTest` 명령을 재실행해 전용 언팔로우와 통합 팔로우 API 경로의 회귀가 모두 통과하는지 확인한다. + - [x] **REFACTOR:** 두 공개 method의 계약과 비관적 잠금 순서를 유지하고, 공통화가 한 번만 쓰이는 추상화나 신규 계층은 추가하지 않는다. + +#### Phase 4 3차 리뷰 회귀 Gate + +**Goal 실행 `P4-R2-GATE`:** `P4-R2`의 통합 언팔로우 경로와 기존 publish·언팔로우 동시성 계약을 최종 판정한다. + +- **시작 조건:** `P4-R2` 완료. +- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적됨. +- **범위 밖:** 테스트 삭제·완화, 공개 API·DDL 변경, 이번 회귀와 무관한 코드 수정. + +```bash +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest" +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" +./gradlew --no-daemon ktlintCheck +``` + +**Expected:** 전용 언팔로우와 `creatorFollow(..., isActive=false)` 어느 경로를 사용해도 새 active 팔로우가 생기지 않고 기존 active inbox가 비활성화되며, 재팔로우는 과거 inbox를 복구하지 않는다. + ### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End - [x] **Task 5.1: HomeFollowingFacade 통합** @@ -772,6 +931,178 @@ data class HomeFollowingNewsInboxRecord( - 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL` - 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다. +- [x] **Task 6.3: `newsId` 공개 계약을 현재 구현과 동기화** + + **Goal 실행 `P6-R1`:** `REV-P6-001`에 따라 `newsId`가 `home_following_news_inbox.id`의 10진 문자열임을 PRD에 명시하고, `scheduleId`의 `{TYPE}:{targetId}` 계약과 분리한다. + + - **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-001` 확정, 현재 repository/test/E2E의 `newsId` 동작 확인. + - **완료 증거:** PRD 식별자 정책과 최근 소식 정렬·동률 해소 설명이 현재 구현과 일치하고, 대체 검증 결과를 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적. + - **범위 밖:** 공개 응답 필드 추가·삭제, runtime 코드·테스트·DDL 변경, 기존 `newsId` 값 형식 변경. + - **Files:** + - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` + - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + - **TDD 예외 사유:** 현재 공개 동작을 변경하지 않는 문서 계약 정합성 보완이며 신규 production behavior가 없다. + - [x] `prd.md`의 공통 식별자 설명에서 `scheduleId`만 `{TYPE}:{targetId}` 형식으로 유지하고, `newsId`는 inbox PK의 10진 문자열이며 같은 노출 시각의 정렬·`CONTENT_RANKING` 동률 해소에 사용한다고 명시한다. + - [x] 아래 검색으로 PRD, repository, repository test, E2E의 `newsId` 형식을 정적으로 대조한다. + + ```bash + rg -n "newsId|scheduleId|home_following_news_inbox\\.id" docs/20260625_메인_홈_팔로잉_탭_API/prd.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt + git diff --check + ``` + + - [x] 문서 명령 유효성만 확인하는 `./gradlew tasks --all`을 실행해 `BUILD SUCCESSFUL`을 확인한다. 컴파일과 테스트는 실행하지 않는다. + +#### Phase 6 Review Gate + +**Goal 실행 `P6-R-GATE`:** `P6-R1`의 식별자 계약 정합성과 공개 동작 무변경을 최종 판정한다. + +- **시작 조건:** `P6-R1` 완료. +- **완료 증거:** 정적 검색과 `git diff --check`, `./gradlew tasks --all` 결과가 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적됨. +- **범위 밖:** 컴파일·테스트 재실행, runtime 코드·테스트·DDL 변경. + +**Expected:** `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 inbox PK 10진 문자열로 문서와 현재 구현이 일치하며 API 응답 동작은 변경되지 않는다. + +- [x] **Task 6.4: 크리에이터 랭킹 최신 공개 배치 기준 문서 동기화** + + **Goal 실행 `P6-R2`:** `REV-P6-002`에 따라 PRD의 최신 `CREATOR_RANKING` 배치 판정 기준을 현재 구현의 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 정책과 일치시킨다. + + - **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-002` 확정, `P7-R1`과 `P7-R-GATE` 완료. + - **완료 증거:** PRD Feature F와 기술 제약의 최신 배치 설명이 Task 7.2 및 repository 조건과 일치하고, 정적 검색 결과를 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적. + - **범위 밖:** runtime 조회 조건·테스트·DDL·공개 응답 변경, 랭킹 집계·점수·공개 시각 정책 변경. + - **Files:** + - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` + - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - **TDD 예외 사유:** 현재 공개 동작을 바꾸지 않는 문서 정합성 보완이며 신규 production behavior가 없다. + - [x] `prd.md`의 두 최신 공개 배치 설명을 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각 우선으로 수정한다. + - [x] 적용 가능한 `DONE` job이 전혀 없는 legacy/backfill 데이터에서만 최신 공개 snapshot 시각을 fallback으로 사용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시한다. + - [x] 아래 검색으로 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조한다. + + ```bash + rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt + git diff --check + ``` + + - [x] 문서 명령 유효성만 확인하는 `./gradlew tasks --all`을 실행해 `BUILD SUCCESSFUL`을 확인한다. 컴파일과 테스트는 실행하지 않는다. + +#### Phase 6 3차 리뷰 회귀 Gate + +**Goal 실행 `P6-R2-GATE`:** `P6-R2`의 최신 공개 배치 문서 정합성과 runtime 무변경을 최종 판정한다. + +- **시작 조건:** `P6-R2` 완료. +- **완료 증거:** 정적 검색과 `git diff --check`, `./gradlew tasks --all` 결과가 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적됨. +- **범위 밖:** 컴파일·테스트 실행, runtime 코드·테스트·DDL 변경. + +**Expected:** PRD가 최신 완료 job의 빈 배치를 포함한 현재 판정과 일치하고, 적용 가능한 `DONE` job이 없는 경우에만 snapshot fallback을 허용한다. + +### Phase 7: 최근 소식 랭킹 조회 정책 보완 + +**Phase 결과:** 최근 소식에서 크리에이터 랭킹은 최신 공개 배치만, 콘텐츠 랭킹은 동일 콘텐츠의 최신 소식 하나만 표시된다. + +**선행조건:** Phase 1~6 완료와 2026-07-30 확정 요구사항 반영. + +**Phase 완료 조건:** `P7-T1`과 `P7-GATE` 완료, focused test·직접 영향 회귀·문서 검증 기록 누적. + +- [x] **Task 7.1: 최신 크리에이터 랭킹 배치 필터와 콘텐츠 랭킹 중복 제거** + + **Goal 실행 `P7-T1`:** `findRecentNews(...)`가 랭킹 정책을 최대 30개 제한 전에 적용해 최신 크리에이터 배치와 콘텐츠별 최신 랭킹 소식만 반환한다. + + - **시작 조건:** PRD Feature F와 Decision Log의 2026-07-30 결정 확인. + - **완료 증거:** 아래 TDD 체크박스 전체 완료, focused test 통과, 실제 결과를 `## 6. 검증 기록`에 누적. + - **범위 밖:** `CONTENT_RANKING` inbox 발행, 콘텐츠 랭킹 스냅샷 연동, 공개 API 스키마·DDL 변경, 다른 최근 소식 타입 리팩터링. + - **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - **Interfaces:** + - Consumes: `creator_ranking_snapshot.ranking_type`, `visible_from_at`, `home_following_news_inbox.news_type`, `target_id`, `visible_from_at_utc`, `id`. + - Produces: 기존 `HomeFollowingQueryPort.findRecentNews(memberId, canViewAdultContent, nowUtc, limit)` 계약을 변경하지 않은 필터링 결과. + - [x] **RED:** `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews` 테스트에 직전·최신 공개 크리에이터 랭킹 스냅샷과 inbox를 저장한다. 신규 배치 공개 전에는 직전 배치가 조회되고, 공개 후에는 최신 배치만 조회되며, 최신 배치에 없는 크리에이터의 과거 순위는 제외되는지 검증한다. + - [x] **RED:** `shouldFindLatestContentRankingNewsPerContentBeforeLimit` 테스트에 동일 `contentId`의 서로 다른 `visibleFromAtUtc` row, 같은 시각의 서로 다른 `newsId` row, 다른 콘텐츠 row를 저장한다. `visibleFromAtUtc desc`, `newsId desc` 기준 최신 row 하나만 남고 중복 제거 후 `limit`까지 다른 고유 소식이 채워지는지 검증한다. + - [x] **RED 확인:** 아래 focused test를 실행해 과거 크리에이터 배치 노출, `CONTENT_RANKING` 미조립 또는 동일 콘텐츠 중복 노출 때문에 assertion이 실패하는지 확인한다. + + ```bash + ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" + ``` + + - [x] **GREEN:** `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 `creator_ranking_snapshot`의 `rankingType = WEEKLY`, `visibleFromAtUtc <= nowUtc` 중 최신 공개 시각과 일치하는 `CREATOR_RANKING`만 허용하는 조건을 추가한다. 공개 스냅샷이 없으면 `CREATOR_RANKING`을 반환하지 않는다. + - [x] **GREEN:** `CONTENT_RANKING`을 활성 오디오 콘텐츠 target과 조인·조립하고, 동일 회원·동일 `targetId`의 노출 가능한 더 최신 row가 존재하지 않는 항목만 남기는 조건을 추가한다. 최신 비교는 `visibleFromAtUtc`, 동률이면 inbox `id`를 사용하며 이 조건을 전체 `limit`보다 먼저 적용한다. + - [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 두 회귀 테스트와 기존 repository 테스트가 모두 통과하는지 확인한다. + - [x] **REFACTOR:** 이번 Task가 추가한 QueryDSL alias와 조건 함수만 정리하고, 공개 port/DTO·DDL은 변경하지 않는다. 아래 직접 영향 회귀와 lint를 실행해 결과를 기록한다. + + ```bash + ./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" + ./gradlew --no-daemon ktlintCheck + ``` + +#### Phase 7 Gate + +**Goal 실행 `P7-GATE`:** Phase 7의 최신 배치·콘텐츠 중복 제거 정책과 기존 팔로잉 탭 API 회귀를 최종 판정한다. + +- **시작 조건:** `P7-T1` 완료. +- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`에 누적됨. +- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, `CONTENT_RANKING` 발행 기능 추가. + +```bash +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" +./gradlew --no-daemon ktlintCheck +``` + +**Expected:** 과거 크리에이터 랭킹 배치와 동일 콘텐츠의 중복 랭킹이 최근 소식에 포함되지 않고, 기존 팔로잉 탭 응답 계약과 다른 소식 타입 회귀가 없다. + +- 전체 `./gradlew test`는 조회 repository 한 파일과 해당 테스트만 변경하는 국소 보완이므로 기본 Gate에서 생략한다. focused test 또는 E2E에서 공유 경계 회귀를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장하고 근거와 결과를 기록한다. + +- [x] **Task 7.2: 빈 최신 크리에이터 랭킹 배치에서 과거 소식 제외** + + **Goal 실행 `P7-R1`:** `REV-P7-001`에 따라 최신 완료 배치의 결과가 0건이어도 이전 배치의 크리에이터 랭킹 소식을 노출하지 않는다. + + - **시작 조건:** `reviews/phase-7-review.md`의 `REV-P7-001` 확정, 기존 `P7-GATE` 완료. + - **완료 증거:** 빈 최신 배치 재현 테스트의 RED 확인, 최소 구현 후 repository/E2E 회귀 통과, 검증 기록 누적. + - **범위 밖:** 랭킹 집계·점수 정책 변경, `CONTENT_RANKING` 발행, 신규 테이블/공개 API 변경. + - **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + - [x] **RED:** 직전 공개 스냅샷/inbox와 더 최신 `WEEKLY`, `DONE` snapshot job을 저장하되 최신 배치 스냅샷은 0건으로 두고, 과거 `CREATOR_RANKING`이 제외되어야 하는 테스트를 추가해 실패를 확인한다. + - [x] **GREEN:** 최신 공개 배치 식별은 결과 row가 없어도 남는 `creator_ranking_snapshot_job`의 `WEEKLY`, `DONE`, + `visibleFromAtUtc <= nowUtc` 최신 시각을 기준으로 한다. 기존 snapshot만 있고 적용 가능한 job 이력이 전혀 없는 데이터의 호환 fallback이 필요하면 그 경우로만 제한하며, 빈 `DONE` 배치에서는 과거 snapshot으로 fallback하지 않는다. + - [x] **GREEN 확인:** repository focused test를 재실행해 최신 빈 배치와 기존 신규 공개 전/후 정책이 모두 통과하는지 확인한다. + - [x] **REFACTOR:** 최신 배치 조건 helper와 필요한 QueryDSL alias만 정리하고 port/DTO/DDL은 변경하지 않는다. + +- [x] **Task 7.3: 노출 가능한 콘텐츠 랭킹 row만 최신 중복 제거 기준으로 사용** + + **Goal 실행 `P7-R2`:** `REV-P7-002`에 따라 더 최신이지만 노출 불가능한 `CONTENT_RANKING` row가 이전의 노출 가능한 row를 가리지 않도록 한다. + + - **시작 조건:** `P7-R1` 완료와 `reviews/phase-7-review.md`의 `REV-P7-002` 확정. + - **완료 증거:** `rank=null` 또는 회원에게 노출 불가한 최신 row 재현 테스트의 RED 확인, 최소 구현 후 focused test 통과, 검증 기록 누적. + - **범위 밖:** 콘텐츠 랭킹 발행/스냅샷 연동, 콘텐츠 동일성 기준 변경, 공개 응답 스키마 변경. + - **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - [x] **RED:** 같은 `memberId/targetId`에서 이전 row는 노출 가능하고 더 최신 row는 `rank=null`인 fixture를 추가해 이전 row가 유지되어야 하는 테스트를 작성하고 실패를 확인한다. + - [x] **RED:** 비성인 회원에게 더 최신 inbox row만 `isAdult=true`인 경우에도 이전의 노출 가능한 row가 유지되는 테스트를 작성하고 실패를 확인한다. + - [x] **GREEN:** `latestContentRankingNewsCondition(...)`의 newer-row 판정에 `rank is not null`, 회원별 inbox 성인 조건 등 row마다 달라질 수 있는 외부 조회와 동일한 노출 조건을 적용한다. + - [x] **GREEN 확인:** repository focused test를 재실행해 노출 불가 newer row와 기존 최신 시각/id tie-break 회귀가 모두 통과하는지 확인한다. + - [x] **REFACTOR:** 중복 제거 조건만 최소 수정하고 다른 최근 소식 타입의 조인·조립은 변경하지 않는다. + +#### Phase 7 리뷰 회귀 Gate + +**Goal 실행 `P7-R-GATE`:** Phase 7 리뷰에서 확정된 빈 배치와 노출 가능 row 기준을 최종 판정한다. + +- **시작 조건:** `P7-R1`, `P7-R2` 완료. +- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-7-review.md`에 누적됨. +- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, 신규 발행 기능. + +```bash +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" +./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest" +./gradlew --no-daemon ktlintCheck +``` + --- ## 5. 구현 순서 요약 @@ -783,6 +1114,11 @@ data class HomeFollowingNewsInboxRecord( 5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다. 6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다. 7. End-to-End 테스트와 전체 회귀 검증을 수행한다. +8. 후속 회귀 수정으로 `CREATOR_RANKING` 최신 공개 배치 필터와 `CONTENT_RANKING` 콘텐츠별 최신 소식 중복 제거를 적용한다. +9. Phase 3 리뷰 회귀 수정으로 On Air와 라이브 스케줄에 기존 라이브 입장 제한을 적용한다. +10. Phase 7 리뷰 회귀 수정으로 빈 최신 랭킹 배치와 노출 불가 콘텐츠 랭킹 newer row를 처리한다. +11. Phase 4 리뷰 회귀 수정으로 publish와 팔로우 상태 변경의 동시성 경계를 보장한다. +12. Phase 6 리뷰 후속 문서 수정으로 `newsId` 식별자 계약을 현재 구현과 동기화한다. --- @@ -865,3 +1201,96 @@ data class HomeFollowingNewsInboxRecord( - 직접 검색 결과 정렬 계약을 명시한 위치는 `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`, `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`, `DefaultHomeFollowingQueryRepositoryTest.shouldFindActiveFollowingCreatorsByOldestFollowOrder`였고, 호출부는 `HomeFollowingQueryService`가 반환 순서를 그대로 조립하는 구조로 확인했다. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon tasks --all` 실행 결과 `BUILD SUCCESSFUL`. + +- 2026-07-30 Phase 7 요구사항·계획 문서 반영: + - PRD에 `CREATOR_RANKING` 최신 공개 `WEEKLY` 배치 한정, 신규 배치 공개 전 직전 배치 유지, 최신 배치에 없는 크리에이터의 과거 순위 미보충 정책을 추가했다. + - PRD에 `CONTENT_RANKING`의 동일 `contentId`별 최신 항목 한 건 조회와 발행 기능 제외 정책을 추가했다. + - `plan-task.md`에 미완료 회귀 수정 Goal `P7-T1`과 Phase Gate `P7-GATE`를 추가하고 RED → GREEN → REFACTOR, focused test, 직접 영향 E2E, lint 검증 명령을 연결했다. + - `rg -n -S "최신 공개|과거.*랭킹|동일.*contentId|CONTENT_RANKING.*발행|P7-T1|P7-GATE|Task 7\\.1|Phase 7" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`로 두 정책과 Goal 연결을 확인했다. + - `git diff --check` 실행 결과 오류가 없었다. + - `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle 캐시 접근 제한으로 실패했으며, 동일 명령을 승인된 권한으로 재실행해 `BUILD SUCCESSFUL`을 확인했다. + - 이번 요청은 문서 반영만 수행했으므로 `P7-T1` 구현 체크박스와 Phase 7 상태는 대기로 유지했다. + +- 2026-07-30 Phase 7 구현 검증: + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`, `shouldFindLatestContentRankingNewsPerContentBeforeLimit` assertion 실패로 `BUILD FAILED`. + - `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 최신 공개 `WEEKLY` 크리에이터 랭킹 배치 필터, `CONTENT_RANKING` 활성 오디오 target 조립, 동일 contentId 최신 row 조건을 추가했다. 공개 port/DTO/DDL은 변경하지 않았다. + - GREEN 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 직접 영향 E2E 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 최초 실행은 E2E fixture에 최신 공개 `creator_ranking_snapshot`이 없어 `BUILD FAILED`; fixture 보강 후 재실행 결과 `BUILD SUCCESSFUL`. + - Gate lint 확인: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. + +- 2026-07-30 Phase 1~7 정적 코드 리뷰: + - 사용자 요청에 따라 컴파일과 테스트는 재실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 정적으로 대조했다. + - 문서 명령 유효성 확인을 위한 `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, + 승인된 권한으로 같은 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 이 명령은 컴파일과 테스트를 실행하지 않는다. + - Phase별 판정은 `docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md`부터 + `phase-7-review.md`까지 기록했으며, Phase 5.5는 `phase-5.5-review.md`로 분리했다. + - Phase 3의 라이브 입장 제한 누락 `REV-P3-001`을 확정하고 `P3-R1`, `P3-R-GATE`를 추가했다. + - Phase 7의 빈 최신 크리에이터 랭킹 배치 판정 `REV-P7-001`과 노출 불가 콘텐츠 랭킹 newer row 판정 + `REV-P7-002`를 확정하고 `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다. + - Phase 1, 2, 4, 5, 5.5, 6은 이번 정적 리뷰 범위에서 확정 발견 사항이 없다. + +- 2026-07-30 Phase 3·7 리뷰 보완 구현 검증: + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `findOnAirLives`/`findMonthlySchedules`의 effective gender·creator 여부 인자 미반영 컴파일 오류로 `BUILD FAILED`. + - `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 여부를 port로 전달하고, `DefaultHomeFollowingQueryRepository`의 On Air·라이브 스케줄에 `genderRestriction`/`isAvailableJoinCreator` 조건을 적용했다. + - `CREATOR_RANKING` 최신 배치 식별을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE` 공개 시각 기준으로 보강해 최신 빈 배치에서 과거 랭킹을 보충하지 않도록 했다. + - `CONTENT_RANKING` 중복 제거의 newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 추가해 노출 불가 row가 이전 노출 가능 row를 가리지 않도록 했다. + - GREEN 확인: 위 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`. + - E2E 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - Gate lint 확인: `./gradlew --no-daemon ktlintCheck` 최초 실행은 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 게이트: Oracle reviewer가 P3-R1, P7-R1, P7-R2, 공개 API/DDL 비확장, 테스트/문서 기록을 검토했고 Critical/Important blocker 없음으로 승인했다. + +- 2026-07-30 Phase 1~7 2차 정적 코드 리뷰: + - 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 다시 대조했다. + - Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 반영 코드 및 기존 Gate 성공 기록을 정적으로 재확인했다. + - Phase 4에서 active follower 조회와 inbox insert 사이에 언팔로우가 완료되면 뒤늦게 active inbox가 생성되고, 이후 재팔로우 시 언팔로우 이전 이벤트가 노출될 수 있는 `REV-P4-001`을 확정했다. + - Phase 6에서 PRD는 `newsId`를 `{TYPE}:{targetId}`로 설명하지만 repository와 테스트는 inbox PK의 10진 문자열을 사용하는 `REV-P6-001`을 확정했다. + - 후속 작업으로 Task 4.6 / `P4-R1` / `P4-R-GATE`와 Task 6.3 / `P6-R1` / `P6-R-GATE`를 추가했다. + - Phase 1, 2, 3, 5, 5.5, 7에서는 이번 2차 정적 리뷰의 신규 확정 발견 사항이 없다. + - 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다. + +- 2026-07-30 Phase 4·6 리뷰 보완 구현 검증: + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed"` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`. + - `creator_following` active follower 조회에 `for update`를 적용하고, `MemberService.creatorFollow(...)`/`creatorUnFollow(...)`가 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 보강했다. 공개 API·port·DDL은 변경하지 않았다. + - GREEN 확인: 위 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`. + - P4-R1 focused test `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. + - P4-R-GATE E2E `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - P4-R-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다. + - P4-R-GATE lint 재실행 결과 `BUILD SUCCESSFUL`. + - P6-R1 문서 보완으로 PRD에서 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 정렬·동률 해소에 사용하는 계약으로 분리했다. + - P6-R-GATE 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD, repository, repository test, E2E가 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 inbox PK 문자열 계약으로 일치함을 확인했다. + - P6-R-GATE `git diff --check` 실행 결과 오류 없음. + - P6-R-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 게이트: Oracle reviewer가 P4 동시성 보완과 P6 문서 계약 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다. + +- 2026-07-30 Phase 1~7 3차 정적 코드 리뷰: + - 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 리뷰·검증 기록을 Phase별로 다시 대조했다. + - Phase 4에서 공개 `POST /member/creator/follow`가 `isActive=false`를 전달해도 관계가 없으면 새 active 팔로우를 만들고, 기존 관계에서는 inbox 비활성화를 호출하지 않아 재팔로우 시 과거 소식이 다시 노출될 수 있는 `REV-P4-002`를 확정했다. + - Phase 6에서 PRD의 최신 `CREATOR_RANKING` 배치 설명이 snapshot만 기준으로 적혀 있어, `P7-R1`로 반영된 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 동작과 불일치하는 `REV-P6-002`를 확정했다. + - 후속 작업으로 Task 4.7 / `P4-R2` / `P4-R2-GATE`와 Task 6.4 / `P6-R2` / `P6-R2-GATE`를 추가했다. + - Phase 1, 2, 3, 5, 5.5, 7에서는 이번 3차 정적 리뷰의 신규 확정 발견 사항이 없다. + - 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다. + +- 2026-07-30 Phase 4 3차 리뷰 보완 구현 검증: + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`. + - 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다. + - GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`. + - P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. + - P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`. + +- 2026-07-30 Phase 6 3차 리뷰 보완 문서 검증: + - PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다. + - 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다. + - P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다. + - P6-R2-GATE `git diff --check` 실행 결과 오류 없음. + - P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. + - 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 게이트: Oracle reviewer가 P4-R2 통합 언팔로우 보완과 P6-R2 문서 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다. + +- 2026-07-30 Phase 1~7 4차 정적 코드 리뷰: + - 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, DDL, production/test 코드와 기존 리뷰 후속 구현을 Phase별로 다시 대조했다. + - Phase 1은 공개 endpoint·비회원 응답·DTO, Phase 2는 inbox 저장·중복·잠금, Phase 3은 섹션별 조회·라이브 제한을 확인했다. + - Phase 4는 after-commit 발행·팔로우 상태 동시성·통합 inactive 경로, Phase 5와 5.5는 facade/E2E·nested payload·무료 커뮤니티 정책을 확인했다. + - Phase 6은 문서·식별자·latest batch 계약, Phase 7은 최신 빈 배치와 노출 가능한 콘텐츠 랭킹 중복 제거를 확인했다. + - 각 Phase의 4차 판정은 `reviews/phase-1-review.md`부터 `phase-7-review.md`까지 누적했으며 Phase 5.5는 별도 보고서에 기록했다. + - 이번 차수의 신규 확정 발견 사항이 없어 신규 회귀 수정 Task/Goal은 추가하지 않았고 기존 Phase 완료 판정을 유지한다. + - 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md index 6270d69e..a28fbdfb 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md @@ -12,6 +12,8 @@ - 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다. - 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다. - 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다. +- 현재 최근 소식 조회는 노출 가능한 과거 `CREATOR_RANKING` inbox도 함께 조회하므로, 전체 소식이 30개 미만이면 직전 공개 배치보다 오래된 크리에이터 순위가 표시될 수 있다. +- 향후 `CONTENT_RANKING` inbox가 적재되면 같은 콘텐츠가 여러 랭킹 소식에 포함될 수 있으므로, 조회 시 동일 콘텐츠의 중복 노출을 방지하는 정책이 필요하다. --- @@ -24,6 +26,8 @@ - DM/AI 채팅방 중 최신 대화순 10개를 응답한다. - 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다. - 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다. +- `CREATOR_RANKING` 최근 소식은 현재 시점에 공개된 최신 크리에이터 랭킹 배치만 응답한다. +- `CONTENT_RANKING` 최근 소식은 같은 콘텐츠의 노출 가능한 inbox가 여러 개여도 가장 최신 항목 하나만 응답한다. - 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다. - 새로 팔로우한 사용자는 과거 소식을 받지 않는다. - 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다. @@ -42,6 +46,7 @@ - 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다. - 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다. - 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다. +- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다. - 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다. --- @@ -59,7 +64,8 @@ - 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다. - 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다. - 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다. -- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다. +- 사용자는 팔로우한 크리에이터의 최신 공개 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다. +- 사용자는 과거 크리에이터 랭킹 배치나 같은 콘텐츠의 중복 랭킹 소식 없이 최근 소식을 보고 싶다. - 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다. --- @@ -174,6 +180,13 @@ - inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다. - API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다. - 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다. +- `CREATOR_RANKING`은 적용 가능한 `creator_ranking_snapshot_job` 중 `rankingType = WEEKLY`, `status = DONE`, `visibleFromAtUtc <= nowUtc`를 만족하는 최신 완료 job의 `visibleFromAtUtc`와 같은 inbox row만 조회한다. +- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot`의 `WEEKLY`, `visibleFromAtUtc <= nowUtc` 최신 공개 시각을 fallback으로 사용한다. +- 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다. +- 다음 랭킹 배치가 공개되기 전에는 직전 공개 배치를 최신 배치로 유지하고, 새 배치가 공개된 뒤에는 이전 배치의 `CREATOR_RANKING` inbox를 조회하지 않는다. +- 사용자가 팔로우한 크리에이터가 최신 공개 배치에 없으면 해당 크리에이터의 과거 `CREATOR_RANKING` inbox로 대체하지 않는다. +- `CONTENT_RANKING`은 같은 회원과 같은 `targetId`를 콘텐츠 동일성 기준으로 사용하고, 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 첫 항목 하나만 조회한다. +- 랭킹 배치 필터와 콘텐츠 중복 제거는 전체 최대 30개 제한보다 먼저 적용한다. - 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다. - `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다. - 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다. @@ -190,12 +203,16 @@ - 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다. - 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다. - 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다. +- 월요일 신규 랭킹 공개 전에는 직전 공개 배치의 `CREATOR_RANKING` 소식을 표시하고, 신규 배치 공개 시점부터는 신규 배치 소식만 표시한다. - 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다. #### Edge Cases - inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다. - inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다. - 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다. +- 최신 공개 크리에이터 랭킹 배치에 해당하는 inbox가 없으면 과거 배치로 보충하지 않고 `CREATOR_RANKING` 소식을 가능한 개수만 응답한다. +- 같은 콘텐츠의 `CONTENT_RANKING` row가 여러 개이고 `visibleFromAtUtc`가 같으면 `newsId`가 큰 row 하나만 응답한다. +- 과거 크리에이터 랭킹과 중복 콘텐츠 랭킹을 제외한 뒤 최근 소식이 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다. - 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다. - 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다. - 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다. @@ -328,7 +345,8 @@ enum class FollowingNewsType { ``` - `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다. -- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다. +- `scheduleId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 사용한다. +- `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며, 최근 소식의 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다. --- @@ -370,7 +388,9 @@ enum class FollowingNewsType { - publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다. - 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다. - `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다. -- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다. +- `CREATOR_RANKING` 조회의 최신 공개 배치는 적용 가능한 `creator_ranking_snapshot_job`의 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 조건으로 우선 판정한다. 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot`의 최신 공개 시각을 fallback으로 사용하며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다. +- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값을 유지하되, 이번 보완에서는 발행 기능을 추가하지 않고 기존 또는 향후 적재된 inbox의 조회 정책만 정의한다. +- `CONTENT_RANKING`은 `targetId`별 최신 노출 가능 row 하나만 남기고, `visibleFromAtUtc`가 같으면 `newsId` 내림차순으로 하나를 선택한다. - 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. - 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다. - 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다. @@ -395,3 +415,10 @@ enum class FollowingNewsType { ## 12. Open Questions - 현재 PRD 기준의 미결정 요구사항은 없다. - 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다. + +--- + +## 13. Decision Log + +- 2026-07-30: `CREATOR_RANKING` 최근 소식은 KST 달력 주간이 아니라 현재 시점의 최신 공개 `WEEKLY`, `DONE` 크리에이터 랭킹 job을 기준으로 한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 노출하지 않으며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다. +- 2026-07-30: `CONTENT_RANKING` 발행 기능은 이번 보완 범위에서 제외한다. 조회 시 동일 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 응답한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md new file mode 100644 index 00000000..fbc454a1 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md @@ -0,0 +1,59 @@ +# Phase 1 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / Task 1.1~1.2 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- DTO/domain 모델, 비로그인 응답, controller, Security `permitAll`이 공개 계약과 일치하는지 정적으로 대조했다. +- `HomeFollowingTabResponse.kt`, `HomeFollowingController.kt`, `HomeFollowingFacade.kt`, `SecurityConfig.kt`와 대응 테스트를 포함했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. `plan-task.md`의 기존 성공 기록은 참고 증거로만 사용했다. + +## 3. 검토 근거 + +- `HomeFollowingTabResponse.loginRequired()`는 로그인 필요 상태와 다섯 개 빈 배열을 생성한다. +- controller는 nullable 인증 회원을 facade에 전달하고 `ApiResponse.ok(...)`로 감싼다. +- `SecurityConfig`는 `GET /api/v2/home/following`을 `permitAll`로 허용한다. +- DTO 테스트와 controller 테스트는 비회원/인증 회원 계약 및 nested 최근 소식 변환을 다룬다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 3차 정적 리뷰 — 2026-07-30 + +- controller, facade, DTO, Security permitAll과 비로그인 빈 응답 계약을 현재 working tree 기준으로 다시 대조했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다. + +## 8. 4차 정적 리뷰 — 2026-07-30 + +- 공개 endpoint, nullable 인증 principal, `ApiResponse.ok(...)`, 로그인 필요 빈 응답과 nested DTO 변환을 현재 working tree에서 다시 대조했다. +- controller·facade·DTO 테스트가 비회원/인증 회원 분기와 공개 응답 계약을 고정하는지 정적으로 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md new file mode 100644 index 00000000..80066e32 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md @@ -0,0 +1,59 @@ +# Phase 2 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 2 / Task 2.1~2.2 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, `create-home-following-news-inbox-table.sql` | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- inbox entity/JPA repository/adapter와 MySQL DDL의 컬럼·유니크 키·인덱스·비활성화 정책을 정적으로 대조했다. +- 중복 충돌 retry, 활성 follower 조회, 테스트 격리와 기존 통합 테스트 범위를 확인했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- entity와 DDL은 `member_id/news_type/source_key` 유니크 정책, timestamp, 길이, 활성 상태 컬럼이 일치한다. +- adapter는 입력 중복 제거 후 기존 수신 회원을 일괄 조회하고 `saveAll`/`flush`하며, unique 충돌 시 새 트랜잭션으로 한 번 재시도한다. +- 언팔로우 비활성화 쿼리와 활성 follower 조회 쿼리는 계획의 키 조건을 사용한다. +- 통합 테스트는 실제 unique 충돌 후 트랜잭션 사용 가능 여부, 비활성화, 활성 follower 조회를 포함한다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | entity/repository/adapter/DDL/test 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 3차 정적 리뷰 — 2026-07-30 + +- inbox DDL, Entity, JPA adapter의 중복 방지·retry·비활성화 계약을 현재 working tree 기준으로 다시 대조했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다. + +## 8. 4차 정적 리뷰 — 2026-07-30 + +- DDL·Entity의 컬럼 길이, timestamp, unique/index 정책과 adapter의 중복 제거·충돌 재시도·비활성화 동작을 다시 대조했다. +- 활성 follower 조회의 잠금이 Phase 4 팔로우 상태 변경 경계와 연결되고 관련 persistence 테스트가 이를 고정하는지 정적으로 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md new file mode 100644 index 00000000..b07acc64 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md @@ -0,0 +1,120 @@ +# Phase 3 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 3 / Task 3.1~3.6 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md` Feature B~F, `plan-task.md` Phase 3 | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 repository와 query service를 기존 접근 정책까지 포함해 정적으로 대조했다. +- `DefaultHomeFollowingQueryRepository.kt`, `HomeFollowingQueryPort.kt`, `HomeFollowingQueryService.kt`와 대응 테스트를 검토했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +## 3. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P3-001` | High | 확정 | On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다 | Task 3.7 | `P3-R1` | + +## 4. 발견 사항 상세 + +### REV-P3-001 — On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C의 성별·크리에이터 입장 제한, Feature E의 기존 채널 스케줄 정책 재사용 +- **소유 Task:** 신규 Task 3.7 / `P3-R1` + +**관찰 내용** + +`HomeFollowingQueryService`는 회원 id와 성인 콘텐츠 허용 여부만 port에 전달한다. +`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`와 live schedule 조회는 활성/채널/성인/차단 조건만 적용하며 +`live_room.gender_restriction`과 크리에이터 회원의 `is_available_join_creator` 조건을 적용하지 않는다. + +반면 기존 `LiveRoomQueryRepositoryImpl.getLiveRoomListNow(...)`와 +`DefaultCreatorChannelHomeQueryRepository.findCurrentLive/findSchedules(...)`는 effective gender와 크리에이터 입장 제한을 적용한다. + +**근거** + +- 코드: `HomeFollowingQueryService.kt:19-30` +- 코드: `DefaultHomeFollowingQueryRepository.kt:70-99`, `242-273` +- 비교 코드: `LiveRoomRepository.kt:97-115` +- 비교 코드: `DefaultCreatorChannelHomeQueryRepository.kt:136-145`, `225-233` +- 테스트 공백: `DefaultHomeFollowingQueryRepositoryTest`의 On Air 테스트는 활성/성인/정렬만 검증한다. + +**정적 재현 절차** + +1. 남성 회원이 여성 전용 라이브를 연 팔로잉 크리에이터를 조회한다고 가정한다. +2. 라이브를 `isActive=true`, non-empty `channelName`, 비성인으로 두면 현재 팔로잉 조회 조건을 모두 통과한다. +3. 실제 라이브 입장 정책은 성별 불일치로 입장을 거부하지만 팔로잉 탭 On Air에는 노출된다. +4. 크리에이터 회원이 `isAvailableJoinCreator=false`인 타 크리에이터 라이브를 조회하는 경우도 동일하게 노출된다. + +**영향** + +팔로잉 탭에 터치해도 입장할 수 없는 라이브 또는 스케줄이 노출되어 기존 라이브 접근 정책과 API 결과가 불일치한다. + +**권장 조치** + +회원의 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부를 query service에서 전달하고, 기존 QueryDSL 조건을 +On Air와 live schedule에 최소 적용한다. 성별 불일치와 크리에이터 입장 불가 회귀 테스트를 먼저 추가한다. + +**판정 기록** + +- 2026-07-30 — PRD와 기존 라이브 조회 구현을 대조해 확정했다. 런타임 테스트는 사용자 지시에 따라 실행하지 않았다. + +## 5. plan·goal 전환 + +- `plan-task.md` Phase 3에 Task 3.7, `P3-R1`, `P3-R-GATE`를 추가했다. +- 기존 Task 3.1~3.6의 완료 체크와 검증 기록은 유지했다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 3 repository/service/test 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 3.7 / `P3-R1` | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 필요. + +**남은 항목:** `P3-R1` 구현 후 `P3-R-GATE`와 이 문서의 수정 후 검증 기록을 수행한다. + +## 7. 수정 후 검증 기록 + +- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 effective gender·creator 여부 port 인자 미반영 컴파일 오류로 `BUILD FAILED`. +- 2026-07-30 — `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 회원 여부를 `HomeFollowingQueryPort`에 전달하고, On Air와 라이브 스케줄 QueryDSL에 `genderRestriction` 및 `isAvailableJoinCreator` 조건을 적용했다. +- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`. +- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`와 `./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`. +- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다. + +## 8. 후속 종료 판정 — 2026-07-30 + +- `HomeFollowingQueryService`가 본인인증 성별 우선 effective gender와 크리에이터 여부를 On Air·스케줄 조회에 전달하는 현재 코드를 정적으로 재확인했다. +- `DefaultHomeFollowingQueryRepository`가 On Air와 라이브 스케줄에 `genderRestriction` 및 `isAvailableJoinCreator` 조건을 적용하는 현재 코드를 정적으로 재확인했다. +- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다. + +**최종 결론:** `P3-R1` 및 `P3-R-GATE` 수정 검증 완료. + +**남은 항목:** 없음. + +## 9. 3차 정적 리뷰 — 2026-07-30 + +- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 조회 조건과 `P3-R1`의 성별·크리에이터 입장 제한 반영을 현재 working tree에서 다시 추적했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다. + +## 10. 4차 정적 리뷰 — 2026-07-30 + +- 팔로잉 크리에이터·On Air·월간 스케줄·최근 소식의 활성/role/차단/성인/시간 범위/정렬/limit 조건을 PRD와 다시 대조했다. +- `P3-R1`의 effective gender와 크리에이터 입장 제한이 service→port→On Air·라이브 스케줄 query에 동일하게 전달되는지 확인했다. +- repository/service 테스트가 KST 월간 경계, 동률 정렬, 원천 target 활성 상태와 라이브 접근 제한을 고정하는지 정적으로 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md new file mode 100644 index 00000000..e1ea0378 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md @@ -0,0 +1,203 @@ +# Phase 4 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 4 / Task 4.1~4.5 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md` Feature F, `plan-task.md` Phase 4 | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- source key, follower fan-out, 언팔로우 비활성화, 랭킹/오디오/무료 커뮤니티 발행 연결을 정적으로 추적했다. +- 원 트랜잭션 commit 이후 발행, 발행 실패 격리, 공개 시각과 중복 방지 키를 확인했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- source key는 타입과 원천 id/기간으로 안정적으로 생성된다. +- publish service는 활성 follower에게만 record를 만들고 DB 컬럼 길이에 맞춰 title/body를 제한한다. +- 언팔로우는 기존 inbox를 비활성화하며 재팔로우가 이를 복구하지 않는다. +- 랭킹, 즉시/예약 오디오, 무료 커뮤니티 생성 경로는 commit 이후 publish service를 호출하고 실패를 원 처리와 격리한다. +- 관련 단위·서비스 테스트는 발행 성공, 예약 공개, 유료 미발행, 발행 실패 격리를 포함한다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | 발행 경로와 호출부 정적 추적 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 2차 리뷰 — 2026-07-30 + +### 리뷰 범위 + +- active follower 조회, inbox insert의 트랜잭션 경계와 언팔로우·재팔로우 상태 변경을 함께 정적으로 추적했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. + +### REV-P4-001 — follower 판정 후 언팔로우가 완료되면 stale active inbox가 생성될 수 있음 + +**심각도:** Medium + +**상태:** 확정 + +**근거** + +- `HomeFollowingNewsPublishService.publishToFollowers(...)`는 active follower id 목록을 먼저 읽고 이후 별도 호출로 inbox row를 insert한다. +- `HomeFollowingNewsInboxJpaRepository.findActiveFollowerIds(...)`는 `creator_following`을 잠그지 않는 조회다. +- `HomeFollowingNewsInboxPersistenceAdapter.insertIgnoreAll(...)`의 실제 insert는 `REQUIRES_NEW` 경로에서 실행될 수 있어 follower 조회와 하나의 직렬화 경계를 공유하지 않는다. +- `MemberService.creatorUnFollow(...)`는 언팔로우 시점에 존재하는 active inbox만 비활성화하고, `creatorFollow(...)`의 재팔로우는 기존 관계 row를 다시 active로 바꾼다. + +**정적 재현 절차** + +1. publish 트랜잭션 A가 active follower인 회원을 읽고 insert 전에 멈춘다. +2. 트랜잭션 B가 같은 회원의 언팔로우와 현재 inbox 비활성화를 완료한다. +3. A가 기존 follower snapshot으로 active inbox를 뒤늦게 insert한다. +4. 언팔로우 중에는 조회의 active-following 조건으로 숨겨지지만, 회원이 다시 팔로우하면 해당 언팔로우 이전 이벤트가 노출 가능해진다. + +**영향** + +재팔로우 시 기존 비활성 row를 복구하지 않고 재팔로우 이후의 새 이벤트만 제공한다는 정책을 우회해, 언팔로우 이전 이벤트가 최근 소식으로 나타날 수 있다. + +**권장 조치** + +follower 판정부터 inbox insert까지를 하나의 트랜잭션 경계로 묶고, 해당 `creator_following` row를 팔로우·언팔로우 상태 변경과 같은 순서로 잠근다. publish가 먼저 끝나면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 끝나면 publish가 row를 생성하지 않는 두 순서를 동시성 테스트로 고정한다. + +**판정 기록** + +- 2026-07-30 — publish/follow/unfollow 호출 흐름과 트랜잭션 경계를 대조해 확정했다. 런타임 테스트는 실행하지 않았다. + +### plan·goal 전환 + +- `plan-task.md` Phase 4에 Task 4.6, `P4-R1`, `P4-R-GATE`를 추가했다. +- 기존 Task 4.1~4.5의 완료 체크와 검증 기록은 유지했다. + +### 2차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | publish/follow/unfollow 트랜잭션 경계 정적 추적 | +| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 4.6 / `P4-R1` | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 필요. + +**남은 항목:** 없음. `P4-R1`과 `P4-R-GATE` 수정 후 검증은 아래 기록에 누적했다. + +## 8. 수정 후 검증 — 2026-07-30 + +- RED 확인: `HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`. +- follower 조회와 inbox insert 경계에서 `creator_following` row를 잠그도록 active follower 조회에 `for update`를 적용했고, `creatorFollow(...)`/`creatorUnFollow(...)`도 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 변경했다. +- GREEN 확인: 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`. +- P4-R1 focused test `HomeFollowingNewsInboxPersistenceAdapterTest`, `HomeFollowingNewsPublishServiceTest`, `MemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`. +- P4-R-GATE E2E `HomeFollowingEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`. +- P4-R-GATE lint `ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다. +- P4-R-GATE lint `ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`. +- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다. + +**수정 후 결론:** `REV-P4-001` 보완 완료. + +## 9. 3차 정적 리뷰 — 2026-07-30 + +### 리뷰 범위 + +- 공개 팔로우·언팔로우 controller 경로, `MemberService` 상태 전이, inbox 비활성화와 재팔로우 조회 결과를 함께 정적으로 추적했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. + +### REV-P4-002 — 통합 팔로우 API의 `isActive=false` 경로가 기존 inbox를 비활성화하지 않음 + +**심각도:** High + +**상태:** 확정 + +**관련 요구사항:** PRD Feature F의 언팔로우 시 기존 inbox 비활성화, 재팔로우 시 기존 inbox 미복구 + +**소유 Task:** Task 4.7 / `P4-R2` + +**관찰 내용** + +`POST /member/creator/follow`는 request의 `isActive=false`를 `MemberService.creatorFollow(...)`에 전달해 팔로우·언팔로우를 함께 처리한다. 관계 row가 없으면 전달된 `isActive`를 반영하지 않은 기본 active 관계를 만들고, 기존 관계 row가 있으면 `creatorFollowing.isActive=false`만 반영한 채 inbox 비활성화를 호출하지 않는다. 이후 같은 API로 다시 활성화하면 기존 inbox row가 계속 active이므로 active-following 조회 조건을 다시 만족해 언팔로우 이전 소식이 노출될 수 있다. + +**근거** + +- `MemberController.creatorFollow(...)`는 nullable `request.isActive`를 기본값과 함께 service에 전달한다. +- `CreatorFollowRequest`는 `isActive`를 공개 request 필드로 정의한다. +- `MemberService.creatorFollow(...)`의 신규 관계 분기는 기본값이 `isActive=true`인 `CreatorFollowing()`을 저장하고, 기존 관계 분기는 `isNotify`와 `isActive`만 변경한다. +- `MemberService.creatorUnFollow(...)`만 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다. +- `MemberServiceTest`는 전용 `creatorUnFollow(...)` 경로만 검증하며 `creatorFollow(..., isActive=false)` 경로는 검증하지 않는다. + +**정적 재현 절차** + +1. 관계가 없는 회원이 `POST /member/creator/follow`에 `isActive=false`를 보내면 기본 active 관계가 새로 생성된다. +2. 별도로 active 팔로우와 active inbox row가 있는 회원이 같은 요청을 보내면 관계 row만 inactive가 되고 inbox는 active로 남는다. +3. 같은 API에 `isActive=true`를 보내 재팔로우한다. +4. 기존 active inbox가 다시 active-following 조건을 만족해 최근 소식 조회 후보가 된다. + +**영향** + +관계가 없는 회원은 언팔로우 요청으로 오히려 active follower가 될 수 있다. 기존 follower는 언팔로우 경로에 따라 과거 소식 보존 상태가 달라지고, 통합 언팔로우 경로에서 재팔로우 이후 과거 소식 미복구 계약을 위반한다. + +**권장 조치** + +관계가 없고 `isActive=false`면 전용 언팔로우 경로와 동일하게 새 active 관계를 만들지 않는다. 기존 관계를 `isActive=false`로 반영하는 같은 트랜잭션에서는 inbox를 비활성화한다. `isActive=true`인 알림 변경·재팔로우는 기존 비활성 inbox를 복구하지 않도록 유지하고, 두 경우를 `MemberServiceTest`로 고정한다. + +**판정 기록** + +- 2026-07-30 — request/controller/service의 공개 `isActive=false` 흐름과 PRD 재팔로우 정책을 정적으로 대조해 확정했다. 런타임 테스트는 실행하지 않았다. + +### plan·goal 전환 + +- `plan-task.md` Phase 4에 Task 4.7, `P4-R2`, `P4-R2-GATE`를 추가했다. +- 기존 Task 4.1~4.6의 완료 체크와 검증 기록은 유지했다. + +### 3차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | 두 공개 상태 변경 경로와 조회 조건 정적 추적 | +| 후보 항목 판정 완료 | 충족 | `REV-P4-002` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 4.7 / `P4-R2` | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 정적 근거 기록 | + +**최종 결론:** 수정 goal 필요. + +**남은 항목:** 없음. `P4-R2`와 `P4-R2-GATE` 수정 후 검증은 아래 기록에 누적했다. + +## 10. 3차 수정 후 검증 — 2026-07-30 + +- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`. +- 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다. +- GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`. +- P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`. +- P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`. +- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`. +- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다. + +**수정 후 결론:** `REV-P4-002` 보완 완료. + +## 11. 4차 정적 리뷰 — 2026-07-30 + +- source key, after-commit 발행, follower fan-out, 랭킹·오디오·무료 커뮤니티 호출부와 발행 실패 격리를 다시 추적했다. +- `P4-R1`의 `creator_following` 잠금 순서와 `P4-R2`의 통합 `isActive=false` 경로가 전용 언팔로우와 같은 inbox 최종 상태를 만드는지 확인했다. +- persistence·publish·`MemberService` 테스트가 중복 방지, 동시 publish/unfollow, 신규 inactive 요청과 재팔로우 미복구를 고정하는지 정적으로 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. `REV-P4-001`, `REV-P4-002` 수정 완료와 기존 Phase 4 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md new file mode 100644 index 00000000..12849a4d --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md @@ -0,0 +1,58 @@ +# Phase 5 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 5 / Task 5.1~5.2 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md` Feature A·D, `plan-task.md` Phase 5 | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- facade의 비회원 단락, 도메인 조회와 기존 최근 대화 10개 조립, E2E API 표면을 정적으로 대조했다. +- `HomeFollowingFacade.kt`, facade/controller/E2E 테스트를 검토했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- 비회원은 query/chat service 호출 없이 로그인 필요 응답을 받는다. +- 로그인 회원은 `ChatRoomListService.getRooms(member, "ALL", null, 10)` 결과를 도메인 조회 결과에 조립한다. +- E2E 테스트는 비회원 빈 섹션과 로그인 회원의 다섯 섹션, 최근 소식 JSON surface를 검증한다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | facade/controller/E2E 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 3차 정적 리뷰 — 2026-07-30 + +- facade의 로그인 분기, 최근 대화 재사용, E2E 조립 범위를 현재 working tree 기준으로 다시 대조했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다. + +## 8. 4차 정적 리뷰 — 2026-07-30 + +- 비회원 조기 반환, 로그인 회원의 도메인 조회와 `ChatRoomListService.getRooms(..., limit = 10)` 조립을 다시 대조했다. +- controller/facade/E2E 테스트가 비회원의 조회 생략과 로그인 회원의 다섯 섹션 공개 응답을 고정하는지 정적으로 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md new file mode 100644 index 00000000..1b972005 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md @@ -0,0 +1,60 @@ +# Phase 5.5 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 5.5 / Task 5.5.1~5.5.5 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md` Feature F·G, `plan-task.md` Phase 5.5 | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 전용 발행과 E2E JSON 계약을 정적으로 대조했다. +- DTO/domain/repository/커뮤니티 호출부와 대응 테스트를 포함했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. + +## 3. 검토 근거 + +- 최근 소식 최상위는 공통 필드와 다섯 nested 필드만 노출하고 flat 이동/표시 필드를 제거했다. +- repository는 랭킹, 활성 오디오, 활성 무료 커뮤니티 원천을 타입별 payload로 조립한다. +- 커뮤니티 좋아요와 최상위 댓글은 active row만 집계한다. +- 유료 커뮤니티는 발행과 조회 양쪽에서 제외된다. +- DTO 및 E2E 테스트는 nested payload의 상호 배타적 null 계약과 제거된 flat 필드를 검증한다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | DTO/repository/call site/test 정적 대조 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. + +## 7. 3차 정적 리뷰 — 2026-07-30 + +- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 발행 제한과 E2E JSON 계약을 현재 working tree 기준으로 다시 대조했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다. + +## 8. 4차 정적 리뷰 — 2026-07-30 + +- 최근 소식 공통 필드와 타입별 상호 배타적 nested DTO, CDN/UTC 변환, 원천 오디오·무료 커뮤니티 enrichment를 다시 대조했다. +- 유료 커뮤니티와 비활성·성인 원천 target 제외, active like·top-level comment 집계가 repository와 E2E 테스트에 연결되는지 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md new file mode 100644 index 00000000..595e3c44 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md @@ -0,0 +1,185 @@ +# Phase 6 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 6 / Task 6.1~6.2 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, DDL, 기존 검증 기록 | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- PRD, 구현 계획, DDL, 실제 공개 DTO의 현재 계약과 Phase별 기존 검증 기록을 정적으로 대조했다. +- 현재 compile/test 통과 상태는 사용자 설명과 `plan-task.md` 기존 기록을 근거로 삼았으며 직접 재실행하지 않았다. + +## 3. 검토 근거 + +- PRD와 계획의 endpoint, 섹션 limit, nested 최근 소식 필드, 무료 커뮤니티 정책, DDL 유니크 키가 현재 코드와 일치한다. +- `plan-task.md`에는 Phase 1~7의 focused/회귀/lint 성공 기록과 실패 후 보완 이력이 누적되어 있다. +- 이번 리뷰에서 확정된 후속 결함은 기존 완료 상태를 되돌리지 않고 소유 Phase의 신규 Task로 추가했다. +- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, 승인된 권한으로 재실행한 결과 + `BUILD SUCCESSFUL`이었다. task 목록만 확인했으며 컴파일과 테스트는 실행하지 않았다. + +## 4. 발견 사항 + +확정 발견 사항 없음. + +## 5. plan·goal 전환 + +전환 항목 없음. Phase 3과 Phase 7의 코드 발견 사항은 각 Phase 보고서와 Task에 귀속했다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD/plan/DDL/code/기존 기록 정적 대조 | +| 후보 항목 판정 완료 | 충족 | Phase 6 자체 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 다른 Phase 소유 항목은 해당 Phase에 반영 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 | + +**최종 결론:** 확정 발견 사항 없음. + +**남은 항목:** 없음. Phase 3·7 회귀 Goal과 Phase 6 후속 문서 검증 기록은 이후 섹션에 누적했다. + +## 7. 2차 리뷰 — 2026-07-30 + +### 기존 후속 항목 확인 + +- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 구현 및 기존 Gate 성공 기록이 누적된 것을 확인했다. +- 이번 확인에서는 컴파일과 테스트를 재실행하지 않았다. + +### REV-P6-001 — PRD의 `newsId` 형식이 현재 공개 동작과 불일치 + +**심각도:** Low + +**상태:** 확정 + +**근거** + +- PRD 공통 식별자 설명은 `scheduleId`와 `newsId` 모두 `{TYPE}:{targetId}` 형식을 기본안으로 적고 있다. +- `DefaultHomeFollowingQueryRepository`는 `home_following_news_inbox.id`를 문자열로 변환해 `newsId`로 반환한다. +- repository test와 E2E도 inbox PK의 10진 문자열을 계약으로 검증한다. +- 최근 소식 정렬과 `CONTENT_RANKING` 동률 해소도 inbox `id`를 사용하므로, 현재 동작을 유지한 문서 수정이 가장 작은 정합성 보완이다. + +**영향** + +클라이언트 또는 후속 구현자가 PRD만 보면 `newsId`를 타입·target 기반 식별자로 해석할 수 있어 실제 응답 파싱, 정렬 의미, 동일 target의 여러 소식 식별을 잘못 구현할 수 있다. + +**권장 조치** + +runtime 코드와 공개 응답 값은 변경하지 않는다. PRD에서 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 같은 노출 시각의 정렬·동률 해소에 쓰인다고 분리해 명시한다. + +**판정 기록** + +- 2026-07-30 — PRD, repository, repository test, E2E를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다. + +### plan·goal 전환 + +- `plan-task.md` Phase 6에 문서 전용 Task 6.3, `P6-R1`, `P6-R-GATE`를 추가했다. +- 기존 Task 6.1~6.2의 완료 체크와 검증 기록은 유지했다. + +### 2차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD/repository/test/E2E 식별자 계약 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 6.3 / `P6-R1` | +| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유 기록 | + +**최종 결론:** 문서 수정 goal 필요. + +**남은 항목:** 없음. `P6-R1`과 `P6-R-GATE` 수정 후 검증은 아래 기록에 누적했다. + +## 8. 수정 후 검증 — 2026-07-30 + +- PRD에서 `scheduleId`와 `newsId` 식별자 설명을 분리했다. +- `scheduleId`는 `{TYPE}:{targetId}` 형식, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다고 명시했다. +- runtime 코드, 테스트, DDL, 공개 응답 필드는 변경하지 않았다. +- 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD와 repository/test/E2E의 식별자 계약이 일치함을 확인했다. +- `git diff --check` 실행 결과 오류 없음. +- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다. + +**수정 후 결론:** `REV-P6-001` 문서 보완 완료. + +## 9. 3차 정적 리뷰 — 2026-07-30 + +### 리뷰 범위 + +- PRD의 최근 소식 최신 배치 정책, Phase 7 회귀 Task, repository 조건과 회귀 테스트를 정적으로 대조했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. + +### REV-P6-002 — PRD의 최신 크리에이터 랭킹 배치 판정 기준이 현재 구현과 불일치 + +**심각도:** Low + +**상태:** 확정 + +**관련 요구사항:** 최신 공개 `WEEKLY` 배치만 조회하고 빈 최신 배치에서 과거 순위를 보충하지 않는 정책 + +**소유 Task:** Task 6.4 / `P6-R2` + +**관찰 내용** + +PRD Feature F와 기술 제약은 최신 `CREATOR_RANKING` 배치를 `creator_ranking_snapshot`의 최대 공개 시각으로 판정한다고 기술한다. 그러나 `REV-P7-001` 보완 이후 현재 repository는 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각을 우선 사용하고, 해당 job이 전혀 없을 때만 snapshot으로 fallback한다. + +**근거** + +- PRD의 최신 배치 설명 두 곳은 `creator_ranking_snapshot`만 판정 원천으로 명시한다. +- `plan-task.md` Task 7.2는 빈 최신 완료 배치를 식별하기 위해 job 우선·legacy snapshot 제한 fallback을 확정했다. +- `DefaultHomeFollowingQueryRepository.latestVisibleCreatorRankingBatchCondition(...)`은 최신 DONE job 시각을 우선하고 `hasRankingJob.not()`일 때만 snapshot 시각을 사용한다. +- `DefaultHomeFollowingQueryRepositoryTest.shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty`는 최신 DONE job의 snapshot row가 0건이어도 과거 소식을 반환하지 않는 현재 계약을 검증한다. + +**영향** + +후속 구현자가 PRD만 따르면 최신 빈 완료 배치에서 과거 snapshot을 다시 최신으로 판정해 이미 수정한 회귀를 재도입할 수 있다. + +**권장 조치** + +runtime은 변경하지 않는다. PRD 두 곳을 최신 공개 DONE job 우선으로 동기화하고, 적용 가능한 DONE job이 전혀 없는 legacy/backfill 데이터에서만 snapshot fallback을 허용하며 빈 DONE 배치는 과거 snapshot으로 보충하지 않는다고 명시한다. + +**판정 기록** + +- 2026-07-30 — PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다. + +### plan·goal 전환 + +- `plan-task.md` Phase 6에 문서 전용 Task 6.4, `P6-R2`, `P6-R2-GATE`를 추가했다. +- 기존 Task 6.1~6.3의 완료 체크와 검증 기록은 유지했다. + +### 3차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD/Task/repository/test 최신 배치 기준 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P6-002` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R2` | +| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유와 정적 근거 기록 | + +**최종 결론:** 문서 수정 goal 필요. + +**남은 항목:** 없음. `P6-R2`와 `P6-R2-GATE` 수정 후 검증은 아래 기록에 누적했다. + +## 10. 3차 수정 후 검증 — 2026-07-30 + +- PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다. +- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다. +- P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다. +- P6-R2-GATE `git diff --check` 실행 결과 오류 없음. +- P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`. +- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다. + +**수정 후 결론:** `REV-P6-002` 문서 보완 완료. + +## 11. 4차 정적 리뷰 — 2026-07-30 + +- PRD·plan·DDL·공개 DTO와 현재 repository의 endpoint, limit, 식별자, latest ranking batch, nested 최근 소식 계약을 다시 대조했다. +- `newsId`의 inbox PK 문자열 계약과 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 설명이 현재 코드·테스트와 일치함을 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. `REV-P6-001`, `REV-P6-002` 문서 보완 완료와 기존 Phase 6 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md new file mode 100644 index 00000000..49fb1b3b --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md @@ -0,0 +1,162 @@ +# Phase 7 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 7 / Task 7.1 | +| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md` Feature F·Decision Log, `plan-task.md` Phase 7 | +| 리뷰 상태 | 판정 완료 | + +## 2. 목적과 범위 + +- 최신 공개 크리에이터 랭킹 배치 필터와 콘텐츠별 최신 랭킹 중복 제거가 모든 확정 edge case에서 limit 전에 적용되는지 검토했다. +- 현재 working tree의 repository, repository test, E2E fixture 변경을 포함했다. +- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. 기존 Phase 7 성공 기록은 참고 증거로만 사용했다. + +## 3. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P7-001` | High | 확정 | 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다 | Task 7.2 | `P7-R1` | +| `REV-P7-002` | High | 확정 | 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다 | Task 7.3 | `P7-R2` | + +## 4. 발견 사항 상세 + +### REV-P7-001 — 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** 최신 공개 배치만 노출, 최신 배치에 없는 크리에이터의 과거 순위 미보충 +- **소유 Task:** 신규 Task 7.2 / `P7-R1` + +**관찰 내용** + +최신 배치 조건은 `creator_ranking_snapshot.visibleFromAtUtc.max()`만 조회한다. 최신 집계가 성공했지만 점수 조건을 통과한 +snapshot이 0건이면 해당 배치를 나타내는 snapshot row가 없으므로 max 값은 직전 배치 시각으로 남는다. + +랭킹 job은 결과가 0건이어도 `creator_ranking_snapshot_job`에 `WEEKLY`, `DONE`, `visibleFromAtUtc`를 남기므로 완료 배치를 +식별할 근거가 이미 있지만 현재 팔로잉 조회는 이를 사용하지 않는다. + +**근거** + +- 코드: `DefaultHomeFollowingQueryRepository.kt:428-451` +- 코드: `DefaultCreatorRankingSnapshotRepository.kt:51-65` +- 코드: `CreatorRankingSnapshotJobService.kt:52-69` +- 테스트 공백: `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`는 최신 배치에 snapshot이 1건 이상인 경우만 검증한다. + +**정적 재현 절차** + +1. 직전 주간 snapshot과 같은 시각의 `CREATOR_RANKING` inbox를 둔다. +2. 더 최신 주간 job을 `DONE`으로 완료하지만 집계 결과 snapshot은 0건으로 둔다. +3. 최신 job 공개 시각 이후 API를 조회한다. +4. 요구 결과는 크리에이터 랭킹 0건이지만, 현재 subquery max는 직전 snapshot 시각이므로 과거 inbox가 노출된다. + +**영향** + +활동 부족 등으로 최신 배치 결과가 비어 있는 주에 명시적으로 금지된 과거 랭킹 보충이 발생한다. + +**권장 조치** + +결과 row와 독립적으로 남는 최신 공개 `DONE` job을 배치 식별 기준으로 사용하고, 최신 빈 배치에서는 과거 snapshot으로 +fallback하지 않는 회귀 테스트를 추가한다. + +**판정 기록** + +- 2026-07-30 — snapshot 교체와 job 완료 흐름을 정적으로 추적해 확정했다. + +### REV-P7-002 — 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** 같은 콘텐츠의 노출 가능한 row 중 최신 1건 조회 +- **소유 Task:** 신규 Task 7.3 / `P7-R2` + +**관찰 내용** + +외부 row는 `CONTENT_RANKING`의 `rank is not null`, inbox 성인 조건 등으로 노출 가능 여부를 확인한다. 그러나 +`latestContentRankingNewsCondition(...)`의 newer-row subquery는 member/type/target/active/visible/order만 확인하고 +`rank is not null`과 회원별 성인 조건을 확인하지 않는다. + +따라서 더 최신 row의 `rank`가 null이면 최신 row는 외부 조건에서 제외되면서도 이전 정상 row를 subquery에서 가려 결과가 0건이 된다. + +**근거** + +- 코드: `DefaultHomeFollowingQueryRepository.kt:154-166` +- 코드: `DefaultHomeFollowingQueryRepository.kt:428-438` +- 코드: `DefaultHomeFollowingQueryRepository.kt:454-471` +- 테스트 공백: 현재 콘텐츠 중복 테스트는 모든 newer row가 노출 가능한 경우만 검증한다. + +**정적 재현 절차** + +1. 같은 회원·콘텐츠에 `rank=2`, 노출 가능 시각 09:00인 정상 row를 둔다. +2. 같은 키에 `rank=null`, 노출 가능 시각 10:00인 active row를 둔다. +3. 10:00 이후 조회하면 이전 row는 newer row 존재로 제외되고, newer row는 `rank is not null` 조건에서 제외된다. +4. 요구 결과는 노출 가능한 row 중 최신인 09:00 row 1건이지만 실제 결과는 0건이다. + +**영향** + +부분 적재나 정책 변경으로 최신 inbox row가 노출 불가능해지면 유효한 이전 콘텐츠 랭킹까지 사라져 “노출 가능한 row 중 최신” 계약을 위반한다. + +**권장 조치** + +newer-row 판정에 외부 조회와 동일한 row별 노출 조건을 적용한다. 최소한 `rank is not null`과 회원별 inbox 성인 조건의 회귀 +테스트를 추가하고 기존 시각/id tie-break 테스트를 유지한다. + +**판정 기록** + +- 2026-07-30 — 외부 where와 correlated subquery 조건을 대조해 확정했다. + +## 5. plan·goal 전환 + +- `plan-task.md` Phase 7에 Task 7.2/7.3, `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다. +- 기존 Task 7.1과 `P7-GATE`의 완료 기록은 유지했다. + +## 6. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | working tree repository/test/E2E 정적 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P7-001`, `REV-P7-002` 확정 | +| 확정 항목 plan 반영 | 충족 | Task 7.2/7.3 | +| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 | + +**최종 결론:** 수정 goal 필요. + +**남은 항목:** `P7-R1` → `P7-R2` → `P7-R-GATE` 실행 후 이 문서에 수정 검증을 누적한다. + +## 7. 수정 후 검증 기록 + +- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 최신 빈 배치와 노출 불가 newer row 보완 전 컴파일/회귀 실패를 확인했다. +- 2026-07-30 — `CREATOR_RANKING` 최신 배치 기준을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc`로 보강해 최신 완료 배치가 비어 있어도 과거 배치를 보충하지 않도록 했다. +- 2026-07-30 — `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 적용해 노출 불가 최신 row가 이전 노출 가능 row를 가리지 않도록 했다. +- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`. +- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`와 `./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`. +- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다. + +## 8. 후속 종료 판정 — 2026-07-30 + +- 최신 공개 크리에이터 랭킹 배치가 빈 결과여도 과거 배치로 fallback하지 않는 현재 job 기준 조건을 정적으로 재확인했다. +- `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건이 적용된 현재 코드를 정적으로 재확인했다. +- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다. + +**최종 결론:** `P7-R1`, `P7-R2` 및 `P7-R-GATE` 수정 검증 완료. + +**남은 항목:** 없음. + +## 9. 3차 정적 리뷰 — 2026-07-30 + +- 최신 DONE job 우선의 크리에이터 랭킹 배치 판정, legacy snapshot fallback, 노출 가능한 콘텐츠 랭킹 중복 제거를 현재 working tree에서 다시 추적했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- runtime 신규 확정 발견 사항 없음. PRD의 stale 최신 배치 설명은 문서 소유 Phase의 `REV-P6-002`와 Task 6.4로 전환했으며, 기존 Phase 7 완료 판정을 유지한다. + +## 10. 4차 정적 리뷰 — 2026-07-30 + +- 최신 공개 `WEEKLY`, `DONE` job 기준과 job 부재 시 snapshot fallback, 최신 빈 배치의 과거 랭킹 미보충을 다시 추적했다. +- `CONTENT_RANKING`의 노출 가능한 newer row 판정에 active/rank/성인/visible/id 조건이 적용되고 전체 limit 전에 콘텐츠별 한 건만 남는지 확인했다. +- repository 테스트가 신규 배치 공개 전후, 빈 최신 배치, rank null·성인 newer row와 동률 id 해소를 고정하는지 정적으로 확인했다. +- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다. +- 신규 확정 발견 사항 없음. `REV-P7-001`, `REV-P7-002` 수정 완료와 기존 Phase 7 완료 판정을 유지한다. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md index cdae3212..2c0c13b1 100644 --- a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md @@ -1,11 +1,23 @@ # 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task +## 후속 변경 상태 + +| 항목 | 내용 | +|---|---| +| 상태 | 3차 Phase별 리뷰 완료 | +| 확정일 | 2026-07-31 | +| 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 | +| 현재 Phase | Phase 1~7 3차 리뷰 완료 | +| 현재 활성 Goal | 없음 | +| 다음 Goal | 없음 | + ## 시나리오 계약 - Happy path: 인기 커뮤니티와 동일한 최근 7일 UTC half-open 범위로 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`. - Score: 응원 점수는 `((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost`다. 후원 금액은 `CHANNEL_DONATION`과 `DONATION`의 `use_can_calculate.can`을 그대로 사용하고, 후원 수는 `UseCanCalculate.useCan` 기준으로 중복 제거한다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. - Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. - Fallback: 최신 `CHEER_CREATOR` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`. - Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`. +- Personalized filter: 인증 회원의 `cheerCreators`에서 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외한다. 비활성 팔로우 이력과 비회원 조회는 기존 동작을 유지한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`. - Adjacent regression: 메인 홈 추천 API URL과 `CHEER_CREATOR` 응답 필드는 변경하지 않는다. AI 캐릭터, 인기 커뮤니티, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`. ## 범위와 전제 @@ -16,12 +28,15 @@ - 집계 기간은 인기 커뮤니티와 동일하게 KST 전날을 포함한 최근 7일이며, DB 조회에는 UTC half-open window를 사용한다. - fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다. - 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다. +- 본인·팔로우 제외는 스냅샷 생성이 아닌 `findCheerCreatorRecommendationDetails(...)` 상세 조회 시점에서 기존 `memberId`로 적용한다. +- 기존 16명 스냅샷 후보 안에서만 필터링하며, 필터 결과가 8명 미만이어도 하위 후보 조회나 스냅샷 저장 수 확대를 하지 않는다. ## 기존 CHEER_CREATOR 로직 유지/변경 경계 - 유지: `RecommendedSectionType.CHEER_CREATOR` enum 값과 code는 변경하지 않는다. - 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다. - 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다. - 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다. +- 유지: 비회원 조회와 비활성 팔로우 이력이 있는 크리에이터 조회는 기존 동작을 유지한다. - 유지: 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다. - 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 후원 수 10%로 바꾼다. - 변경: 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다. @@ -30,6 +45,7 @@ - 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다. - 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다. - 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다. +- 추가: 인증 회원 본인과 활성 팔로우 중인 크리에이터를 `cheerCreators` 상세 조회에서 제외한다. ## 실행 명령 - 문서 명령 확인: `./gradlew tasks --all` @@ -55,6 +71,32 @@ - REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다. - 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다. +#### Task R1.1 PRD 관련 문서 경로 정합성 복구 + +**Goal 실행 `P1-R1`:** `REV-P1-001`에서 확인한 존재하지 않는 샘플 PRD 경로를 실제 가이드 경로로 정정한다. + +- **시작 조건:** `reviews/phase-1-review.md`의 `REV-P1-001` 확정. +- **완료 증거:** 문서 경로 정정, 대상 파일 존재 확인, `git diff --check` 통과, 전체 검증 기록 누적. +- **범위 밖:** PRD 요구사항·결정 내용 변경, 코드·테스트 변경, 다른 문서의 링크 일괄 정리. +- **TDD 예외 사유:** 문서 링크 정정만 수행하며 런타임 동작을 변경하지 않는다. + +- [x] `prd.md`의 `docs/prd/sample-prd.md`를 실제 파일인 `docs/sample/sample-prd.md`로 정정한다. +- [x] `test -f docs/sample/sample-prd.md`와 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`로 링크 대상과 문서 diff를 확인한다. +- [x] 무엇을/왜/어떻게 검증했는지 전체 검증 기록에 누적한다. + +#### Task R1.2 후속 변경 상태 정합성 복구 + +**Goal 실행 `P1-R2`:** `REV-P1-002`에서 확인한 상단 상태표의 요구사항 범위·Phase·다음 Goal을 현재 계획과 검증 상태에 맞게 정리한다. + +- **시작 조건:** `reviews/phase-1-review.md`의 `REV-P1-002` 확정. +- **완료 증거:** 상태표가 PRD 전체, Phase 1~7, 미완료 후속 Goal을 정확히 가리킴, `git diff --check`와 문서 명령 유효성 확인, 전체 검증 기록 누적. +- **범위 밖:** PRD 요구사항 변경, 코드·테스트 변경, 이전 검증 기록 삭제. +- **TDD 예외 사유:** 현재 작업 상태 문구만 복구하며 런타임 동작을 변경하지 않는다. + +- [x] 상태표의 요구사항 기준을 PRD 전체로, Phase 상태를 Phase 1~7 현재 판정으로, 다음 Goal을 실제 미완료 Goal로 맞춘다. +- [x] `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`와 `./gradlew tasks --all`로 문서 diff와 명령 유효성을 확인한다. +- [x] 완료 후 다음 Goal을 `P3-R2`로 갱신하고 전체 검증 기록에 무엇을/왜/어떻게 검증했는지 누적한다. + --- ### Phase 2: 산식과 부스트 정책 @@ -144,6 +186,32 @@ - REFACTOR: AI 캐릭터와 인기 커뮤니티 스냅샷 query가 영향받지 않았는지 focused test로 확인한다. - 기대 결과: `CHEER_CREATOR` 스냅샷 저장 후보만 정확히 변경된다. +#### Task R3.1 `CHEER_CREATOR` 후보 경계·정렬 완료 근거 보강 + +**Goal 실행 `P3-R1`:** `REV-P3-001`에서 누락이 확인된 Task 3.5의 후보 제외·상위 16개·저장 정렬 회귀 증거를 테스트로 고정한다. + +- **시작 조건:** `reviews/phase-3-review.md`의 `REV-P3-001` 확정. +- **완료 증거:** 실패 재현 테스트 작성·확인, 필요한 경우 최소 구현, repository/persistence focused test 통과, 전체 검증 기록 누적. +- **범위 밖:** 점수 가중치·집계 기간·스냅샷 저장 수 변경, 랜덤 정책 변경, 다른 추천 섹션 쿼리 수정. + +- [x] **RED:** 실제 데뷔 이력이 있지만 후원·팬Talk가 모두 0인 후보, 미래 데뷔 이력만 있는 후보, 비활성 크리에이터가 제외되는 테스트를 추가한다. +- [x] **RED:** 17개 이상의 점수 후보에서 점수 내림차순 상위 16개만 반환되는 repository 테스트와, 저장된 동점 스냅샷이 `randomTieBreaker` 오름차순으로 조회되는 `CHEER_CREATOR` persistence 테스트를 추가한다. +- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 해당 조건·정렬 경로를 최소 수정하고, 테스트 누락뿐이면 프로덕션 코드는 변경하지 않는다. +- [x] **REFACTOR/GATE:** `DefaultHomeRecommendationQueryRepositoryTest`와 `RecommendationSnapshotPersistenceAdapterTest` focused test 및 `git diff --check`를 실행해 결과를 누적한다. + +#### Task R3.2 종료된 라이브 데뷔 이력 복구 + +**Goal 실행 `P3-R2`:** `REV-P3-002`에서 확인한 `CHEER_CREATOR` 데뷔일 계산이 채널명이 있는 종료 라이브를 제외하는 문제를 수정하고 회귀를 방지한다. + +- **시작 조건:** `reviews/phase-3-review.md`의 `REV-P3-002` 확정과 `P1-R2` 완료. +- **완료 증거:** 종료 라이브 재현 테스트의 의도한 실패, `CHEER_CREATOR` 데뷔 CTE 최소 수정, repository focused test·`ktlintCheck`·`git diff --check` 통과, 전체 검증 기록 누적. +- **범위 밖:** 점수 가중치·집계 window·저장 수·정렬 변경, 다른 추천 섹션의 데뷔 정책 변경, 공개 API 변경. + +- [x] **RED:** 활성 콘텐츠는 없고 `channel_name`이 있는 종료 라이브와 최근 7일 응원 활동만 있는 활성 크리에이터가 `CHEER_CREATOR` 후보에 포함되는 실패 테스트를 추가한다. 빈 `channel_name`의 종료 라이브는 데뷔 이력으로 인정하지 않는 경계를 함께 유지한다. +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 현재 `lr.is_active = true` 조건 때문에 종료 라이브 후보가 누락되는 assertion 실패를 확인한다. +- [x] **GREEN:** `findCheerCreatorSnapshots(...)`의 `creator_debut` 라이브 branch에서 `lr.is_active = true`만 제거하고, `channel_name is not null`, `channel_name <> ''`, `begin_date_time <= :snapshotAt` 조건은 유지한다. +- [x] **REFACTOR/GATE:** repository focused test, `./gradlew ktlintCheck`, `git diff --check`를 실행하고 점수·window·후보 상한·다른 섹션 쿼리가 변경되지 않았음을 기록한다. + --- ### Phase 4: refresh 저장과 empty marker @@ -178,6 +246,19 @@ - REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다. - 기대 결과: 운영에서 `CHEER_CREATOR` refresh 결과 0건과 실패를 구분할 수 있다. +#### Task R4.1 `CHEER_CREATOR` refresh 실패 로그 보강 + +**Goal 실행 `P4-R1`:** `REV-P4-001`에서 확인한 섹션별 refresh 실패 로그 누락을 보완해 성공·빈 결과·실패를 운영 로그로 구분한다. + +- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-001` 확정. +- **완료 증거:** 실패 로그 RED 테스트, 최소 로그 구현, refresh focused test 통과, 전체 검증 기록 누적. +- **범위 밖:** fallback 실패 로그 형식 변경, 로그 수집 인프라·메트릭 시스템 추가, refresh 트랜잭션 정책 변경. + +- [x] **RED:** `refreshCheerCreatorSnapshots(nowUtc)`의 query 또는 저장 실패 시 `event=cheer_creator_recommendation_snapshot_refresh_failure`, window·`snapshotAt`, 오류 정보가 기록되고 예외는 기존처럼 전파되는 테스트를 추가한다. +- [x] **GREEN:** 성공 경로를 변경하지 않는 최소 `runCatching` 또는 `try/catch` 로그를 추가한 뒤 원래 예외를 다시 던진다. +- [x] **REFACTOR/GATE:** `RecommendationSnapshotRefreshServiceTest`와 `git diff --check`를 실행하고 중복 로그가 의도된 event key로 구분되는지 확인한다. +- [x] 무엇을/왜/어떻게 검증했는지 전체 검증 기록에 누적한다. + --- ### Phase 5: fallback refresh @@ -224,6 +305,19 @@ - REFACTOR: 스냅샷 후보 16개 조회, 상세 조회 후 최대 8개 반환, 차단 필터 전달은 기존 동작을 유지한다. - 기대 결과: 홈 통합 조회의 최근 응원이 많은 크리에이터 섹션이 스냅샷 없음 상황을 자체 복구할 수 있다. +#### Task R5.1 fallback single-flight·double-check 회귀 증거 보강 + +**Goal 실행 `P5-R1`:** `REV-P5-001`에서 누락이 확인된 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 결정적 테스트로 고정한다. + +- **시작 조건:** `reviews/phase-5-review.md`의 `REV-P5-001` 확정. +- **완료 증거:** 동시성·double-check RED 테스트 작성·확인, 필요한 경우 최소 구현, fallback focused test 통과, 전체 검증 기록 누적. +- **범위 밖:** worker 수 조정, timeout·lock 대기 값 변경, 다른 섹션 fallback 정책 확대. + +- [x] **RED:** 동일 JVM에서 동시에 들어온 둘 이상의 `CHEER_CREATOR` fallback 요청이 하나의 refresh future만 공유하고 `refreshCheerCreatorSnapshots(...)`를 1회만 호출하는 테스트를 latch 기반으로 추가한다. +- [x] **RED:** 최초 조회 뒤 lock 획득 전 다른 요청/스케줄러가 대상일 marker 또는 실제 row를 저장하면 lock 안의 double-check가 refresh를 생략하고 최신 상태를 다시 조회하는 테스트를 추가한다. +- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다. +- [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다. + --- ### Phase 6: API 회귀와 최종 검증 @@ -262,18 +356,104 @@ - REFACTOR: 문서와 코드의 산식/timeout/window 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다. - 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다. +#### Task R6.1 `cheerCreators` item 공개 스키마 회귀 테스트 보강 + +**Goal 실행 `P6-R1`:** `REV-P6-001`에서 확인한 빈 배열 중심 스키마 검증을 실제 item의 정확한 필드 계약 검증으로 보강한다. + +- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-001` 확정. +- **완료 증거:** 비어 있지 않은 item 직렬화/컨트롤러 테스트, 정확한 필드 집합 검증, API focused test 통과, 전체 검증 기록 누적. +- **범위 밖:** DTO 필드 추가·이름 변경, API URL 변경, 다른 홈 섹션 스키마 정리. + +- [x] **RED:** `cheerCreators`에 item을 넣고 `creatorId`, `creatorNickname`, `creatorProfileImage` 값과 필드 수 3을 검증하는 직렬화 테스트를 추가한다. +- [x] **RED:** 홈 API 테스트에서 비어 있지 않은 `cheerCreators` item의 동일 필드 계약을 검증하고 예상 밖 필드가 없음을 확인한다. +- [x] **GREEN:** 기존 DTO가 테스트를 만족하면 프로덕션 코드는 변경하지 않고, 계약 불일치가 드러날 때만 기존 공개 스키마로 최소 복구한다. +- [x] **REFACTOR/GATE:** `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`, `git diff --check`를 실행해 결과를 누적한다. + +--- + +### Phase 7: 본인·팔로우 크리에이터 노출 제외 + +**Phase 결과:** 인증 회원의 `cheerCreators`에서 조회자 본인과 활성 팔로우 크리에이터가 제외되고, 비활성 팔로우 이력과 비회원 조회는 기존 동작을 유지한다. + +**선행조건:** Phase 1~6 완료와 PRD Feature D의 2026-07-31 후속 요구사항 확정. + +**Phase 완료 조건:** `P7-T1`과 `P7-GATE` 완료, focused test·영향 범위 회귀·문서 검증 결과 누적. + +#### Task 7.1 `cheerCreators` 상세 조회 개인화 필터 보강 + +**Goal 실행 `P7-T1`:** 기존 `memberId` 기반 상세 조회에서 본인과 활성 팔로우 크리에이터만 제외하는 최소 조회 조건을 추가한다. + +- **시작 조건:** PRD Feature D의 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 16명 후보 안에서만 필터링한다는 결정 확정. +- **완료 증거:** 아래 체크박스 전체 완료, repository focused test 통과, 직접 영향 회귀 통과, 실행 결과를 이 문서의 전체 검증 기록에 누적. +- **범위 밖:** 스냅샷 산식·정렬·저장 수, 16명 밖 하위 후보 보충, 공개 DTO/API, 다른 추천 섹션의 팔로우 필터. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` +- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` +- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + +**Interfaces:** + +- Consumes: `HomeRecommendationQueryPort.findCheerCreatorRecommendationDetails(creatorIds: List, memberId: Long?): List` +- Produces: 같은 시그니처와 반환 타입을 유지하면서, `memberId != null`일 때만 본인과 활성 팔로우를 제외하는 상세 조회 계약. + +- [x] **RED:** `DefaultHomeRecommendationQueryRepositoryTest`에 조회자 크리에이터, 활성 팔로우 크리에이터, 비활성 팔로우 이력만 있는 크리에이터, 관계가 없는 크리에이터를 준비한다. `memberId = viewer.id`로 조회했을 때 비활성 팔로우 이력 크리에이터와 관계가 없는 크리에이터만 반환하는 `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails` 테스트를 작성한다. `memberId = null`은 모든 활성 후보를 유지하는 `shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters` 테스트도 작성한다. +- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 현재 조회가 조회자 본인과 활성 팔로우 크리에이터를 함께 반환하여 첫 번째 테스트가 의도한 assertion 실패를 내는지 확인한다. +- [x] **GREEN:** `findCheerCreatorRecommendationDetails(...)`에 `memberId`가 있을 때 `member.id != memberId`를 적용하고, 동일 회원과 후보 크리에이터 사이에 `CreatorFollowing.isActive == true`인 row가 존재하지 않는 조건을 추가한다. `memberId == null`이면 두 조건은 적용하지 않는다. +- [x] **GREEN 확인:** 같은 repository focused test를 재실행해 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 기존 양방향 차단 제외 테스트가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** 이번 조건에 필요한 QueryDSL alias/helper만 남기고, `HomeRecommendationQueryServiceTest`로 기존 16명 후보 조회·필터 후 최대 8명 조립이 유지되는지 확인한다. `HomeRecommendationControllerTest`와 `ktlintCheck`로 공개 스키마·포맷 회귀를 확인하고 실제 명령·결과를 전체 검증 기록에 남긴다. + +### Phase 7 완료 조건 + +- [x] `P7-T1`의 RED·GREEN·REFACTOR 체크박스와 완료 증거가 모두 충족됐다. +- [x] 인증 회원 본인·활성 팔로우만 제외되고 비활성 팔로우·비회원·양방향 차단·활성 크리에이터 정책이 조합되는 것이 검증됐다. +- [x] 스냅샷 산식·저장 수·정렬, 응답 DTO, 다른 추천 섹션에 변경이 없다. + +#### Phase 7 Gate + +**Goal 실행 `P7-GATE`:** `cheerCreators` 개인화 필터와 직접 영향 회귀를 최종 판정한다. + +- **시작 조건:** `P7-T1` 완료. +- **완료 증거:** 아래 명령 전부 통과, `git diff --check` 출력 없음, 전체 검증 기록 누적. +- **범위 밖:** 게이트 통과를 위한 테스트 삭제·완화와 관련 없는 리팩터링. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest +./gradlew ktlintCheck +git diff --check +``` + +**Expected:** 모든 명령이 exit code 0이고, 인증 회원 본인·활성 팔로우는 제외되며 비활성 팔로우·비회원 결과와 기존 API 스키마는 유지된다. + --- ## Coverage Check - Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다. - Feature B: Task 3.1, Task 3.3에서 최근 7일 KST 범위를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다. - Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다. -- Feature D: Task 3.5, Task 5.4, Task 6.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 기존 응답 스키마 유지를 검증한다. +- Feature D: Task 3.5, Task 5.4, Task 6.1, Task 7.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 기존 응답 스키마 유지를 검증한다. - Feature E: Task 5.1, Task 5.2, Task 5.3, Task 5.4에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다. - Feature F: Task 4.1, Task 4.2, Task 5.3에서 `CHEER_CREATOR` empty marker 저장, 조회 제외, 존재 여부 true, marker 기반 fallback 반복 방지를 검증한다. -- Non-Goals: Task 4.1, Task 6.1, Task 6.3에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다. +- Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다. ## 전체 검증 기록 +- 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-31: `P3-R2`로 `CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut`가 `AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다. +- 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-31: Phase 1~7 2차 리뷰로 PRD·plan-task·현재 코드·테스트·관련 구현 이력을 정적 대조했다. `REV-P1-002`의 상단 상태표 불일치와 `REV-P3-002`의 종료 라이브 데뷔 이력 제외를 확정해 각각 `P1-R2`, `P3-R2` 신규 회귀 Task로 전환했고, Phase 2·4·5·6·7은 추가 확정 발견 사항이 없다. 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 2차 리뷰로 각각 누적했다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했고, `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 후 승인된 동일 명령으로 재실행해 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-31: `P1-R1`, `P3-R1`, `P4-R1`, `P5-R1`, `P6-R1` 후속 보완의 최종 focused 회귀로 `./gradlew cleanTest test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 실행해 `BUILD SUCCESSFUL`로 통과했다. 이어서 `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 통과했다. 전체 `./gradlew test`는 테스트 assertion 실패가 아니라 `build/test-results/test/TEST-*.xml` 결과 파일 쓰기 실패로 중단되어 별도 환경/파일시스템 이슈 확인이 필요하다. +- 2026-07-31: `P1-R1`로 `prd.md`의 샘플 PRD 링크를 실제 파일 `docs/sample/sample-prd.md`로 정정했다. `test -f docs/sample/sample-prd.md`와 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`를 실행해 출력 없이 통과했다. +- 2026-07-31: `P3-R1`로 `CHEER_CREATOR` 후보 제외·상위 16개·동점 저장 정렬 회귀 테스트를 보강했다. 리뷰 판정처럼 구현 결함은 드러나지 않아 프로덕션 코드는 변경하지 않았다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다. +- 2026-07-31: `P4-R1`로 `refreshCheerCreatorSnapshots(nowUtc)` 실패 시 `event=cheer_creator_recommendation_snapshot_refresh_failure`와 window·`snapshotAt`·오류 정보를 남기고 원 예외를 전파하도록 보강했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest.shouldLogCheerCreatorRefreshFailureWithWindow`가 `AssertionFailedError`로 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다. +- 2026-07-31: `P5-R1`로 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 sleep 없이 latch 기반 테스트로 보강했다. 리뷰 판정처럼 구현 결함은 드러나지 않아 프로덕션 코드는 변경하지 않았다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다. +- 2026-07-31: `P6-R1`로 `cheerCreators` item 직렬화와 홈 API 응답의 `creatorId`, `creatorNickname`, `creatorProfileImage` 3개 필드 계약을 보강했다. 최초 controller focused test는 대상일 snapshot fixture 불일치와 테스트 환경 CDN host 기대값 불일치로 실패했고, fixture를 `RecommendationSnapshotWindowPolicy.previousKstDayUtcWindow(LocalDateTime.now(UTC)).snapshotAt` 및 실제 테스트 host 설정에 맞춘 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`가 `BUILD SUCCESSFUL`로 통과했다. 관련 `git diff --check`는 출력 없이 통과했다. +- 2026-07-31: Phase별 리뷰 문서와 회귀 Task 추가 후 `git diff --check`를 실행해 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패했고, 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`로 통과했다. compile/test task는 실행하지 않았다. +- 2026-07-31: Phase 1~7 구현 상태를 PRD·plan-task·현재 코드·테스트와 정적으로 대조했다. 사용자 지시에 따라 Gradle compile/test는 새로 실행하지 않았고 기존 검증 기록을 실행 증거로 참조했다. 확정 항목은 `reviews/phase-1-review.md`부터 `reviews/phase-7-review.md`까지 Phase별로 기록했으며, `REV-P1-001`, `REV-P3-001`, `REV-P4-001`, `REV-P5-001`, `REV-P6-001`을 각각 `P1-R1`, `P3-R1`, `P4-R1`, `P5-R1`, `P6-R1` 신규 회귀 Task로 전환했다. Phase 2와 Phase 7은 확정 발견 사항이 없다. +- 2026-07-31: Phase 7 `P7-T1` 구현으로 `findCheerCreatorRecommendationDetails(...)`가 인증 회원 조회 시 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외하도록 보강했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails` assertion 실패를 확인했고, GREEN 후 같은 명령은 `BUILD SUCCESSFUL`로 통과했다. 직접 영향 회귀 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`와 `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-31: 후속 요구사항으로 인증 회원 본인과 활성 팔로우 크리에이터를 `cheerCreators` 상세 조회에서 제외하는 정책을 PRD Feature D와 Phase 7 `P7-T1`/`P7-GATE`에 반영했다. 비활성 팔로우·비회원 유지와 16명 후보 밖 보충 없음을 경계로 고정했다. `git diff --check`는 출력 없이 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 권한으로 실패했고, 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-10: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다. - 2026-07-10: 문서 검증으로 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`를 실행해 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-10: 구현 RED 확인으로 `RecommendationScorePolicyTest`는 `CHEER_NEW_BOOST_*`와 `calculateCheerCreatorNewBoost(...)` 미구현 컴파일 실패를 확인했고, `DefaultHomeRecommendationQueryRepositoryTest`는 half-open/distinct 집계 기대값 불일치 실패를 확인했다. `RecommendationSnapshotPersistenceAdapterTest`는 `CHEER_CREATOR` empty marker 미지원 실패를 확인했다. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md index 1b2fb30b..56eddff9 100644 --- a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md @@ -1,7 +1,7 @@ # PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정 ## 1. Overview -메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. +메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. 인증 회원에게는 조회자 본인과 현재 활성 팔로우 중인 크리에이터를 `cheerCreators` 응답에서 제외한다. --- @@ -11,6 +11,7 @@ - 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다. - 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다. - 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다. +- 현재 `CHEER_CREATOR` 상세 조회는 활성 크리에이터와 양방향 차단 조건만 적용하여, 인증 회원 본인이나 이미 팔로우 중인 크리에이터가 추천에 노출될 수 있다. --- @@ -23,21 +24,24 @@ - 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다. - fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `CHEER_CREATOR` 섹션 빈 배열로 처리한다. - 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다. +- 인증 회원의 `cheerCreators` 상세 조회에서 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외한다. +- 비활성 팔로우 이력과 비회원 조회는 기존 조회 정책을 유지한다. --- ## 4. Non-Goals - 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다. - `CHEER_CREATOR` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다. -- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다. +- 관리자 화면, 수동 추천 편집, A/B 테스트, 사용자별 응원 점수·스냅샷 순서 산정은 이번 범위에 포함하지 않는다. - 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다. - 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다. +- 팔로우/본인 필터링으로 8명이 채워지지 않을 때 스냅샷 저장 수나 조회 후보를 16명 이상으로 늘리는 작업은 범위에 포함하지 않는다. --- ## 5. Target Users - 회원/비회원: 메인 홈 추천 탭에서 최근 7일 응원 반응이 많았던 크리에이터를 발견하는 사용자 -- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트 +- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 점수 순서와 인증 회원 조회 필터를 반영한 결과를 받는 클라이언트 - 운영자: 최근 7일 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자 --- @@ -46,6 +50,7 @@ - 사용자는 메인 홈 추천 탭에서 최근 7일 응원이 많았던 크리에이터를 우선 보고 싶다. - 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다. - 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다. +- 인증 회원은 자신과 이미 팔로우 중인 크리에이터를 제외한 새로운 응원 크리에이터를 보고 싶다. - 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다. - 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다. @@ -118,10 +123,15 @@ - 크리에이터 닉네임 - 크리에이터 프로필 이미지 - 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다. +- 인증 회원이 크리에이터인 경우 `creatorId == memberId`인 조회자 본인을 제외한다. +- 인증 회원과 크리에이터 사이의 `CreatorFollowing.isActive == true`인 팔로우 관계가 있으면 해당 크리에이터를 제외한다. +- 과거 언팔로우로 `CreatorFollowing.isActive == false`인 이력만 있는 크리에이터는 제외하지 않는다. +- 비회원은 본인과 팔로우 관계를 판정할 `memberId`가 없으므로 해당 필터를 적용하지 않는다. - 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다. #### Edge Cases - 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다. +- 본인과 활성 팔로우 크리에이터를 제외한 결과가 8명보다 적으면 16명 스냅샷 후보 범위 안에서 조회 가능한 수만 반환하고, 16명 밖의 하위 후보로 보충하지 않는다. - 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다. - 스냅샷 정렬 순서와 응답 순서는 일치해야 한다. @@ -175,6 +185,7 @@ - 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다. - 기존 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort` 기반 저장/조회 구조를 재사용한다. - 공개 API 응답 DTO는 필드 추가 없이 유지한다. +- 본인과 활성 팔로우 제외는 기존 `memberId`를 사용하는 `findCheerCreatorRecommendationDetails(...)` 상세 조회 경로에서 적용하고, 스냅샷 생성 산식과 저장 데이터는 변경하지 않는다. - 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다. - fallback refresh 기능은 AI 캐릭터 전용 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `CHEER_CREATOR`에 필요한 최소 추상화만 적용한다. - `CHEER_CREATOR` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다. @@ -208,6 +219,9 @@ - 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다. - 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다. - 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다. +- 인증 회원 본인과 활성 팔로우 중인 크리에이터는 `cheerCreators`에서 제외하고, 비활성 팔로우 이력은 제외 근거로 사용하지 않는다. +- 필터링 후 8명 미만이어도 기존 16명 스냅샷 후보 범위를 넘어서 보충하지 않는다. +- 비회원은 기존 `CHEER_CREATOR` 조회 결과를 유지한다. --- @@ -244,7 +258,7 @@ --- ## 13. Related Documents -- `docs/prd/sample-prd.md` +- `docs/sample/sample-prd.md` - `docs/agent-guides/작업절차.md` - `docs/agent-guides/문서유지보수.md` - `docs/20260529_메인_홈_추천_API/prd.md` diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-1-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-1-review.md new file mode 100644 index 00000000..9cbf1750 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-1-review.md @@ -0,0 +1,125 @@ +# Phase 1 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / Task 1.1 | +| 기준 commit 또는 working tree | `5123494e` 기준 working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md` | +| 리뷰 상태 | 보완 완료 | + +## 2. 리뷰 목적과 범위 + +- PRD와 구현 계획이 같은 작업 디렉터리에 있고 구현 기준을 충분히 고정했는지 확인한다. +- 문서 참조 경로와 완료 체크가 현재 저장소 근거와 일치하는지 확인한다. +- 코드·테스트 동작과 다른 Phase의 구현 품질은 제외한다. + +## 3. 판정 기준 + +- 심각도는 `docs/sample/sample-review.md`의 Blocker/High/Medium/Low 기준을 사용한다. +- 존재하지 않는 근거 문서 링크는 문서 정합성 문제인 Low로 판정한다. + +## 4. 검토한 근거 + +- 계획: Task 1.1 +- 문서: `prd.md`의 Related Documents, `plan-task.md`의 시나리오 계약·범위·Phase 분해 +- 정적 검증: `test -e docs/prd/sample-prd.md`, `test -e docs/sample/sample-prd.md`, `git diff --check` +- 문서 명령 검증: `./gradlew tasks --all`은 sandbox 권한 실패 후 승인된 동일 명령에서 `BUILD SUCCESSFUL` +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P1-001` | Low | 보완 완료 | PRD의 샘플 문서 링크가 실제 경로와 다르다 | Task R1.1 | `P1-R1` | + +## 6. 발견 사항 상세 + +### REV-P1-001 — PRD의 샘플 문서 링크가 실제 경로와 다르다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** 문서 유지보수 규칙의 샘플 PRD 참조 +- **소유 Task:** Task R1.1 + +**관찰 내용** + +`prd.md`는 `docs/prd/sample-prd.md`를 관련 문서로 가리키지만 해당 파일은 없고, 가이드가 지정한 실제 파일은 `docs/sample/sample-prd.md`다. + +**영향** + +후속 요구사항 보강 시 잘못된 템플릿 경로를 따라가 문서 작성 절차가 중단될 수 있다. 런타임 영향은 없다. + +**권장 조치** + +요구사항 내용은 바꾸지 않고 관련 문서 경로 한 곳만 실제 파일로 정정한다. + +**판정 기록** + +- 2026-07-31 — 두 경로의 파일 존재 여부를 정적으로 확인해 확정했다. +- 2026-07-31 — `prd.md` 링크를 `docs/sample/sample-prd.md`로 정정하고 `test -f docs/sample/sample-prd.md`, `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 출력 없음으로 보완 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +- `plan-task.md` Phase 1에 Task R1.1 / `P1-R1`을 추가했다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD·plan-task·가이드 대조 완료 | +| 후보 항목 판정 완료 | 충족 | `REV-P1-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task R1.1 | +| 검증 명령과 결과 기록 | 충족 | 파일 존재 확인과 diff 공백 검사 | + +**최종 결론:** 보완 완료 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +### 리뷰 정보·범위 + +- **기준:** `5123494e` 기준 미커밋 working tree, PRD·`plan-task.md`·문서 유지보수 가이드. +- **목적:** 후속 요구사항과 Phase 7 구현 이후 상단 작업 상태가 현재 근거와 일치하는지 점검한다. +- **검증:** `plan-task.md` 상태표·Phase 1~7·전체 검증 기록을 정적 대조했다. 사용자가 컨파일·테스트 통과 상태를 전제로 제공했으므로 Gradle compile/test는 실행하지 않았다. + +### 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P1-002` | Low | 보완 완료 | 상단 후속 변경 상태가 현재 계획·검증 상태와 다르다 | Task R1.2 | `P1-R2` | + +#### REV-P1-002 — 상단 후속 변경 상태가 현재 근거와 다르다 + +- **심각도:** Low +- **상태:** 보완 완료 +- **관련 요구사항:** 문서 유지보수 규칙의 현재 상태·활성/다음 Goal 기록 +- **근거:** `plan-task.md:7-12`는 요구사항 기준을 Feature D로만 표시하고 Phase 7을 현재 Phase에서 누락하며, 다음 Goal로 이전 XML 파일 쓰기 실패 조사를 유지한다. 반면 문서 본문은 Feature A~F와 Phase 7 완료를 기록하고, 사용자는 현재 컨파일·테스트가 통과했다고 명시했다. +- **영향:** 다음 작업자가 이미 종료된 환경 이슈를 다음 Goal로 오인하거나 전체 요구사항 범위를 Feature D로 잘못 판단할 수 있다. 런타임 영향은 없다. +- **권장 조치:** 상태표만 현재 PRD 전체, Phase 1~7, 미완료 후속 Goal 기준으로 정리한다. +- **판정 기록:** 2026-07-31 — 상태표와 Phase 7·전체 검증 기록을 정적 대조해 확정했다. +- **보완 기록:** 2026-07-31 — `plan-task.md` 상단 상태표를 PRD 전체, Phase 1~7 후속 보완 상태, 다음 Goal `P3-R2` 기준으로 정리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다. + +### plan·goal 전환과 종료 판정 + +- `plan-task.md` Phase 1에 Task R1.2 / `P1-R2`를 추가했다. +- **최종 결론:** 보완 완료. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 1 / Task 1.1·Task R1.1~R1.2, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `prd.md`, `plan-task.md`의 상태표·시나리오 계약·Phase 1~7·전체 검증 기록, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`. +- **검증 방법:** PRD·plan-task 동시 존재, 실제 샘플 문서 경로, 완료 Task와 현재 상태·활성/다음 Goal의 일치 여부를 정적으로 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. 상단 상태만 3차 리뷰 완료로 갱신했다. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-2-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-2-review.md new file mode 100644 index 00000000..975ffcb0 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-2-review.md @@ -0,0 +1,79 @@ +# Phase 2 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 2 / Task 2.1~2.2 | +| 기준 commit 또는 working tree | 구현 commit `c9e35f2e`, 현재 `5123494e` 기준 working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | PRD Feature A·C, `plan-task.md` Phase 2 | +| 리뷰 상태 | 판정 완료 | + +## 2. 리뷰 목적과 범위 + +- 응원 점수 가중치와 전용 신규 부스트가 PRD 값 및 경계일 계약과 일치하는지 확인한다. +- 기존 크리에이터·AI·커뮤니티 점수 정책 값의 회귀 여부를 정적으로 확인한다. + +## 3. 판정 기준 + +- 확정 가중치 `0.45/0.30/0.10`, 부스트 `1.15/1.10/1.05/1.0`, 경계일 `0·10/11·20/21·30/31`을 기준으로 판정한다. + +## 4. 검토한 근거 + +- 코드: `RecommendationScoreSpec.kt`, `RecommendationScorePolicy.kt` +- 테스트: `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays` +- 이력: `git show c9e35f2e` +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았으며 plan-task의 기존 통과 기록을 참조했다. + +## 5. 발견 사항 요약 + +확정 발견 사항 없음. + +## 6. 발견 사항 상세 + +- 점수 함수와 DB가 공유하는 상수 값이 PRD와 일치한다. +- 0일, 10/11일, 20/21일, 30/31일 단위 테스트가 모두 존재한다. +- 기존 `calculateCreatorNewBoost(...)` 값은 `1.5/1.3/1.2`로 유지된다. + +## 7. 확정 항목의 plan·goal 전환 + +전환 항목 없음. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | 코드·테스트·구현 이력 대조 완료 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검토 및 기존 검증 기록 참조 | + +**최종 결론:** 확정 발견 사항 없음 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 2 / Task 2.1~2.2, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationScoreSpec.kt:16-18,29-31`, `RecommendationScorePolicy.kt:21-29,67-78`, `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`. +- **검증 방법:** PRD의 `0.45/0.30/0.10`, `1.15/1.10/1.05/1.0`, 10/20/30일 경계를 코드·테스트와 정적 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 2 / Task 2.1~2.2, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationScoreSpec.kt:16-18,29-31`, `RecommendationScorePolicy.kt:21-29,67-78`, `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`. +- **검증 방법:** 점수 가중치 `0.45/0.30/0.10`, 부스트 `1.15/1.10/1.05/1.0`, 0·10/11·20/21·30/31일 경계와 기존 크리에이터 부스트 유지 여부를 코드·테스트로 정적 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-3-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-3-review.md new file mode 100644 index 00000000..eedb0425 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-3-review.md @@ -0,0 +1,130 @@ +# Phase 3 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 3 / Task 3.1~3.5 | +| 기준 commit 또는 working tree | 구현 commit `4f348c36`, 보정 commit `6da2378b`, 현재 `5123494e` 기준 working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | PRD Feature A~C, `plan-task.md` Phase 3 | +| 리뷰 상태 | 보완 완료 | + +## 2. 리뷰 목적과 범위 + +- 최근 7일 UTC half-open window, 후원·팬Talk 집계, DB-side 점수·부스트, 후보·정렬·limit 구현을 확인한다. +- 완료 처리된 Task의 테스트 증거가 명시된 경계 조건을 직접 고정하는지 확인한다. + +## 3. 판정 기준 + +- 구현 결함과 완료 증거 누락을 구분한다. 이번 발견은 현재 코드 동작 위반이 아니라 회귀 테스트 근거 누락으로 Low다. + +## 4. 검토한 근거 + +- 코드: `RecommendationSnapshotRefreshService.kt`, `RecommendationSnapshotWindowPolicy.kt`, `DefaultHomeRecommendationQueryRepository.kt` +- 테스트: `RecommendationSnapshotRefreshServiceTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotPersistenceAdapterTest` +- 이력: `git show 4f348c36`, `git show 6da2378b` +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P3-001` | Low | 보완 완료 | Task 3.5의 일부 후보·상위 16개·동점 정렬 완료 증거가 직접 테스트로 고정되지 않았다 | Task R3.1 | `P3-R1` | + +## 6. 발견 사항 상세 + +### REV-P3-001 — Task 3.5의 일부 완료 증거가 직접 테스트로 고정되지 않았다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** Feature A·C Edge Cases, Task 3.5 +- **소유 Task:** Task R3.1 + +**관찰 내용** + +현재 테스트는 donation 종류·상태·half-open 경계, distinct 후원 수, 점수/부스트, 데뷔 이력 없음과 `limit = 1` 점수 우선순위를 검증한다. 그러나 Task 3.5에 명시된 다음 계약의 직접 테스트는 확인되지 않았다. + +- 실제 데뷔 이력은 있으나 후원·팬Talk가 모두 0인 후보 제외 +- 미래 데뷔 이력만 있는 후보와 비활성 크리에이터 제외 +- 17개 이상 후보의 상위 16개 제한 +- `CHEER_CREATOR` 저장 row의 동점 `randomTieBreaker` 오름차순 조회 + +코드는 해당 조건을 구현하고 있어 현재 런타임 결함으로 판정하지 않는다. + +**영향** + +후속 native SQL 수정에서 후보 조건·저장 수·동점 순서가 회귀해도 focused test가 이를 직접 잡지 못할 수 있다. + +**권장 조치** + +프로덕션 코드를 선제 변경하지 않고 누락된 경계 테스트를 먼저 추가하며, 실제 실패가 드러나는 조건만 최소 수정한다. + +**판정 기록** + +- 2026-07-31 — 테스트명·fixture·assertion과 Task 3.5 체크리스트를 대조해 확정했다. +- 2026-07-31 — 후보 제외, 상위 16개 제한, `CHEER_CREATOR` 동점 `randomTieBreaker` 오름차순 조회 테스트를 추가했다. focused repository/persistence test는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +- `plan-task.md` Phase 3에 Task R3.1 / `P3-R1`을 추가했다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | query·window·정책·테스트 대조 완료 | +| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task R3.1 | +| 검증 명령과 결과 기록 | 충족 | 정적 검색·구현 이력 확인 | + +**최종 결론:** 보완 완료 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +### 리뷰 정보·범위 + +- **기준:** `5123494e` 기준 미커밋 working tree, PRD Feature A~C, `plan-task.md` Phase 3, 선행 홈 추천 PRD Feature E. +- **목적:** 최종 SQL의 7일 window·집계·점수·데뷔일·후보 조건을 현재 요구사항과 다시 대조한다. +- **검증:** `findCheerCreatorSnapshots(...)` SQL과 관련 repository 테스트, `075ca88f` 종료 라이브 데뷔 판정 보강 이력을 정적 추적했다. Gradle compile/test는 실행하지 않았다. + +### 발견 사항 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P3-002` | High | 보완 완료 | 종료된 라이브가 `CHEER_CREATOR` 데뷔 이력에서 제외된다 | Task R3.2 | `P3-R2` | + +#### REV-P3-002 — 종료된 라이브가 `CHEER_CREATOR` 데뷔 이력에서 제외된다 + +- **심각도:** High +- **상태:** 보완 완료 +- **관련 요구사항:** PRD Feature C, Task 3.4 +- **근거:** 현재 PRD는 데뷔일을 선행 홈 추천 PRD와 동일하게 계산하도록 한다(`prd.md:102-109`). 선행 PRD는 `channel_name`이 있는 종료 라이브도 데뷔 이력으로 인정하고 `live_room.is_active`를 조건으로 사용하지 않도록 확정한다(`docs/20260529_메인_홈_추천_API/prd.md:157-159`). 그러나 `DefaultHomeRecommendationQueryRepository.kt:589-594`의 `CHEER_CREATOR` 데뷔 CTE는 `lr.is_active = true`를 요구한다. 관련 스냅샷 테스트는 빈 채널명과 활성 라이브만 검증하고 종료 라이브 경계를 고정하지 않는다. +- **재현 경로:** 활성 콘텐츠는 없고 채널명이 있는 `is_active = false` 종료 라이브와 최근 7일 응원 활동만 있는 활성 크리에이터를 준비한다. 현재 SQL에서는 `creator_debut` row가 생성되지 않아 후보에서 제외된다. +- **영향:** 정상적으로 라이브를 종료한 크리에이터가 응원 점수가 있어도 스냅샷 후보에서 누락되고, 더 늦은 활성 콘텐츠가 있으면 실제 최초 데뷔일보다 높은 신규 부스트를 받을 수 있다. +- **권장 조치:** `CHEER_CREATOR` CTE의 라이브 branch에서 `lr.is_active = true`만 제거하고, 종료 라이브·빈 채널명 경계를 repository 회귀 테스트로 고정한다. +- **판정 기록:** 2026-07-31 — 요구사항·SQL·선행 보강 commit·테스트 누락을 정적 대조해 확정했다. +- **보완 기록:** 2026-07-31 — 채널명이 있는 종료 라이브와 최근 7일 응원 활동만 있는 크리에이터가 `CHEER_CREATOR` 후보에 포함되는 RED 테스트를 추가했고, 빈 `channel_name` 종료 라이브 제외 경계를 함께 고정했다. RED 확인에서 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut`가 `AssertionFailedError`로 실패했고, `findCheerCreatorSnapshots(...)` 라이브 branch의 `lr.is_active = true`만 제거한 뒤 repository focused test가 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다. + +### plan·goal 전환과 종료 판정 + +- `plan-task.md` Phase 3에 Task R3.2 / `P3-R2`를 추가했다. +- **최종 결론:** 보완 완료. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 3 / Task 3.1~3.5·Task R3.1~R3.2, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationSnapshotWindowPolicy.kt:7-34`, `DefaultHomeRecommendationQueryRepository.kt:573-647,1185-1227`, `RecommendationSnapshotRefreshService.kt:91-127`, 산식·half-open 경계·후원 distinct·후보 상한·종료 라이브·동점 정렬 repository/persistence 테스트. +- **검증 방법:** 최근 7일 UTC half-open window, `CHANNEL_DONATION`·`DONATION`, `UseCanCalculate.useCan` distinct 후원 수, active 팬Talk, DB-side 점수·부스트, 미래/데뷔 없음/비활성 후보 제외, 종료 라이브 데뷔 이력, 점수순 상위 16개와 저장 정렬을 정적으로 추적했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-4-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-4-review.md new file mode 100644 index 00000000..abe3931d --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-4-review.md @@ -0,0 +1,101 @@ +# Phase 4 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 4 / Task 4.1~4.3 | +| 기준 commit 또는 working tree | 구현 commit `64b05dee`, `ce43cf2c`, 현재 `5123494e` 기준 working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | PRD Feature F·Metrics, `plan-task.md` Phase 4 | +| 리뷰 상태 | 보완 완료 | + +## 2. 리뷰 목적과 범위 + +- empty marker 저장·조회 제외·존재 판정·실제 row 대체를 확인한다. +- `CHEER_CREATOR` refresh 성공·실패 관측성 완료 여부를 확인한다. + +## 3. 판정 기준 + +- 응답/데이터 무결성 문제와 운영 관측성 누락을 구분한다. 섹션 실패 로그 누락은 Low다. + +## 4. 검토한 근거 + +- 코드: `RecommendationSnapshotPersistenceAdapter.kt`, `RecommendationSnapshotRepository.kt`, `RecommendationSnapshotRefreshService.kt` +- 테스트: `RecommendationSnapshotPersistenceAdapterTest`, `RecommendationSnapshotRefreshServiceTest` +- 정적 검색: `cheer_creator_recommendation_snapshot_refresh_success|failure` +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P4-001` | Low | 보완 완료 | `CHEER_CREATOR` 섹션별 refresh 실패 로그가 없다 | Task R4.1 | `P4-R1` | + +## 6. 발견 사항 상세 + +### REV-P4-001 — `CHEER_CREATOR` 섹션별 refresh 실패 로그가 없다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** PRD Metrics, Task 4.3 GREEN·기대 결과 +- **소유 Task:** Task R4.1 + +**관찰 내용** + +`refreshCheerCreatorSnapshots(...)`는 커밋 후 `event=cheer_creator_recommendation_snapshot_refresh_success`를 남긴다. 반면 query 또는 저장 실패를 같은 섹션 event로 기록하는 코드는 없고, 일괄 refresh의 공통 실패 로그 또는 fallback 공통 실패 로그만 남는다. + +**영향** + +운영에서 스케줄러·fallback 중 어느 경로에서 `CHEER_CREATOR` 생성이 실패했는지 섹션 event만으로 일관되게 집계하기 어렵다. 저장/응답 동작 자체의 결함은 확인되지 않았다. + +**권장 조치** + +성공 경로와 예외 전파를 유지하면서 섹션 실패 event와 window·오류 정보를 최소 추가한다. + +**판정 기록** + +- 2026-07-31 — 코드와 테스트 전체에서 섹션 실패 event가 없음을 정적 검색해 확정했다. +- 2026-07-31 — 실패 로그 RED 테스트를 추가해 `AssertionFailedError`를 확인한 뒤 `refreshCheerCreatorSnapshots`에 최소 실패 로그와 예외 재전파를 추가했다. `RecommendationSnapshotRefreshServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +- `plan-task.md` Phase 4에 Task R4.1 / `P4-R1`을 추가했다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | marker·로그 경로 확인 | +| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task R4.1 | +| 검증 명령과 결과 기록 | 충족 | 정적 검색과 코드 대조 | + +**최종 결론:** 보완 완료 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 4 / Task 4.1~4.3·Task R4.1, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationSnapshotPersistenceAdapter.kt:38-50,73-96`, `RecommendationSnapshotRepository.kt:10-55`, `RecommendationSnapshotRefreshService.kt:91-127`, marker 대체·조회 제외·존재 판정·성공/실패 로그 테스트. +- **검증 방법:** marker가 응답 조회에서 제외되면서 대상일 refresh 존재 판정에는 포함되는지, 실제 row 재실행이 marker를 대체하는지, 성공·실패 event가 분리되는지를 정적 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 4 / Task 4.1~4.3·Task R4.1, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationSnapshotPersistenceAdapter.kt:38-50,73-96`, `RecommendationSnapshotRepository.kt:10-55`, `RecommendationSnapshotRefreshService.kt:91-127`, marker 저장·조회 제외·존재 판정·실제 row 대체·성공/실패 로그 테스트. +- **검증 방법:** `CHEER_CREATOR` 빈 결과가 `targetId = 0` marker로 저장되고 응답 조회에서는 제외되는지, 대상일 존재 판정과 실제 row 재실행 대체가 유지되는지, 성공·실패 event가 구분되고 원 예외가 전파되는지 정적으로 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md new file mode 100644 index 00000000..48996e24 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md @@ -0,0 +1,104 @@ +# Phase 5 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 5 / Task 5.1~5.4 | +| 기준 commit 또는 working tree | 구현 commit `391acf9e`, `7d0cf0a8`, 현재 `5123494e` 기준 working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | PRD Feature E·F, `plan-task.md` Phase 5 | +| 리뷰 상태 | 보완 완료 | + +## 2. 리뷰 목적과 범위 + +- 대상일 조회, section lock, lock 내부 double-check, single-flight, timeout·실패 격리, refresh 후 재조회 흐름을 확인한다. +- 구현된 동시성 계약의 직접 회귀 테스트가 존재하는지 확인한다. + +## 3. 판정 기준 + +- 구현에는 해당 경로가 존재하지만 직접 테스트가 없는 경우 완료 증거 누락인 Low로 판정한다. + +## 4. 검토한 근거 + +- 코드: `RecommendationSnapshotFallbackService.kt`, `HomeRecommendationQueryService.kt` +- 테스트: `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest` +- 정적 검색: `single|동시|refreshFutures|double-check`와 latch 기반 테스트 +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` | + +## 6. 발견 사항 상세 + +### REV-P5-001 — single-flight와 double-check의 직접 회귀 테스트가 없다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** Feature E, Task 5.1·5.3 +- **소유 Task:** Task R5.1 + +**관찰 내용** + +구현은 section별 `refreshFutures`와 lock 획득 후 `hasSnapshot(...)` 재확인을 수행한다. 현재 테스트는 lock miss, refresh 실패, timeout 후 worker 지속, marker 선존재, AI 작업 중 CHEER 독립 실행을 검증하지만 다음 경쟁 조건을 직접 재현하지 않는다. + +- 동일 섹션의 동시 요청이 실제 refresh 1회만 공유하는지 +- 최초 조회 뒤 lock 진입 전에 다른 실행 주체가 row/marker를 저장했을 때 refresh를 생략하는지 + +**영향** + +향후 executor·future·lock 코드 변경에서 중복 refresh 방지의 핵심 경쟁 조건이 깨져도 focused test가 탐지하지 못할 수 있다. + +**권장 조치** + +sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하고, 실패가 확인될 때만 프로덕션 코드를 최소 수정한다. + +**판정 기록** + +- 2026-07-31 — fallback 테스트 전체와 구현의 동시성 분기를 대조해 확정했다. +- 2026-07-31 — 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 latch 기반 테스트로 추가했다. 현 구현이 테스트를 만족해 프로덕션 코드는 변경하지 않았고, `RecommendationSnapshotFallbackServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +- `plan-task.md` Phase 5에 Task R5.1 / `P5-R1`을 추가했다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | fallback 코드·테스트 분기 대조 완료 | +| 후보 항목 판정 완료 | 충족 | `REV-P5-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task R5.1 | +| 검증 명령과 결과 기록 | 충족 | 정적 검색과 기존 기록 참조 | + +**최종 결론:** 보완 완료 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 5 / Task 5.1~5.4·Task R5.1, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationSnapshotFallbackService.kt:81-205`, `HomeRecommendationQueryService.kt:112-136`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`. +- **검증 방법:** 대상일 exact snapshot 조회, marker 존재 판정, section lock·double-check·single-flight, 300ms/1,500ms, timeout 후 worker 유지, refresh 실패 격리, 16명 후보 재조회를 코드·테스트와 정적 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 5 / Task 5.1~5.4·Task R5.1, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `RecommendationSnapshotFallbackService.kt:28-245`, `HomeRecommendationQueryService.kt:112-136`, fallback service와 query service의 대상일·marker·lock miss·timeout·실패·동시성 테스트. +- **검증 방법:** 대상일 exact snapshot, section별 lock key, 300ms lock 대기, 1,500ms 홈 대기, lock 내부 double-check, section별 single-flight, timeout 후 worker 유지, 실패 격리, refresh 후 재조회와 16명 후보 전달을 정적으로 추적했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-6-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-6-review.md new file mode 100644 index 00000000..0e569551 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-6-review.md @@ -0,0 +1,101 @@ +# Phase 6 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 6 / Task 6.1~6.3 | +| 기준 commit 또는 working tree | 현재 `5123494e` 기준 working tree와 plan-task 기존 검증 기록 | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | PRD Non-Goals·Feature D, `plan-task.md` Phase 6 | +| 리뷰 상태 | 보완 완료 | + +## 2. 리뷰 목적과 범위 + +- 홈 API URL과 `cheerCreators` item의 공개 필드가 유지되는지 확인한다. +- 완료 처리된 API 스키마 회귀 테스트가 비어 있지 않은 item 계약을 직접 고정하는지 확인한다. + +## 3. 판정 기준 + +- DTO 구현은 맞지만 회귀 테스트가 필드 계약을 검출하지 못하는 경우 완료 증거 누락인 Low로 판정한다. + +## 4. 검토한 근거 + +- 코드: `HomeRecommendationResponse.kt`, `HomeRecommendationFacade.kt`, `HomeRecommendationQueryPort.kt` +- 테스트: `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest` +- 기존 실행 증거: plan-task의 2026-07-10, 2026-07-31 검증 기록 +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P6-001` | Low | 보완 완료 | `cheerCreators` item의 정확한 3개 필드 계약이 테스트로 고정되지 않았다 | Task R6.1 | `P6-R1` | + +## 6. 발견 사항 상세 + +### REV-P6-001 — `cheerCreators` item의 정확한 3개 필드 계약이 테스트로 고정되지 않았다 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** Feature D, Non-Goals, Task 6.1 +- **소유 Task:** Task R6.1 + +**관찰 내용** + +응답 DTO는 기존 `HomeCreatorItem`을 사용해 `creatorId`, `creatorNickname`, `creatorProfileImage`를 유지한다. 그러나 `HomeRecommendationResponseTest`는 `cheerCreators = emptyList()`로 직렬화하고, controller 테스트는 배열 존재만 확인한다. 따라서 item 필드가 추가·삭제·개명되어도 현재 두 assertion은 통과할 수 있다. + +**영향** + +향후 DTO 변경에서 공개 API 스키마 회귀가 focused test를 빠져나갈 수 있다. 현재 DTO 자체의 계약 위반은 확인되지 않았다. + +**권장 조치** + +비어 있지 않은 item을 사용해 값과 정확한 필드 수 3을 직렬화 및 controller 계층에서 고정한다. + +**판정 기록** + +- 2026-07-31 — 관련 테스트 fixture와 JSON assertion을 대조해 확정했다. +- 2026-07-31 — `cheerCreators` item 직렬화와 홈 API 응답의 정확한 3개 필드 계약을 테스트로 추가했다. focused API test는 fixture 보정 후 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +- `plan-task.md` Phase 6에 Task R6.1 / `P6-R1`을 추가했다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | DTO·facade·API 테스트 대조 완료 | +| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 | +| 확정 항목 plan 반영 | 충족 | Task R6.1 | +| 검증 명령과 결과 기록 | 충족 | 정적 검토와 기존 실행 기록 참조 | + +**최종 결론:** 보완 완료 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 6 / Task 6.1~6.3·Task R6.1, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `HomeRecommendationResponseTest.kt:61-67,117-123`, `HomeRecommendationControllerTest.kt:529-549`, `HomeRecommendationFacade.kt`, `HomeRecommendationQueryService.kt`. +- **검증 방법:** `cheerCreators` 실제 item의 `creatorId`, `creatorNickname`, `creatorProfileImage` 값과 정확한 3개 필드 계약, 기존 API URL·DTO 유지, 대상일 snapshot fixture를 정적 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 6 / Task 6.1~6.3·Task R6.1, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `HomeRecommendationController.kt`, `HomeRecommendationResponse.kt:20-51`, `HomeRecommendationFacade.kt:50-95,294-298`, `HomeRecommendationControllerTest.shouldKeepCheerCreatorItemSchemaOnHomeRecommendations`, `HomeRecommendationResponseTest`의 정확한 필드 집합 assertion. +- **검증 방법:** 기존 `GET /api/v2/home/recommendations` URL과 `cheerCreators` item의 `creatorId`, `creatorNickname`, `creatorProfileImage` 3개 필드만 유지되는지, 대상일 스냅샷 fixture가 실제 조회 경로와 일치하는지 정적으로 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-7-review.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-7-review.md new file mode 100644 index 00000000..7c1e7251 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-7-review.md @@ -0,0 +1,82 @@ +# Phase 7 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 7 / Task 7.1 / `P7-GATE` | +| 기준 commit 또는 working tree | `5123494e` 기준 미커밋 working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | PRD Feature D, `plan-task.md` Phase 7 | +| 리뷰 상태 | 판정 완료 | + +## 2. 리뷰 목적과 범위 + +- 인증 회원 본인과 활성 팔로우 크리에이터만 제외되는지 확인한다. +- 비활성 팔로우 이력, 비회원, 기존 양방향 차단, 스냅샷 순서·후보 16개/응답 8개 정책이 유지되는지 확인한다. + +## 3. 판정 기준 + +- 공개 API나 스냅샷 산식을 변경하지 않고 상세 조회의 `memberId != null` 조건에서만 개인화 필터를 적용해야 한다. + +## 4. 검토한 근거 + +- 변경 diff: `DefaultHomeRecommendationQueryRepository.kt`, `DefaultHomeRecommendationQueryRepositoryTest.kt`, PRD, plan-task +- 코드: `notViewerCondition(...)`, `notActiveFollowedCreatorCondition(...)`, 기존 `notBlockedCreatorCondition(...)` +- 테스트: `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails`, `shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters`, 기존 양방향 차단 테스트 +- 정적 검증: `git diff --check`, `git diff --name-status` +- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았으며 plan-task의 기존 통과 기록을 참조했다. + +## 5. 발견 사항 요약 + +확정 발견 사항 없음. + +## 6. 발견 사항 상세 + +- `memberId == null`이면 신규 두 조건이 모두 생략된다. +- 인증 회원이면 `member.id != memberId`와 활성 `CreatorFollowing` row의 `not exists`가 적용된다. +- `isActive == false` 팔로우 이력은 제외 조건이 아니며 기존 차단 조건은 그대로 조합된다. +- 서비스는 기존처럼 스냅샷 순서로 상세를 재조립하고 최대 8개만 반환한다. +- 변경 범위는 PRD·plan-task·repository·repository test 네 파일로 한정되어 공개 DTO나 스냅샷 생성 경로를 수정하지 않았다. + +## 7. 확정 항목의 plan·goal 전환 + +전환 항목 없음. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | 요구사항·diff·테스트 정적 대조 완료 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 검증 명령과 결과 기록 | 충족 | diff 검사 및 기존 검증 기록 참조 | + +**최종 결론:** 확정 발견 사항 없음 + +**남은 항목:** 없음 + +--- + +## 9. 2차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 7 / Task 7.1·`P7-GATE`, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `DefaultHomeRecommendationQueryRepository.kt:804-828,1274-1304`, `HomeRecommendationQueryService.kt:112-120`, 본인·활성/비활성 팔로우·비회원·양방향 차단 repository 테스트. +- **검증 방법:** `memberId == null`의 필터 생략, 인증 회원의 본인 제외, `CreatorFollowing.isActive == true` 필터, 비활성 이력 유지, 기존 차단 조건 조합, snapshot 순서·최대 8명 재조립을 정적 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. + +--- + +## 10. 3차 리뷰 기록 — 2026-07-31 + +- **리뷰 대상:** Phase 7 / Task 7.1·`P7-GATE`, `5123494e` 기준 미커밋 working tree. +- **검토 근거:** `DefaultHomeRecommendationQueryRepository.kt:803-827,1273-1303`, `HomeRecommendationQueryService.kt:112-120`, 본인·활성/비활성 팔로우·비회원·양방향 차단 repository 테스트와 service의 memberId·순서·limit 테스트. +- **검증 방법:** `memberId == null` 필터 생략, 인증 회원 본인 제외, `CreatorFollowing.isActive == true`의 `not exists`, 비활성 이력 유지, 기존 양방향 차단, 16명 후보 안에서 스냅샷 순서대로 최대 8명 반환을 정적으로 대조했다. Gradle compile/test는 실행하지 않았다. +- **발견 사항:** 확정 발견 사항 없음. +- **plan·goal 전환:** 전환 항목 없음. +- **최종 결론:** 확정 발견 사항 없음. +- **남은 항목:** 없음. diff --git a/docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md b/docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md deleted file mode 100644 index 748f302b..00000000 --- a/docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md +++ /dev/null @@ -1,395 +0,0 @@ -# Frontend Original Work Management Add-on Prompt - -이 문서는 이미 구현 또는 구현 중인 AI 캐릭터 관리자 Frontend에 원작 관리 기능만 추가하기 위한 delta 프롬프트다. -기존 PRD 27.8의 stack, 인증·세션, 환경별 API Base URL, Jenkins, noindex, UTC/KST, feedback와 공통 API 규칙은 그대로 유지한다. - -## Copy-Paste Prompt - -```text -당신은 기존 AI 캐릭터 관리자 Frontend에 원작 관리 기능을 추가한다. - -[작업 원칙] -- 기존 기술 stack, package version, lockfile, VITE_API_BASE_URL mode 설정과 Jenkins 명령을 변경하지 않는다. -- .env.development와 .env.production은 같은 VITE_API_BASE_URL key에 서로 다른 실제 dev·production URL을 유지하고 source code에 URL을 하드코딩하지 않는다. -- Jenkins의 install·검증 명령은 기존 pnpm install --frozen-lockfile, pnpm run ci:prod를 그대로 사용한다. -- 기존 로그인, sessionStorage, fetch wrapper, ApiResponse envelope, error handling, noindex, UTC/KST와 Sonner 규칙을 재사용한다. -- 아래 실제 Request/Response JSON과 명시된 nullable JSON 값으로 TypeScript type, API 함수와 mock을 만든다. Backend DTO/data class의 존재나 이름을 전제하지 않는다. -- legacy /admin/chat/original/**와 /api/chat/original/**를 호출하지 않는다. -- 메뉴는 Backend에서 조회하지 않고 기존 typed static menu configuration에 추가한다. -- 새 원작 route에도 기존 접근 제어와 noindex 정책을 동일하게 적용하고 검색 노출 가능한 public route나 metadata를 만들지 않는다. -- 도메인별 화면을 범용 CRUD 설정 하나로 합치지 않는다. 두 화면 이상에서 실제 반복되는 UI와 동작만 component로 추출한다. - -[완료 목표] -- global 원작 목록·검색·상세·등록·수정·삭제 화면을 제공한다. -- 원작 상세에서 연결된 활성·비활성 캐릭터를 조회하고, 활성 AI 캐릭터를 배정하며 기존 연결을 해제할 수 있게 한다. -- 기존 캐릭터 등록·수정 form에서 원작을 검색·선택하거나 연결을 해제할 수 있게 한다. -- 아래 ORIGINAL-WORK-01~08을 기존 API client, query key, 화면과 test에 연결한다. -- 기존 58개 신규 관리자 Operation에 8개를 더한 66개 신규 관리자 Operation을 브라우저에서 사용할 수 있게 한다. - -[메뉴와 route] -- 기존 typed static menu에 다음 global item을 추가한다. - { key: "original-works", label: "원작 관리", to: "/ai-characters/original-works", scope: "GLOBAL" } -- 원작 관리는 character-scoped 메뉴가 아니며 진입 전에 캐릭터를 선택하지 않는다. -- route는 다음을 사용한다. - /ai-characters/original-works - /ai-characters/original-works/new - /ai-characters/original-works/:originalWorkId - /ai-characters/original-works/:originalWorkId/edit -- /ai-characters/original-works의 static segment가 /ai-characters/:characterId보다 우선 매칭되는지 route test로 고정한다. - -[Page와 Dialog 결정] -- 원작 목록, 상세, 등록, 수정은 Page로 만든다. 서버 페이징, URL 복원, 이미지와 다중 field form이 있으므로 Dialog로 만들지 않는다. -- 원작 상세의 캐릭터 배정은 검색·다중 선택 Dialog로 만든다. -- 연결 캐릭터 해제는 table row action 또는 table bulk action으로 제공하고 실행 전에 AlertDialog로 확인한다. -- 원작 삭제는 AlertDialog로 확인한다. characterCount가 0보다 크면 UI에서 이유를 표시하고 비활성화하되, race condition에 대한 Backend 409도 처리한다. -- 캐릭터 등록·수정 form의 원작 선택은 form 안의 searchable Combobox 또는 선택 Dialog로 구현한다. 별도 사전 선택 Page를 추가하지 않는다. - -[캐릭터 등록·수정의 원작 선택] -- ORIGINAL-WORK-01의 page, size, search를 사용해 isDeleted=false 원작을 검색한다. -- 캐릭터 생성 form의 선택값이 없으면 CHAR-03 request.originalWorkId에 null을 보낸다. -- 캐릭터 수정 form은 CHAR-02 response.originalWork의 id, title, imageUrl로 현재 선택을 복원한다. -- CHAR-02 response.originalWork 자체는 null일 수 있다. null이면 현재 원작 미선택 상태로 복원하고 ORIGINAL-WORK-02를 호출하지 않는다. -- 수정 form에서 선택을 지우면 CHAR-04 request.originalWorkId에 명시적 null을 보낸다. -- originalWorkId=0을 보내지 않는다. -- CHAR-04는 기존 계약대로 originalWorkId key 자체를 반드시 포함한다. -- 수정 진입 시 현재 선택된 원작이 첫 검색 page에 없어도 CHAR-02의 brief를 선택값으로 유지하고, 사용자가 검색 결과를 선택할 때만 교체한다. -- 현재 brief의 ORIGINAL-WORK-02가 404이면 legacy 데이터의 삭제·누락 원작 연결로 표시하고, 새 원작 선택 또는 명시적 해제 전에는 수정을 제출하지 않는다. - -[원작 form] -- 생성 기본값은 isAdult=false, description="", nullable string=null, originalLinks=[], tags=[]다. -- 수정 요청은 아래 11개 JSON key를 항상 모두 보낸다. - title, contentType, category, isAdult, description, originalWork, originalLink, - writer, studio, originalLinks, tags -- nullable string을 지울 때 null, 링크와 태그를 모두 지울 때 []를 보낸다. update key를 생략하지 않는다. -- title, contentType, category는 trim 후 필수다. -- originalLink와 originalLinks는 http/https 절대 URL만 허용한다. -- originalLinks와 tags는 trim하고 빈 값을 제거하며 첫 등장 순서를 유지해 중복을 제거한다. -- create의 image는 필수, update의 image는 선택이다. update에서 새 image를 선택하지 않으면 image part를 보내지 않는다. -- multipart request part에는 아래 Request JSON을 JSON.stringify한 문자열을 넣고 multipart 전체 Content-Type은 직접 지정하지 않는다. - -[재사용 component] -- OriginalWorkForm: create와 edit Page가 공유하되 create/update API 호출은 각 Page에 둔다. -- OriginalWorkSelector: 캐릭터 create/edit form이 공유한다. -- OriginalWorkSummaryCell: 원작 목록과 캐릭터 form의 선택 결과가 공유한다. -- AssignedCharacterTable: 원작 상세의 연결 캐릭터 목록과 bulk selection을 담당한다. -- CharacterAssignmentDialog: CHAR-01 검색과 ORIGINAL-WORK-07 호출을 담당한다. -- 기존 Pagination, DataTable, PageHeader, LoadingState, EmptyState, ErrorState, FormErrorSummary, Confirm AlertDialog를 재사용한다. -- 원작 전용 validation과 캐릭터 배정 규칙을 generic CRUD schema로 추상화하지 않는다. - -[Query key와 invalidation] -- 목록: ["original-works", { page, size, search }] -- 상세: ["original-work", originalWorkId] -- 연결 캐릭터: ["original-work", originalWorkId, "characters", { page, size, search, isActive }] -- 캐릭터 배정 후보는 기존 CHAR-01 global query를 isActive=true로 조회한다. -- ORIGINAL-WORK-03 성공 후 원작 목록을 invalidate한다. -- ORIGINAL-WORK-04 성공 후 해당 상세와 원작 목록을 invalidate하고, title/image brief가 바뀔 수 있으므로 ["ai-character"] prefix의 CHAR-02 상세 query도 invalidate한다. -- ORIGINAL-WORK-05 성공 후 해당 상세를 제거하고 목록으로 이동한 뒤 원작 목록을 invalidate한다. -- ORIGINAL-WORK-07/08 성공 후 원작 목록 전체와 ["original-work"] prefix의 상세·연결 캐릭터 query를 invalidate한다. 배정은 이전 원작에서 이동할 수 있으므로 새 원작 cache만 갱신하지 않는다. 응답 characterIds 각각의 CHAR-02 query도 invalidate한다. -- CHAR-03/04 성공 후 새 원작과 이전 원작이 있으면 해당 원작 상세·연결 캐릭터 목록·목록의 characterCount를 invalidate한다. -- CHAR-05 성공 후 삭제 캐릭터가 연결된 원작의 연결 캐릭터 목록과 characterCount를 invalidate한다. - -[배정·해제 UX] -- 배정 후보는 CHAR-01의 활성 AI 캐릭터만 표시한다. -- 배정 확인문에 “다른 원작에 연결된 캐릭터는 이 원작으로 이동합니다”를 명시한다. -- ORIGINAL-WORK-07은 선택한 characterIds 전체를 한 요청으로 전송한다. -- ORIGINAL-WORK-08은 DELETE method와 JSON body를 함께 사용하고 Content-Type: application/json을 명시한다. -- 배정·해제에서 일부 성공을 가정하지 않는다. 성공 Response 뒤에만 선택을 비우고 toast를 표시한다. -- 배정·해제 성공 Response의 characterIds는 실제 변경 여부와 관계없이 검증된 요청 ID 전체가 요청 순서대로 반환된다. characterCount는 처리 건수가 아니라 작업 후 해당 원작에 연결된 전체 캐릭터 수다. -- 연결 목록의 creatorId는 legacy 불일치 정리를 위해 null일 수 있다. null이면 캐릭터 row는 계속 표시하고 “연결 크리에이터 없음” 상태를 표시하며 해제 action을 숨기지 않는다. -- 400 errorProperty=characterIds는 Dialog의 selection error로 표시한다. -- 409 errorProperty=title은 trim·대소문자 무시 중복 제목 field error로 표시한다. -- 409 errorProperty=characterIds는 비활성 상태 변경 가능성을 설명하고 후보 목록을 refetch한다. -- 409 errorProperty=originalWorkId는 삭제된 원작 또는 연결 캐릭터가 남은 삭제 요청으로 처리하고 상세·연결 목록을 refetch한다. -- originalWorkId와 원작 route param은 양수만 전송한다. 0 이하는 client validation으로 차단하고, 404 originalWorkId는 누락·삭제된 조회 대상으로, mutation의 409 originalWorkId는 삭제 상태 또는 연결 존재 충돌로 구분해 처리한다. -- 이미 삭제가 성공한 원작의 DELETE 재시도는 같은 성공 Response가 올 수 있으므로 오류로 간주하지 않는다. - -[날짜와 feedback] -- createdAtUtc와 updatedAtUtc는 UTC Z 원문으로 cache·비교하고 기존 formatUtcInKst utility로 KST 표시한다. -- mutation 성공은 기존 Sonner success toast, field 오류는 inline, 최초 조회 실패는 ErrorState, 삭제·해제 사전 확인은 AlertDialog 규칙을 유지한다. -- characterCount에는 활성·비활성 및 연결 creator 상태와 관계없이 모든 연결 캐릭터가 포함되므로 UI에서 임의로 다시 계산하지 않는다. - -[응답 nullable JSON 값] -- 아래 값은 기존 Operation Response JSON의 해당 위치에 나타날 수 있는 유효 JSON이다. 예시의 non-null 값만 보고 required string/number로 좁히지 않는다. -- ORIGINAL-WORK-01 data.items[0]과 ORIGINAL-WORK-02 data의 imageUrl, createdAtUtc, updatedAtUtc는 null일 수 있다. -- ORIGINAL-WORK-02 data의 originalWork, originalLink, writer, studio는 null일 수 있다. -- ORIGINAL-WORK-06 data.items[0]의 creatorId, imageUrl, createdAtUtc는 null일 수 있다. -- CHAR-02 data.originalWork는 아래 null 또는 brief 객체다. brief의 imageUrl도 null일 수 있다. - -ORIGINAL-WORK-01 data.items[0] nullable JSON: -{ - "originalWorkId": 71, - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "imageUrl": null, - "characterCount": 2, - "createdAtUtc": null, - "updatedAtUtc": null -} - -ORIGINAL-WORK-02 data nullable JSON: -{ - "originalWorkId": 71, - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "", - "originalWork": null, - "originalLink": null, - "writer": null, - "studio": null, - "originalLinks": [], - "tags": [], - "imageUrl": null, - "characterCount": 0, - "createdAtUtc": null, - "updatedAtUtc": null -} - -ORIGINAL-WORK-06 data.items[0] nullable JSON: -{ - "characterId": 101, - "creatorId": null, - "name": "루나", - "imageUrl": null, - "isActive": false, - "createdAtUtc": null -} - -CHAR-02 data.originalWork nullable JSON: -null - -CHAR-02 data.originalWork brief nullable JSON: -{ - "id": 71, - "title": "달빛 도서관", - "imageUrl": null -} - -[Operation Catalog] -공통 성공 envelope key는 success, message, data, errorProperty다. -공통 page data key는 items, page, size, totalCount, hasNext다. -공통 오류 Response JSON: -{"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"originalWorkId"} -배정·해제 field 오류 Response JSON: -{"success":false,"message":"캐릭터 선택을 확인해 주세요.","data":null,"errorProperty":"characterIds"} -중복 제목 오류 Response JSON: -{"success":false,"message":"이미 사용 중인 원작 제목입니다.","data":null,"errorProperty":"title"} - -ORIGINAL-WORK-01 GET /admin/ai-characters/original-works - Path: 없음 - Query: page=0&size=20&search=달빛 - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "originalWorkId": 71, - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "imageUrl": "https://cdn.example.com/originals/71/original.webp", - "characterCount": 2, - "createdAtUtc": "2026-07-20T01:00:00Z", - "updatedAtUtc": "2026-07-20T02:00:00Z" - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - search는 title, contentType, category의 대소문자 무시 부분 검색이다. - -ORIGINAL-WORK-02 GET /admin/ai-characters/original-works/{originalWorkId} - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "밤에만 문을 여는 도서관의 이야기", - "originalWork": "Moonlight Library", - "originalLink": "https://example.com/works/71", - "writer": "김작가", - "studio": "소다 스튜디오", - "originalLinks": [ - "https://example.com/works/71", - "https://example.com/works/71/official" - ], - "tags": ["힐링", "판타지"], - "imageUrl": "https://cdn.example.com/originals/71/original.webp", - "characterCount": 2, - "createdAtUtc": "2026-07-20T01:00:00Z", - "updatedAtUtc": "2026-07-20T02:00:00Z" - }, - "errorProperty": null - } - -ORIGINAL-WORK-03 POST /admin/ai-characters/original-works - Content-Type: multipart/form-data - Parts: image 필수, request 필수 JSON string - Request JSON: - { - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "밤에만 문을 여는 도서관의 이야기", - "originalWork": "Moonlight Library", - "originalLink": "https://example.com/works/71", - "writer": "김작가", - "studio": "소다 스튜디오", - "originalLinks": [ - "https://example.com/works/71", - "https://example.com/works/71/official" - ], - "tags": ["힐링", "판타지"] - } - Response JSON: - { - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "isDeleted": false - }, - "errorProperty": null - } - -ORIGINAL-WORK-04 PUT /admin/ai-characters/original-works/{originalWorkId} - Content-Type: multipart/form-data - Parts: image 선택, request 필수 JSON string - Request JSON: - { - "title": "달빛 도서관 개정판", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "개정된 작품 소개", - "originalWork": null, - "originalLink": null, - "writer": "김작가", - "studio": "소다 스튜디오", - "originalLinks": [], - "tags": ["판타지"] - } - Response JSON: - { - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "isDeleted": false - }, - "errorProperty": null - } - -ORIGINAL-WORK-05 DELETE /admin/ai-characters/original-works/{originalWorkId} - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "isDeleted": true - }, - "errorProperty": null - } - 삭제되지 않은 원작에 연결된 캐릭터가 하나라도 있으면 409다. 이미 삭제된 원작의 재시도는 같은 성공 Response다. - -ORIGINAL-WORK-06 GET /admin/ai-characters/original-works/{originalWorkId}/characters - Query: page=0&size=20&search=루나&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "characterId": 101, - "creatorId": 10001, - "name": "루나", - "imageUrl": "https://cdn.example.com/characters/101.webp", - "isActive": true, - "createdAtUtc": "2026-07-20T01:30:00Z" - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - isActive를 생략하면 활성·비활성 연결 캐릭터를 모두 반환한다. creatorId는 null일 수 있다. - -ORIGINAL-WORK-07 POST /admin/ai-characters/original-works/{originalWorkId}/characters - Content-Type: application/json - Request JSON: - { - "characterIds": [101, 102] - } - Response JSON: - { - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "characterIds": [101, 102], - "characterCount": 2 - }, - "errorProperty": null - } - -ORIGINAL-WORK-08 DELETE /admin/ai-characters/original-works/{originalWorkId}/characters - Content-Type: application/json - Request JSON: - { - "characterIds": [101, 102] - } - Response JSON: - { - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "characterIds": [101, 102], - "characterCount": 0 - }, - "errorProperty": null - } - -[필수 test] -- static 원작 route가 dynamic :characterId route보다 우선한다. -- 원작 목록의 page, search, empty, loading, error와 KST 날짜 표시가 동작한다. -- create는 image를 필수로 보내고 update는 선택 image를 생략할 수 있다. -- update가 nullable key를 누락하지 않고 null과 []를 정확히 직렬화한다. -- 원작 삭제가 characterCount>0에서 UI상 차단되고 Backend 409 race도 표시한다. -- 배정이 다른 원작 연결 이동 경고를 표시하고 성공 뒤 관련 query를 invalidate한다. -- 같은 원작 반복 배정에서도 Response characterIds가 요청 순서를 유지하고 characterCount를 처리 건수로 오인하지 않는다. -- creatorId=null인 연결 캐릭터를 fallback 상태로 표시하고 해제할 수 있다. -- DELETE body 해제가 정확한 method, header와 JSON body를 전송한다. -- 캐릭터 생성에서 미선택 null, 수정에서 현재 원작 복원, 명시적 해제 null을 전송하고 0을 보내지 않는다. -- CHAR-02 originalWork=null을 미선택 상태로 복원하고, 원작·연결 캐릭터 Response의 모든 nullable field가 null이어도 화면과 typecheck가 정상 동작한다. -- 401, 403, 400 originalWorkId/characterIds, 404 originalWorkId, 409 title/originalWorkId/characterIds와 최초 조회 실패가 기존 feedback 규칙을 따른다. -- API가 반환한 UTC Z를 유지하고 화면에서만 Asia/Seoul로 표시한다. - -[검증과 결과물] -- 기존 package scripts와 Jenkins의 pnpm install --frozen-lockfile, pnpm run ci:prod 경로가 그대로 통과해야 한다. -- typecheck, ESLint, unit/UI test와 production build를 모두 실행한다. -- 핵심 E2E에 원작 CRUD, 캐릭터 배정·해제, 캐릭터 form의 원작 선택·해제를 추가한다. -- README 또는 기존 API 문서에 원작 route, 8개 Operation, DELETE JSON body와 캐시 무효화 규칙을 추가한다. -- 구현하지 못한 항목은 숨기지 말고 Operation ID와 이유를 명시한다. -``` diff --git a/docs/20260720_AI캐릭터_관리자기능/plan-task.md b/docs/20260720_AI캐릭터_관리자기능/plan-task.md deleted file mode 100644 index a65fe6c3..00000000 --- a/docs/20260720_AI캐릭터_관리자기능/plan-task.md +++ /dev/null @@ -1,1021 +0,0 @@ -# AI 캐릭터 관리자 기능 Implementation Plan - -> **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:** 기존 관리자 로그인, legacy 원작 API와 AWS upload-complete 계약을 유지하면서 `ROLE_ADMIN` 전용 AI 캐릭터 관리자 API 66개를 v2 도메인 경계 안에 구현하고, 원작 관리와 AI 캐릭터의 비동기 콘텐츠·커뮤니티 운영을 안전하게 제공한다. - -**Architecture:** 신규 web adapter와 DTO는 `kr.co.vividnext.sodalive.v2.admin.aicharacter`가 소유하고, 업무 규칙은 `v2.aicharacter`, `v2.originalwork`, `v2.content.*`, `v2.creator.channel.*`의 각 도메인이 소유한다. admin facade는 실제 `ADMIN` principal의 ID를 유지하고, character-scoped 요청에서만 Path의 `characterId`를 `AiCharacterAdminTarget`으로 해석한다. global 원작 facade는 원작 use case를 직접 조정하고 배정·해제에서만 캐릭터 집합을 검증한다. 기존 JPA entity와 QueryDSL Q type은 v2 persistence adapter에서만 사용하고, 기존 Service·Repository·web DTO는 신규 v2 업무 로직에서 호출하지 않는다. - -**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Security, Spring Data JPA, QueryDSL, AWS S3/CloudFront infrastructure client, JUnit 5, MockMvc, Gradle Wrapper, ktlint - ---- - -## 0. 구현 범위와 고정 결정 - -- 기준 문서: `docs/20260720_AI캐릭터_관리자기능/prd.md` -- 이 계획은 Backend 구현만 다룬다. PRD 27장의 Frontend handoff는 클라이언트 구현 입력이며 Backend 완료 조건에 포함하지 않는다. -- 기존 `POST /admin/member/login`을 그대로 사용한다. 신규 로그인, 사칭 토큰, menu/capability Endpoint를 만들지 않는다. -- 메뉴는 클라이언트의 typed static configuration이 소유한다. Backend의 기존 `GET /menu` 및 메뉴 코드는 수정하지 않는다. -- Kotlin package는 v2지만 HTTP base path는 `/admin/ai-characters`다. `/v2`, `/admin/v2`, `/v2/admin` prefix를 추가하지 않는다. -- 신규 웹 Operation은 PRD에 명시된 66개다. 기존 `PUT /audio-content/upload-complete`는 호환 계약이므로 신규 Operation 수에 포함하지 않는다. -- 기존 `PUT /audio-content/upload-complete`는 인증 정보 없음·유효하지 않은 JWT에 `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할에 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 유지한다. -- `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`은 `/admin/ai-characters/original-works`의 global 원작 CRUD·검색·캐릭터 배정 계약이다. legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response와 일반 사용자용 `/api/chat/original/**`는 호환을 위해 유지한다. -- `CONTENT-08`이라는 신규 Endpoint, V1/V2 dispatcher와 v2 completion use case를 만들지 않는다. v2 콘텐츠도 기존 row·S3 계약을 따라 현재 callback이 동일하게 처리한다. -- AWS S3 Trigger worker 코드, worker 스케줄, metadata 계약 및 AWS Trigger 설정은 생성·수정하지 않는다. -- v2 전용 예약 공개 scheduler를 만들지 않는다. 기존 scheduler component의 cron·lock은 유지하고 활성 creator만 선택하도록 기존 release query의 안전 조건만 보강한다. -- 이번 기능을 위한 DB 테이블·컬럼·인덱스·JPA mapping, DDL, backfill과 데이터 migration을 생성·수정하지 않는다. -- 콘텐츠 `status`는 기존 `isActive`, `releaseDate`, `duration`과 creator 활성 상태에서 계산한다. `content` 경로는 Signed URL 발급 전 canonical output key 검증에만 사용한다. -- 캐릭터 삭제 시 소유 콘텐츠의 기존 raw `isActive`만 `false`로 전환하고 `releaseDate`, `content`, `duration`과 구매 이력은 보존한다. 이는 기존 컬럼의 논리 상태 변경이며 schema·JPA mapping 변경이 아니다. -- 캐릭터 삭제가 실제 commit된 경우에만 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale` cache를 1회 clear한다. 동적 cache key 열거, Redis key scan과 범용 cache invalidation framework는 만들지 않는다. -- `previewStartTime`, `previewEndTime`은 생성 Request에서 기존 S3 metadata로만 전달하고 DB와 목록·상세 Response에는 저장하지 않는다. -- 신규 v2 domain/application/port는 v2 외부의 legacy Controller, Service, Repository, Request/Response DTO를 호출하지 않는다. -- 원작은 `v2.originalwork`가 소유하고 기존 `OriginalWork` 관련 entity와 `ChatCharacter.originalWork` 관계는 persistence adapter에서만 사용한다. 원작용 table·column·index·JPA mapping·DDL·backfill·data migration을 만들지 않는다. -- 원작 삭제는 연결 캐릭터가 0명일 때만 `isDeleted=true`로 전환한다. 배정·해제는 전건 검증 후 한 transaction에서 처리하며 누락 ID를 무시하는 부분 성공을 허용하지 않는다. -- legacy 원작 등록·수정·삭제·배정·해제는 원작 호환 adapter가 같은 v2 원작 input port를 호출한다. legacy 캐릭터 등록·수정 전체는 캐릭터 호환 adapter가 같은 v2 캐릭터 command를 호출하고 해당 command가 v2 원작 참조·잠금 정책을 사용한다. v2는 legacy Controller·Service·DTO를 참조하지 않으며 legacy 조회 API는 기존 구현을 유지한다. -- 원작 관계 mutation은 양수 연결 대상 또는 Path 원작 row가 있으면 해당 원작을 `PESSIMISTIC_WRITE`로 먼저 잠그고 캐릭터 row를 ID 오름차순으로 잠근 뒤 재검증한다. `CHAR-04`의 `null`과 legacy 수정의 `0` 해제는 대상 원작이 없으므로 캐릭터 row만 잠그고 재검증한다. 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` transaction과 deadlock·serialization 실패 시 최대 1회 새 transaction 재시도로 동시성 불변식을 지킨다. -- 이미 삭제된 원작의 반복 DELETE는 연결 수보다 먼저 판정해 성공한다. 배포 전 삭제 원작 연결 불일치가 1건 이상이면 자동 migration을 만들지 않고 별도 승인된 보정을 완료하기 전 mutation 전환을 활성화하지 않는다. -- 원작 이미지 port는 저장과 해당 request가 새로 만든 object 삭제를 지원한다. 원작 command에도 서버 생성 `requestId`를 사용한다. 이미지 저장 자체의 실패는 502다. 이미지 저장 후 재시도 가능한 DB 실패는 해당 attempt object 삭제 보상이 성공한 뒤 최대 1회 새 transaction으로 재시도한다. 재조회에서 실제 중복이 확인되면 409이고, 비재시도 DB 실패·재시도 소진·보상 실패는 500이다. -- 신규 `CHAR-03`의 `originalWorkId=null`은 미연결, `CHAR-04`의 명시적 `null`은 해제이며 신규 계약은 `0` sentinel을 허용하지 않는다. legacy 호환 adapter는 기존 등록의 `0`을 미연결로, 수정의 `0`만 해제로 변환한다. -- legacy 캐릭터 등록의 `originalWorkId=null`과 `0`은 미연결, 수정의 `null`은 변경 없음, `0`은 해제다. 호환 adapter는 이 네 경우를 v2 command의 명시적 의미로 변환한다. -- 캐릭터 삭제가 기존 공개 화면에 반영되도록 legacy 소비자 query와 cache에 필요한 active guard만 최소 보강한다. 이는 v2 업무 로직에서 legacy Service·Repository를 호출하거나 재사용하는 것이 아니다. -- 공통 JWT, `ApiResponse`, 기존 JPA entity/Q type, S3·CloudFront·외부 캐릭터 client는 PRD가 허용한 adapter 경계 뒤에서만 재사용한다. -- 기존 소비자용 v2 시리즈·커뮤니티·FanTalk query는 마스킹과 소비자 필터가 있어 관리자 조회에 직접 재사용하지 않는다. 같은 v2 도메인 아래 관리자 전용 projection과 use case를 추가한다. -- 독립 리소스는 논리 삭제한다. 시리즈-콘텐츠와 Member-creator tag 연결 해제만 join row 물리 삭제가 가능하고, `CategoryContent`는 기존 `isActive` 모델을 유지한다. -- 모든 절대 날짜·시간 응답은 UTC `Z`로 직렬화한다. duration과 preview offset `HH:mm:ss`는 timezone 변환 대상이 아니다. -- 시간 의존 서비스는 생성자에 `Clock`을 받고 운영 기본값은 `Clock.systemUTC()`로 둔다. 테스트는 fixed `Clock`을 사용한다. -- 알림·번역처럼 기존 `@TransactionalEventListener(AFTER_COMMIT)`가 처리하는 event는 transaction 안에서 publish해 listener가 commit 후 실행하게 한다. home news처럼 transactional listener를 거치지 않는 직접 호출만 `AfterCommitExecutor`에 등록한다. 외부 캐릭터 API/S3는 commit 전 실행하되 실패·rollback·commit 예외를 보상한다. 어느 경로든 rollback, 동일 상태 전이 재시도, 멱등 삭제 재시도에서 외부 효과를 중복 실행하지 않는다. -- 순수 policy는 JUnit unit test, application은 outbound port fake/mock test, persistence는 `@DataJpaTest`와 기존 `QueryDslConfig` 패턴, HTTP 계약은 `@SpringBootTest`+MockMvc로 검증한다. Redis가 실제 경로에 필요한 경우에만 기존 embedded Redis fixture를 사용한다. - -## 1. 성공 기준과 Operation 추적 - -### 1.1 Operation coverage - -| 구현 Task | Operation ID | 수 | -|---|---|---:| -| Task 2.4 | `CHAR-01`~`CHAR-04` | 4 | -| Task 2.7 | `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08` | 8 | -| Task 10.2 | `CHAR-05` | 1 | -| Task 4.3 | `CONTENT-01`~`CONTENT-07` | 7 | -| Task 5.2 | `CONTENT-COMMENT-01`~`CONTENT-COMMENT-05` | 5 | -| Task 6.2 | `CATEGORY-01`~`CATEGORY-09` | 9 | -| Task 7.2 | `SERIES-01`~`SERIES-11` | 11 | -| Task 8.2 | `COMMUNITY-POST-01`~`COMMUNITY-POST-06` | 6 | -| Task 8.4 | `COMMUNITY-COMMENT-01`~`COMMUNITY-COMMENT-05` | 5 | -| Task 9.2 | `FAN-TALK-01`~`FAN-TALK-05` | 5 | -| Task 9.4 | `NOTICE-01`~`NOTICE-02`, `CREATOR-TAG-01`, `CHANNEL-PROFILE-01`~`CHANNEL-PROFILE-02` | 5 | -| 합계 | 신규 관리자 Operation | 66 | - -### 1.2 공통 완료 조건 - -- 모든 신규 Controller가 class level `@PreAuthorize("hasRole('ADMIN')")`를 사용한다. -- 비로그인은 401, ADMIN 외 인증 사용자는 403이며 두 경우 모두 `ApiResponse` 오류 JSON을 반환한다. -- 400/404/409/500/502 오류가 PRD 20장의 `errorProperty`와 HTTP status를 따른다. -- page는 음수이면 0, size는 1 미만이면 20, 50 초과이면 50으로 정규화한다. 기존 소비자용 page 정책은 변경하지 않는다. -- character-scoped 작업은 `AiCharacterAdminTarget` 해석 후 각 리소스 소유권을 다시 검증한다. -- global 원작 CRUD는 character target 없이 수행하고, 배정·해제만 Request의 전체 AI 캐릭터 집합과 현재 원작 귀속을 검증한다. -- 목록·상세·mutation JSON field, nullable, 기본값, 정렬, UTC 규칙은 PRD 9장과 각 Operation의 Request/Response JSON을 그대로 따른다. -- `CONTENT-01`, `CONTENT-02`의 Signed URL과 만료 시각은 같은 절대 `Instant`를 사용하고 `Cache-Control: private, no-store`를 반환한다. -- 사람 관리자 mutation은 `adminMemberId`, `characterId`, `creatorMemberId`, `action`, `resourceType`, `resourceId`, `result`를 구조화 로그에 남긴다. global 원작 CRUD의 두 캐릭터 field는 `null`이고 배정·해제는 캐릭터별 로그를 남긴다. -- 신규 v2 domain/application/port에서 legacy Service·Repository·web DTO와 JPA entity/Q type import가 검출되지 않는다. -- 기존 로그인, 메뉴, callback, 예약 공개 scheduler와 소비자용 v2 API의 회귀 테스트가 통과한다. - -### 1.3 구현 전 기술 Gate - -다음은 제품·API·UX 미결정 사항이 아니라 외부 시스템의 실제 지원 범위를 확인하는 구현 선행 Gate다. - -1. Weraser 외부 캐릭터 API의 create/update idempotency 전달 방식, 생성 결과 식별자, nullable field 삭제·빈 목록 전체 삭제 표현, remote 현재값 조회, 보상 update 지원 여부를 운영 계약 또는 실제 client 문서와 대조한다. -2. 지원하지 않는 기능을 임의 header나 가짜 delete 호출로 만들지 않는다. PRD의 보상 요구를 충족할 수 없으면 코드 작성 전에 PRD에 지원 가능한 보상·divergence 처리 경계를 명시하고 승인받는다. -3. 외부 캐릭터 레코드를 물리 삭제하거나 `inactive_*`로 rename하지 않는다. -4. 실제 worker/Trigger 코드는 이 저장소 밖에 있으므로 staging에서 v2 생성 input object가 기존 callback으로 완료되는 E2E를 배포 전 1회 확인한다. S3 저장과 DB commit 사이에 callback이 도착할 때 worker가 조회 실패를 retry하는지도 확인하며, 이 검증을 위해 worker 코드·설정이나 새 callback을 만들지는 않는다. -5. 배포 전 운영 데이터에서 `isDeleted=true` 원작을 참조하는 캐릭터 건수를 읽기 전용으로 확인한다. 0건이면 mutation 전환을 진행한다. 1건 이상이면 자동 해제·backfill을 추가하지 않고 대상과 영향 범위를 보고해 별도 승인된 데이터 보정을 완료한 뒤 mutation 전환을 활성화한다. -6. H2 test만으로 MySQL 잠금 의미를 대신하지 않는다. 배포 전 MySQL 8 환경의 서로 다른 connection에서 동일 제목 생성·수정, 원작 삭제 대 배정·해제·CHAR-03/04를 동시에 실행해 중복 활성 제목과 삭제 원작 참조가 모두 0건인지 확인한다. 실패하면 schema 변경 없이 안전하다고 간주하지 않고 배포를 중단해 PRD를 재검토한다. - -## 2. 파일 구조 계획 - -### 2.1 공통 관리자 계약 - -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` - -### 2.2 AI 캐릭터 - -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterModels.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterPolicy.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterQueryService.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterExternalPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterImageStoragePort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterEventPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/external/WeraserAiCharacterAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/storage/S3AiCharacterImageStorageAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/event/AiCharacterEventAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidator.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiCharacterDtos.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiCharacterFacade.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterController.kt` - -### 2.3 원작 - -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminModels.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminPolicy.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminService.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/in/OriginalWorkAdminUseCase.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkAdminPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkImageStoragePort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkEventPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/persistence/DefaultOriginalWorkAdminRepository.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/storage/S3OriginalWorkImageStorageAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminOriginalWorkDtos.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminOriginalWorkFacade.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminOriginalWorkController.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/LegacyOriginalWorkMutationAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/LegacyAiCharacterMutationAdapter.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` -- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt` - -### 2.4 콘텐츠와 기존 callback·scheduler 호환 - -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentModels.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentPolicy.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentStatusPolicy.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentOutputKeyPolicy.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/in/ContentCreatorDeactivationUseCase.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentManagementPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentFileStoragePort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/SignedAudioUrlPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentEventPort.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepository.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/storage/S3ContentFileStorageAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/cloudfront/CloudFrontSignedAudioUrlAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/event/ContentEventAdapter.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiContentDtos.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiContentFacade.kt` -- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiContentController.kt` -- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` -- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` -- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` - -### 2.5 나머지 creator 작업 도메인 - -- Content comment: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment` -- Content category: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category` -- Series admin extension: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series` -- Community admin extension: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` -- FanTalk admin extension: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` -- Channel notice: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice` -- Channel profile: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile` -- 위 패키지의 정확한 파일은 해당 Phase의 Task에 명시한다. - -## 3. 구현·배포 순서 - -```text -Phase 0 기존 callback·소비자 계약 고정 - -> Phase 1 공통 계약/보안/대상 해석 - -> Phase 2 CHAR-01~04와 ORIGINAL-WORK-01~08 - -> Phase 3 계산 status와 기존 callback/scheduler 안전 조건 - -> Phase 4~9 콘텐츠·하위 도메인 53개 Operation - -> Phase 10 CHAR-05 삭제 cascade/legacy 호환 - -> Phase 11 전체 계약/경계/회귀 검증 -``` - -운영 DB schema 선행 작업은 없다. 배포 diff에서 `content`·원작 관련 테이블 DDL, JPA 컬럼·관계 mapping, V1/V2 dispatcher와 신규 scheduler가 추가되지 않았는지 확인한다. - -### 3.1 TDD 공통 실행 규칙 - -- 각 Task의 `실패 확인` 명령은 production 변경 전에 실행해 신규 기대가 실패하는지 확인한다. -- 구현 시작 전에 기준 commit hash와 이미 존재하는 dirty/untracked file manifest를 5장에 기록한다. Task 11.3의 금지 변경 검증은 이 기준 이후 이번 구현이 만든 diff만 판정한다. -- `GREEN` 구현 직후에는 같은 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. 이 재실행은 각 Task의 GREEN 검증 항목이며 실제 결과를 5장에 기록한다. -- `REFACTOR` 후에도 같은 명령과 Task에 명시된 인접 회귀 test를 다시 실행한다. -- `TDD 예외` Task는 명시한 대체 검증을 실행하고 의도적인 실패를 만들지 않는다. -- Gradle test는 kapt 임시 파일 충돌을 피하도록 문서 순서대로 실행하며 서로 병렬 실행하지 않는다. - ---- - -### Phase 0: 기존 callback·소비자 계약 고정 - -- [x] **Task 0.1: 변경 전 callback·소비자 API 계약을 회귀 테스트로 고정** - - Files: - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` - - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt` - - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` - - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` - - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt` - - RED: TDD 예외 사유: 변경 대상이 아닌 기존 callback·소비자 계약을 characterization test로 고정하는 작업이므로 의도적인 production 결함을 먼저 만들지 않는다. - - 대체 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`를 실행해 callback의 Method·Path·Request·`ADMIN`/`BOT` 성공 Response·인가 status, legacy 캐릭터·원작 관리자의 Method·Path·Request·성공 Response, 일반 사용자용 원작 API와 소비자 API 계약 중 현재 구현과 어긋난 지점이 있으면 먼저 조사하고, 모두 일치하면 최초 통과 결과를 baseline으로 기록한다. callback은 인증 정보 없음·유효하지 않은 JWT `401`, 인증됐지만 `ADMIN`/`BOT`이 아닌 역할 `403`을 고정한다. legacy 검색은 실제 repository/service로 검색 field, 활성·삭제 제외, 정렬과 pagination을 고정한다. legacy 원작 mutation은 이후 같은 v2 정책으로 수렴하므로 이 Task에서는 Method·Path·Request·성공 Response를 고정하고 잘못된 mutation을 성공시키는 내부 동작을 호환 계약으로 고정하지 않는다. - - GREEN: 이 Task에서는 신규 route를 구현하지 않는다. callback과 기존 소비자 API가 현재 상태에서 통과하는지 baseline을 기록한다. - - REFACTOR: 테스트 fixture는 실제 Spring mapping과 응답 surface를 검증하며 운영 코드를 위한 범용 endpoint registry를 만들지 않는다. - - 기대 결과: 기존 callback, legacy 캐릭터·원작 관리자와 소비자 API 계약이 초록색 baseline으로 고정된다. - -### Phase 1: 공통 관리자 계약, 보안, 대상 해석 - -- [x] **Task 1.1: 공통 page/응답/multipart JSON 계약 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt` - - RED: page `null/-1/0`, size `null/0/1/20/50/51`, page response `hasNext`, multipart JSON과 일반 JSON body의 필수 key 누락/명시적 `null` 구분, 빈 multipart JSON과 단일 root 뒤의 추가 root·garbage 거부, 이미지 bytes의 실제 MIME `image/jpeg`, `image/png`, `image/gif`, 10MB·한 변 20,000px·총 40,000,000 pixels 초과 거부를 테스트한다. PNG는 ancillary payload 합계 1MB·chunk 4,096개 상한과 `ignoreMetadata=true`를 검증한다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels 상한을 검증하고, logical canvas·모든 frame header의 동일 상한, 선언 pixel 수와 정확히 일치하는 LZW 출력, 조기 EOI·연속 clear·EOI 뒤 data와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력 거부를 고정한다. 실제 format 확인 시 모든 GIF frame을 각각 1x1 출력 영역으로 decode하는지와 `allowGif` 조건도 테스트한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `AdminPageResponse`, 공통 mutation/comment request와 PRD의 정규화 규칙만 구현한다. `AdminJsonRequestParser`는 multipart JSON string과 `JsonNode` 모두에서 required nullable key를 검증하고, multipart JSON string은 parser 전용 strict reader로 단일 root만 허용한다. `AdminImagePartValidator`는 v2 web adapter에서 실제 MIME을 검사한다. PNG ImageIO 입력은 `ignoreMetadata=true`로 metadata를 읽지 않고, container preflight에서 ancillary payload 합계 1MB와 chunk 4,096개를 제한한다. GIF preflight는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 전체 frame 누적 40,000,000 pixels를 제한하고 각 frame LZW 출력이 선언 pixel 수와 정확히 일치하는지 bounded code parser로 확인한다. 실제 format 확인 decode는 모든 frame의 1x1 출력 영역으로 제한하고, 입력 유래 decoder 예외를 잘못된 이미지로 처리한다. domain port에는 admin DTO나 Spring `Pageable`을 넘기지 않고 정규화된 offset/limit을 전달한다. - - REFACTOR: 기존 `CreatorChannel*QueryPolicy`는 size 1~19 처리 계약이 다르므로 수정하거나 재사용하지 않는다. - - 기대 결과: 모든 관리자 목록과 multipart update가 하나의 명시적 계약을 사용하고, image part는 bytes·container 구조·metadata·frame·LZW 작업량의 bounded 안전 경계를 공유한다. - -- [x] **Task 1.2: `/admin/ai-characters/**` 전용 오류·인증 응답 경계 구현** - - Files: - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt` - - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt` - - RED: 기존 `POST /admin/member/login` 응답 token으로 신규 API 호출 성공, 무JWT 401, 잘못된 JWT 401, `USER/CREATOR/AGENT/CONTENT_MANAGER` 403, ADMIN 통과 및 400/404/409/500/502별 `ApiResponse` body와 `errorProperty`를 검증한다. 실제 Spring Boot context에서 두 production advice와 `SecurityConfig`를 함께 로드하고, DispatcherServlet에 연결된 test `MultipartResolver`가 handler 선택 전에 실패할 때 admin 경로는 400, legacy 경로는 기존 HTTP 200과 unknown 메시지를 유지하는지도 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `SodaException` 생성자 끝에 legacy 기본 동작을 보존하는 선택 HTTP status를 추가하고, 우선순위가 높은 admin Controller 범위 advice와 path-specific security handler만 그 status를 응답에 사용한다. - - REFACTOR: 기존 `SodaExceptionHandler`, `JwtAuthenticationEntryPoint`, `JwtAccessDeniedHandler`의 응답을 변경하지 않는다. - - 기대 결과: 신규 관리자 API만 PRD 20장의 status/envelope를 사용하고 legacy API는 영향받지 않는다. - -- [x] **Task 1.3: AI 캐릭터 관리자 대상 해석과 owner 입력 차단 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt` - - RED: 존재하지 않는 character 404, 연결 Member 없음/`role!=CREATOR`/`memberKind!=AI_CHARACTER` 404, 생성·수정 시 둘 중 하나 비활성 409, 삭제·상태 조회 시 비활성 target 반환을 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: PRD의 여섯 필드만 갖는 `AiCharacterAdminTarget`을 반환하고, web body에는 `creatorId`/writer ID를 추가하지 않는다. - - REFACTOR: JPA `ChatCharacter`, `Member`, Q type은 persistence adapter 밖으로 노출하지 않는다. - - 기대 결과: 모든 character-scoped facade가 동일한 대상 해석 결과를 사용한다. - -- [x] **Task 1.4: direct after-commit 실행과 구조화 관리자 audit 기반 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt` - - RED: direct callback의 commit 후 1회 실행과 rollback 미실행을 검증하고, 실제 `TransactionTemplate`에서 첫 attempt가 rollback된 동일 command를 재시도해 commit하면 callback이 두 attempt 합계 1회 실행되는 기존 동작을 characterization으로 고정한다. `AfterCommitExecutor`에는 command identity가 없으므로 attempt 간 dedup은 요구하지 않는다. 기존 `FcmEvent`는 commit 후, `LanguageDetectEvent`는 commit 후·rollback 시, `LanguageTranslationEvent(waitTransactionCommit=true)`는 commit 후 listener 동작을 각각 검증하고 세 listener의 `AFTER_COMMIT` 선언도 고정한다. mutation 성공/실패 audit field와 민감 본문 미기록을 검증한다. global 원작 context는 `ORIGINAL_WORK`의 `CREATE/UPDATE/DELETE`만, 원작 배정·해제는 캐릭터별 `ORIGINAL_WORK_CHARACTER` context만 허용한다. character-scoped context의 `creatorMemberId`는 `UNASSIGN`에서만 nullable로 두되, factory가 정상/과거 불일치 해제 타입을 구분하지 않는다. factory 검증을 우회하는 public `copy`도 노출하지 않는다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `AfterCommitExecutor`는 home news 같은 direct callback만 Spring transaction synchronization에 등록한다. 기존 `FcmEvent`, `LanguageDetectEvent`, `LanguageTranslationEvent(waitTransactionCommit=true)`는 transaction 안에서 publish하고 각 listener의 AFTER_COMMIT 경계를 유지한다. structured audit logger는 private factory로 global/character-scoped context와 action/resource/creator 조합을 강제하되 정상/과거 불일치 해제용 별도 command나 factory를 만들지 않으며, audit table이나 AOP framework도 만들지 않는다. - - REFACTOR: 비밀번호, JWT, system prompt 전체, 댓글/게시글 본문, 업로드 파일 내용이 logger argument에 들어갈 수 없도록 audit context를 ID와 enum 중심으로 제한한다. - - 기대 결과: 이후 facade와 event adapter가 같은 commit/audit 원칙을 반복 구현하지 않는다. - -### Phase 2: AI 캐릭터와 원작 관리 (`CHAR-01`~`CHAR-04`, `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`) - -- [ ] **Task 2.1: 캐릭터 policy, 관리자 projection과 persistence command 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterPolicy.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterPolicyTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt` - - RED: characterType, age, ISO region, 양수 원작 ID의 `isDeleted=false` 검증, 생성 `null` 미연결, 수정 명시적 `null` 해제와 `0` 거부, 중첩 문자열 trim, 이름 예약/중복, 목록 검색·상태·정렬, 상세의 systemPrompt 위치, character와 AI creator Member의 unique 1:1 동시 저장·동기화를 검증한다. 신규 Member는 `role=CREATOR`, `memberKind=AI_CHARACTER`, `email=null`, `password=""`이고 일반·크리에이터 관리자 로그인 대상이 아님을 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterPolicyTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 관리자 전용 domain model/projection과 v2 persistence query/command를 구현한다. 캐릭터 생성 시 `role=CREATOR`, `memberKind=AI_CHARACTER` Member를 연결하고 수정 시 nickname/profileImage/introduce를 동기화한다. - - REFACTOR: legacy `AdminChatCharacterService`, repository와 DTO를 주입하지 않고 기존 entity mapping만 adapter에서 사용한다. - - 기대 결과: 외부 시스템과 web 계층 없이도 캐릭터 데이터 규칙이 고정된다. - -- [ ] **Task 2.2: 외부 캐릭터·이미지 port와 보상 경계 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterExternalPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterImageStoragePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/external/WeraserAiCharacterAdapter.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/storage/S3AiCharacterImageStorageAdapter.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/external/WeraserAiCharacterAdapterTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/storage/S3AiCharacterImageStorageAdapterTest.kt` - - RED: 기술 Gate에서 확인한 create/update/idempotency 계약, 검증 완료 이미지의 upload/delete 보상, timeout/4xx/5xx의 502 매핑을 먼저 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.external.WeraserAiCharacterAdapterTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.storage.S3AiCharacterImageStorageAdapterTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: command별 `requestId`를 두 port에 전달하고, 외부/파일 생성 후 DB 실패 시 지원되는 보상을 실행한다. 보상 미지원·실패는 request/resource/stage만 orphan/divergence 로그에 남긴다. - - REFACTOR: 외부 API key, bucket, 원본 prompt나 파일을 로그에 기록하지 않고 기존 설정 주입 방식을 유지한다. - - 기대 결과: 외부 실패가 부분 성공으로 반환되지 않고 재처리 근거가 남는다. - -- [ ] **Task 2.3: 캐릭터 query/create/update service와 side effect 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterQueryService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/event/AiCharacterEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterQueryServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandServiceTest.kt` - - RED: 생성의 외부 create→로컬 character/Member flush로 ID 확보→`characters/{characterId}` 이미지 저장→DB commit 각 단계 실패와 commit 예외 보상, 수정 remote 성공 후 DB 실패의 compensating update/divergence를 검증한다. 수정에서는 nullable field·`originalWorkId`의 명시적 null과 목록의 `[]`가 local/remote 양쪽에서 실제 clear로 표현되는지, 양수 원작의 `isDeleted=false` 검증과 `0` 거부, 이미지 생략 유지, region 변경 불가, 비활성 수정 409, 등록 description 언어 감지와 수정 번역의 commit 후 1회 실행도 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterQueryServiceTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterCommandServiceTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: query/create/update orchestration만 구현하고 delete cascade는 하위 도메인 deactivation port가 준비되는 Phase 10까지 보류한다. 외부·파일 작업이 포함된 command는 `TransactionTemplate` 경계 밖에서 commit 예외까지 포착해 생성 리소스 삭제 또는 remote compensating update를 실행한다. - - REFACTOR: requestId 생성, 보상 순서, after-commit event 발행을 작은 private 함수로만 분리하고 범용 workflow engine을 만들지 않는다. - - 기대 결과: CHAR-01~04의 업무 흐름이 web DTO와 분리되어 검증된다. - -- [ ] **Task 2.4: CHAR-01~04 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiCharacterDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiCharacterFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterControllerIntegrationTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: PRD 11장의 CHAR-01/02 JSON 전체 field·nullable·UTC, CHAR-03/04 multipart part 이름·필수 key·기본값과 이미지 실제 MIME/GIF 거부, 목록에 systemPrompt 미노출, 생성된 AI Member의 일반·크리에이터 관리자 로그인 거부, ADMIN 인가, 400/404/409/502, audit success/failure를 MockMvc로 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminAiCharacterControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: Controller는 JSON key와 image bytes를 외부 호출 전에 검증하고 principal에서 실제 `adminMemberId`만 추출해 facade에 전달한다. facade는 target 해석과 use case 호출/응답 변환/audit만 담당한다. - - REFACTOR: multipart `request`는 `AdminJsonRequestParser`를 사용하고 DTO에 `creatorId`, `characterId`, `isActive` writable field를 추가하지 않는다. route inventory는 이 Task에서 구현한 CHAR-01~04만 실제 mapping과 대조하고 이후 Phase가 자기 Operation을 누적한다. - - 기대 결과: CHAR-01~04 mapping이 route inventory에서 통과하고 PRD JSON으로 호출 가능하다. - -- [ ] **Task 2.5: 원작 policy, 관리자 projection과 기존 mapping 기반 persistence 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkAdminPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/persistence/DefaultOriginalWorkAdminRepository.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/domain/OriginalWorkAdminPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/persistence/DefaultOriginalWorkAdminRepositoryTest.kt` - - RED: 필수 문자열 trim·빈 값, nullable 빈 문자열의 null 정규화, http/https 링크, 링크·태그 첫 등장 순서 보존과 중복 제거, trim·대소문자 무시 생성/수정 제목 충돌과 현재 ID 제외, 수정 11개 key 전체 교체를 검증한다. link ID·tag-mapping ID 명시 정렬과 요청 순서 재생성, `isDeleted=false` 목록·대소문자 무시 검색·`createdAt DESC,id DESC`·공통 page, 상세, creator 누락을 포함한 활성·비활성 연결 캐릭터 목록과 전체 `ChatCharacter` 기준 `characterCount`, 연결 0건 삭제·연결 존재 409·이미 삭제된 상태 우선의 반복 삭제도 기존 entity와 관계를 사용한 persistence test로 고정한다. 양수 target/path 원작 `PESSIMISTIC_WRITE`와 캐릭터 ID 오름차순 잠금 query, null 해제의 캐릭터 단독 잠금도 test에서 확인한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.originalwork.domain.OriginalWorkAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.persistence.DefaultOriginalWorkAdminRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: v2 원작 command/query record와 policy를 만들고 adapter가 `EntityManager`·QueryDSL로 기존 `OriginalWork`, link, tag와 `ChatCharacter.originalWork`를 읽고 쓴다. 양수 연결 대상 또는 Path 원작이 있는 relation mutation은 원작을 먼저, 캐릭터를 ID 오름차순으로 잠그고 잠금 뒤 상태·귀속을 재검증한다. `CHAR-04`의 `null`과 legacy 수정의 `0` 해제는 캐릭터만 잠그고 재검증한다. domain/application에는 legacy entity·Q type을 반환하지 않는다. - - REFACTOR: legacy `OriginalWorkRepository`, `OriginalWorkTagRepository`, `ChatCharacterRepository`를 주입하지 않고 새 `@Entity`, relation mapping, DDL 또는 data migration을 만들지 않는다. - - 기대 결과: 원작 CRUD·검색·연결 조회의 데이터 규칙이 legacy service 없이 고정된다. - -- [ ] **Task 2.6: 원작 application, 이미지·언어 event와 원자적 캐릭터 배정 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/in/OriginalWorkAdminUseCase.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkImageStoragePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/port/out/OriginalWorkEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/storage/S3OriginalWorkImageStorageAdapter.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapter.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/storage/S3OriginalWorkImageStorageAdapterTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/aws/s3/S3UploaderTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapterTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandServiceTest.kt` - - RED: 생성 DB·`originals/{originalWorkId}/...` 이미지 저장·commit 각 실패의 새 이미지 보상, 수정 이미지 생략 유지와 실패 보상, 기존 이미지 보존, 삭제 시 이미지 미삭제를 검증한다. storage port의 `store(originalWorkId, validatedImage, requestId, attemptNumber)`와 `delete(objectKey)`, 서버 생성 `requestId`, attempt별 고유 key, 저장 실패 502, 저장 후 비재시도 DB 실패와 보상 성공·실패의 500 및 구조화 ERROR orphan 로그를 구분한다. 첫 attempt가 이미지 저장 후 commit deadlock이면 첫 key를 삭제한 뒤에만 두 번째 `REQUIRES_NEW` `SERIALIZABLE` transaction을 시작하고, 두 번째 성공 후 S3에는 최종 key 하나만 남아야 한다. 첫 key 보상 실패 시 재시도하지 않으며, retry 뒤 실제 중복만 409이고 재시도 소진은 500인지 검증한다. 생성 언어 감지와 정규화된 `title`, `contentType`, `category`, `description`, `tags`가 실제 바뀐 수정의 번역 갱신은 commit 후 1회, 다른 field 변경·rollback·동일 값 수정·배정·해제는 0회여야 하며 감지 결과 commit 전 후속 번역이 시작되지 않아야 한다. 배정은 중복 없는 전건 활성 AI 캐릭터 검증 후 다른 원작에서 이동하고 같은 원작 반복은 멱등해야 하며, 해제는 creator 상태와 무관하게 기존 캐릭터의 Path 원작 귀속을 전건 검증한다. 배정의 누락·비활성 ID 또는 해제의 누락·미연결·다른 원작 ID가 있으면 부분 변경 0건인지 확인한다. CHAR-03/04도 같은 `isDeleted=false`, 신규 null/`0` 규칙 및 relation lock을 사용하는지 고정한다. concurrent 배정과 삭제, 연결 변경과 삭제를 latch 기반 통합 test로 교차 실행해 삭제 원작 참조가 0건인지 검증하고, 동시 동일 제목 생성·수정의 transaction isolation과 재시도 정책도 확인한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.originalwork.application.OriginalWorkAdminServiceTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.storage.S3OriginalWorkImageStorageAdapterTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.event.OriginalWorkEventAdapterTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterCommandServiceTest --tests kr.co.vividnext.sodalive.aws.s3.S3UploaderTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 원작 CRUD와 배정·해제 transaction을 `OriginalWorkAdminService`와 input port에 구현한다. image port에는 `originalWorkId`, 검증된 image, requestId와 attempt number를 전달하고 반환된 exact object key를 attempt별로 기록한다. 비transactional orchestration은 외부 이미지 저장을 포함한 각 DB attempt를 하나의 `REQUIRES_NEW` `TransactionTemplate.execute`로 감싸 commit 예외까지 포착하며 outer REQUIRED transaction이나 중첩 template을 만들지 않는다. 생성과 제목이 실제 바뀌는 수정에만 `SERIALIZABLE` isolation을 적용하고, 제목이 바뀌지 않는 수정은 기본 isolation을 사용한다. retry 가능한 실패는 해당 attempt key 보상 성공 후에만 새 transaction으로 최대 한 번 재시도한다. 재조회에서 실제 중복이 확인되면 409로, 비재시도·재시도 소진 또는 보상 실패는 500으로 끝낸다. 언어 event는 원작 transaction commit 후 시작하고 캐릭터 command는 같은 v2 원작 참조·잠금 규칙을 사용한다. - - REFACTOR: legacy 원작 Service/Repository/DTO, Controller의 S3 직접 호출, v2 public/domain command의 `0` sentinel, 부분 성공과 범용 workflow engine을 도입하지 않는다. legacy adapter의 기존 등록 `0 -> 미연결`, 수정 `0 -> 해제` 변환은 호환 경계에만 둔다. 공용 `S3Uploader` 확장은 정확한 bucket·object key를 받는 `delete` 한 메서드로 제한하고 존재 확인·prefix 삭제·정리 scheduler를 추가하지 않는다. - - 기대 결과: 이미지·DB·언어 side effect와 캐릭터 관계 변경이 명시적인 실패·rollback 계약을 갖는다. - -- [ ] **Task 2.7: ORIGINAL-WORK-01~08 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminOriginalWorkDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminOriginalWorkFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminOriginalWorkController.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/LegacyOriginalWorkMutationAdapter.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminOriginalWorkControllerIntegrationTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/LegacyOriginalWorkMutationAdapterTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: PRD 11.7의 8개 exact method/path, Query, 전체 envelope JSON field·nullable·UTC, 생성/수정 multipart part와 11개 key, 이미지 실제 MIME/GIF 거부, DELETE JSON body와 proxy 통과, ADMIN 인가와 정확한 400/404/409/500/502를 MockMvc로 고정한다. assignment response의 요청 순서 전체 ID와 작업 후 전체 `characterCount`, 같은 원작 멱등 ID, nullable `creatorId`, global CRUD audit의 nullable character field와 배정·해제 캐릭터별 audit도 검증한다. 해제 audit은 캐릭터의 `creatorMemberId`를 확인할 수 있으면 실제 값을 기록하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 기록한다. legacy 원작 5개 mutation route는 Method·Path·Request·성공 `data=null`을 유지하면서 같은 v2 원작 input port를 호출하고, legacy update의 null은 잠금 transaction 안에서 현재 값 유지로 병합되며 연결 삭제·부분 배정·오귀속 해제는 더 이상 성공하지 않는지 확인한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminOriginalWorkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.original.LegacyOriginalWorkMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 신규 Controller는 `AdminJsonRequestParser`, `AdminImagePartValidator`와 실제 `adminMemberId`만 사용하고 facade는 원작 use case 호출, Response 변환과 audit만 담당한다. `ORIGINAL-WORK-08`은 `Content-Type: application/json` DELETE body를 명시적으로 매핑한다. legacy 원작 compatibility adapter는 legacy DTO와 multipart의 non-null update field만 v2 호환 patch command로 변환한다. 현재 값 병합은 adapter 선조회가 아니라 v2 application이 원작 row를 잠근 transaction 안에서 수행한다. 원작 조회는 기존 service를 유지하고 legacy 원작 5개 direct mutation만 v2 input으로 수렴한다. legacy 캐릭터가 아직 호출하는 `assignOneCharacter`는 Phase 10의 전체 캐릭터 호환 전환 전까지만 남기고, 나머지 미사용 direct mutation 메서드와 그로 인해 불필요해진 dependency만 제거해 OSIV 회귀 fixture의 constructor를 맞춘다. - - REFACTOR: 신규 v2에 별도 `/search`, legacy와 같은 `/register`, `/update`, `/assign-characters`, `/unassign-characters`, 원작 복구 또는 공개 원작 Endpoint를 추가하지 않는다. v2가 legacy adapter나 DTO를 import하지 않고 legacy Controller가 신규 Controller를 호출하지 않는다. - - 기대 결과: CHAR-01~04와 ORIGINAL-WORK-01~08의 12개 mapping이 route inventory를 통과하고 PRD JSON으로 호출 가능하며, legacy 원작 5개 mutation도 외부 성공 계약을 유지한 채 같은 불변식을 적용한다. - -### Phase 3: 계산 status와 기존 callback·scheduler 안전 조건 - -- [ ] **Task 3.1: 기존 컬럼 기반 content status와 Signed output key 정책 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentStatusPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentOutputKeyPolicy.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentStatusPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentOutputKeyPolicyTest.kt` - - RED: `!isActive && releaseDate==null -> DELETED`, creator 비활성 `SUSPENDED`, active `PUBLISHED`, duration null `PROCESSING`, 가공 완료·미래 공개 `SCHEDULED`, 나머지 `SUSPENDED` 우선순위와 fixed UTC `Clock`을 표 기반 test로 고정한다. output key는 정확한 `output/{contentId}/...`만 허용하고 URI scheme, host, 선행 slash/backslash, query/fragment, percent encoding, 빈 segment, `.`/`..`, 다른 content ID와 input/raw/preview를 거부하는지 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.domain.ContentStatusPolicyTest --tests kr.co.vividnext.sodalive.v2.content.management.domain.ContentOutputKeyPolicyTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: DB에 status를 저장하지 않는 순수 `ContentStatusPolicy`와 Signed URL 발급 직전에만 호출하는 `ContentOutputKeyPolicy`를 구현한다. - - REFACTOR: 계산 status와 output key 검증을 결합하거나 key를 정규화해 통과시키지 않는다. 한 요청의 목록 필터와 Response는 같은 `now`를 사용한다. - - 기대 결과: 신규 컬럼 없이 PRD 12.2의 status와 Signed URL 안전 조건을 재현한다. - -- [ ] **Task 3.2: 기존 callback·예약 공개의 삭제 및 비활성 creator guard 보강** - - Files: - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt` - - RED: 기존 callback의 method/path/Request와 `ADMIN`/`BOT` 성공 `data={}`는 그대로이고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인 상태에서 v2 생성과 동일한 기존 row가 정상 완료되는지 검증한다. 처리 중 삭제된 `releaseDate=null` row와 비활성 creator row는 callback이 output path·duration을 기록해도 raw `content.isActive=false`와 공개 FCM/home news 0회를 유지해야 한다. 비활성 creator의 과거 불일치 row가 raw `content.isActive=true`이면 callback 후 `false`로 보정한다. 활성 creator의 즉시 공개는 최초 false→true에서만 공개 side effect를 내며 동일 callback 재시도는 이를 중복하지 않아야 한다. 예약 공개 query는 `isActive=false`, non-null due releaseDate, non-null duration, 활성 creator를 모두 만족하는 row만 반환하는지 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 기존 `AudioContentService.uploadComplete`의 공개 조건에 non-null releaseDate, 활성 creator와 최초 활성 전이를 추가하고 비활성 creator의 raw `content.isActive`를 `false`로 유지·보정한다. 기존 release query에는 활성 creator 조건을 추가한다. Controller의 Method·Path·Request와 `ADMIN`/`BOT` 성공 Response, scheduler component의 cron·lock은 변경하지 않는다. - - REFACTOR: 신규 callback Controller/DTO/use case, V1/V2 dispatcher, pipeline metadata와 v2 scheduler가 생기지 않았는지 diff를 확인한다. - - 기대 결과: 기존 callback과 scheduler를 모든 콘텐츠가 공용하면서 삭제 콘텐츠와 비활성 creator를 다시 공개하지 않는다. - -### Phase 4: 콘텐츠 관리 API (`CONTENT-01`~`CONTENT-07`) - -- [ ] **Task 4.1: 콘텐츠 생성·수정·삭제·고정 domain policy와 persistence 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentManagementPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/event/ContentEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/domain/ContentPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepositoryTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/event/ContentEventAdapterTest.kt` - - RED: title/detail, price 0/1~4/5, 테마 12~14, purchaseOption, limited, preview pair/15초, 무료 preview, rental/full-detail 파생 규칙, 수정 불가 field, 기존 유료 무료 전환 금지, 소유권, 계산 status 전체의 삭제, 공개·최대 3개 pin 교체와 생성·수정 언어 작업의 commit/rollback/retry를 테스트한다. 생성은 `isActive=false`, `duration=null`, `releaseDate=request 값 또는 fixed Clock now`이고 status 필터와 Response 계산이 Task 3.1 policy와 일치하는지도 persistence test로 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.domain.ContentPolicyTest --tests kr.co.vividnext.sodalive.v2.content.management.application.ContentManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.persistence.DefaultContentManagementRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.event.ContentEventAdapterTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 생성은 기존 `content` row에 `isActive=false`, non-null releaseDate와 input path를 저장하고, 삭제는 `isActive=false`, `releaseDate=null`로 기록한다. status는 저장하지 않고 Task 3.1 policy로 계산하며 pin은 조건부 update/가장 오래된 pin 교체로 구현한다. 생성·수정은 언어 코드가 없으면 감지, 있으면 번역 작업을 commit 후 1회 예약한다. - - REFACTOR: legacy `CreatorAdminContentService`, `AudioContentRepository`, theme service를 호출하지 않고 persistence adapter의 자체 query로 소유권과 기준정보를 검증한다. - - 기대 결과: CONTENT command 규칙과 원자성이 web/storage와 독립적으로 고정된다. - -- [ ] **Task 4.2: 콘텐츠 S3 업로드, Signed URL과 UTC 만료 계약 구현** - - Files: - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementServiceTest.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentFileStoragePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/SignedAudioUrlPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/storage/S3ContentFileStorageAdapter.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/cloudfront/CloudFrontSignedAudioUrlAdapter.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/storage/S3ContentFileStorageAdapterTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/cloudfront/CloudFrontSignedAudioUrlAdapterTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFrontTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` - - RED: 빈 audio 거부, 검증 완료 cover와 `input/{contentId}/{contentId}-content-...` key, 기존 `generateFileName(prefix = "${contentId}-content")` basename 규칙과 그 basename을 유지한 output key가 callback의 content ID 검증을 통과하는지 검증한다. `generate_preview` 및 선택 preview metadata만 전달되고 새 pipeline/callback metadata는 추가되지 않는지, preview 값이 DB projection이나 Response에 저장되지 않는지, 부분 업로드 보상, `(duration HH + 2)시간`, fixed Clock의 URL policy/응답 expiresAt 동일성, 서명 실패 500/no fallback도 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.storage.S3ContentFileStorageAdapterTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.cloudfront.CloudFrontSignedAudioUrlAdapterTest --tests kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFrontTest --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `AudioContentCloudFront`에 절대 `expiresAt: Instant` overload를 추가하고 기존 상대 TTL 함수를 유지한다. `ContentManagementService.create`는 `TransactionTemplate` 안에서 content ID를 만든 뒤 기존 filename 생성 규칙으로 `input/{contentId}/{contentId}-content-...` 업로드를 수행하고, execute/commit 예외를 바깥에서 포착해 DB rollback과 이미 생성된 object 보상을 실행한다. query service는 한 번 얻은 `now`에서 expiresAt을 계산해 port와 응답에 같은 값을 쓴다. - - REFACTOR: 기존 callback이 저장한 canonical output key와 duration은 query에서 읽기만 하고 생성된 Signed URL과 expiresAt은 domain/persistence에 저장하지 않는다. cover만 공통 CDN 절대 URL 변환을 사용한다. - - 기대 결과: 계산 status가 SCHEDULED/PUBLISHED이고 canonical key+duration이 있을 때만 Signed URL을 받고 raw path는 노출되지 않는다. - -- [ ] **Task 4.3: CONTENT-01~07 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminAiContentDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiContentFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiContentController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiContentControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: CONTENT-01 검색/계산 status/유효 isActive 필터/정렬/전체 field와 `previewStartTime`, `previewEndTime` 미포함, CONTENT-02 재조회 URL 갱신과 두 preview field 미포함, 01/02 `private,no-store`, 03 multipart preview 입력·metadata/default/계산 PROCESSING과 cover 실제 MIME/GIF 거부, 04 immutable field와 선택 cover 검증, 05 delete, 06 pin, 07 비페이징 active theme JSON과 모든 오류/audit를 MockMvc로 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminAiContentControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: facade가 target을 해석하고 콘텐츠 use case를 호출하며 DTO는 PRD 12장의 실제 JSON 이름과 nullability를 그대로 반환한다. status와 유효 isActive는 기존 필드와 creator 활성 상태로 계산하고 preview offset은 Response DTO에 두지 않는다. 파일 저장과 보상 orchestration은 `ContentManagementService`가 소유한다. - - REFACTOR: 목록 query에서 구매·성인 선호·차단 마스킹을 적용하지 않고, 브라우저용 URL refresh Endpoint를 추가하지 않는다. - - 기대 결과: CONTENT-01~07이 route inventory와 클라이언트 JSON 계약을 통과한다. - -### Phase 5: 콘텐츠 댓글 API (`CONTENT-COMMENT-01`~`CONTENT-COMMENT-05`) - -- [ ] **Task 5.1: 콘텐츠 댓글·답글 domain, persistence와 side effect 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/domain/ContentCommentModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/domain/ContentCommentPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/application/ContentCommentManagementService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/port/out/ContentCommentManagementPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/port/out/ContentCommentEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/adapter/out/persistence/DefaultContentCommentManagementRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/comment/adapter/out/event/ContentCommentEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/comment/domain/ContentCommentPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/comment/application/ContentCommentManagementServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/comment/adapter/out/persistence/DefaultContentCommentManagementRepositoryTest.kt` - - RED: 루트/직접 답글 정렬, 동일 콘텐츠의 활성 루트에만 답글, 답글의 답글 거부, AI 작성자만 수정, 작성자 또는 콘텐츠 소유자의 삭제, 다른 owner의 Path 콘텐츠·댓글은 404, body의 부모 미존재·다른 콘텐츠 귀속·중첩 답글은 400과 `parentCommentId`, 반복 삭제, 필터 값과 무관한 활성 직접 replyCount를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.comment.domain.ContentCommentPolicyTest --tests kr.co.vividnext.sodalive.v2.content.comment.application.ContentCommentManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.comment.adapter.out.persistence.DefaultContentCommentManagementRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 댓글 writer는 body가 아니라 resolved `creatorMemberId`로 고정하고, 콘텐츠 알림과 언어 감지는 생성 transaction commit 후에만 발행한다. - - REFACTOR: legacy `AudioContentCommentService/Repository`를 호출하지 않고 entity/Q type은 persistence adapter에서만 변환한다. - - 기대 결과: 본문 수정 권한과 소유자 moderation 권한이 서로 분리된다. - -- [ ] **Task 5.2: CONTENT-COMMENT-01~05 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminContentCommentDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminContentCommentFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCommentController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCommentControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: 두 목록의 root/direct-child 범위와 정렬, Query `isActive` 생략 시 true 및 false 명시 조회, PRD 13장의 response JSON, create 기본값, update body에 content만 허용, 타 작성자 update 404/403 대신 소유권 은닉 404, 소유 콘텐츠 타인 댓글 delete 성공과 audit를 MockMvc로 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminContentCommentControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: facade가 character/content/comment 귀속을 순서대로 검증하고 공통 `AdminCommentUpdateRequest`와 mutation response를 매핑한다. - - REFACTOR: client가 writer/creator ID를 주입할 수 있는 field를 만들지 않는다. - - 기대 결과: CONTENT-COMMENT-01~05가 route inventory를 통과한다. - -### Phase 6: 콘텐츠 카테고리 API (`CATEGORY-01`~`CATEGORY-09`) - -- [ ] **Task 6.1: 콘텐츠 카테고리 domain, 구성·순서 persistence와 event 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/domain/ContentCategoryModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/domain/ContentCategoryPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/application/ContentCategoryManagementService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/in/ContentCategoryCreatorDeactivationUseCase.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/out/ContentCategoryManagementPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/out/ContentCategoryEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/adapter/out/persistence/DefaultContentCategoryManagementRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/adapter/out/event/ContentCategoryEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/category/domain/ContentCategoryPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/category/application/ContentCategoryManagementServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/category/adapter/out/persistence/DefaultContentCategoryManagementRepositoryTest.kt` - - RED: title trim/2자/활성 중복, create contentIds 전건 동일 소유·활성·중복 없음, 활성 카테고리 전체 order 집합, 포함/가용 목록 정렬, add 전건 rollback, inactive link 재활성화, active 중복 409, 반복 remove `[]`, category delete 시 link 비활성화를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.category.domain.ContentCategoryPolicyTest --tests kr.co.vividnext.sodalive.v2.content.category.application.ContentCategoryManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.category.adapter.out.persistence.DefaultContentCategoryManagementRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: category와 `CategoryContent`의 기존 테이블을 adapter에서 사용하고 create/title update의 언어 감지·번역을 commit 후 1회 발행한다. - - REFACTOR: category-content는 물리 삭제하지 않고 기존 `isActive` 및 `orders` 의미를 유지한다. - - 기대 결과: 카테고리 mutation과 구성 변경이 전건 검증 후 하나의 transaction으로 수행된다. - -- [ ] **Task 6.2: CATEGORY-01~09 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminContentCategoryDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminContentCategoryFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCategoryController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminContentCategoryControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: CATEGORY-01 page/filter/order/count, 02~04 mutation JSON, 05 정확한 전체 순서, 06/07 포함·가용 page, 08/09 affected IDs와 오류/audit를 PRD 18.1~18.7 JSON으로 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminContentCategoryControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: target의 creator ID만 use case에 넘기고 모든 ID 집합 오류를 요청 전체 실패로 매핑한다. - - REFACTOR: 콘텐츠 응답에 raw cover path를 노출하지 않고 공통 CDN absolute URL만 사용한다. - - 기대 결과: CATEGORY-01~09가 route inventory를 통과한다. - -### Phase 7: 시리즈 API (`SERIES-01`~`SERIES-11`) - -- [ ] **Task 7.1: 관리자 시리즈 domain, persistence, image와 event 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorSeriesAdminModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorSeriesAdminPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorSeriesAdminService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/in/SeriesCreatorDeactivationUseCase.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorSeriesAdminPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/SeriesCoverStoragePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorSeriesEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorSeriesAdminRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/storage/S3SeriesCoverStorageAdapter.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/event/CreatorSeriesEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorSeriesAdminPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorSeriesAdminServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorSeriesAdminRepositoryTest.kt` - - RED: 요일 허용값/빈 배열/RANDOM 단독, 활성 genre, update nullable writer/studio와 필수 key, 상태, 동일 소유권, 검증 완료 이미지의 저장 보상, 포함·가용 정렬, add 전건 rollback과 기존 활성 연결 중복 409, join row 물리 제거/반복 remove, 활성 시리즈 전체 order 집합, 언어 event commit 후 1회를 검증한다. SERIES-11 장르 응답에는 PRD에 없는 순서 assertion을 추가하지 않는다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorSeriesAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorSeriesAdminServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorSeriesAdminRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 기존 `Series`, `SeriesContent`, genre 테이블을 persistence adapter에서 사용하고 관리자 projection을 별도로 구현한다. - - REFACTOR: 기존 `CreatorChannelSeriesQueryService`와 소비자 DTO는 수정하지 않고, 공통화가 정확히 일치하는 CDN 변환 외에는 관리자 query에 끌어오지 않는다. - - 기대 결과: 관리자 시리즈 계약이 소비자 필터/마스킹과 독립적으로 동작한다. - -- [ ] **Task 7.2: SERIES-01~11 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminSeriesDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminSeriesFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminSeriesController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminSeriesControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: SERIES-01의 search/isActive/state 필터와 `order ASC, seriesId ASC`, SERIES-01/02 field·nullable·UTC, SERIES-03 문자열 trim/nonblank·`isAdult=false`·nullable writer/studio 기본값, 03/04 multipart·누락/null 구분과 이미지 실제 MIME/GIF 거부, 05 logical delete 후 포함 content 보존, 06/07 page/정렬, 08 기존 활성 연결 중복 409와 08/09 affected IDs, 10 전체 order, 11 비페이징 genre와 오류/audit를 MockMvc로 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminSeriesControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: PRD 14장의 method/path/request/response를 그대로 mapping하고 image part 생략 시 기존 이미지를 유지한다. - - REFACTOR: PRD가 요일 response 배열과 genre metadata의 순서를 정의하지 않았으므로 특정 순서 assertion을 만들지 않고 값의 보존만 검증한다. - - 기대 결과: SERIES-01~11이 route inventory를 통과한다. - -### Phase 8: 커뮤니티 게시글·댓글 API - -- [ ] **Task 8.1: 관리자 커뮤니티 게시글 domain, persistence, file과 event 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminPostService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/in/CommunityCreatorDeactivationUseCase.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityAdminPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityFilePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/storage/S3CreatorCommunityFileAdapter.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/event/CreatorCommunityEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminPostServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepositoryTest.kt` - - RED: 전체 본문 관리자 projection, fixed/created 정렬, content trim 후 nonblank, price 기본 0과 0 이상, 유료 또는 audio 첨부 시 image 필수, update의 price/audio 불변, image 유지, delete 소유권과 하위 댓글 row 보존, 활성 게시글만 fixed, 캐릭터별 최대 3개 및 네 번째 고정은 자동 교체 없이 409, 검증 완료 파일의 부분 업로드 보상, 구독자 알림과 무료 home news commit 후 1회를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorCommunityAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorCommunityAdminPostServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorCommunityAdminRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 게시글 persistence/storage/event port와 관리자 전용 service를 구현한다. storage adapter는 web 경계에서 검증된 file만 받아 저장하고 DB commit 실패 시 생성 object를 보상한다. - - REFACTOR: 단일 커뮤니티 audio 검증을 전역 media framework로 일반화하지 않고 게시글 web/storage 경계에 둔다. - - 기대 결과: 유료 본문도 축약·마스킹 없이 관리자에게 반환되고 업로드 실패가 부분 성공을 남기지 않는다. - -- [ ] **Task 8.2: COMMUNITY-POST-01~06 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommunityPostDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminCommunityPostFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityAudioPartValidator.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityPostController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityAudioPartValidatorTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityPostControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: POST-01/02 전체 JSON과 nullable/UTC, 03 multipart 조합·기본값, audio bytes의 실제 MIME `audio/mp4|audio/x-m4a|audio/aac`, 무료 이미지 GIF 거부와 유료 이미지 GIF 허용, 04 허용 field만 수정, 05 logical delete, 06 fixed와 ADMIN/owner/error/audit를 PRD 15장 기준으로 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminCommunityAudioPartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminCommunityPostControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: web adapter가 Tika 기반으로 file bytes를 검사하고 확장자만 신뢰하지 않는다. 이미지는 공통 `AdminImagePartValidator`에 유료 여부를 전달한다. facade는 target과 post 소유권을 검증한 뒤 service를 호출하고 CDN URL만 응답한다. - - REFACTOR: 기존 소비자용 `CreatorChannelCommunityQueryService`의 유료 마스킹과 차단 조건을 관리자 응답에 적용하지 않는다. - - 기대 결과: COMMUNITY-POST-01~06이 route inventory를 통과한다. - -- [ ] **Task 8.3: 관리자 커뮤니티 댓글 domain과 persistence 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminCommentService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminModels.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicy.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorCommunityAdminPort.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepository.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorCommunityAdminPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminCommentServiceTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorCommunityAdminRepositoryTest.kt` - - RED: 루트/직접 답글 정렬, 동일 활성 post의 활성 root만 parent 허용, 중첩 답글 거부, AI 작성자만 update, 작성자 또는 post 소유자 delete, secret/작성자 정보, 필터 값과 무관한 활성 직접 replyCount, 다른 owner의 Path post·comment는 404, body 부모 귀속·중첩 우회는 400과 `parentCommentId`를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorCommunityAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorCommunityAdminCommentServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorCommunityAdminRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: resolved creator ID를 writer로 사용하고 post/comment/root 귀속을 persistence에서 한 번 더 확인한다. - - REFACTOR: 콘텐츠 댓글과 규칙이 비슷해도 서로 다른 entity/알림 계약을 범용 comment engine으로 합치지 않는다. - - 기대 결과: community comment mutation 권한이 PRD 19장의 순서로 검증된다. - -- [ ] **Task 8.4: COMMUNITY-COMMENT-01~05 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommunityCommentDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminCommunityCommentFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityCommentController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminCommunityCommentControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: COMMENT-01/02 JSON·root/direct child·정렬과 Query `isActive` 생략 시 true 및 false 명시 조회, 03 기본 parent/isSecret 및 body parent 귀속 오류 400, 04 content-only update, 05 소유자 moderation, 다른 owner의 Path resource 404와 audit를 MockMvc로 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminCommunityCommentControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 공통 update DTO를 재사용하되 community 전용 response와 use case를 사용한다. - - REFACTOR: 선택 캐릭터가 작성하지 않은 댓글에는 update 권한을 만들지 않는다. - - 기대 결과: COMMUNITY-COMMENT-01~05가 route inventory를 통과한다. - -### Phase 9: FanTalk, 채널 공지와 프로필 API - -- [ ] **Task 9.1: 관리자 FanTalk 조회·답글·moderation domain과 persistence 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorFanTalkAdminModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorFanTalkAdminPolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorFanTalkAdminService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorFanTalkAdminPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorFanTalkEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorFanTalkAdminRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/event/CreatorFanTalkEventAdapter.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorFanTalkAdminPolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorFanTalkAdminServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorFanTalkAdminRepositoryTest.kt` - - RED: 목록에 활성 creator replies 중첩/replyId 포함, 별도 reply query 없음, root target 귀속, AI writer 답글만 update/delete, root owner moderation, 반복 delete, 비활성 creator 신규 답글 409, reply create 언어 감지 commit 후 1회를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorFanTalkAdminPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorFanTalkAdminServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorFanTalkAdminRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: FanTalk root와 reply를 관리자 projection으로 함께 조회하고 root 삭제 시 답글 row를 물리 삭제하지 않는다. - - REFACTOR: 기존 소비자용 `CreatorChannelFanTalkQueryService`는 수정하지 않고 reply 전용 GET use case를 만들지 않는다. - - 기대 결과: 사용자 이력을 보존하면서 선택 캐릭터 답글만 관리할 수 있다. - -- [ ] **Task 9.2: FAN-TALK-01~05 DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminFanTalkDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminFanTalkFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminFanTalkController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminFanTalkControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: FAN-TALK-01 page item과 중첩 replies JSON, 02/03 reply response, 04 reply delete, 05 root delete, 다른 creator reply 수정/삭제 차단과 audit를 PRD 17장 기준으로 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminFanTalkControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 목록 하나로 root와 replies를 반환하고 네 mutation route만 추가한다. - - REFACTOR: `/replies` GET mapping이 route inventory에 존재하지 않는지 negative assertion을 유지한다. - - 기대 결과: FAN-TALK-01~05만 노출되고 불필요한 답글 조회 API가 생기지 않는다. - -- [ ] **Task 9.3: 채널 공지·프로필·creator tag domain과 persistence 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/domain/CreatorChannelNoticeModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/application/CreatorChannelNoticeService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/port/out/CreatorChannelNoticePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/port/out/CreatorChannelNoticeEventPort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/adapter/out/persistence/DefaultCreatorChannelNoticeRepository.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/adapter/out/event/CreatorChannelNoticeEventAdapter.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/domain/CreatorChannelProfileModels.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/domain/CreatorChannelProfilePolicy.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/application/CreatorChannelProfileService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/port/out/CreatorChannelProfilePort.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/adapter/out/persistence/DefaultCreatorChannelProfileRepository.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/notice/application/CreatorChannelNoticeServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/domain/CreatorChannelProfilePolicyTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/application/CreatorChannelProfileServiceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/profile/adapter/out/persistence/DefaultCreatorChannelProfileRepositoryTest.kt` - - RED: 공지 없음의 empty/null, upsert와 실제 변경 때만 알림, URL empty 또는 http(s) absolute, donation period, tagIds 중복/active 전건 검증·전체 교체, 빈 tag, metadata 정렬, `kakaoOpenChatUrl <-> Member.websiteUrl`, UTC update를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.notice.application.CreatorChannelNoticeServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.profile.domain.CreatorChannelProfilePolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.profile.application.CreatorChannelProfileServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.profile.adapter.out.persistence.DefaultCreatorChannelProfileRepositoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 공지는 `ChannelNotice`, 프로필은 `Member`와 `MemberCreatorTag`를 adapter에서 사용한다. tag 전체 교체 시 join row만 제거/생성하고 tag 자체는 변경하지 않는다. - - REFACTOR: `ChatCharacter.tags`와 Member creator tag를 동기화하지 않고, 이름·이미지·소개 update를 profile API에 중복 구현하지 않는다. - - 기대 결과: 캐릭터 원본 정보와 채널 운영 설정의 책임이 분리된다. - -- [ ] **Task 9.4: NOTICE/CREATOR-TAG/CHANNEL-PROFILE DTO, facade, Controller contract 구현** - - Files: - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminChannelSettingsDtos.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminChannelNoticeFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminChannelProfileFacade.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminChannelNoticeController.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminChannelProfileController.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminChannelSettingsControllerIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: NOTICE-01/02, CREATOR-TAG-01, PROFILE-01/02의 exact path와 Request/Response JSON, tag 비페이징, 빈 문자열 의미, `donationRankingPeriod` key 누락 거부와 명시적 null 허용, 다른 owner 404, invalid URL/tag 400, audit를 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminChannelSettingsControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 공지와 프로필 facade가 각 도메인 service만 호출하고 응답 날짜를 UTC `Z`로 변환한다. - - REFACTOR: 별도 POST notice, menu, profile image/name update Endpoint를 추가하지 않는다. - - 기대 결과: CHAR-05를 제외한 65개 Operation이 route inventory를 통과한다. - -### Phase 10: 캐릭터 삭제 cascade와 legacy 비활성화 - -- [ ] **Task 10.1: 도메인별 creator 비활성화 use case와 단일 transaction cascade 구현** - - Files: - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementService.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/in/ContentCreatorDeactivationUseCase.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/port/out/ContentManagementPort.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepository.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/application/ContentCategoryManagementService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/category/port/in/ContentCategoryCreatorDeactivationUseCase.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorSeriesAdminService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/in/SeriesCreatorDeactivationUseCase.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorCommunityAdminPostService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/in/CommunityCreatorDeactivationUseCase.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterCommandService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterEventPort.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/event/AiCharacterEventAdapter.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/application/ContentManagementServiceTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/management/adapter/out/persistence/DefaultContentManagementRepositoryTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterDeletionServiceIntegrationTest.kt` - - RED: 한 transaction에서 character/Member inactive, 소유 콘텐츠 raw `isActive=false`, active series/post/category inactive, FanTalk/replies 불변, join/이력 물리 삭제 0건, 중간 실패 전체 rollback, 반복 삭제 동일 응답을 검증한다. 콘텐츠의 `releaseDate`, `content`, `duration`과 구매 이력은 보존하면서 기존 미삭제 콘텐츠 `SUSPENDED`, 기존 삭제 콘텐츠 `DELETED`로 계산되는지도 함께 검증한다. 실제 active→inactive 전이에서만 deactivation event 1회, rollback·반복 삭제에서는 0회인지 고정한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.management.application.ContentManagementServiceTest --tests kr.co.vividnext.sodalive.v2.content.management.adapter.out.persistence.DefaultContentManagementRepositoryTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterDeletionServiceIntegrationTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: `AiCharacterCommandService.delete`가 content, category, series, community의 좁은 deactivation input port를 호출하고 마지막에 character와 Member를 비활성화한다. content port는 소유 row의 raw `isActive`만 `false`로 전환하며 모든 port는 같은 transaction에 참여한다. 실제 active→inactive 전이에서만 같은 transaction 안에 deactivation event를 1회 publish한다. - - REFACTOR: aicharacter persistence adapter가 다른 도메인 테이블을 직접 update하지 않게 하고 FanTalk deactivation port는 만들지 않는다. - - 기대 결과: PRD 7.4의 cascade가 물리 삭제 없이 원자적으로 완료된다. - -- [ ] **Task 10.2: CHAR-05, legacy 비활성화·복구 거부와 공개 콘텐츠·채팅 차단 구현** - - Files: - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminAiCharacterFacade.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterController.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/LegacyAiCharacterMutationAdapter.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminAiCharacterControllerIntegrationTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/LegacyAiCharacterMutationAdapterTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterInactiveTransitionTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/InactiveAiCharacterPublicSurfaceTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/chat/room/service/InactiveAiCharacterChatAccessTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaControllerTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaServiceTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/InactiveAiCharacterPublicContentVisibilityTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/PurchasedContentPlaybackAfterAiCharacterDeletionTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderRepository.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerServiceTest.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt` - - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/AudioContentMainTabRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/ContentMainTabTagCurationRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt` - - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidator.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidatorTest.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/RedisConfig.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/rank/RankingRepositoryTest.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepositoryTest.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt` - - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerServiceIntegrationTest.kt` - - RED: CHAR-05 exact response/반복 삭제/audit, legacy active→false가 같은 cascade 호출, false→true 409, 이름·표시 정보 보존, 외부 rename/delete 미호출을 검증한다. legacy 캐릭터 등록·수정 2개는 기존 multipart/body ID/nullable patch/성공 `data=null`을 유지하면서 전체 외부·이미지·DB mutation을 v2 캐릭터 command로 호출한다. 등록의 `originalWorkId=null`·`0`은 미연결, 수정의 `null`은 변경 없음·`0`은 해제이며, 원작 최종 재검증 실패도 같은 보상 경계를 사용하는지 검증한다. 삭제 후 공개 캐릭터 상세·이미지 목록/구매·댓글 조회/작성, 방 생성/입장/session 상태/목록/메시지 조회·전송·구매/초기화, room quota 캔·광고 구매를 모두 차단하고, 목록에서는 제외하며 캔 차감이나 외부 session 호출 전에 실패하는지도 검증한다. 삭제 캐릭터의 콘텐츠는 legacy/v2 목록·검색·추천·크리에이터 채널·content/creator latest/previous ranking snapshot·legacy creator ranking·공개 캐릭터/콘텐츠/시리즈 banner와 미구매 상세에서 제외한다. 공개 콘텐츠 댓글·답글 조회·신규 등록·본문 수정·재활성화는 차단하고 권한 있는 논리 삭제만 허용한다. 현재 v2 FanTalk 탭과 legacy FanTalk 목록·신규 원문 등록은 비활성 creator를 거부하고 기존 FanTalk row는 보존하는지 검증한다. 기존 KEEP/RENTAL 구매자는 `AudioContentService.getDetail`·`generateUrl`에서 전체 Signed URL 재생을 유지하되 댓글과 관련 콘텐츠는 받지 않는다. ranking snapshot과 연결형 banner row는 변경하지 않고 `EVENT`, `LINK`처럼 연결 대상이 없는 banner의 공개 결과도 바꾸지 않는다. `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale`를 pre-warm한 뒤 commit 시 stale DTO가 사라지고 rollback·반복 삭제에서는 cache clear 0회인지 검증한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminAiCharacterControllerIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest --tests kr.co.vividnext.sodalive.admin.chat.character.LegacyAiCharacterMutationAdapterTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterInactiveTransitionTest --tests kr.co.vividnext.sodalive.chat.character.InactiveAiCharacterPublicSurfaceTest --tests kr.co.vividnext.sodalive.chat.room.service.InactiveAiCharacterChatAccessTest --tests kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaControllerTest --tests kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.InactiveAiCharacterPublicContentVisibilityTest --tests kr.co.vividnext.sodalive.content.PurchasedContentPlaybackAfterAiCharacterDeletionTest --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.cache.AiCharacterVisibilityCacheInvalidatorTest --tests kr.co.vividnext.sodalive.rank.RankingRepositoryTest --tests kr.co.vividnext.sodalive.explorer.ExplorerQueryRepositoryTest --tests kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest --tests kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerServiceIntegrationTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 신규 DELETE와 legacy 비활성화가 동일한 v2 delete use case를 호출한다. legacy 캐릭터 compatibility adapter는 register를 v2 create command로, update의 non-null field를 v2 patch command로, 등록의 null·0을 미연결로, 수정의 null을 변경 없음·0을 명시적 원작 해제로, `isActive=false`를 delete로 변환하며 inactive→true 복구를 거부한다. 이 전환 후 `AdminOriginalWorkService.assignOneCharacter`를 제거한다. `ChatCharacterService`의 공통 active-character guard를 소비성·외부 호출 전에 적용한다. 기존 공개 목록·검색·추천은 content deactivation을 통해 inactive 콘텐츠를 제외한다. `AudioContentService` 직접 상세는 미구매자에게 비활성 creator/content를 거부하고 구매자에게 재생 경로만 유지한다. `AudioContentCommentService`는 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화를 거부하고 권한 있는 논리 삭제만 허용한다. 기존 v2 FanTalk 조회의 active creator 조건은 유지하고 `ExplorerService.writeCheers`, `getCreatorProfileCheers`는 대상 creator가 비활성이면 저장·조회 전에 거부한다. audio/creator latest·previous ranking snapshot native query와 legacy creator ranking, 공개 언어별 character/content/series banner query는 현재 연결 대상의 active 상태를 확인한다. AFTER_COMMIT listener는 deactivation event를 받아 기존 세 cache namespace를 clear한다. - - REFACTOR: legacy Controller에서 외부 캐릭터·이미지·DB orchestration과 원작 직접 연결을 제거하고 호환 Request 변환과 `ApiResponse.ok(null)`만 남긴다. 이미 `content.isActive`를 검사하는 공개 query에는 중복 creator predicate를 추가하지 않고 snapshot을 삭제·갱신하거나 ranking job을 강제 실행하지 않는다. 동적 key prefix 삭제나 신규 cache abstraction을 만들지 않는다. - - 기대 결과: CHAR-05를 포함한 신규 66개 mapping이 route inventory를 통과하고, 삭제 캐릭터와 미구매 콘텐츠는 로컬 공개·소비 진입점에서 즉시 차단되며 기존 구매 재생은 유지된다. - -### Phase 11: 전체 계약, 보안, 경계와 회귀 검증 - -- [ ] **Task 11.1: 66개 Operation의 소유권·상태·audit 교차 시나리오 검증** - - Files: - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminOwnershipEndToEndTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminInactiveStateEndToEndTest.kt` - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminAuditEndToEndTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminRouteInventoryTest.kt` - - RED: route inventory가 PRD의 method/path 66개와 중복·누락·추가 없이 정확히 일치하는지 먼저 검증한다. 이어서 리소스군마다 다른 character 소유 Path ID는 404, Request body의 잘못된 parent/root 귀속은 400과 해당 field, inactive character의 GET·DELETE 허용과 그 외 mutation 409, inactive parent mutation 409, 논리 리소스 DELETE retry 200, 실제 ADMIN과 대행 creator 구분, 모든 mutation success/failure audit를 parameterized E2E로 작성한다. global 원작은 character 선택 없이 접근되고, 0 이하·미존재·삭제 ID의 정확한 400/404/409, 연결 존재 삭제 409, 이미 삭제된 상태 우선의 반복 DELETE 200, 배정·해제 전건 검증·원자성, 다른 원작 이동과 audit nullable/캐릭터별 규칙도 포함한다. 정상/과거 불일치 해제를 별도 command나 factory로 나누지 않되, 해제 audit은 확인 가능한 실제 `creatorMemberId`를 기록하고 연결 정보가 없거나 해석할 수 없을 때만 `null`을 기록한다. 같은 원작 재배정은 요청 순서 ID 전체를 반환하는 멱등 성공이고 이미 해제된 관계의 ORIGINAL-WORK-08 재시도는 귀속 불일치 400인지 구분한다. legacy 원작 mutation 5개와 캐릭터 mutation 2개 전체도 각각 같은 v2 원작·캐릭터 command를 사용하며 동시 삭제·배정에서 삭제 원작 참조를 만들지 않는지 포함한다. - - 실패 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdmin*EndToEndTest' --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminRouteInventoryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 누락된 owner/status/audit 연결만 각 facade/use case에 보완한다. - - REFACTOR: E2E fixture를 공유하되 production에 범용 CRUD/impersonation abstraction을 추가하지 않는다. - - 기대 결과: route inventory의 66개 mapping과 공통 보안 경계가 모두 통과한다. - -- [ ] **Task 11.2: side effect와 기존 callback·scheduler 호환 통합 검증** - - Files: - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminSideEffectIntegrationTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/application/OriginalWorkAdminServiceTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/originalwork/adapter/out/event/OriginalWorkEventAdapterTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/cache/AiCharacterVisibilityCacheInvalidatorTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentReleaseQueryTest.kt` - - RED: PRD 21.3 side effect 표의 관리자 command를 commit/rollback/retry로 실행하고 0회 또는 정확히 1회인지 검증한다. 원작 생성 언어 감지와 정규화된 `title`, `contentType`, `category`, `description`, `tags` 중 실제 변경된 수정의 번역은 commit 후 1회, 다른 field 변경·동일 값·배정·해제·rollback에서는 0회여야 한다. 감지 결과 transaction이 commit되기 전에 후속 번역이 시작되지 않는지도 포함한다. v2 생성과 같은 기존 row가 현재 callback과 scheduler에서 즉시/예약 공개되고, 삭제 row·비활성 creator·동일 callback 재시도에서는 공개 FCM/home news가 0회 또는 최초 1회를 넘지 않는지도 함께 검증한다. 캐릭터 삭제 cache clear도 commit 시 1회, rollback·반복 삭제 시 0회여야 한다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminSideEffectIntegrationTest --tests kr.co.vividnext.sodalive.v2.originalwork.application.OriginalWorkAdminServiceTest --tests kr.co.vividnext.sodalive.v2.originalwork.adapter.out.event.OriginalWorkEventAdapterTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.cache.AiCharacterVisibilityCacheInvalidatorTest --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentReleaseQueryTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 각 command는 동일 상태인지 먼저 판별하고 실제 최초 상태 전이가 있을 때만 기존 transactional listener용 event를 transaction 안에서 publish한다. direct home-news 호출만 `AfterCommitExecutor`에 등록하며 별도 lifecycle/completion 결과 모델은 만들지 않는다. - - REFACTOR: 이벤트 이름과 payload는 resource ID와 필요한 최소 값만 포함하고 대용량 본문을 넣지 않는다. - - 기대 결과: rollback과 멱등 재시도가 외부 알림·번역·home news 또는 cache clear를 중복 생성하지 않는다. - -- [ ] **Task 11.3: v2 의존성·Endpoint 및 DB schema·JPA·migration 변경 금지 검증** - - Files: - - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/AiCharacterAdminArchitectureTest.kt` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/originalwork` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content` - - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` - - RED: 이 계획에서 신규 생성·수정한 v2 class manifest를 기준으로 domain/application/port의 legacy Service/Repository/web DTO 및 JPA entity/Q type import, `/v2` HTTP prefix, 신규 login/menu/callback/reply-GET mapping, legacy 형태의 원작 `/search`·`/register`·`/update` mapping, 신규 v2 `@Entity`/`@Table` 선언이 있으면 실패하는 architecture test를 작성한다. legacy compatibility adapter가 v2 input port를 호출하는 방향은 허용하되 v2 package가 해당 adapter나 legacy DTO를 import하면 실패한다. 기존 v2 파일의 선행 부채를 이번 변경 위반으로 오탐하지 않는다. - - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.AiCharacterAdminArchitectureTest` - - 통과 확인: GREEN 구현 후 위 실패 확인 명령을 다시 실행해 `BUILD SUCCESSFUL`을 확인한다. - - GREEN: 위반 import/mapping만 adapter 또는 기존 허용 경계로 이동한다. - - REFACTOR: 정적 검증을 위해 새 architecture dependency를 추가하지 않고 classpath/reflection과 소스 resource 검사로 충분히 구현한다. - - 추가 검증: - - `rg -n '"/(v2/admin|admin/v2)|upload-complete|member/login|"/menu' src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter` - - `rg -n 'lifecycle_status|upload_pipeline_version|ContentUploadCompletionUseCase|V2ContentReleaseScheduler|AudioContentUploadCompletionDispatcher' src/main/kotlin` - - 5장에 기록한 구현 시작 기준 commit과 dirty/untracked manifest를 현재 changed/untracked file manifest와 대조해 이번 구현이 생성·수정한 `*.sql`이 0건인지 확인한다. - - 같은 manifest에서 기존 `@Entity` source 수정 0건과 신규 v2 production source의 `@Entity`, `@Table` 선언 0건을 확인한다. - - 구현 시작 기준과 비교해 `AudioContent.kt`, `AudioContentController.kt`, `AudioContentReleaseScheduledTask.kt`에 diff가 없는지 확인한다. - - 구현 시작 기준과 비교해 `OriginalWork.kt`, `OriginalWorkLink.kt`, `OriginalWorkTag.kt`, `OriginalWorkTagMapping.kt`, `ChatCharacter.originalWork` JPA mapping에 diff가 없는지 확인한다. - - `docs/20260720_AI캐릭터_관리자기능` 아래 SQL 파일이 0건인지 확인한다. - - 위 `rg`는 출력 0건이 기대 결과이며, match 없음에 따른 exit code 1은 위반 검출 실패가 아니다. - - 기대 결과: PRD 7.6과 21.1~21.4의 경계 위반, 금지 Endpoint, DB schema·JPA mapping·migration과 pipeline 분기 추가가 0건이다. - -- [ ] **Task 11.4: 최종 targeted/full regression, 포맷과 문서 검증** - - Files: - - Modify: `docs/20260720_AI캐릭터_관리자기능/plan-task.md` - - Verify: `docs/20260720_AI캐릭터_관리자기능/prd.md` - - Verify: `docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` - - RED: TDD 예외. 이 Task는 구현 완료 후 전체 회귀와 문서/API/DB schema·JPA·migration 비변경 정합성을 확인하는 최종 gate다. - - GREEN: 다음 순서로 검증한다. - 1. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.admin.aicharacter.*'` - 2. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.aicharacter.*'` - 3. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.originalwork.*'` - 4. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.*'` - 5. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*'` - 6. `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentUploadCompletion*' --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests 'kr.co.vividnext.sodalive.scheduler.*'` - 7. `./gradlew test` - 8. `./gradlew ktlintCheck` - 9. `./gradlew tasks --all` - 10. `git diff --check` - 11. `rg -o '^\| .(CHAR|ORIGINAL-WORK|CONTENT|CONTENT-COMMENT|CATEGORY|SERIES|COMMUNITY-POST|COMMUNITY-COMMENT|FAN-TALK|NOTICE|CREATOR-TAG|CHANNEL-PROFILE)-[0-9]{2}.' docs/20260720_AI캐릭터_관리자기능/prd.md | sort -u | wc -l` 결과가 66인지 확인한다. - 12. `rg -c '^ORIGINAL-WORK-0[1-8] (GET|POST|PUT|DELETE) ' docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 결과가 8인지 확인한다. - 13. `awk '$0=="### 27.8 Copy-Paste Frontend Development Prompt"{section=1; next} section && $0=="```text"{capture=1} capture{print} capture && $0=="```"{exit}' docs/20260720_AI캐릭터_관리자기능/prd.md | shasum -a 256` 결과가 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`인지 확인한다. - 14. `find docs/20260720_AI캐릭터_관리자기능 -type f -name '*.sql' -print` 출력이 0건인지 확인한다. - 15. `rg -o '^### Phase [0-9]+' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 12인지 확인한다. - 16. `rg -o '^- \[[ x]\] \*\*Task [0-9]+\.[0-9]+:' docs/20260720_AI캐릭터_관리자기능/plan-task.md | wc -l` 결과가 37인지 확인한다. - 17. `rg -c '^ - 실패 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md`와 `rg -c '^ - 통과 확인:' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 각각 35인지 확인한다. - 18. `rg -c '^ - RED: TDD 예외' docs/20260720_AI캐릭터_관리자기능/plan-task.md` 결과가 2인지 확인한다. - 19. `rg -n $'\t| +$' docs/20260720_AI캐릭터_관리자기능/prd.md docs/20260720_AI캐릭터_관리자기능/plan-task.md docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 출력이 0건인지 확인하고, 각 문서의 code fence 개수가 짝수인지 확인한다. untracked 문서는 `git diff --check`만으로 검사되지 않으므로 파일 자체 검사도 수행한다. - 20. `rg -c 'nullable JSON:$' docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` 결과가 5인지 확인하고, PRD 11.7의 nullable response field와 CHAR-02 `originalWork` nullable 계약이 해당 JSON 값 및 Frontend test에 모두 나타나는지 대조한다. - 21. PRD의 fenced JSON 전체와 `frontend-original-work-prompt.md`의 `JSON:` label 값 전체를 JSON parser로 읽어 각각 51/51, 20/20이 유효한지 확인한다. 이어서 PRD 11.7과 Frontend delta의 `(Operation ID, Method, Path)` 8개가 exact equality인지 대조하고, Operation별 Request/Response JSON `(Operation ID, Request|Response)` 12개가 모두 존재하며 key, value, 배열과 null 구조가 12/12 exact equality인지 확인한다. - - REFACTOR: 실패가 발생하면 해당 Phase의 가장 좁은 RED test로 돌아가 최소 수정한 뒤 targeted와 전체 검증을 다시 수행한다. - - 문서화: 각 Task 완료 시 checkbox와 실제 RED/GREEN 명령 결과를 갱신한다. 실행하지 않은 staging worker E2E와 production 배포를 완료로 표시하지 않는다. - - 기대 결과: 66개 신규 Operation, 기존 callback/scheduler 호환, 원작 관리, 삭제 cascade, 보안·audit·경계 검증이 모두 통과한다. - -## 4. 명시적 비범위 확인 - -다음 항목은 구현 중 발견해도 이 계획에 임의로 추가하지 않는다. - -- Frontend 프로젝트, 메뉴, route guard, SEO/noindex, Jenkins frontend build 설정 -- 이미 적용된 PRD 27.8 Frontend baseline 프롬프트의 수정. 원작용 별도 delta 프롬프트만 문서로 제공한다. -- 신규 관리자 로그인, AI 캐릭터 사칭 token, Backend menu/capability Endpoint -- legacy `/admin/chat/original/**`의 Method·Path·Request·성공 Response 제거·변경 또는 일반 사용자용 `/api/chat/original/**` 변경. legacy mutation을 v2 input port로 수렴시키는 내부 변경은 범위에 포함한다. -- 원작 복구, 연결 캐릭터의 암묵적 일괄 해제, 원작 이미지 정리 작업 -- 신규 upload-complete Endpoint, AWS worker/Trigger/worker schedule 변경 -- 콘텐츠·원작 schema·JPA mapping 변경, DDL, backfill, 선제 데이터 migration -- V1/V2 callback dispatcher, v2 completion use case, v2 전용 예약 공개 scheduler -- FanTalk reply 별도 GET Endpoint -- 라이브, DM, 정산, 후원 분석, 시그니처 후원 -- 캐릭터 복구, 삭제 리소스 자동 복원 -- 영속 audit table, 범용 impersonation framework, 범용 CRUD engine -- legacy API 전체의 HTTP status/error envelope 일괄 변경 - -## 5. 구현 시 검증 기록 - -구현 에이전트는 각 Task에서 실제로 실행한 명령, RED 실패 원인, GREEN 성공 결과와 미실행 외부 Gate를 이 절에 누적한다. Phase 0/1은 완료했고 Phase 2 이후 구현 Task는 미착수 상태로 유지한다. - -- 문서 생성 검증: 아래 “문서 자체 검증 기록”에만 기록한다. -- 코드 구현 검증: Phase 0/1 구현 중 아래 명령을 실행해 `BUILD SUCCESSFUL`을 확인했다. - - RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 미구현 클래스 참조로 실패했다. - - 리뷰 4차 RED 확인: parser trailing root·garbage 2건, 이미지 전체 decode·dimension·pixel 3건, audit invalid 조합 3건과 public `copy` 1건이 각각 기존 구현에서 예상한 이유로 실패했다. Phase 0 legacy fixed-wire 보강과 rollback→retry는 기존 동작을 고정하는 characterization test라 production 변경 없이 통과했다. - - 리뷰 5차 RED 확인: malformed GIF의 `IndexOutOfBoundsException`·입력 유래 `IllegalArgumentException`, 20,001px logical canvas, 40MP 초과 후속 frame 4건과 character-scoped `ORIGINAL_WORK`, `UNASSIGN` 외 `creatorMemberId=null` audit 2건이 기존 구현에서 예상한 이유로 실패했다. 빈 multipart JSON, 실제 legacy 검색 query, multipart downstream 전달 보강은 기존 동작을 고정하는 characterization test로 추가했다. - - Phase 0 baseline: `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 통과. - - Phase 1 GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 이 명령의 `AdminImagePartValidatorTest`는 10MB 초과 거부와 JPEG/PNG/GIF 실제 MIME·GIF 허용 조건을 고정한다. - - Phase 0+1 통합 targeted: 위 Phase 0/1 대상 테스트 전체를 한 Gradle 명령으로 실행해 통과. - - 리뷰 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest` 통과. 이 보강은 legacy 캐릭터·원작 관리자 mutation 성공 `ApiResponse` surface, `/admin/ai-characters` segment 경계, exception handler status 행렬, target resolver 404/409 행렬, 기존 event listener AFTER_COMMIT 경계, audit `PIN` action을 포함한다. - - 리뷰 2차 보강 targeted: `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest' --tests 'kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest' --tests 'kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest' --tests 'kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest'` 통과. 이 보강은 `/admin/member/login`이 생성한 token의 기존 `TokenProvider` 검증·인증 복원, legacy 캐릭터·원작 mutation의 `data` 부재 계약, 일반 event publish의 commit/rollback transactional listener 경계를 포함한다. - - 리뷰 3차 보강 targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest` 통과. 이 보강은 실제 FCM event listener의 commit, 언어 감지 listener의 commit/rollback, `waitTransactionCommit=true` 언어 번역 listener의 commit과 세 listener의 `AFTER_COMMIT` 선언, upload-complete production `SecurityConfig`의 ADMIN/BOT JWT 허용·USER 403·익명 401, 일반 원작·legacy 원작·legacy 캐릭터 응답 field, CONTENT_MANAGER 실제 로그인 JWT의 신규 관리자 API 403을 포함한다. - - Task 1.4 retry characterization targeted: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. 실제 `TransactionTemplate`에서 첫 attempt rollback 후 동일 command를 재시도해 commit하면 direct callback은 두 attempt 합계 1회 실행됐고, 새 테스트가 production `AfterCommitExecutor` 변경 없이 통과해 현 구현의 회귀 계약으로 기록했다. - - 리뷰 4차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. production 원작 공개 matcher, legacy 캐릭터·원작 검색과 캐릭터 multipart 전체 wire, trailing JSON 거부, 이미지 decode·dimension·pixel 경계, audit action/resource/creator 조합과 public `copy` 부재, rollback 후 direct callback 재시도를 함께 검증했다. - - 리뷰 5차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.chat.original.controller.OriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterControllerTest --tests kr.co.vividnext.sodalive.admin.chat.original.AdminOriginalWorkControllerContractTest --tests kr.co.vividnext.sodalive.legacy.LegacyAdminSearchQueryContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest` 통과. GIF logical canvas·모든 frame header와 malformed decoder 경계, audit global/character creator 불변식, 실제 JPA legacy 검색 field·상태·정렬·pagination, 캐릭터 외부 API exact method/path/JSON 및 원작 update DTO 전달을 함께 검증했다. - - 리뷰 6차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 첫 frame은 정상이지만 LZW 데이터가 손상된 두 번째 frame을 기존 구현이 decode하지 않아 예외가 발생하지 않았고, 16건 중 신규 테스트 1건이 `AssertionFailedError`로 실패했다. - - 리뷰 6차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest` 통과. 33건의 실패·오류가 0건이며, GIF 모든 frame의 1x1 decode, callback의 `ADMIN`/`BOT` 성공·`USER` 403·유효하지 않은 JWT와 익명 401, 해제 audit factory의 nullable 경계를 함께 검증했다. - - Full regression: `./gradlew --no-daemon test` 통과. 리뷰 6차 GIF 구현 후 재실행도 `BUILD SUCCESSFUL in 4m 26s`로 통과했다. - - Lint: `./gradlew --no-daemon ktlintCheck` 통과. 리뷰 6차 최종 Kotlin 변경 후 재실행도 `BUILD SUCCESSFUL in 26s`로 통과했다. - - 리뷰 7차 RED 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`에서 PNG ancillary chunk와 GIF extension payload가 ImageIO custom provider까지 도달해 신규 preflight 테스트 2건이 실패했다. `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`는 legacy malformed multipart 메시지 계약 변경을 신규 테스트로 고정했다. Phase 1 누락 테스트는 hard-coded ID 전달, missing character, `creatorMember=null`, JsonNode missing-key 경계를 보강했다. - - 리뷰 7차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest`, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest`가 각각 통과했다. 이어서 위 5개 테스트 class를 한 Gradle 명령으로 묶어 재실행해 `BUILD SUCCESSFUL in 1m 2s`를 확인했다. - - 리뷰 7차 최종 검증: reviewer gate의 PNG length overflow와 정상 metadata 오탐 지적을 반영한 뒤 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest`가 `BUILD SUCCESSFUL in 30s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 23s`, `./gradlew --no-daemon test`가 `BUILD SUCCESSFUL in 7m 14s`로 통과했다. LSP는 기존과 동일하게 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했다. - - 리뷰 8차 RED 확인: PNG metadata reader flag, GIF extension 개수·sub-block 개수·누적 frame pixels·초과 LZW 출력 5건이 기존 구현에서 ImageIO 전에 거부되지 않아 `AdminImagePartValidatorTest`의 신규 assertion으로 실패했다. - - 리뷰 8차 수정 targeted: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.content.AudioContentUploadCompletionContractTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminImagePartValidatorTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminSecurityIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AiCharacterAdminExceptionHandlerTest --tests kr.co.vividnext.sodalive.legacy.LegacySodaExceptionHttpStatusContractTest --tests kr.co.vividnext.sodalive.admin.member.AdminMemberLoginServiceTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AdminPagePolicyTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.in.web.AdminJsonRequestParserTest --tests kr.co.vividnext.sodalive.v2.aicharacter.application.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence.DefaultAiCharacterPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitExecutorTest --tests kr.co.vividnext.sodalive.v2.common.application.AfterCommitEventBoundaryIntegrationTest --tests kr.co.vividnext.sodalive.v2.admin.aicharacter.application.AiCharacterAdminAuditLoggerTest`가 `BUILD SUCCESSFUL in 4m 51s`로 통과했다. 정확한 PNG ancillary/chunk, GIF frame/extension/sub-block/cumulative pixel 및 LZW 조기 종료·연속 clear·EOI 뒤 data 경계와 실제 Spring Boot DispatcherServlet multipart pre-handler admin 400/legacy 200 계약을 포함한다. - - 리뷰 8차 lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 1m 3s`로 통과했다. - - 리뷰 8차 full regression: `./gradlew --no-daemon test`가 `BUILD SUCCESSFUL in 14m 7s`로 통과했다. - - LSP: 현재 환경에 `kotlin-ls`가 설치되어 있지 않아 `lsp_diagnostics`는 실행하지 못했다. -- DB schema migration: 없음. -- 구현 기준 commit/기존 dirty·untracked manifest: 시작 기준 commit `5b700892`, 기존 dirty/untracked 없음. -- staging worker/Trigger E2E: 아직 실행하지 않음. -- production 배포: 이 계획의 코드 작성 단계만으로 완료 처리하지 않음. - -## 6. 문서 자체 검증 기록 - -- 초기 문서 검증 기록: DDL 설계 당시 Operation 58개, Phase 12개, Task 40개, RED/GREEN 명령 36쌍, TDD 예외 Task 4개, `./gradlew tasks --all`의 `BUILD SUCCESSFUL in 12s`를 확인했다. -- 무DDL 보강 후 Operation coverage: PRD의 고유 신규 Operation ID 58개와 1.1 표 합계 58개가 일치한다. -- 무DDL 보강 후 구조 검증: Phase 12개, Task 34개, RED 실행 명령 32개, 대응 GREEN 통과 확인 32개다. 나머지 2개 Task는 사유와 대체 검증을 명시한 TDD 예외다. -- 무DDL 보강 후 Task 필드 검증: 모든 Task에 `Files`, `RED`, `GREEN`, `REFACTOR`, 기대 결과가 있다. -- 무DDL 보강 후 금지 범위 검증: placeholder, 신규 login/menu/callback/FanTalk reply GET, `/v2` 관리자 URL prefix, Frontend 구현 Task, 콘텐츠 DDL·pipeline 분기·v2 scheduler 구현 Task가 모두 0건이다. -- 무DDL 보강 후 Markdown 검증: code fence 짝 일치, trailing whitespace 0건, tab 0건이다. -- 원작 보강 전 Frontend baseline API 계약 검증: Frontend catalog의 `AUTH-01` 1개와 신규 관리자 Operation 58개가 중복 없이 존재하고, 59개 모두 Request JSON 표기와 유효한 Response JSON을 가진다. Request body가 있는 Operation은 25개이며 Response에 `previewStartTime`, `previewEndTime`은 0건이다. -- 최종 삭제·공개 경계 검증: 기존 컬럼의 논리 상태 변경, 구매 콘텐츠 재생 예외, 댓글·FanTalk 차단, ranking·banner 현재 활성 상태 결합, 지정된 cache 3종 무효화가 PRD와 Task 10.1~10.2에서 일치한다. -- 최종 callback 계약 검증: CONTENT-03의 `input/{contentId}/{contentId}-content-...` basename, 기존 callback·scheduler 재사용, 신규 worker·callback·pipeline 분기 금지가 PRD와 Task 0.1, 3.2, 4.2, 11.2에서 일치한다. -- 최종 DB 변경 금지 검증: 문서 디렉터리의 SQL 파일 0건이며, 계획에 DB table·column·index·JPA mapping·DDL·backfill·migration 생성·수정 Task가 없다. -- 원작 보강 전 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL in 1s`다. -- 원작 보강 후 Operation 검증: PRD의 고유 Backend Operation 66개와 `frontend-original-work-prompt.md`의 `ORIGINAL-WORK-01`~`08` 8개가 일치한다. 기존 27.8 fenced block hash는 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`로 유지됐다. -- 원작 보강 후 구조 검증: Phase 12개, Task 37개, 실패 확인 35개, 통과 확인 35개, TDD 예외 2개이며 모든 37개 Task에 `Files`, `RED`, `GREEN`, `REFACTOR`, 기대 결과가 있다. -- 원작 보강 후 계약 검증: PRD의 JSON code block 51개와 Frontend delta의 label JSON 값 20개(Operation Request/Response 12개, 공통 오류 3개, nullable 값 5개)가 모두 유효하다. PRD와 Frontend delta의 원작 `(Operation ID, Method, Path)`는 8/8, Operation Request/Response JSON의 key·value·배열·null 구조는 12/12 exact equality다. 원작 DTO nullable·배정 응답 의미, legacy mutation 5개와 캐릭터 mutation 2개의 v2 수렴, 잠금·보상·오류 경계는 PRD와 plan에서 일치한다. -- 원작 보강 후 Markdown/DB 검증: 세 문서의 code fence 수가 각각 146개, 2개, 2개로 짝이 맞고 trailing whitespace·tab과 SQL 파일은 0건이다. -- 원작 보강 후 Gradle 구성 검증: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이다. -- 리뷰 6차 문서·Gradle 구성 검증: `./gradlew --no-daemon tasks --all`은 `BUILD SUCCESSFUL in 7s`, `git diff --check`와 세 문서의 trailing whitespace·tab 검사는 출력 0건이다. code fence 수는 각각 146개, 2개, 2개로 짝이 맞고 callback 전체 Response를 호환 대상으로 표현하는 잔여 문구도 0건이다. -- 리뷰 8차 문서 검증: Task 1.1/1.2 checkbox를 완료로 갱신했고 `git diff --check`, 세 문서의 trailing whitespace·tab 및 code fence 짝 검사를 실행해 이상 없음을 확인했다. `./gradlew tasks --all`은 기존 검증 결과를 유지하며, 이번 재실행은 Gradle wrapper lock 파일 권한 오류로 실행하지 못했다. -- 미실행 외부 Gate: 구현 전 문서 검증 단계이므로 실제 운영 데이터의 삭제 원작 연결 건수, MySQL 8 동시성, staging worker/Trigger E2E와 production 배포는 실행하지 않았다. diff --git a/docs/20260720_AI캐릭터_관리자기능/prd.md b/docs/20260720_AI캐릭터_관리자기능/prd.md deleted file mode 100644 index 34fcbb4e..00000000 --- a/docs/20260720_AI캐릭터_관리자기능/prd.md +++ /dev/null @@ -1,4626 +0,0 @@ -# PRD: AI 캐릭터 관리자 기능 - -## 1. Overview - -AI 캐릭터는 `ChatCharacter`와 `Member(role=CREATOR, memberKind=AI_CHARACTER)`가 1:1로 연결되어 있지만, AI 캐릭터 자신은 로그인할 수 없다. - -사람 관리자(`MemberRole.ADMIN`)가 AI 캐릭터와 원작을 관리하고, 하나의 AI 캐릭터를 선택한 뒤 해당 캐릭터의 크리에이터 기능을 대행할 수 있는 관리자 API를 제공한다. 관리자는 원작, 캐릭터, 콘텐츠, 댓글, 시리즈, 커뮤니티 게시글, 커뮤니티 댓글, FanTalk를 한 화면 흐름에서 관리한다. - -이번 문서는 제품 범위, 인증·메뉴 정책, 신규 API 계약, v2 도메인 구현 경계 및 현재 크리에이터 기능과의 차이를 확정한다. 구현 계획과 구현은 이 문서의 범위가 아니다. - -## 2. Problem - -현재는 다음 문제가 있다. - -- AI 캐릭터 생성·조회·수정 API는 존재하지만 명시적인 삭제 API가 없다. -- AI 캐릭터와 연결된 `creatorMember`는 크리에이터 기능을 수행할 수 있는 데이터 모델이지만 직접 로그인할 수 없다. -- 기존 크리에이터 API는 인증된 `ROLE_CREATOR` 본인을 기준으로 동작하므로 `ROLE_ADMIN`이 그대로 호출할 수 없다. -- 관리자 대행 작업을 위해 AI 캐릭터용 로그인이나 사칭 토큰을 발급하면 기존의 AI 캐릭터 로그인 금지 정책과 충돌하고 실제 작업 관리자를 추적하기 어렵다. -- 사용자가 요구한 기능 목록에는 현재 크리에이터가 수행할 수 있는 일부 보조 기능과 라이브·정산 영역이 빠져 있다. -- 기존 관리자 메뉴는 서버가 DB 데이터를 기준으로 내려주지만 `role`만으로 조회하므로, 동일한 `ADMIN`이 사용하는 전체 플랫폼 관리자와 AI 캐릭터 관리자 화면의 메뉴를 구분할 수 없다. -- legacy 서비스에는 시리즈 순서 변경과 댓글·FanTalk 답글의 부모 귀속 검증처럼 그대로 계승하면 안 되는 규칙과 결손이 있다. -- 원작 등록·수정·삭제·검색·캐릭터 배정 기능은 legacy `/admin/chat/original/**`에만 있고 v2에는 없다. 캐릭터 등록·수정은 `originalWorkId`를 받지만, 신규 클라이언트가 원작을 검색·선택할 v2 계약이 없어 legacy API 없이는 화면을 완성할 수 없다. -- legacy 원작 배정은 존재하지 않거나 비활성인 캐릭터 ID를 조용히 무시하고, 다른 원작 소속 캐릭터도 경로 원작 확인 없이 해제한다. 원작 삭제는 연결 캐릭터를 남기지만 삭제 후 해제 API도 막아 그대로 이관할 수 없다. -- v2가 legacy 비즈니스 서비스와 DTO에 직접 의존하면 새 API의 권한·오류·페이징 계약이 legacy 구현에 다시 결합된다. - -## 3. Goals - -- 기존 관리자 계정으로만 AI 캐릭터 관리자 기능에 접근하게 한다. -- 관리자가 AI 캐릭터를 조회·등록·수정·논리 삭제할 수 있게 한다. -- 관리자가 원작을 검색·조회·등록·수정·논리 삭제하고, 원작과 AI 캐릭터의 연결을 조회·배정·해제할 수 있게 한다. -- 관리자가 선택한 AI 캐릭터의 `creatorMember` 명의로 다음 작업을 수행하게 한다. - - 콘텐츠 조회·등록·수정·논리 삭제 - - 콘텐츠 상단 고정·고정 해제 - - 콘텐츠 댓글과 답글 조회·등록·수정·논리 삭제 - - 선택한 AI 캐릭터의 콘텐츠에 작성된 다른 사용자의 댓글 논리 삭제 - - 콘텐츠 카테고리 조회·등록·수정·논리 삭제·순서 변경 및 콘텐츠 구성 - - 시리즈 조회·등록·수정·논리 삭제 - - 시리즈 콘텐츠 추가·제거·조회 및 시리즈 순서 변경 - - 커뮤니티 게시글 조회·등록·수정·논리 삭제 - - 커뮤니티 게시글 고정·고정 해제 - - 커뮤니티 게시글 댓글과 답글 조회·등록·수정·논리 삭제 - - 선택한 AI 캐릭터의 게시글에 작성된 다른 사용자의 댓글 논리 삭제 - - FanTalk 조회 - - FanTalk에 AI 캐릭터 답글 등록·수정·논리 삭제 - - 선택한 AI 캐릭터를 대상으로 작성된 FanTalk 원문 논리 삭제 - - 크리에이터 채널 공지 조회·등록·수정 - - 크리에이터 채널 SNS URL, 크리에이터 태그와 후원 랭킹 공개 설정 조회·수정 -- 신규 관리자 Endpoint의 Request와 Response 계약을 문서로 고정한다. -- v2 내부에서 재사용할 도메인 기능과 v2 각 도메인에 새로 구현할 command 기능을 구분한다. -- legacy 비즈니스 로직은 실행 의존성이 아니라 현행 동작을 파악하기 위한 참고 근거와 회귀 테스트 기준으로만 사용한다. -- 현재 크리에이터 기능 중 요구 목록에서 빠진 기능을 식별하고 이번 범위 포함 여부를 확정한다. -- v2 관리자 페이지 메뉴의 소유권과 제공 방식을 확정한다. - -## 4. Non-Goals - -- AI 캐릭터 또는 연결된 `creatorMember`의 로그인 허용 -- AI 캐릭터를 가장하는 JWT, 세션 또는 토큰 발급 -- `CONTENT_MANAGER`, `AGENT`, `CREATOR`, 일반 사용자의 AI 캐릭터 관리 허용 -- 기존 공개 크리에이터 API의 인증·권한 계약 변경 -- 기존 `/admin/chat/character/**` Endpoint의 즉시 삭제 또는 호환성 파괴 -- 기존 `/admin/chat/original/**` Endpoint의 Method·Path·Request·성공 Response 호환성 파괴. 데이터 불변식을 지키기 위한 mutation 정책 수렴은 이번 범위에 포함한다. -- 일반 사용자용 `/api/chat/original/**` 계약 변경 -- 캐릭터 및 연관 리소스의 물리 삭제 -- 삭제한 캐릭터의 복구 기능 -- 삭제한 원작의 복구 기능 -- 좋아요, 구매, 팔로우, 후원, DM 등 일반 사용자 행동의 관리자 대행 -- AI 캐릭터의 라이브 방송 운영, 예약 방송, 라이브 메뉴, 룰렛 -- AI 캐릭터의 정산·매출 조회 및 지급 처리 -- 시그니처 후원 설정 -- 관리자 메뉴 시스템 전체 개편 또는 신규 capability 시스템 도입 -- FanTalk 답글 전용 조회 Endpoint -- 기존 AWS S3 Trigger 기반 오디오 가공 worker의 코드·스케줄·설정 변경 -- 기존 upload-complete 계약을 대체하는 신규 내부 callback Endpoint의 선제 구현 -- 기존 `content` 테이블의 컬럼 추가·backfill 또는 v2 전용 콘텐츠 테이블 생성 -- V1/V2 upload pipeline 분기와 v2 전용 예약 공개 scheduler 생성 -- 관리자 화면 UI 구현 -- 이미 적용된 27.8 Frontend baseline 프롬프트의 수정 -- 구현 계획 또는 서버 코드 구현 - -## 5. Target Users - -### Primary User - -- `MemberRole.ADMIN`을 가진 내부 운영 관리자 - -### 접근 불가 사용자 - -- 비로그인 사용자 -- `CONTENT_MANAGER` -- `AGENT` -- 일반 `CREATOR` -- `USER` -- `memberKind=AI_CHARACTER`인 연결 크리에이터 Member - -## 6. Current-State Investigation - -### 6.1 AI 캐릭터와 크리에이터 Member - -- `ChatCharacter.creatorMember`는 non-null, unique 1:1 관계다. -- AI 캐릭터 등록 시 연결 Member는 다음 값으로 생성된다. - - `role=CREATOR` - - `memberKind=AI_CHARACTER` - - `email=null` - - `password=""` -- 캐릭터의 이름, 프로필 이미지, 소개는 연결 Member의 `nickname`, `profileImage`, `introduce`와 동기화된다. -- 기존 크리에이터 공개 API의 `creatorId`는 계속 `Member.id`를 의미한다. -- AI 캐릭터 Member는 일반 로그인과 크리에이터 관리자 로그인 모두에서 차단된다. - -따라서 관리자 대행 API는 `characterId`로 대상 AI 캐릭터와 연결 `creatorMemberId`를 해석하고, 이를 v2 각 도메인 use case의 명시적인 작업 대상 ID로 전달해야 한다. - -### 6.2 현재 관리자 인증 - -현재 관리자 로그인은 이미 존재한다. - -```http -POST /admin/member/login -Content-Type: application/json -``` - -`ADMIN`과 `CONTENT_MANAGER` 계정이 로그인할 수 있고 JWT를 반환한다. AI 캐릭터 관리 API는 이 중 `ADMIN`만 허용한다. - -별도 AI 캐릭터 관리자 로그인 Endpoint는 같은 Member 조회, 비밀번호 검증, JWT 발급 및 보안 설정을 중복할 뿐 새로운 보안 경계를 만들지 못한다. - -### 6.3 현재 AI 캐릭터 관리자 API - -기존 `/admin/chat/character`에는 다음 `ADMIN` 전용 기능이 있다. - -| Method | Endpoint | 기능 | -|---|---|---| -| `GET` | `/admin/chat/character/list` | 활성 캐릭터 목록 | -| `GET` | `/admin/chat/character/search` | 활성 캐릭터 검색 | -| `GET` | `/admin/chat/character/{characterId}` | 캐릭터 상세 | -| `POST` | `/admin/chat/character/register` | 캐릭터 등록 | -| `PUT` | `/admin/chat/character/update` | 캐릭터 수정 및 활성 상태 변경 | - -명시적인 `DELETE` Endpoint는 없으며 수정 요청의 `isActive=false`가 삭제 역할을 한다. 등록·수정 성공 응답은 현재 `data=null`이다. - -### 6.4 현재 메뉴 - -현재 관리자 메뉴는 다음 흐름으로 서버가 결정한다. - -```text -JWT principal Member - -> MenuService.getMenus(member) - -> MenuRepository.getMenu(member.role) - -> role, isActive, orders 기준 조회 - -> title, route, items 반환 -``` - -`GET /menu`는 요청한 관리 화면 또는 애플리케이션을 식별하지 않고 로그인 Member의 `role`만 사용한다. 따라서 같은 `ADMIN`이 여러 관리자 페이지를 사용할 때 페이지별로 다른 메뉴를 반환할 수 없다. v2 AI 캐릭터 관리자 메뉴를 이 Endpoint에 추가하면 모든 legacy ADMIN 화면에도 같은 메뉴가 노출된다. - -관리 화면은 다음 세 surface로 구분한다. - -| Admin surface | 로그인 주체 | 메뉴 판단 | -|---|---|---| -| 전체 플랫폼 관리자 | `ADMIN`, `CONTENT_MANAGER` | legacy `GET /menu` 현행 유지 | -| AI 캐릭터 관리자 v2 | `ADMIN` | 신규 클라이언트 정적 메뉴 | -| 크리에이터 관리자 | `CREATOR`, `AGENT` | legacy `GET /menu` 현행 유지 | - -전체 플랫폼 관리자와 AI 캐릭터 관리자는 역할이 같으므로 기존 role만으로 메뉴를 분리할 수 없다. - -### 6.5 v2 구현 및 재사용 조사 - -legacy 코드는 요구 기능의 데이터 모델과 현행 동작을 확인하는 근거로 사용하되 런타임 비즈니스 로직으로 호출하지 않는다. - -| 영역 | 현행 근거 | v2 판단 | v2에 필요한 기능 | -|---|---|---|---| -| 캐릭터 | legacy `AdminChatCharacterService`, `ChatCharacterService` | 신규 구현 | 캐릭터 CRUD use case, 연결 creator 생성·동기화 port, 외부 캐릭터 API·파일 port | -| 원작 | legacy `AdminOriginalWorkService`, `OriginalWorkRepository` | 신규 구현 | 원작 CRUD·검색, 이미지·번역 port, 캐릭터 연결 조회·배정·해제 use case | -| 콘텐츠 | legacy `AudioContentService`, `CreatorAdminContentService` | 신규 구현 | 관리자용 content command/query use case와 소유권 정책 | -| 콘텐츠 가공 완료 연동 | AWS S3 Trigger worker, 기존 `PUT /audio-content/upload-complete` | 기존 외부 계약과 처리 흐름 유지 | v2 생성도 기존 S3 key·metadata·DB 필드 계약을 따르고 별도 분기·worker·scheduler를 만들지 않음 | -| 콘텐츠 댓글 | legacy `AudioContentCommentService` | 신규 구현 | comment command/query use case, 작성자·콘텐츠 소유자 삭제 정책, 부모 귀속 정책 | -| 콘텐츠 카테고리 | legacy `CategoryService`, `CreatorAdminCategoryService` | 신규 구현 | category command/query use case, 카테고리와 포함 콘텐츠의 동일 소유자 정책 | -| 시리즈 | legacy `CreatorAdminContentSeriesService` | 신규 구현 | series command/query use case, 콘텐츠 구성과 소유자 한정 순서 정책 | -| 커뮤니티 조회 | 기존 v2 `CreatorChannelCommunityQueryService`와 query port | v2 내부 확장 가능 | 관리자 전용 projection 또는 admin query use case | -| 커뮤니티 변경 | legacy `CreatorCommunityService` | 신규 구현 | v2 community command use case와 게시글·댓글 소유권 정책 | -| FanTalk 조회 | 기존 v2 `CreatorChannelFanTalkQueryService`와 query port | v2 내부 재사용·확장 가능 | 관리자 조회 use case와 관리자 API DTO 매핑 | -| FanTalk 답글 | legacy `ExplorerService` | 신규 구현 | v2 FanTalk reply command use case와 루트·답글 귀속 정책 | -| 채널 공지 | legacy `ExplorerService.saveNotice` | 신규 구현 | v2 creator channel notice upsert/query use case와 알림 port | -| 채널 프로필·크리에이터 태그 | legacy `MemberService.profileUpdate`, `MemberTagService` | 신규 구현 | v2 channel profile query/update use case와 활성 creator tag metadata query | - -결론은 다음과 같다. - -- legacy Controller, `*Service`, Request/Response DTO는 호출하거나 import하지 않는다. -- 기존 v2 domain/application/port는 계약이 맞는 경우에만 v2 내부에서 재사용하거나 확장한다. -- 없는 command 기능은 콘텐츠, 댓글, 시리즈, 커뮤니티, FanTalk 등 각 v2 도메인 패키지에 구현한다. -- 원작은 캐릭터 등록·수정과 관리자 원작 화면에서 함께 사용되므로 `v2.originalwork` 독립 도메인이 소유하고, 캐릭터 도메인은 원작의 `isDeleted=false` 참조 계약만 사용한다. -- 기존 `PUT /audio-content/upload-complete`는 AWS 연동 계약과 현재 처리 흐름을 그대로 사용한다. v2 콘텐츠 생성 use case가 기존과 같은 `content` row와 S3 입력 계약을 만들면 callback은 생성 경로를 구분하지 않고 동일하게 처리할 수 있다. -- 관리자 API 계층은 대상 AI 캐릭터를 해석하고 각 v2 use case를 조정한다. -- Controller 간 호출과 내부 HTTP 호출은 하지 않는다. -- 기존 테이블과 JPA 매핑을 복제하지 않는다. v2 persistence adapter가 기존 스키마에 접근해 v2 port의 record/domain model로 변환한다. - -### 6.6 현재 원작 관리자 API - -legacy `/admin/chat/original`에는 `ROLE_ADMIN` 전용 원작 등록·수정·논리 삭제, 목록, 검색, 상세, 연결 캐릭터 목록, 캐릭터 일괄 배정·해제 기능이 있다. 캐릭터는 한 원작에만 연결될 수 있고 새 원작 배정은 기존 연결을 교체한다. - -다만 신규 v2 계약에서는 다음 legacy 동작을 계승하지 않는다. - -- 목록과 검색을 별도 Endpoint로 나누고 검색만 비페이징으로 전체 반환하는 동작 -- 존재하지 않거나 비활성인 캐릭터 ID를 조용히 무시하는 부분 성공 -- 요청 경로의 원작 소속인지 확인하지 않고 다른 원작의 캐릭터까지 해제하는 동작 -- 연결 캐릭터를 남긴 채 원작을 삭제하고, 삭제 후에는 해당 연결을 해제할 수도 없게 만드는 동작 -- 수정의 nullable field에서 `null`을 값 삭제가 아닌 “변경 없음”으로 처리하는 부분 수정 의미 -- 생성 후 이미지 업로드가 실패하면 이미지 없는 원작 row를 남기고, DB 실패 시 업로드 이미지를 보상하지 않는 실행 순서 -- mutation 성공 응답의 `data=null` - -v2는 legacy 기능을 1:1 URL 복사하지 않는다. 목록의 `search` Query로 목록·검색을 합치고, 나머지 CRUD·상세·연결 관리 능력을 11.7의 8개 Operation으로 제공한다. legacy Endpoint의 Method·Path·Request·성공 Response는 기존 클라이언트 호환을 위해 유지하되, 원작을 변경하는 요청은 같은 v2 command와 잠금 정책으로 수렴해 공존 중 불변식을 우회하지 못하게 한다. - -## 7. Product Decisions - -### 7.1 로그인 - -- 별도의 로그인 기능을 만들지 않는다. -- 기존 `POST /admin/member/login`과 Bearer JWT를 재사용한다. -- 로그인 Endpoint 자체는 `CONTENT_MANAGER`에도 토큰을 발급할 수 있지만 AI 캐릭터 관리 Endpoint는 `ROLE_ADMIN`만 허용한다. -- AI 캐릭터 Member로 로그인하거나 AI 캐릭터 사칭 토큰을 발급하지 않는다. -- 모든 관리자 대행 변경 작업의 인증 주체는 실제 사람 관리자이며, 도메인 작업 대상만 AI 캐릭터의 `creatorMember`다. 기존 AWS worker callback은 관리자 대행 API가 아니며 현재 인증·응답 계약을 유지한다. - -### 7.2 v2 관리자 메뉴 - -- v2 AI 캐릭터 관리자 메뉴는 해당 클라이언트가 정적으로 소유한다. -- legacy `GET /menu`를 v2 AI 캐릭터 관리자에서 호출하지 않는다. -- 신규 v2 menu Endpoint도 만들지 않는다. -- 전체 플랫폼 관리자와 크리에이터 관리자의 기존 메뉴 방식은 변경하지 않는다. -- 서버 방식으로 분리하려면 클라이언트가 `surface=PLATFORM_ADMIN|AI_CHARACTER_ADMIN|CREATOR_ADMIN` 같은 값을 보내야 한다. 클라이언트가 이미 알고 있는 UI 문맥을 서버가 다시 route로 돌려주는 구조이고 인가에도 사용할 수 없으므로 이번 v2에는 채택하지 않는다. -- 클라이언트는 현재 관리자 surface가 `AI_CHARACTER_V2`임을 알고 있으므로 해당 surface의 제목, 순서, 계층, route와 화면 컴포넌트를 함께 관리한다. - -대안별 판단은 다음과 같다. - -| 방식 | 판단 | 이유 | -|---|---|---| -| legacy `GET /menu` 그대로 재사용 | 불가 | 입력이 인증 Member의 `role`뿐이라 같은 `ADMIN`의 전체 플랫폼 관리자와 AI 캐릭터 관리자 surface를 구분할 수 없음 | -| `GET /menu?surface=...`로 확장 | 이번 v2에서 미채택 | 호출 클라이언트가 이미 아는 화면 문맥으로 정적 route를 다시 조회하며, 메뉴 응답이 Backend 인가를 대신할 수도 없음 | -| surface별 신규 menu Endpoint | 이번 v2에서 미채택 | 현재 요구에는 사용자별 메뉴 차이, 운영 중 메뉴 토글 또는 세부 capability가 없어 서버 계약과 저장소만 추가됨 | -| v2 클라이언트 정적 메뉴 | 채택 | 신규 AI 캐릭터 관리자 surface의 route·컴포넌트와 메뉴를 한 곳에서 함께 변경할 수 있음 | - -- 최소 전역 진입 메뉴는 다음과 같다. - -```json -[ - { - "key": "ai-characters", - "title": "AI 캐릭터 관리", - "route": "/ai-characters" - }, - { - "key": "original-works", - "title": "원작 관리", - "route": "/ai-characters/original-works" - } -] -``` - -- 원작 관리는 캐릭터 선택이 필요 없는 global route다. 캐릭터 선택 후 콘텐츠, 시리즈, 커뮤니티, FanTalk, 채널 설정은 클라이언트의 하위 route 또는 tab으로 구성한다. -- 로그인 응답의 `role=ADMIN`은 클라이언트 route guard에 사용할 수 있지만 보안 경계가 아니다. -- 백엔드의 모든 관리 API가 `ROLE_ADMIN`을 독립적으로 검증한다. -- 역할별 세부 권한, 서버 feature flag 또는 운영 중 메뉴 활성·비활성 전환이 필요해지는 시점에는 UI route를 내려주는 메뉴 API보다 v2 capability API를 별도 PRD로 검토한다. - -### 7.3 신규 API 형태 - -- 신규 API는 v2 패키지에 구현하되 현재 관리자 URL 관례에 맞춰 `/admin/ai-characters`를 base path로 사용한다. -- 기존 `/admin/chat/character/**`는 호환성을 위해 유지한다. -- 기존 `/admin/chat/original/**`도 호환성을 위해 유지한다. -- legacy 원작 관리자 mutation인 `POST /register`, `PUT /update`, `DELETE /{id}`, `POST /{id}/assign-characters`, `POST /{id}/unassign-characters`는 기존 URL·Request·성공 Response를 유지한 채 같은 v2 원작 command를 호출한다. legacy 캐릭터 `POST /admin/chat/character/register`, `PUT /admin/chat/character/update`는 외부 캐릭터·이미지·DB 작업 뒤 원작 최종 검증만 실패하는 부분 성공을 피하기 위해 전체 mutation을 같은 v2 캐릭터 command로 호출하고, 그 command가 v2 원작 참조·잠금 정책을 사용한다. legacy web adapter가 legacy DTO를 v2 command로 변환하며 v2가 legacy Controller·Service·DTO를 호출하는 역방향 의존은 만들지 않는다. -- legacy 원작 `PUT /update`는 기존 nullable 부분 수정 의미를 유지한다. legacy adapter는 non-null 입력만 v2 호환 patch command로 변환하고, v2 application이 원작 row를 잠근 같은 transaction 안에서 현재 값에 병합한다. adapter가 먼저 읽은 stale snapshot으로 전체 교체하지 않는다. legacy DTO는 누락과 명시적 `null`을 구분하지 못하므로 legacy 경로의 `null`은 계속 “변경 없음”이며, 명시적 값 삭제는 신규 v2 API에서만 지원한다. -- legacy 캐릭터 등록·수정 Request의 nullable 부분 수정과 body `id`는 호환 adapter에서 v2 캐릭터 create/patch/delete command로 변환한다. 원작 값의 정확한 호환 의미는 다음 표와 같고, 신규 `CHAR-03`·`CHAR-04`는 계속 0을 400으로 거부한다. - -| 경로 | `originalWorkId` 누락 또는 `null` | `originalWorkId=0` | 양수 ID | -|---|---|---|---| -| legacy 캐릭터 등록 | 원작 미연결 생성 | 원작 미연결 생성 | 활성 원작에 연결 | -| legacy 캐릭터 수정 | 기존 연결 변경 없음 | 기존 연결 해제 | 활성 원작으로 연결·이동 | -| 신규 `CHAR-03` | 원작 미연결 생성 | 400 | 활성 원작에 연결 | -| 신규 `CHAR-04` | 기존 연결 해제 | 400 | 활성 원작으로 연결·이동 | -- legacy 원작 목록·검색·상세·연결 캐릭터 목록과 일반 사용자용 `/api/chat/original/**` 조회 계약은 그대로 유지한다. legacy mutation의 오류는 기존 legacy 오류 envelope를 유지하되 연결된 원작 삭제, 잘못된 배정·해제처럼 데이터 불변식을 깨는 요청은 더 이상 성공시키지 않는다. -- 신규 API는 REST resource 형태를 사용하고 삭제는 `DELETE`로 표현한다. -- 캐릭터와 creator 작업 리소스는 기존 `isActive=false`, 원작은 기존 `isDeleted=true`인 논리 삭제로 처리한다. -- 원작 관리의 global base path는 `/admin/ai-characters/original-works`다. 원작 목록의 `search` Query가 legacy 목록과 검색 능력을 하나로 합치며 별도 `/search` Endpoint는 만들지 않는다. -- 캐릭터 범위 대행 작업은 Path의 `characterId`가 명시적인 대상이며 Request body에는 `creatorId`를 받지 않는다. global 원작 CRUD는 `characterId`를 요구하지 않는다. -- 신규 Endpoint는 v2 외부의 legacy Controller, Service 또는 DTO를 호출하지 않는다. - -### 7.4 삭제 - -- 관리 API에서 독립 업무 리소스로 다루는 캐릭터, 콘텐츠, 댓글, 카테고리, 시리즈, 게시글, FanTalk 원문과 답글은 물리 삭제하지 않는다. -- 원작 삭제는 기존 `OriginalWork.isDeleted=true`로 처리하고 링크, 태그, 이미지와 번역 이력을 보존한다. -- 활성·비활성 여부와 관계없이 연결 캐릭터가 하나라도 남은 원작 삭제는 409를 반환한다. 관리자가 11.7의 연결 목록과 해제 API로 관계를 명시적으로 정리한 뒤 삭제하게 하며, 삭제 요청이 캐릭터를 암묵적으로 일괄 해제하지 않는다. -- 삭제된 원작은 목록·검색·상세와 신규 배정 대상에서 제외한다. 삭제 command는 `isDeleted`를 연결 수보다 먼저 판정하므로, 과거 불일치로 연결 캐릭터가 남아 있더라도 이미 삭제된 같은 원작의 반복 삭제는 멱등하게 `isDeleted=true`를 반환한다. 복구는 제공하지 않는다. -- legacy 삭제로 이미 `isDeleted=true` 원작을 가리키는 캐릭터가 있는지는 배포 전에 읽기 전용으로 건수를 확인한다. 이번 구현이 이를 자동 해제하거나 data migration하지 않는다. 결과가 0건이면 배포를 진행하고, 1건 이상이면 mutation 전환을 활성화하기 전에 대상과 영향 범위를 보고해 별도 승인된 데이터 보정 계획을 먼저 완료한다. -- 시리즈-콘텐츠와 creator-Member-tag 연결은 독립 업무 리소스나 보존 대상 이력이 아니므로 관계 해제 시 join row만 물리 제거한다. 해당 관계 해제 작업에서는 시리즈·콘텐츠·Member·creator tag 자체를 변경하지 않는다. 콘텐츠 카테고리 연결은 기존 `CategoryContent.isActive` 모델을 유지한다. -- 캐릭터 삭제는 `ChatCharacter.isActive=false`와 연결 `creatorMember.isActive=false`를 같은 작업으로 처리한다. -- 연결 `creatorMember`와 기존 콘텐츠·정산·구매 이력 데이터는 삭제하지 않는다. -- 캐릭터 삭제 시 연결 Member의 이름을 `inactive_*`로 변경하거나 기존 표시 정보를 덮어쓰지 않는다. -- 삭제한 캐릭터의 이름은 재사용하지 않고 예약 상태로 유지한다. -- 외부 캐릭터 레코드는 물리 삭제하거나 rename하지 않는다. 모든 채팅 진입점이 로컬 `ChatCharacter.isActive=false`를 확인해 사용을 차단한다. -- 비활성 캐릭터는 관리자 목록에서 상태 필터로 조회할 수 있다. -- 비활성 캐릭터에 대한 신규 등록·수정·답글 작업은 거부한다. 조회와 기존 리소스의 논리 삭제만 허용한다. -- 캐릭터 삭제 시 소유 콘텐츠 row는 raw `content.isActive=false`로 전환하되 `releaseDate`, 가공 경로, `duration`과 구매 이력은 변경하지 않는다. 따라서 기존에 삭제되지 않은 콘텐츠의 계산 상태는 `SUSPENDED`가 되고, 이미 `isActive=false && releaseDate=null`인 `DELETED` 콘텐츠도 그대로 유지한다. 활성 시리즈·커뮤니티 게시글·콘텐츠 카테고리도 비활성 상태로 전환하며 데이터와 관계는 보존한다. -- 예약 콘텐츠와 처리 중 콘텐츠는 삭제된 캐릭터 명의로 공개되지 않는다. -- 기존 upload-complete는 `releaseDate=null`인 삭제 콘텐츠 또는 비활성 creator의 콘텐츠를 다시 활성화하지 않고, 기존 예약 공개 조회도 활성 creator의 콘텐츠만 대상으로 한다. scheduler component의 cron·lock과 AWS worker는 변경하지 않는다. -- 일반 사용자용 콘텐츠 목록·검색·추천·크리에이터 채널은 기존 `content.isActive=true` 공개 조건으로 삭제 캐릭터의 콘텐츠를 제외한다. 직접 상세 조회도 미구매 사용자에게는 거부하되, 기존 `KEEP`·`RENTAL` 구매자는 주문 이력 기반 상세 조회와 Signed URL 재생을 유지한다. -- 콘텐츠·크리에이터 랭킹 snapshot row는 이력으로 보존한다. latest/previous visible snapshot 조회 시 현재 콘텐츠와 creator Member의 활성 상태를 결합해 삭제 캐릭터와 해당 콘텐츠를 제외하며 snapshot을 삭제하거나 다시 생성하지 않는다. -- snapshot을 사용하지 않는 legacy creator ranking도 조회 시 현재 creator Member가 활성인지 확인한다. -- 기존 캐릭터·콘텐츠·시리즈 banner row는 보존하되 공개 언어별 banner 조회에서 연결 캐릭터, creator 또는 시리즈와 시리즈 소유자가 모두 활성인 대상만 반환한다. 운영 관리자용 banner 목록은 기존 row를 계속 조회할 수 있다. -- 삭제 캐릭터 콘텐츠의 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화는 차단한다. 작성자 또는 콘텐츠 소유자의 기존 댓글 논리 삭제는 허용한다. 기존 구매 유지 예외는 오디오 재생에만 적용하며 댓글·답글 또는 다른 소셜 작업을 허용하는 근거가 아니다. -- 삭제 commit 후 기존 공개 DTO가 남지 않도록 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale` cache namespace를 1회 clear한다. 동적 key를 전부 열거하거나 Redis key scan을 추가하지 않는다. rollback과 이미 비활성인 캐릭터의 반복 삭제에서는 clear하지 않으며 legacy 비활성화도 같은 v2 삭제 use case를 통해 동일하게 처리한다. -- FanTalk 원문과 기존 AI 답글은 사용자 작성 이력 보존을 위해 변경하지 않지만 비활성 creator 채널은 공개 조회와 새 FanTalk 수신을 거부한다. -- 이미 구매한 콘텐츠는 구매 이력 기반 재생을 유지한다. -- 캐릭터 복구 기능은 이번 범위에 없으므로 비활성화한 연관 리소스를 자동 복구하지 않는다. -- 공존 기간의 legacy `PUT /admin/chat/character/update`가 활성 캐릭터를 `isActive=false`로 바꾸는 경우에도 동일한 v2 캐릭터 삭제 use case를 호출해 위 cascade를 적용한다. -- legacy 경로를 포함해 비활성 캐릭터의 `isActive=true` 전환은 거부한다. 복구가 필요하면 별도 PRD에서 데이터·외부 시스템 복구 정책을 먼저 정의한다. - -### 7.5 “모든 크리에이터 기능”의 범위 - -현재 요구에 열거된 기능만으로는 실제 크리에이터 기능 전체와 일치하지 않는다. - -이번 PRD는 AI 캐릭터의 비동기 발행·커뮤니티 운영 기능을 완성하는 범위로 정의한다. 요구 목록과 직접 결합된 다음 누락 기능은 이번 범위에 포함한다. - -- 콘텐츠 상단 고정·고정 해제 -- 콘텐츠 카테고리 조회·등록·수정·논리 삭제·순서 변경 및 콘텐츠 구성 -- 콘텐츠 테마 조회 -- 시리즈 콘텐츠 조회·검색·추가·제거 -- 시리즈 순서 변경 -- 시리즈 장르 조회 -- 커뮤니티 게시글 고정·고정 해제 -- 크리에이터 채널 공지 조회·등록·수정 -- 크리에이터 채널 SNS·크리에이터 태그·후원 랭킹 공개 설정 조회·수정 -- 크리에이터 소유자 권한의 FanTalk 원문 논리 삭제 - -조사에서 추가로 확인한 주요 기능의 포함·제외 판단은 다음과 같다. - -| 빠진 기능 | 이번 범위 | 판단 | -|---|---|---| -| 콘텐츠 카테고리 조회·편집·순서 변경 | 포함 | 콘텐츠 발행 화면과 직접 결합된 개인 분류이며 콘텐츠 소유권 검증을 v2에 구현함 | -| 콘텐츠 테마·시리즈 장르 조회 | 포함 | legacy API를 호출하지 않고 v2 content·series query port로 같은 기준정보를 조회함 | -| 크리에이터 채널 공지 수정 | 포함 | 기존 채널 notice 저장소를 v2 port로 연결하고 관리자용 조회·upsert API를 제공함 | -| 채널 SNS URL·후원 랭킹 공개 설정 | 포함 | 캐릭터 원본 정보와 겹치지 않는 Member 채널 설정을 별도 v2 API로 관리함 | -| 크리에이터 Member 태그 조회·편집 | 포함 | `ChatCharacter.tags`와 다른 채널 탐색용 데이터이며 태그가 없으면 일부 크리에이터 탐색 조회에서 제외되므로 별도 관리함 | -| FanTalk 원문 moderation | 포함 | 대상 크리에이터가 타인 작성 FanTalk를 비활성화할 수 있는 현재 권한을 보존함 | -| 팔로워 목록·후원 내역·후원 랭킹 조회 | 제외 | 대행 mutation이 아닌 운영 분석 조회이며 개인정보·재무 권한과 함께 별도 관리자 분석 범위로 다룸 | -| 채널 후원 메시지 조회 | 제외 | 비밀 메시지 시야와 재무 권한 정책이 필요함 | -| 시그니처 후원 설정 | 제외 | 후원 상품·정산 책임 정책 필요 | -| 라이브 방 생성·예약·메뉴·룰렛 | 제외 | 실시간 진행 주체와 장애 대응 정책 필요 | -| 라이브·콘텐츠·후원·커뮤니티 매출/정산 조회 | 제외 | 관리자 전역 정산 기능과 권한·개인정보 정책으로 다뤄야 함 | -| 크리에이터 로그인·로그아웃·메뉴 | 제외 | AI 캐릭터 로그인 금지 정책을 유지함 | -| 이름·이미지·소개 수정 | 캐릭터 수정으로 대체 | `ChatCharacter`가 원본이고 연결 Member로 동기화함 | -| Member 성별 수정 | 캐릭터 수정으로 대체 | AI의 성별 원본은 `ChatCharacter.gender`로 유지하고, 라이브가 제외된 AI creator의 `Member.gender`를 별도 관리하지 않음 | -| 프로필 요청의 `container` | 제외 | 클라이언트 실행 환경 값이지 채널의 관리 대상 설정이 아님 | -| 좋아요·구매·팔로우·후원·DM | 제외 | 크리에이터 운영 작업이 아닌 소비자 행동이며 AI 캐릭터 DM은 금지됨 | - -따라서 이번 범위 완료만으로 라이브·정산을 포함한 문자 그대로의 “크리에이터 전체 기능 동등성”을 선언하지 않는다. 비동기 콘텐츠·커뮤니티 운영 범위의 기능 동등성을 완료 기준으로 삼는다. - -### 7.6 v2 의존성 경계 - -“v2 외부 로직을 재사용하지 않는다”는 비즈니스 로직 경계로 확정한다. - -| 구분 | 정책 | -|---|---| -| legacy Controller | 사용 금지 | -| legacy application/domain Service | 사용 금지 | -| legacy Request/Response DTO | 사용 금지 | -| 기존 v2 domain/application/port | 계약이 맞으면 v2 내부 재사용·확장 허용 | -| 공통 인증과 `ApiResponse` | 플랫폼 공통 기능이므로 재사용 허용 | -| `Member` security principal | web adapter에서 `adminMemberId`로 변환하는 용도로만 허용 | -| 기존 DB 테이블·JPA entity·QueryDSL Q type | persistence adapter에서만 재사용 허용 | -| S3, CDN, 외부 캐릭터 API client | v2 outbound port 뒤의 infrastructure adapter에서 사용 허용 | - -v2 domain과 application은 legacy JPA entity, legacy Repository, security principal 및 web DTO를 public signature에 노출하지 않는다. persistence adapter는 기존 테이블을 읽고 쓰되 v2 port record 또는 v2 domain model로 변환한다. - -### 7.7 원작 관리와 캐릭터 연결 - -- 원작은 특정 캐릭터의 소유 리소스가 아닌 global 관리자 기준정보다. 원작 CRUD 화면에 들어가기 전에 캐릭터를 선택하지 않는다. -- 캐릭터 등록·수정 화면에서는 11.7의 원작 페이징 검색을 사용해 원작을 선택한다. -- 신규 `CHAR-03`의 `originalWorkId=null`은 미연결 생성이고, 신규 `CHAR-04`의 명시적 `originalWorkId=null`은 기존 연결 해제다. 신규 계약은 legacy의 `originalWorkId=0` sentinel을 사용하지 않으며 0 이하는 400이다. -- 양수 `originalWorkId`는 `OriginalWork.isDeleted=false`인 원작만 허용한다. -- 한 캐릭터는 최대 한 원작에만 연결된다. 배정 API로 이미 다른 원작에 연결된 활성 캐릭터를 선택하면 새 원작으로 원자적으로 이동한다. -- 배정은 연결 `creatorMember`까지 유효한 활성 AI 캐릭터만 허용한다. 해제는 삭제 전 관계 정리를 위해 현재 원작에 연결된 활성·비활성 캐릭터를 허용하며, legacy 불일치 관계도 정리할 수 있도록 연결 `creatorMember`의 누락·상태·종류를 해제 조건으로 사용하지 않는다. -- 배정·해제 Request의 ID는 중복 없이 하나 이상이어야 한다. 배정은 모든 ID의 활성 AI 캐릭터 상태를, 해제는 모든 ID의 존재와 현재 원작 귀속을 먼저 검증하며 하나라도 실패하면 전체를 rollback한다. -- 해제는 각 캐릭터가 Path의 원작에 실제 연결되어 있을 때만 수행한다. 다른 원작 소속 또는 미연결 캐릭터를 해제하려는 요청은 `characterIds` 400이다. -- 원작 삭제, 배정, 해제와 `CHAR-03`·`CHAR-04`의 양수 연결 변경은 추가 연결 대상 또는 원작 Path row를 `PESSIMISTIC_WRITE`로 먼저 잠그고, 대상 캐릭터 row를 ID 오름차순으로 잠근 뒤 상태와 귀속을 다시 검증한다. `CHAR-04`의 `null` 해제와 legacy 수정의 `0` 해제는 양수 target 원작이 없으므로 캐릭터 row만 잠그고 현재 귀속을 다시 확인한다. 따라서 삭제의 연결 수 확인과 새 연결 사이에 삭제 원작 참조가 생기지 않는다. -- 삭제되지 않은 동일 제목의 동시 생성을 처리하는 원작 생성과 제목이 실제 바뀌는 수정 command만 DB schema 변경 없이 MySQL `SERIALIZABLE` transaction에서 실행한다. deadlock·serialization 실패는 한 번만 새 transaction으로 재시도한다. 이미지가 저장된 attempt가 실패하면 해당 attempt의 object를 먼저 보상하고, 보상 성공 후에만 다음 transaction을 시작한다. 재조회에서 실제 중복이 확인된 경우에만 `title` 409를 반환한다. -- 원작 생성·수정은 9.5의 전체 교체·nullable 규칙을 따른다. 이미지 생략만 기존 이미지를 유지하고 nullable JSON field의 명시적 `null`과 빈 목록은 값을 지운다. -- 원작 생성은 언어 감지를, 정규화된 `title`, `contentType`, `category`, `description`, `tags` 중 하나 이상이 실제 변경된 수정은 번역 갱신을 commit 후 한 번 예약한다. `writer`, `studio`, URL, 이미지 변경과 단순 캐릭터 배정·해제는 원작 번역 작업을 만들지 않는다. -- 기존 `OriginalWork`, link, tag, `ChatCharacter.originalWork` 테이블·관계를 그대로 사용하며 schema, JPA mapping 또는 데이터 migration을 추가하지 않는다. - -## 8. User Stories - -- 관리자로서 기존 관리자 계정으로 로그인해 AI 캐릭터 관리 메뉴에 접근하고 싶다. -- 관리자로서 활성·비활성 AI 캐릭터를 검색하고 상세 상태를 확인하고 싶다. -- 관리자로서 AI 캐릭터를 등록·수정·논리 삭제하고 싶다. -- 관리자로서 원작을 검색·조회·등록·수정·논리 삭제하고, 원작에 연결된 AI 캐릭터를 배정·해제하고 싶다. -- 관리자로서 캐릭터 등록·수정 화면에서 원작을 이름으로 검색해 선택하거나 연결을 해제하고 싶다. -- 관리자로서 선택한 AI 캐릭터 명의로 콘텐츠와 시리즈를 관리하고 싶다. -- 관리자로서 선택한 AI 캐릭터의 콘텐츠 카테고리 순서와 포함 콘텐츠를 관리하고 싶다. -- 관리자로서 AI 캐릭터 명의로 콘텐츠 및 커뮤니티 댓글을 작성하고 수정하고 싶다. -- 관리자로서 AI 캐릭터 소유 콘텐츠나 게시글에 달린 부적절한 타인의 댓글을 삭제하고 싶다. -- 관리자로서 선택한 AI 캐릭터 명의로 커뮤니티 게시글을 관리하고 싶다. -- 관리자로서 팬이 남긴 FanTalk를 확인하고 AI 캐릭터 명의의 답글을 관리하고 싶다. -- 관리자로서 선택한 AI 캐릭터의 채널 공지를 조회하고 변경하고 싶다. -- 관리자로서 선택한 AI 캐릭터의 채널 SNS, creator tag와 후원 랭킹 공개 설정을 관리하고 싶다. -- 운영 책임자로서 실제 작업 관리자와 대행 대상 AI 캐릭터를 로그에서 구분하고 싶다. - -## 9. Common API Contract - -### 9.1 Authentication - -AI 캐릭터 관리 Endpoint에는 다음 Header가 필요하다. - -```http -Authorization: Bearer -``` - -### 9.2 Response Envelope - -모든 응답은 기존 `ApiResponse` 형식을 사용한다. - -```json -{ - "success": true, - "message": null, - "data": {}, - "errorProperty": null -} -``` - -오류 응답은 다음 형식을 사용한다. - -```json -{ - "success": false, - "message": "요청을 처리할 수 없습니다.", - "data": null, - "errorProperty": "characterId" -} -``` - -### 9.3 Pagination - -신규 목록 API의 Query 기본값과 보정 규칙은 다음과 같다. - -| Field | Type | Rule | -|---|---|---| -| `page` | `Int?` | 0부터 시작, null 또는 음수는 0 | -| `size` | `Int?` | 기본 20, 1 미만은 20, 50 초과는 50 | - -공통 목록 응답은 다음 형식이다. - -```json -{ - "items": [], - "page": 0, - "size": 20, - "totalCount": 0, - "hasNext": false -} -``` - -콘텐츠 테마, 시리즈 장르와 creator tag는 운영 기준정보 전체를 한 번에 선택해야 하고 데이터 수가 제한되므로 페이징하지 않는 예외다. 그 외 관리자 리소스 목록은 공통 페이징 계약을 사용한다. - -### 9.4 Common Mutation Response - -등록·수정·논리 삭제는 `data=null` 대신 변경된 리소스 식별자와 변경 후 상태 또는 표현을 반환한다. Endpoint Summary에서 전용 Response를 선언한 경우 해당 계약이 우선하며, `AdminMutationResponse`를 선언한 Endpoint만 다음 공통 형식을 사용한다. - -| Field | Type | Nullable | Description | -|---|---:|---:|---| -| `id` | `Long` | No | 변경된 리소스 ID | -| `isActive` | `Boolean` | No | 변경 후 활성 상태 | - -### 9.5 Common Representation Rules - -- 모든 ID는 JSON number 형식의 `Long`이다. -- 모든 절대 날짜·시간 Request/Response는 ISO-8601 UTC 문자열로 전송하고 필드명은 `*AtUtc`를 사용한다. 값은 반드시 UTC를 뜻하는 `Z` suffix를 포함한다. -- 프론트엔드는 UTC 원문 또는 epoch millisecond로 저장·비교하고 화면 표시에서만 `Asia/Seoul`로 변환한다. 브라우저·운영체제의 local timezone에 표시 결과를 맡기지 않는다. -- `duration`과 콘텐츠 생성 Request의 `previewStartTime`, `previewEndTime`은 `HH:mm:ss` 형식의 재생 길이·미디어 offset이므로 날짜 객체로 만들거나 KST로 변환하지 않는다. -- 목록이 없으면 `null`이 아니라 빈 배열을 반환한다. -- 선택 필드에 값이 없으면 `null`을 반환한다. -- 리소스 본문을 수정하는 `PUT`은 해당 리소스의 수정 가능한 JSON 표현을 전체 교체한다. 각 API에서 선택 File part로 명시한 항목만 생략 시 기존 값을 유지한다. -- 리소스 수정 `PUT`의 nullable JSON field도 key 자체는 필수이며 값을 지울 때 명시적으로 `null`을 전송한다. 필수 key 누락은 400이다. -- `/pin`, `/fixed`, `/orders` 같은 명령형 `PUT`은 각 Endpoint에 명시한 Request 계약을 우선한다. -- 클라이언트가 `creatorId`나 writer ID를 지정해 작업 주체를 바꿀 수 없게 한다. -- Path resource가 선택한 `characterId`의 소유가 아니면 존재 여부를 노출하지 않는 동일한 not-found 오류로 처리한다. - -### 9.6 Operation ID and Contract Reading Rule - -- 모든 신규 API에는 문서와 클라이언트 구현에서 공통으로 참조할 고유 `Operation ID`를 부여한다. -- `Operation ID`는 문서 식별자이며 Request field나 HTTP Header로 전송하지 않는다. -- Endpoint Summary의 한 행은 하나의 HTTP Endpoint, 하나의 Request 계약, 하나의 Response Data 계약만 나타낸다. -- `Request` 열의 DTO 이름은 해당 도메인 절에 있는 동일 이름의 field 표와 validation을 따른다. -- `Response Data`는 항상 9.2의 `ApiResponse.data`에 들어가는 타입이다. 표에 envelope 전체가 명시된 로그인 예외를 제외하고 클라이언트가 `data`를 한 번 더 중첩하지 않는다. -- 같은 DTO를 여러 Endpoint가 사용해도 각 행에서 Request와 Response를 생략하지 않는다. -- 이 PRD의 신규 Operation은 모두 AI 캐릭터 관리자 웹 클라이언트가 호출할 수 있다. 기존 AWS worker callback은 신규 Operation에 포함하지 않는다. - -## 10. Authentication and v2 Navigation Contract - -### 10.1 Admin Login - -#### Endpoint - -```http -POST /admin/member/login -Content-Type: application/json -``` - -Operation ID: `AUTH-01` - -#### Request - -| Field | Type | Required | Description | -|---|---|---:|---| -| `email` | `String` | Yes | 관리자 Member 이메일 | -| `password` | `String` | Yes | 관리자 Member 비밀번호 | - -```json -{ - "email": "admin@example.com", - "password": "password" -} -``` - -#### Response Data - -| Field | Type | Nullable | Description | -|---|---|---:|---| -| `token` | `String` | No | Bearer JWT | -| `role` | `String` | No | 로그인 Member 역할 | - -```json -{ - "success": true, - "message": null, - "data": { - "token": "", - "role": "ADMIN" - }, - "errorProperty": null -} -``` - -### 10.2 v2 Client Navigation - -- Backend Endpoint 없음 -- Request/Response 없음 -- legacy `GET /menu` 사용 안 함 -- 클라이언트 route root: `/ai-characters` -- route 진입 조건: 로그인 응답의 `role == "ADMIN"` -- 최종 권한 판단: 각 Backend API의 `ROLE_ADMIN` - -## 11. AI Character API - -Base path는 `/admin/ai-characters`이며 모든 Endpoint는 `ROLE_ADMIN` 전용이다. - -### 11.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `CHAR-01` | `GET` | `/admin/ai-characters` | Query: `page`, `size`, `search`, `isActive` | `AdminPageResponse` | -| `CHAR-02` | `GET` | `/admin/ai-characters/{characterId}` | Path: `characterId` | `AdminAiCharacterDetailResponse` | -| `CHAR-03` | `POST` | `/admin/ai-characters` | multipart: `image`, `request=AdminAiCharacterCreateRequest` | `AdminAiCharacterMutationResponse` | -| `CHAR-04` | `PUT` | `/admin/ai-characters/{characterId}` | Path: `characterId`; multipart: `image?`, `request=AdminAiCharacterUpdateRequest` | `AdminAiCharacterMutationResponse` | -| `CHAR-05` | `DELETE` | `/admin/ai-characters/{characterId}` | Path: `characterId`; Body 없음 | `AdminAiCharacterDeleteResponse` | - -### 11.2 CHAR-01 · Character List - -#### Request - -| Field | In | Type | Required | Description | -|---|---|---|---:|---| -| `page` | Query | `Int` | No | 기본 0 | -| `size` | Query | `Int` | No | 기본 20, 최대 50 | -| `search` | Query | `String` | No | 이름 검색어, trim 후 빈 값은 미적용 | -| `isActive` | Query | `Boolean` | No | null이면 전체, true/false면 상태 필터 | - -#### Response Data - -`AdminAiCharacterSummaryResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `characterId` | `Long` | No | -| `creatorId` | `Long` | No | -| `name` | `String` | No | -| `imageUrl` | `String` | Yes | -| `description` | `String` | No | -| `gender` | `String` | Yes | -| `age` | `Int` | Yes | -| `mbti` | `String` | Yes | -| `region` | `String` | No | -| `tags` | `List` | No | -| `isActive` | `Boolean` | No | -| `createdAtUtc` | `String` | Yes | -| `updatedAtUtc` | `String` | Yes | - -기본 정렬은 응답 기준 `createdAtUtc DESC, characterId DESC`다. - -### 11.3 CHAR-02 · Character Detail - -#### Request - -- Path `characterId: Long` - -#### Response Data - -`AdminAiCharacterDetailResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `characterId` | `Long` | No | -| `creatorId` | `Long` | No | -| `characterUuid` | `String` | No | -| `name` | `String` | No | -| `imageUrl` | `String` | Yes | -| `description` | `String` | No | -| `systemPrompt` | `String` | No | -| `characterType` | `String` | No | -| `age` | `Int` | Yes | -| `gender` | `String` | Yes | -| `mbti` | `String` | Yes | -| `speechPattern` | `String` | Yes | -| `speechStyle` | `String` | Yes | -| `appearance` | `String` | Yes | -| `region` | `String` | No | -| `isActive` | `Boolean` | No | -| `tags` | `List` | No | -| `hobbies` | `List` | No | -| `values` | `List` | No | -| `goals` | `List` | No | -| `relationships` | `List` | No | -| `personalities` | `List` | No | -| `backgrounds` | `List` | No | -| `memories` | `List` | No | -| `originalWork` | `OriginalWorkBrief` | Yes | -| `createdAtUtc` | `String` | Yes | -| `updatedAtUtc` | `String` | Yes | - -중첩 객체 필드는 기존 캐릭터 상세 응답의 다음 계약을 유지한다. - -- `CharacterRelationship`: `personName`, `relationshipName`, `description`, `importance`, `relationshipType`, `currentStatus` -- `CharacterPersonality`: `trait`, `description` -- `CharacterBackground`: `topic`, `description` -- `CharacterMemory`: `title`, `content`, `emotion` -- `OriginalWorkBrief`: `id: Long`, `imageUrl: String?`, `title: String` - -### 11.4 CHAR-03 · Character Create - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | Description | -|---|---|---:|---| -| `image` | File | Yes | 캐릭터 대표 이미지 | -| `request` | JSON string | Yes | `AdminAiCharacterCreateRequest` | - -`AdminAiCharacterCreateRequest` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `name` | `String` | Yes | - | -| `systemPrompt` | `String` | Yes | - | -| `description` | `String` | Yes | - | -| `age` | `Int` | No | `null` | -| `gender` | `String` | No | `null` | -| `mbti` | `String` | No | `null` | -| `speechPattern` | `String` | No | `null` | -| `speechStyle` | `String` | No | `null` | -| `appearance` | `String` | No | `null` | -| `region` | `String` | No | `"KR"` | -| `originalWorkId` | `Long` | No | `null` | -| `characterType` | `String` | No | `"Character"` | -| `tags` | `List` | No | `[]` | -| `hobbies` | `List` | No | `[]` | -| `values` | `List` | No | `[]` | -| `goals` | `List` | No | `[]` | -| `relationships` | `List` | No | `[]` | -| `personalities` | `List` | No | `[]` | -| `backgrounds` | `List` | No | `[]` | -| `memories` | `List` | No | `[]` | - -- `characterType`은 `Character`, `Clone`만 허용한다. -- `age`는 0 이상의 정수다. -- `region`은 대문자 ISO 3166-1 alpha-2 국가 코드다. -- `originalWorkId`는 양수이고 `OriginalWork.isDeleted=false`인 원작을 가리켜야 한다. `null`이면 원작 연결 없이 생성하고 legacy의 해제 sentinel인 `0`은 허용하지 않는다. 0 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 `errorProperty="originalWorkId"`다. - -중첩 Request 계약은 다음과 같다. - -| Type | Required fields | -|---|---| -| `CharacterRelationship` | `personName: String`, `relationshipName: String`, `description: String`, `importance: Int`, `relationshipType: String`, `currentStatus: String` | -| `CharacterPersonality` | `trait: String`, `description: String` | -| `CharacterBackground` | `topic: String`, `description: String` | -| `CharacterMemory` | `title: String`, `content: String`, `emotion: String` | - -중첩 객체의 String field는 trim 후 빈 값일 수 없다. - -#### Response Data - -`AdminAiCharacterMutationResponse` - -| Field | Type | Nullable | Description | -|---|---|---:|---| -| `characterId` | `Long` | No | 생성된 캐릭터 ID | -| `creatorId` | `Long` | No | 함께 생성된 AI 크리에이터 Member ID | -| `isActive` | `Boolean` | No | 변경 후 캐릭터 활성 상태 | - -```json -{ - "characterId": 101, - "creatorId": 10001, - "isActive": true -} -``` - -등록 성공 시 같은 작업 흐름에서 연결 `creatorMember`를 생성하고 그 ID를 응답의 `creatorId`로 반환한다. - -### 11.5 CHAR-04 · Character Update - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | Description | -|---|---|---:|---| -| `image` | File | No | 새 대표 이미지 | -| `request` | JSON string | Yes | `AdminAiCharacterUpdateRequest` | - -`AdminAiCharacterUpdateRequest`는 `region`을 제외한 `AdminAiCharacterCreateRequest`의 모든 field key를 필수로 받는다. `age`, `gender`, `mbti`, `speechPattern`, `speechStyle`, `appearance`, `originalWorkId`는 명시적 `null`로 값을 지울 수 있고, 목록은 빈 배열로 전체 삭제할 수 있다. `characterType`은 non-null이며 `Character`, `Clone` 중 하나여야 한다. `characterId`, `isActive`는 body에서 받지 않는다. - -`region`은 캐릭터 생성 후 변경할 수 없는 값으로 유지한다. 이미지 File part를 생략한 경우에만 기존 이미지를 유지한다. -양수 `originalWorkId`는 `OriginalWork.isDeleted=false`인지 검증하고, 명시적 `null`은 기존 원작 연결을 해제한다. key 누락과 `0` 이하는 400, 미존재 양수 ID는 404, 삭제된 원작 ID는 409이며 모두 `errorProperty="originalWorkId"`다. - -#### Response Data - -`AdminAiCharacterMutationResponse` - -```json -{ - "characterId": 101, - "creatorId": 10001, - "isActive": true -} -``` - -이름, 이미지 또는 소개가 바뀌면 연결 Member의 `nickname`, `profileImage`, `introduce`도 같은 작업 안에서 동기화한다. - -### 11.6 CHAR-05 · Character Delete - -#### Request - -- Path `characterId: Long` -- Body 없음 - -#### Response Data - -`AdminAiCharacterDeleteResponse` - -```json -{ - "characterId": 101, - "creatorId": 10001, - "characterIsActive": false, - "creatorIsActive": false -} -``` - -동일 캐릭터에 대한 반복 삭제는 멱등하게 같은 결과를 반환한다. - -### 11.7 Original Work Management API - -원작은 특정 캐릭터를 먼저 선택하지 않는 global 관리자 리소스다. Base path는 `/admin/ai-characters/original-works`이며 모든 Endpoint는 `ROLE_ADMIN` 전용이다. - -#### 11.7.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `ORIGINAL-WORK-01` | `GET` | `/admin/ai-characters/original-works` | Query: `page`, `size`, `search` | `AdminPageResponse` | -| `ORIGINAL-WORK-02` | `GET` | `/admin/ai-characters/original-works/{originalWorkId}` | Path: `originalWorkId` | `AdminOriginalWorkDetailResponse` | -| `ORIGINAL-WORK-03` | `POST` | `/admin/ai-characters/original-works` | multipart: `image`, `request=AdminOriginalWorkCreateRequest` | `AdminOriginalWorkMutationResponse` | -| `ORIGINAL-WORK-04` | `PUT` | `/admin/ai-characters/original-works/{originalWorkId}` | Path: `originalWorkId`; multipart: `image?`, `request=AdminOriginalWorkUpdateRequest` | `AdminOriginalWorkMutationResponse` | -| `ORIGINAL-WORK-05` | `DELETE` | `/admin/ai-characters/original-works/{originalWorkId}` | Path: `originalWorkId`; Body 없음 | `AdminOriginalWorkMutationResponse` | -| `ORIGINAL-WORK-06` | `GET` | `/admin/ai-characters/original-works/{originalWorkId}/characters` | Path: `originalWorkId`; Query: `page`, `size`, `search`, `isActive` | `AdminPageResponse` | -| `ORIGINAL-WORK-07` | `POST` | `/admin/ai-characters/original-works/{originalWorkId}/characters` | Path: `originalWorkId`; JSON: `AdminOriginalWorkCharacterIdsRequest` | `AdminOriginalWorkCharacterAssignmentResponse` | -| `ORIGINAL-WORK-08` | `DELETE` | `/admin/ai-characters/original-works/{originalWorkId}/characters` | Path: `originalWorkId`; JSON: `AdminOriginalWorkCharacterIdsRequest` | `AdminOriginalWorkCharacterAssignmentResponse` | - -`ORIGINAL-WORK-01`의 `search`가 legacy 목록과 검색 기능을 합친다. 별도 `/search` Endpoint와 비페이징 전체 검색은 만들지 않는다. - -#### 11.7.2 Common Fields and Validation - -원작 생성·수정의 JSON field는 다음과 같다. - -| Field | Type | Create | Update | Default / Null meaning | -|---|---|---:|---:|---| -| `title` | `String` | Required | Required | trim 후 빈 값 불가 | -| `contentType` | `String` | Required | Required | trim 후 빈 값 불가 | -| `category` | `String` | Required | Required | trim 후 빈 값 불가 | -| `isAdult` | `Boolean` | Optional | Required | 생성 기본 `false` | -| `description` | `String` | Optional | Required | 생성 기본 `""`, 빈 문자열 허용 | -| `originalWork` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | -| `originalLink` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | -| `writer` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | -| `studio` | `String?` | Optional | Required | 생성 기본 `null`, 명시적 `null`은 값 삭제 | -| `originalLinks` | `List` | Optional | Required | 생성 기본 `[]`, 빈 배열은 전체 삭제 | -| `tags` | `List` | Optional | Required | 생성 기본 `[]`, 빈 배열은 전체 삭제 | - -- 수정 JSON은 `PUT` 전체 교체이므로 위 11개 key를 모두 전송한다. nullable key 누락은 400이고 값 삭제는 명시적 `null`로 표현한다. -- `title`, `contentType`, `category`, nullable 문자열, 링크와 태그는 trim한다. nullable 문자열의 trim 결과가 빈 값이면 `null`로 정규화한다. -- `originalLink`와 `originalLinks`의 값은 `http` 또는 `https` 절대 URL이어야 한다. -- `originalLinks`와 `tags`는 trim 후 빈 값을 제거하고 첫 등장 순서를 유지한 채 중복을 제거한다. -- 신규 v2 저장은 정규화된 `originalLinks`와 `tags`를 요청 순서대로 다시 만들고, 조회 projection은 link ID와 tag-mapping ID 오름차순으로 명시적으로 정렬한다. JPA collection의 암묵적 조회 순서에는 의존하지 않는다. -- `title` 중복은 양쪽 값을 trim한 뒤 대소문자를 무시해 비교한다. 따라서 `"Moon"`과 `" moon "`은 같은 제목이다. 삭제되지 않은 동일 제목이 있으면 생성과 수정 모두 409, `errorProperty="title"`이며 현재 리소스 자신의 ID는 충돌에서 제외한다. -- 생성 이미지 part는 필수이고 수정 이미지는 선택이다. 실제 MIME이 이미지인지 검증하며 GIF는 거부한다. 수정에서 이미지를 생략한 경우에만 기존 이미지를 유지한다. -- 목록·검색·상세는 `isDeleted=false`인 원작만 반환한다. 삭제된 원작의 수정·배정·해제는 409이고 반복 삭제만 허용한다. -- `characterCount`는 활성·비활성 또는 연결 creator 상태와 관계없이 현재 원작을 참조하는 `ChatCharacter` row 수다. 원작 삭제 가능 여부도 같은 기준을 사용한다. -- Path와 캐릭터 요청의 `originalWorkId`는 양수여야 한다. 0 이하는 400, 미존재 양수 ID는 404다. 삭제된 ID는 조회에서 404, 수정·배정·해제에서 409이며 DELETE 재시도만 200이다. -- 모든 날짜·시간은 UTC `Z` 문자열이다. - -`AdminOriginalWorkSummaryResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `originalWorkId` | `Long` | No | -| `title` | `String` | No | -| `contentType` | `String` | No | -| `category` | `String` | No | -| `isAdult` | `Boolean` | No | -| `imageUrl` | `String` | Yes | -| `characterCount` | `Long` | No | -| `createdAtUtc` | `String` | Yes | -| `updatedAtUtc` | `String` | Yes | - -`AdminOriginalWorkDetailResponse`는 summary field에 다음 field를 추가한다. - -| Field | Type | Nullable | -|---|---|---:| -| `description` | `String` | No | -| `originalWork` | `String` | Yes | -| `originalLink` | `String` | Yes | -| `writer` | `String` | Yes | -| `studio` | `String` | Yes | -| `originalLinks` | `List` | No | -| `tags` | `List` | No | - -`AdminOriginalWorkMutationResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `originalWorkId` | `Long` | No | -| `isDeleted` | `Boolean` | No | - -`AdminOriginalWorkCharacterResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `characterId` | `Long` | No | -| `creatorId` | `Long` | Yes | -| `name` | `String` | No | -| `imageUrl` | `String` | Yes | -| `isActive` | `Boolean` | No | -| `createdAtUtc` | `String` | Yes | - -`creatorId`는 정상 AI 캐릭터에서는 항상 값이 있지만, legacy에서 이미 생긴 잘못된 연결도 목록에 노출하고 해제할 수 있도록 nullable이다. - -`AdminOriginalWorkCharacterIdsRequest` - -| Field | Type | Nullable | Validation | -|---|---|---:|---| -| `characterIds` | `List` | No | 양수 ID 1개 이상, 중복 불가 | - -`AdminOriginalWorkCharacterAssignmentResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `originalWorkId` | `Long` | No | -| `characterIds` | `List` | No | -| `characterCount` | `Long` | No | - -배정·해제 Response의 `characterIds`는 실제 변경 여부와 관계없이 검증을 통과한 요청 ID 전체를 요청 순서대로 반환한다. 따라서 같은 원작 반복 배정의 ID도 포함된다. `characterCount`는 작업 완료 후 해당 원작을 참조하는 전체 `ChatCharacter` row 수이며 처리 건수가 아니다. - -#### 11.7.3 ORIGINAL-WORK-01 · Original Work List and Search - -Request: - -- Path 없음 -- Request JSON 없음 -- Query `page: Int?`, `size: Int?`, `search: String?` -- `search`는 trim 후 빈 값이면 적용하지 않고, 값이 있으면 `title`, `contentType`, `category`의 대소문자 무시 부분 일치를 적용한다. -- 기본 정렬은 `createdAtUtc DESC, originalWorkId DESC`다. - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "items": [ - { - "originalWorkId": 71, - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "imageUrl": "https://cdn.example.com/originals/71/original.webp", - "characterCount": 2, - "createdAtUtc": "2026-07-20T01:00:00Z", - "updatedAtUtc": "2026-07-20T02:00:00Z" - } - ], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null -} -``` - -#### 11.7.4 ORIGINAL-WORK-02 · Original Work Detail - -Request: - -- Path `originalWorkId: Long` -- Request JSON 없음 - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "밤에만 문을 여는 도서관의 이야기", - "originalWork": "Moonlight Library", - "originalLink": "https://example.com/works/71", - "writer": "김작가", - "studio": "소다 스튜디오", - "originalLinks": [ - "https://example.com/works/71", - "https://example.com/works/71/official" - ], - "tags": ["힐링", "판타지"], - "imageUrl": "https://cdn.example.com/originals/71/original.webp", - "characterCount": 2, - "createdAtUtc": "2026-07-20T01:00:00Z", - "updatedAtUtc": "2026-07-20T02:00:00Z" - }, - "errorProperty": null -} -``` - -#### 11.7.5 ORIGINAL-WORK-03 · Original Work Create - -Request: - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | Description | -|---|---|---:|---| -| `image` | File | Yes | 원작 대표 이미지 | -| `request` | JSON string | Yes | 아래 Request JSON을 `JSON.stringify`한 값 | - -Request JSON: - -```json -{ - "title": "달빛 도서관", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "밤에만 문을 여는 도서관의 이야기", - "originalWork": "Moonlight Library", - "originalLink": "https://example.com/works/71", - "writer": "김작가", - "studio": "소다 스튜디오", - "originalLinks": [ - "https://example.com/works/71", - "https://example.com/works/71/official" - ], - "tags": ["힐링", "판타지"] -} -``` - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "isDeleted": false - }, - "errorProperty": null -} -``` - -#### 11.7.6 ORIGINAL-WORK-04 · Original Work Update - -Request: - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | Description | -|---|---|---:|---| -| `image` | File | No | 새 원작 대표 이미지, 생략 시 기존 이미지 유지 | -| `request` | JSON string | Yes | 아래 11개 key를 모두 가진 Request JSON | - -Request JSON: - -```json -{ - "title": "달빛 도서관 개정판", - "contentType": "WEB_NOVEL", - "category": "FANTASY", - "isAdult": false, - "description": "개정된 작품 소개", - "originalWork": null, - "originalLink": null, - "writer": "김작가", - "studio": "소다 스튜디오", - "originalLinks": [], - "tags": ["판타지"] -} -``` - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "isDeleted": false - }, - "errorProperty": null -} -``` - -#### 11.7.7 ORIGINAL-WORK-05 · Original Work Delete - -Request: - -- Path `originalWorkId: Long` -- Request JSON 없음 - -삭제되지 않은 원작에 연결된 `ChatCharacter`가 하나라도 있으면 409와 `errorProperty="originalWorkId"`를 반환한다. 연결이 없으면 `isDeleted=true`로 변경하며 링크, 태그, 이미지와 번역 이력은 보존한다. 이미 `isDeleted=true`이면 연결 수를 검사하지 않고 멱등하게 같은 Response를 반환한다. - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "isDeleted": true - }, - "errorProperty": null -} -``` - -#### 11.7.8 ORIGINAL-WORK-06 · Assigned Character List - -Request: - -- Path `originalWorkId: Long` -- Request JSON 없음 -- Query `page: Int?`, `size: Int?`, `search: String?`, `isActive: Boolean?` -- `search`는 캐릭터 이름의 대소문자 무시 부분 검색이다. -- `isActive=null`이면 활성·비활성 캐릭터를 모두 반환한다. -- 기본 정렬은 `createdAtUtc DESC, characterId DESC`다. - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "items": [ - { - "characterId": 101, - "creatorId": 10001, - "name": "루나", - "imageUrl": "https://cdn.example.com/characters/101.webp", - "isActive": true, - "createdAtUtc": "2026-07-20T01:30:00Z" - } - ], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null -} -``` - -#### 11.7.9 ORIGINAL-WORK-07 · Assign Characters - -`CHAR-01`에서 조회한 활성 AI 캐릭터를 배정한다. 이미 다른 원작에 연결된 캐릭터는 이 원작으로 이동하고, 이미 같은 원작에 연결된 캐릭터의 반복 배정은 멱등하다. - -Request JSON: - -```json -{ - "characterIds": [101, 102] -} -``` - -- `characterIds`는 중복 없는 양수 ID를 하나 이상 포함해야 한다. -- 모든 ID가 존재하고 활성 AI 캐릭터인지 먼저 검증한다. -- 하나라도 잘못되면 아무 캐릭터도 이동하지 않는다. - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "characterIds": [101, 102], - "characterCount": 2 - }, - "errorProperty": null -} -``` - -#### 11.7.10 ORIGINAL-WORK-08 · Unassign Characters - -HTTP `DELETE` 요청에 JSON body를 전송하며 `Content-Type: application/json`을 사용한다. - -Request JSON: - -```json -{ - "characterIds": [101, 102] -} -``` - -- `characterIds`는 중복 없는 양수 ID를 하나 이상 포함해야 한다. -- 활성·비활성 및 연결 creator 상태와 관계없이 기존 `ChatCharacter`를 해제할 수 있지만, 모든 캐릭터가 Path의 원작에 실제 연결되어 있어야 한다. -- 미존재, 미연결 또는 다른 원작 소속 ID가 하나라도 있으면 400과 `errorProperty="characterIds"`를 반환하고 전체를 rollback한다. - -Response JSON: - -```json -{ - "success": true, - "message": null, - "data": { - "originalWorkId": 71, - "characterIds": [101, 102], - "characterCount": 0 - }, - "errorProperty": null -} -``` - -## 12. Content API - -Base path는 `/admin/ai-characters/{characterId}/contents`다. - -### 12.1 Endpoint Summary - -| Operation ID | Caller | Method | Endpoint | Request | Response Data | -|---|---|---|---|---|---| -| `CONTENT-01` | Web | `GET` | `/admin/ai-characters/{characterId}/contents` | Path: `characterId`; Query: `page`, `size`, `search`, `isActive`, `status` | `AdminPageResponse` | -| `CONTENT-02` | Web | `GET` | `/admin/ai-characters/{characterId}/contents/{contentId}` | Path: `characterId`, `contentId` | `AdminAiContentResponse` | -| `CONTENT-03` | Web | `POST` | `/admin/ai-characters/{characterId}/contents` | Path: `characterId`; multipart: `contentFile`, `coverImage`, `request=AdminAiContentCreateRequest` | `AdminAiContentCreateResponse` | -| `CONTENT-04` | Web | `PUT` | `/admin/ai-characters/{characterId}/contents/{contentId}` | Path: `characterId`, `contentId`; multipart: `coverImage?`, `request=AdminAiContentUpdateRequest` | `AdminMutationResponse` | -| `CONTENT-05` | Web | `DELETE` | `/admin/ai-characters/{characterId}/contents/{contentId}` | Path: `characterId`, `contentId`; Body 없음 | `AdminMutationResponse` | -| `CONTENT-06` | Web | `PUT` | `/admin/ai-characters/{characterId}/contents/{contentId}/pin` | Path: `characterId`, `contentId`; JSON: `AdminAiContentPinRequest` | `AdminAiContentPinResponse` | -| `CONTENT-07` | Web | `GET` | `/admin/ai-characters/metadata/content-themes` | 없음 | `List` | - -### 12.2 CONTENT-01 · Content List - -#### Request - -| Field | In | Type | Required | Description | -|---|---|---|---:|---| -| `page` | Query | `Int` | No | 기본 0 | -| `size` | Query | `Int` | No | 기본 20, 최대 50 | -| `search` | Query | `String` | No | 제목·설명 검색 | -| `isActive` | Query | `Boolean` | No | null이면 전체 | -| `status` | Query | `String` | No | `PROCESSING`, `SCHEDULED`, `PUBLISHED`, `SUSPENDED`, `DELETED`; null이면 전체 | - -#### Response Data - -`AdminAiContentResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `contentId` | `Long` | No | -| `title` | `String` | No | -| `detail` | `String` | No | -| `coverImageUrl` | `String` | Yes | -| `contentUrl` | `String` | Yes | -| `contentUrlExpiresAtUtc` | `String` | Yes | -| `themeId` | `Long` | No | -| `theme` | `String` | No | -| `price` | `Int` | No | -| `purchaseOption` | `String` | No | -| `limited` | `Int` | Yes | -| `totalContentCount` | `Int` | Yes | -| `remainingContentCount` | `Int` | Yes | -| `isAdult` | `Boolean` | No | -| `isActive` | `Boolean` | No | -| `isPointAvailable` | `Boolean` | No | -| `isCommentAvailable` | `Boolean` | No | -| `isGeneratePreview` | `Boolean` | No | -| `isOnlyRental` | `Boolean` | No | -| `isFullDetailVisible` | `Boolean` | No | -| `languageCode` | `String` | Yes | -| `isPinned` | `Boolean` | No | -| `status` | `String` | No | -| `duration` | `String` | Yes | -| `releaseAtUtc` | `String` | Yes | -| `tags` | `List` | No | -| `createdAtUtc` | `String` | Yes | -| `updatedAtUtc` | `String` | Yes | - -기본 정렬은 고정 콘텐츠 우선, 그다음 응답 기준 `createdAtUtc DESC, contentId DESC`다. -`status`는 DB에 저장하는 컬럼이 아니라 기존 `content` row와 연결 creator 상태에서 계산하는 `PROCESSING`, `SCHEDULED`, `PUBLISHED`, `SUSPENDED`, `DELETED` 중 하나다. Query application은 요청마다 하나의 UTC 기준 시각을 얻고 다음 우선순위를 목록 필터와 응답에 동일하게 적용한다. - -1. `isActive=false && releaseDate=null`이면 `DELETED` -2. 연결 creator Member가 비활성이면 `SUSPENDED` -3. `isActive=true`이면 `PUBLISHED` -4. `duration=null`이면 `PROCESSING` -5. `isActive=false && duration!=null && releaseDate>now`이면 `SCHEDULED` -6. 나머지 비활성 콘텐츠는 `SUSPENDED` - -저장된 `content` 경로는 상태 계산 컬럼이 아니라 Signed URL 발급 가능성을 판단하는 canonical output key로만 사용한다. -`AdminAiContentResponse.isActive`와 CONTENT-01의 `isActive` 필터는 `content.isActive && creatorMember.isActive`인 유효 활성 상태를 사용한다. 신규·legacy 캐릭터 삭제 cascade는 raw `content.isActive=false`로 전환한다. 과거 데이터 불일치로 비활성 creator row에 raw `content.isActive=true`가 남아 있어도 관리자 응답은 `isActive=false`, `status=SUSPENDED`로 일관되게 반환한다. - -`coverImageUrl`과 콘텐츠 재생 URL의 계약은 다음과 같다. - -| Field | Contract | -|---|---| -| `coverImageUrl` | 커버 저장 경로가 있을 때 반환하는 일반 CDN 절대 URL이다. Signed URL이 아니며 저장 경로나 빈 문자열을 반환하지 않는다. | -| `contentUrl` | 가공 완료된 전체 오디오의 CloudFront Signed URL이다. DB/S3 원시 경로, `input/*` 원본 업로드 경로 또는 `preview/*` URL을 반환하지 않는다. | -| `contentUrlExpiresAtUtc` | `contentUrl`의 만료 시각을 나타내는 ISO-8601 UTC 문자열이다. `contentUrl=null`이면 반드시 `null`이다. | - -Signed URL 발급 규칙은 다음과 같다. - -- `SCHEDULED` 또는 `PUBLISHED`이면서 12.9의 canonical `output/{contentId}/...` 상대 key와 유효한 `duration`이 모두 있을 때만 `contentUrl`을 발급한다. -- `PROCESSING`, `SUSPENDED`, `DELETED`에서는 저장 경로가 남아 있어도 `contentUrl=null`, `contentUrlExpiresAtUtc=null`을 반환한다. -- 목록과 상세는 응답을 만들 때마다 새로운 Signed URL을 발급한다. -- TTL은 legacy 크리에이터 관리자와 동일하게 `(duration의 HH 부분 + 2)시간`이다. -- URL policy의 실제 만료 시각과 `contentUrlExpiresAtUtc`는 하나의 기준 `Instant`에서 계산한 동일한 절대 시각이며 ISO-8601 표현 정밀도 안에서 일치해야 한다. -- URL 만료 또는 만료 임박 시 클라이언트는 `CONTENT-02`를 다시 호출해 갱신한다. 별도 URL 갱신 Endpoint는 만들지 않는다. -- 서명 실패 시 raw path, 일반 CDN URL 또는 빈 문자열로 fallback하지 않고 요청을 실패 처리한다. -- `CONTENT-01`, `CONTENT-02` 응답에는 `Cache-Control: private, no-store`를 적용한다. - -`AdminAiContentResponse` 예시는 다음과 같다. - -```json -{ - "contentId": 2001, - "title": "비 오는 밤", - "detail": "수면을 위한 빗소리", - "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp", - "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...", - "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z", - "themeId": 1, - "theme": "ASMR", - "price": 0, - "purchaseOption": "BOTH", - "limited": null, - "totalContentCount": null, - "remainingContentCount": null, - "isAdult": false, - "isActive": true, - "isPointAvailable": false, - "isCommentAvailable": true, - "isGeneratePreview": false, - "isOnlyRental": false, - "isFullDetailVisible": true, - "languageCode": "ko", - "isPinned": false, - "status": "PUBLISHED", - "duration": "00:10:30", - "releaseAtUtc": "2026-07-20T12:00:00Z", - "tags": ["수면", "빗소리"], - "createdAtUtc": "2026-07-20T11:00:00Z", - "updatedAtUtc": "2026-07-20T12:00:00Z" -} -``` - -### 12.3 CONTENT-02 · Content Detail - -#### Request - -- Path `characterId: Long` -- Path `contentId: Long` -- Query와 Body 없음 - -#### Response Data - -- `AdminAiContentResponse` -- field와 Signed URL 규칙은 12.2와 같다. -- 상세를 다시 조회할 때마다 새로운 `contentUrl`, `contentUrlExpiresAtUtc`를 반환한다. - -### 12.4 CONTENT-03 · Content Create - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | Description | -|---|---|---:|---| -| `contentFile` | File | Yes | 비동기 가공 파이프라인에 전달할 원본 오디오 파일 | -| `coverImage` | File | Yes | 커버 이미지 | -| `request` | JSON string | Yes | `AdminAiContentCreateRequest` | - -`AdminAiContentCreateRequest` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `title` | `String` | Yes | - | -| `detail` | `String` | Yes | - | -| `tags` | `List` | Yes | - | -| `price` | `Int` | Yes | - | -| `purchaseOption` | `String` | No | `"BOTH"` | -| `limited` | `Int` | No | `null` | -| `releaseAtUtc` | `String` | No | `null` | -| `themeId` | `Long` | Yes | - | -| `isAdult` | `Boolean` | No | `false` | -| `isGeneratePreview` | `Boolean` | No | `false` | -| `isOnlyRental` | `Boolean` | No | `false` | -| `isPointAvailable` | `Boolean` | No | `false` | -| `isCommentAvailable` | `Boolean` | No | `false` | -| `isFullDetailVisible` | `Boolean` | No | `true` | -| `previewStartTime` | `String` | No | `null` | -| `previewEndTime` | `String` | No | `null` | -| `languageCode` | `String` | No | `null` | - -- `releaseAtUtc`는 ISO-8601 UTC 형식이며 `null`이면 가공 완료 후 즉시 공개한다. -- `themeId`는 0보다 커야 하고 활성 테마를 가리켜야 한다. -- `previewStartTime`, `previewEndTime`은 둘 다 보내거나 둘 다 생략하며 값 형식은 `HH:mm:ss`다. -- `purchaseOption`은 `BOTH`, `BUY_ONLY`, `RENT_ONLY`만 허용한다. -- `price`는 0 이상이며 1~4는 허용하지 않는다. -- `title`과 `detail`은 trim 후 빈 값일 수 없다. -- `limited`는 `null` 또는 1 이상이다. -- 테마 ID 12, 13, 14는 `price >= 5`여야 하고 `purchaseOption=BUY_ONLY`로 저장한다. -- 미리듣기 구간은 종료가 시작보다 늦고 길이가 15초 이상이어야 한다. -- `limited`가 설정되었거나 최종 `purchaseOption=BUY_ONLY`이면 `isOnlyRental=false`로 저장한다. 이 규칙이 우선하므로 `limited`와 `RENT_ONLY`를 함께 보내도 `false`다. -- 위 조건이 없고 `purchaseOption=RENT_ONLY`이면 `isOnlyRental=true`로 저장한다. `purchaseOption=BOTH`일 때만 Request의 `isOnlyRental` 값을 사용한다. -- `price < 50`이면 `isFullDetailVisible=true`로 저장하고, `price >= 50`일 때만 Request 값을 사용한다. -- 무료 콘텐츠는 `isGeneratePreview=false`로 저장하고 worker에도 같은 값을 전달한다. -- `previewStartTime`, `previewEndTime`은 생성 시 worker용 S3 object metadata로만 전달한다. DB 컬럼에 저장하거나 콘텐츠 목록·상세 Response로 반환하지 않는다. - -#### Response Data - -`AdminAiContentCreateResponse` - -| Field | Type | Nullable | Description | -|---|---|---:|---| -| `contentId` | `Long` | No | 생성된 콘텐츠 ID | -| `isActive` | `Boolean` | No | 생성 직후 `false` | -| `status` | `String` | No | 생성 직후 `PROCESSING` | - -```json -{ - "contentId": 2001, - "isActive": false, - "status": "PROCESSING" -} -``` - -원본 파일 저장 성공은 콘텐츠 공개 완료를 의미하지 않는다. 비동기 가공 완료 전까지 콘텐츠는 비활성 상태다. -응답의 `PROCESSING`은 저장된 status 값이 아니라 생성 직후의 `isActive=false`, `releaseDate!=null`, `duration=null`에서 계산한다. - -### 12.5 CONTENT-04 · Content Update - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | Description | -|---|---|---:|---| -| `coverImage` | File | No | 새 커버 이미지 | -| `request` | JSON string | Yes | `AdminAiContentUpdateRequest` | - -`AdminAiContentUpdateRequest` - -| Field | Type | Required | -|---|---|---:| -| `title` | `String` | Yes | -| `detail` | `String` | Yes | -| `tags` | `List` | Yes | -| `price` | `Int` | Yes | -| `isAdult` | `Boolean` | Yes | -| `isPointAvailable` | `Boolean` | Yes | -| `isCommentAvailable` | `Boolean` | Yes | - -`contentId`와 `isActive`는 body에서 받지 않는다. 활성 상태 변경은 `DELETE`로만 수행한다. - -수정 시에도 `title`·`detail`의 trim 후 빈 값 금지, 1~4 가격 금지 및 테마 12·13·14의 `price >= 5` 검증을 다시 적용한다. 기존 무료 콘텐츠는 `price=0`을 유지하거나 5 이상으로 변경할 수 있고, 기존 유료 콘텐츠의 가격은 5 이상만 허용해 무료 전환을 막는다. 변경 가격이 50 미만이면 `isFullDetailVisible=true`로 전환하고, 50 이상이면 기존 값을 유지한다. - -`purchaseOption`, `limited`, `releaseAtUtc`, `themeId`, `isGeneratePreview`, `isOnlyRental`, `isFullDetailVisible` 및 `languageCode`는 생성 후 직접 변경할 수 없다. `previewStartTime`, `previewEndTime`은 생성 시에만 전달하는 worker metadata이므로 조회하거나 수정하지 않는다. 이 값들의 변경이 필요하면 기존 콘텐츠를 논리 삭제하고 새 콘텐츠를 등록한다. - -#### Response Data - -`AdminMutationResponse` - -### 12.6 CONTENT-05 · Content Delete - -- Request: Path `characterId`, `contentId`; Body 없음 -- Response: `{ "id": 2001, "isActive": false }` -- 선택 캐릭터가 소유하지 않은 콘텐츠는 삭제할 수 없다. -- 계산 상태가 `PROCESSING`, `SCHEDULED`, `PUBLISHED`, `SUSPENDED`인 콘텐츠는 모두 `isActive=false`, `releaseDate=null`로 논리 삭제하며 이후 `DELETED`로 계산한다. -- 처리 중 삭제 후 도착한 upload callback은 콘텐츠를 다시 활성화하지 않는다. - -### 12.7 CONTENT-06 · Content Pin - -#### Request - -`AdminAiContentPinRequest` - -```json -{ - "isPinned": true -} -``` - -| Field | Type | Required | -|---|---|---:| -| `isPinned` | `Boolean` | Yes | - -#### Response Data - -`AdminAiContentPinResponse` - -```json -{ - "contentId": 2001, - "isPinned": true, - "replacedContentId": null -} -``` - -고정은 계산 상태가 `PUBLISHED`이고 공개 시각이 지난 콘텐츠에만 허용한다. 캐릭터별 최대 3개를 유지하며 네 번째 콘텐츠를 고정하면 가장 오래된 고정을 해제하고 그 ID를 `replacedContentId`로 반환한다. 고정 해제 응답의 `replacedContentId`는 `null`이다. - -### 12.8 CONTENT-07 · Content Theme Metadata - -#### Request - -- Body 없음 -- Query 없음 - -#### Response Data - -`AdminContentThemeResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `themeId` | `Long` | No | -| `name` | `String` | No | -| `imageUrl` | `String` | Yes | - -legacy `/audio-content/theme` 또는 legacy service를 호출하지 않는다. v2 content query port가 같은 기준정보 테이블을 조회한다. - -### 12.9 Existing AWS Content Upload Completion Integration - -이번 범위에는 신규 upload-complete Endpoint를 만들지 않는다. 현재 AWS S3 Trigger 기반 가공 worker는 기존 계약을 그대로 사용한다. - -```http -PUT /audio-content/upload-complete -Authorization: Bearer -Content-Type: application/json -``` - -```json -{ - "contentId": 2001, - "contentPath": "output/2001/2001-content.m4a", - "duration": "00:10:30" -} -``` - -기존 성공 Response JSON은 다음과 같다. - -```json -{ - "success": true, - "message": null, - "data": {}, - "errorProperty": null -} -``` - -- 위 Method, Path, Request와 `ADMIN`/`BOT`의 성공 Response 계약은 기존 AWS 연동 계약이며 이번 범위에서 변경하지 않는다. -- 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 이 인가 status는 유지해야 하는 기존 보안 계약이다. -- 이 Endpoint는 기존 API이므로 신규 Operation ID를 부여하거나 12.1의 신규 Endpoint 수에 포함하지 않는다. -- v2 콘텐츠 생성도 기존과 같은 `content` 테이블 필드, S3 bucket의 `input/{contentId}/{contentId}-content-...` 경로와 object metadata 계약을 사용한다. object basename은 기존 `generateFileName(prefix = "${contentId}-content")` 규칙을 따르며 metadata는 기존 `generate_preview`, 선택 `preview_start_time`, `preview_end_time`만 전달한다. -- v2 생성 여부를 저장하는 DB 컬럼이나 worker metadata를 추가하지 않는다. 기존 callback Controller·Request·Response·Service 호출 흐름에도 V1/V2 dispatcher를 추가하지 않는다. -- callback은 기존과 같이 가공 완료 `contentPath`와 `duration`을 기록한다. 공개 활성화는 `releaseDate!=null && releaseDate<=now`이고 연결 creator Member가 활성인 경우에만 허용한다. -- `releaseDate=null`인 삭제 콘텐츠와 비활성 creator의 콘텐츠는 callback 이후에도 raw `content.isActive=false`를 유지한다. 과거 불일치 row가 raw `content.isActive=true`인 상태로 callback을 받으면 `false`로 보정한다. 두 경우 모두 구독자 공개 알림이나 home news를 발행하지 않는다. -- 기존 예약 공개 scheduler component의 cron·lock은 변경하지 않는다. 예약 공개 대상 query는 `isActive=false`, `releaseDate!=null`, `releaseDate<=now`, `duration!=null`과 활성 creator를 모두 만족하는 기존 테이블 row만 반환한다. -- `CONTENT-01`, `CONTENT-02`는 저장된 `contentPath`가 정확히 `output/{contentId}/`로 시작하는 canonical 상대 key인지 Signed URL 발급 직전에 검증한다. 각 후속 segment는 `[A-Za-z0-9][A-Za-z0-9._-]*`만 허용하며 빈 segment, `.`, `..`, URI scheme, host, 선행 `/` 또는 `\`, query, fragment, percent-encoding, `input/`, `raw/`, `preview/`와 다른 콘텐츠 ID를 거부한다. -- worker 코드·스케줄·AWS Trigger 설정 변경은 이번 범위가 아니다. -- 새 내부 callback API는 실제 AWS 전환 일정, 호출 주체와 배포 순서가 확정될 때 별도 PRD에서 Path, 인증 및 Request/Response를 정의한다. - -## 13. Content Comment API - -Base path는 `/admin/ai-characters/{characterId}/contents/{contentId}/comments`다. - -### 13.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `CONTENT-COMMENT-01` | `GET` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments` | Path: `characterId`, `contentId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | -| `CONTENT-COMMENT-02` | `GET` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies` | Path: `characterId`, `contentId`, `commentId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | -| `CONTENT-COMMENT-03` | `POST` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments` | Path: `characterId`, `contentId`; JSON: `AdminContentCommentCreateRequest` | `AdminMutationResponse` | -| `CONTENT-COMMENT-04` | `PUT` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | Path: `characterId`, `contentId`, `commentId`; JSON: `AdminCommentUpdateRequest` | `AdminMutationResponse` | -| `CONTENT-COMMENT-05` | `DELETE` | `/admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | Path: `characterId`, `contentId`, `commentId`; Body 없음 | `AdminMutationResponse` | - -### 13.2 CONTENT-COMMENT-01/02 · List Request - -| Field | In | Type | Required | Description | -|---|---|---|---:|---| -| `page` | Query | `Int` | No | 기본 0 | -| `size` | Query | `Int` | No | 기본 20, 최대 50 | -| `isActive` | Query | `Boolean` | No | 기본 `true`; `false`이면 논리 삭제 댓글 조회 | - -- `CONTENT-COMMENT-01`은 Path `characterId`, `contentId`와 위 Query를 받는다. -- `CONTENT-COMMENT-02`는 Path `characterId`, `contentId`, 루트 `commentId`와 위 Query를 받는다. -- 루트 목록 Endpoint는 `parentCommentId=null`인 댓글만 반환하고 `createdAtUtc DESC, commentId DESC`로 정렬한다. -- 답글 목록 Endpoint는 지정한 루트의 직접 자식만 반환하고 `createdAtUtc ASC, commentId ASC`로 정렬한다. -- `replyCount`는 현재 필터와 관계없이 활성 직접 답글 수다. - -### 13.3 CONTENT-COMMENT-01/02 · Comment Response - -`AdminContentCommentResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `commentId` | `Long` | No | -| `parentCommentId` | `Long` | Yes | -| `writerId` | `Long` | No | -| `writerNickname` | `String` | No | -| `writerProfileImageUrl` | `String` | Yes | -| `content` | `String` | No | -| `languageCode` | `String` | Yes | -| `donationCan` | `Int` | No | -| `isSecret` | `Boolean` | No | -| `isActive` | `Boolean` | No | -| `replyCount` | `Int` | No | -| `createdAtUtc` | `String` | No | -| `updatedAtUtc` | `String` | Yes | - -### 13.4 CONTENT-COMMENT-03 · Comment Create - -#### Request - -`AdminContentCommentCreateRequest` - -```json -{ - "content": "답변 내용", - "parentCommentId": null, - "isSecret": false, - "languageCode": "ko" -} -``` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `content` | `String` | Yes | - | -| `parentCommentId` | `Long` | No | `null` | -| `isSecret` | `Boolean` | No | `false` | -| `languageCode` | `String` | No | `null` | - -작성자는 Request에서 받지 않고 선택 AI 캐릭터의 `creatorMember`로 고정한다. 답글이면 부모 댓글이 같은 `contentId`에 속하고 활성 상태이며 `parentCommentId=null`인 최상위 댓글인지 검증한다. 답글의 답글은 허용하지 않는다. - -#### Response Data - -`AdminMutationResponse` - -### 13.5 CONTENT-COMMENT-04 · Comment Update - -#### Request - -`AdminCommentUpdateRequest` - -```json -{ - "content": "수정한 답변" -} -``` - -| Field | Type | Required | -|---|---|---:| -| `content` | `String` | Yes | - -AI 캐릭터의 `creatorMember`가 직접 작성한 댓글만 본문을 수정할 수 있다. - -`parentCommentId`, `isSecret`, `languageCode`는 등록 후 변경할 수 없다. - -#### Response Data - -`AdminMutationResponse` - -### 13.6 CONTENT-COMMENT-05 · Comment Delete - -- Request: Path `characterId`, `contentId`, `commentId`; Body 없음 -- Response Data: `{ "id": 2101, "isActive": false }` -- AI 캐릭터가 작성한 댓글은 작성자 권한으로 논리 삭제할 수 있다. -- 선택 AI 캐릭터 소유 콘텐츠에 달린 댓글은 다른 사용자가 작성했어도 콘텐츠 소유자 권한으로 논리 삭제할 수 있다. -- 다른 크리에이터의 콘텐츠에 달린 댓글은 AI 캐릭터가 작성했더라도 이 관리자 경로에서 삭제할 수 없다. -- 삭제는 `isActive=false`이며 답글을 물리 삭제하지 않는다. - -## 14. Series API - -Base path는 `/admin/ai-characters/{characterId}/series`다. - -### 14.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `SERIES-01` | `GET` | `/admin/ai-characters/{characterId}/series` | Path: `characterId`; Query: `page`, `size`, `search`, `isActive`, `state` | `AdminPageResponse` | -| `SERIES-02` | `GET` | `/admin/ai-characters/{characterId}/series/{seriesId}` | Path: `characterId`, `seriesId` | `AdminSeriesDetailResponse` | -| `SERIES-03` | `POST` | `/admin/ai-characters/{characterId}/series` | Path: `characterId`; multipart: `image`, `request=AdminSeriesCreateRequest` | `AdminMutationResponse` | -| `SERIES-04` | `PUT` | `/admin/ai-characters/{characterId}/series/{seriesId}` | Path: `characterId`, `seriesId`; multipart: `image?`, `request=AdminSeriesUpdateRequest` | `AdminMutationResponse` | -| `SERIES-05` | `DELETE` | `/admin/ai-characters/{characterId}/series/{seriesId}` | Path: `characterId`, `seriesId`; Body 없음 | `AdminMutationResponse` | -| `SERIES-06` | `GET` | `/admin/ai-characters/{characterId}/series/{seriesId}/contents` | Path: `characterId`, `seriesId`; Query: `page`, `size` | `AdminPageResponse` | -| `SERIES-07` | `GET` | `/admin/ai-characters/{characterId}/series/{seriesId}/available-contents` | Path: `characterId`, `seriesId`; Query: `page`, `size`, `search` | `AdminPageResponse` | -| `SERIES-08` | `POST` | `/admin/ai-characters/{characterId}/series/{seriesId}/contents` | Path: `characterId`, `seriesId`; JSON: `AdminSeriesContentsAddRequest` | `AdminSeriesContentsMutationResponse` | -| `SERIES-09` | `DELETE` | `/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | Path: `characterId`, `seriesId`, `contentId`; Body 없음 | `AdminSeriesContentsMutationResponse` | -| `SERIES-10` | `PUT` | `/admin/ai-characters/{characterId}/series/orders` | Path: `characterId`; JSON: `AdminSeriesOrderRequest` | `AdminSeriesOrderResponse` | -| `SERIES-11` | `GET` | `/admin/ai-characters/metadata/series-genres` | 없음 | `List` | - -### 14.2 SERIES-01 · Series List - -목록 Request는 다음 Query를 받는다. - -| Field | Type | Required | Description | -|---|---|---:|---| -| `page` | `Int` | No | 기본 0 | -| `size` | `Int` | No | 기본 20, 최대 50 | -| `search` | `String` | No | 제목·소개 검색 | -| `isActive` | `Boolean` | No | null이면 전체 | -| `state` | `String` | No | `PROCEEDING`, `SUSPEND`, `COMPLETE` | - -`AdminSeriesSummaryResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `seriesId` | `Long` | No | -| `title` | `String` | No | -| `introduction` | `String` | No | -| `coverImageUrl` | `String` | Yes | -| `publishedDaysOfWeek` | `List` | No | -| `genreId` | `Long` | No | -| `isAdult` | `Boolean` | No | -| `state` | `String` | No | -| `isActive` | `Boolean` | No | -| `writer` | `String` | Yes | -| `studio` | `String` | Yes | - -기본 정렬은 `order ASC, seriesId ASC`다. - -### 14.3 SERIES-02 · Series Detail - -#### Request - -- Path `characterId: Long` -- Path `seriesId: Long` -- Query와 Body 없음 - -#### Response Data - -`AdminSeriesDetailResponse`는 목록 필드에 다음을 추가한다. - -| Field | Type | Nullable | -|---|---|---:| -| `genre` | `String` | No | -| `keyword` | `String` | No | -| `createdAtUtc` | `String` | Yes | -| `updatedAtUtc` | `String` | Yes | - -### 14.4 SERIES-03 · Series Create - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | -|---|---|---:| -| `image` | File | Yes | -| `request` | JSON string | Yes | - -`AdminSeriesCreateRequest` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `title` | `String` | Yes | - | -| `introduction` | `String` | Yes | - | -| `publishedDaysOfWeek` | `Set` | Yes | - | -| `keyword` | `String` | Yes | - | -| `genreId` | `Long` | Yes | - | -| `isAdult` | `Boolean` | No | `false` | -| `writer` | `String` | No | `null` | -| `studio` | `String` | No | `null` | - -`title`, `introduction`, `keyword`는 trim 후 빈 값일 수 없고 `genreId`는 활성 장르를 가리켜야 한다. 요일 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`만 허용하며 빈 집합은 허용하지 않는다. `RANDOM`은 다른 요일 값과 함께 사용할 수 없다. - -#### Response Data - -`AdminMutationResponse` - -```json -{ - "id": 3001, - "isActive": true -} -``` - -### 14.5 SERIES-04 · Series Update - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | -|---|---|---:| -| `image` | File | No | -| `request` | JSON string | Yes | - -`AdminSeriesUpdateRequest` - -| Field | Type | Required | -|---|---|---:| -| `title` | `String` | Yes | -| `introduction` | `String` | Yes | -| `keyword` | `String` | Yes | -| `publishedDaysOfWeek` | `Set` | Yes | -| `genreId` | `Long` | Yes | -| `isAdult` | `Boolean` | Yes | -| `state` | `String` | Yes | -| `writer` | `String?` | Yes | -| `studio` | `String?` | Yes | - -`seriesId`와 `isActive`는 body에서 받지 않는다. - -등록과 같은 제목·소개·키워드·장르·요일 검증을 적용한다. `state`는 `PROCEEDING`, `SUSPEND`, `COMPLETE`만 허용한다. - -#### Response Data - -`AdminMutationResponse` - -### 14.6 SERIES-05 · Series Delete - -- Request: Path `characterId`, `seriesId`; Body 없음 -- Response: `{ "id": 3001, "isActive": false }` -- 시리즈를 삭제해도 포함 콘텐츠는 삭제하지 않는다. - -### 14.7 SERIES-06~09 · Series Contents - -`AdminSeriesContentResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `contentId` | `Long` | No | -| `title` | `String` | No | -| `coverImageUrl` | `String` | Yes | -| `isActive` | `Boolean` | No | -| `order` | `Int` | Yes | - -#### SERIES-06 · Included Content List - -Request: - -| Field | In | Type | Required | -|---|---|---|---:| -| `page` | Query | `Int` | No | -| `size` | Query | `Int` | No | - -- Path는 `characterId`, `seriesId`를 받는다. -- Response Data는 `AdminPageResponse`다. -- 기본 정렬은 `order ASC, contentId ASC`다. - -#### SERIES-07 · Available Content List - -Request: - -| Field | In | Type | Required | -|---|---|---|---:| -| `page` | Query | `Int` | No | -| `size` | Query | `Int` | No | -| `search` | Query | `String` | No | - -- Path는 `characterId`, `seriesId`를 받는다. -- Response Data는 `AdminPageResponse`다. -- 미포함 콘텐츠 검색은 `createdAtUtc DESC, contentId DESC`로 정렬한다. - -#### SERIES-08 · Add Contents - -Request `AdminSeriesContentsAddRequest`: - -```json -{ - "contentIds": [2001, 2002] -} -``` - -| Field | Type | Required | -|---|---|---:| -| `contentIds` | `List` | Yes | - -Response Data `AdminSeriesContentsMutationResponse`: - -```json -{ - "seriesId": 3001, - "affectedContentIds": [2001, 2002] -} -``` - -Response의 `affectedContentIds`는 이번 요청으로 추가된 ID다. 선택 캐릭터가 소유한 활성 콘텐츠만 추가할 수 있다. 중복 ID 또는 소유권·활성 조건을 충족하지 않는 ID가 하나라도 있으면 전체 요청을 rollback한다. - -이미 연결된 콘텐츠의 중복 추가는 409다. - -#### SERIES-09 · Remove Content - -- Request: Path `characterId`, `seriesId`, `contentId`; Body 없음 -- Response Data: `{ "seriesId": 3001, "affectedContentIds": [2001] }` -- 제거는 `series_content` join row만 물리 삭제하고 시리즈와 콘텐츠는 그대로 유지한다. -- 같은 제거 요청을 반복하면 `affectedContentIds=[]`인 200을 반환한다. -- 제거한 콘텐츠를 다시 추가하면 새 join row를 생성해 현재 마지막 순번 뒤에 배치한다. - -### 14.8 SERIES-10 · Series Order - -#### Request - -`AdminSeriesOrderRequest` - -```json -{ - "seriesIds": [3003, 3001, 3002] -} -``` - -#### Response Data - -`AdminSeriesOrderResponse` - -```json -{ - "seriesIds": [3003, 3001, 3002] -} -``` - -`seriesIds`는 선택 캐릭터가 소유한 활성 시리즈 전체를 중복·누락 없이 정확히 한 번씩 포함해야 한다. 조건을 충족하지 않으면 400을 반환하고 순서를 변경하지 않는다. 기존 `updateSeriesOrders(ids)`는 소유자 범위가 없으므로 호출하지 않는다. - -### 14.9 SERIES-11 · Series Genre Metadata - -#### Request - -- Body 없음 -- Query 없음 - -#### Response Data - -`AdminSeriesGenreResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `genreId` | `Long` | No | -| `name` | `String` | No | - -legacy `/creator-admin/audio-content/series/genre` 또는 legacy service를 호출하지 않는다. v2 series query port가 같은 기준정보 테이블을 조회한다. - -## 15. Community Post API - -Base path는 `/admin/ai-characters/{characterId}/community-posts`다. - -### 15.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `COMMUNITY-POST-01` | `GET` | `/admin/ai-characters/{characterId}/community-posts` | Path: `characterId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | -| `COMMUNITY-POST-02` | `GET` | `/admin/ai-characters/{characterId}/community-posts/{postId}` | Path: `characterId`, `postId` | `AdminCommunityPostResponse` | -| `COMMUNITY-POST-03` | `POST` | `/admin/ai-characters/{characterId}/community-posts` | Path: `characterId`; multipart: `audioFile?`, `postImage?`, `request=AdminCommunityPostCreateRequest` | `AdminMutationResponse` | -| `COMMUNITY-POST-04` | `PUT` | `/admin/ai-characters/{characterId}/community-posts/{postId}` | Path: `characterId`, `postId`; multipart: `postImage?`, `request=AdminCommunityPostUpdateRequest` | `AdminMutationResponse` | -| `COMMUNITY-POST-05` | `DELETE` | `/admin/ai-characters/{characterId}/community-posts/{postId}` | Path: `characterId`, `postId`; Body 없음 | `AdminMutationResponse` | -| `COMMUNITY-POST-06` | `PUT` | `/admin/ai-characters/{characterId}/community-posts/{postId}/fixed` | Path: `characterId`, `postId`; JSON: `AdminCommunityPostFixedRequest` | `AdminCommunityPostFixedResponse` | - -### 15.2 COMMUNITY-POST-01 · List Request - -| Field | In | Type | Required | Description | -|---|---|---|---:|---| -| `page` | Query | `Int` | No | 기본 0 | -| `size` | Query | `Int` | No | 기본 20, 최대 50 | -| `isActive` | Query | `Boolean` | No | null이면 전체 | - -기본 정렬은 고정 게시글 우선, 그다음 `createdAtUtc DESC, postId DESC`다. - -### 15.3 COMMUNITY-POST-01/02 · Community Post Response - -`COMMUNITY-POST-01`은 15.2의 Query를 받고 `AdminPageResponse`를 반환한다. -`COMMUNITY-POST-02`는 Path `characterId`, `postId`만 받고 `AdminCommunityPostResponse`를 반환한다. - -`AdminCommunityPostResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `postId` | `Long` | No | -| `creatorId` | `Long` | No | -| `creatorNickname` | `String` | No | -| `creatorProfileImageUrl` | `String` | Yes | -| `imageUrl` | `String` | Yes | -| `audioUrl` | `String` | Yes | -| `content` | `String` | No | -| `price` | `Int` | No | -| `isCommentAvailable` | `Boolean` | No | -| `isAdult` | `Boolean` | No | -| `isFixed` | `Boolean` | No | -| `isActive` | `Boolean` | No | -| `likeCount` | `Int` | No | -| `commentCount` | `Int` | No | -| `createdAtUtc` | `String` | No | -| `updatedAtUtc` | `String` | Yes | - -관리자 응답은 유료 게시글의 본문을 축약하지 않고 전체 내용을 반환한다. - -### 15.4 COMMUNITY-POST-03 · Community Post Create - -#### Request - -```http -Content-Type: multipart/form-data -``` - -| Part | Type | Required | -|---|---|---:| -| `audioFile` | File | No | -| `postImage` | File | No | -| `request` | JSON string | Yes | - -`AdminCommunityPostCreateRequest` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `content` | `String` | Yes | - | -| `isCommentAvailable` | `Boolean` | Yes | - | -| `isAdult` | `Boolean` | Yes | - | -| `price` | `Int` | No | `0` | - -- `content`는 trim 후 빈 값일 수 없고 `price`는 0 이상이다. -- `price > 0`인 유료 게시글은 `postImage`가 필수다. -- `audioFile`을 보내는 게시글은 가격과 관계없이 `postImage`가 필수다. -- 업로드 이미지의 실제 MIME type은 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며 GIF는 유료 게시글에서만 허용한다. -- `audioFile`은 빈 파일일 수 없다. v2 community web adapter가 파일명 확장자가 아니라 실제 bytes를 검사해 M4A/AAC 계열 MIME type인 `audio/mp4`, `audio/x-m4a`, `audio/aac`만 허용하고, 다른 codec이나 MIME type은 400으로 거부한다. - -#### Response Data - -`AdminMutationResponse` - -```json -{ - "id": 4001, - "isActive": true -} -``` - -### 15.5 COMMUNITY-POST-04 · Community Post Update - -#### Request - -| Part | Type | Required | -|---|---|---:| -| `postImage` | File | No | -| `request` | JSON string | Yes | - -`AdminCommunityPostUpdateRequest` - -| Field | Type | Required | -|---|---|---:| -| `content` | `String` | Yes | -| `isCommentAvailable` | `Boolean` | Yes | -| `isAdult` | `Boolean` | Yes | - -`postId`와 `isActive`는 body에서 받지 않는다. - -`price`와 `audioFile`은 등록 후 변경하거나 제거할 수 없다. 선택 `postImage`를 보내면 이미지를 교체하고, 생략하면 기존 이미지를 유지한다. 이미지 제거는 지원하지 않는다. -수정 시에도 `content`는 trim 후 빈 값일 수 없다. 교체 이미지는 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나여야 하며, 기존 게시글의 `price=0`이면 GIF를 허용하지 않는다. 기존 유료 또는 오디오 게시글은 이미지가 없는 상태로 변경할 수 없다. - -#### Response Data - -`AdminMutationResponse` - -### 15.6 COMMUNITY-POST-05 · Community Post Delete - -- Request: Path `characterId`, `postId`; Body 없음 -- Response: `{ "id": 4001, "isActive": false }` -- 게시글 삭제 시 댓글을 물리 삭제하지 않는다. - -### 15.7 COMMUNITY-POST-06 · Community Post Fixed - -#### Request - -`AdminCommunityPostFixedRequest` - -```json -{ - "isFixed": true -} -``` - -| Field | Type | Required | -|---|---|---:| -| `isFixed` | `Boolean` | Yes | - -#### Response Data - -`AdminCommunityPostFixedResponse` - -```json -{ - "postId": 4001, - "isFixed": true -} -``` - -활성 게시글만 고정할 수 있고 캐릭터별 최대 3개까지 허용한다. 이미 3개가 고정된 상태에서 다른 게시글을 고정하면 자동 교체하지 않고 409를 반환한다. - -## 16. Community Post Comment API - -Base path는 `/admin/ai-characters/{characterId}/community-posts/{postId}/comments`다. - -### 16.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `COMMUNITY-COMMENT-01` | `GET` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | Path: `characterId`, `postId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | -| `COMMUNITY-COMMENT-02` | `GET` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | Path: `characterId`, `postId`, `commentId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | -| `COMMUNITY-COMMENT-03` | `POST` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | Path: `characterId`, `postId`; JSON: `AdminCommunityCommentCreateRequest` | `AdminMutationResponse` | -| `COMMUNITY-COMMENT-04` | `PUT` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | Path: `characterId`, `postId`, `commentId`; JSON: `AdminCommentUpdateRequest` | `AdminMutationResponse` | -| `COMMUNITY-COMMENT-05` | `DELETE` | `/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | Path: `characterId`, `postId`, `commentId`; Body 없음 | `AdminMutationResponse` | - -### 16.2 COMMUNITY-COMMENT-01/02 · List Request and Response - -- Query는 `page`, `size`, 선택 `isActive`를 받으며 공통 페이징 규칙을 적용한다. `isActive` 기본값은 `true`이고 `false`이면 논리 삭제 댓글을 조회한다. -- Response item은 `AdminCommunityCommentResponse`를 사용한다. -- `COMMUNITY-COMMENT-01`은 Path `characterId`, `postId`를 받는다. -- `COMMUNITY-COMMENT-02`는 Path `characterId`, `postId`, 루트 `commentId`를 받는다. - -루트 목록 Endpoint는 `parentCommentId=null`인 댓글만 `createdAtUtc DESC, commentId DESC`로 반환한다. 답글 목록 Endpoint는 지정한 루트의 직접 자식만 `createdAtUtc ASC, commentId ASC`로 반환한다. `replyCount`는 현재 필터와 관계없이 활성 직접 답글 수다. - -| Field | Type | Nullable | -|---|---|---:| -| `commentId` | `Long` | No | -| `parentCommentId` | `Long` | Yes | -| `writerId` | `Long` | No | -| `writerNickname` | `String` | No | -| `writerProfileImageUrl` | `String` | Yes | -| `content` | `String` | No | -| `isSecret` | `Boolean` | No | -| `isActive` | `Boolean` | No | -| `replyCount` | `Int` | No | -| `createdAtUtc` | `String` | No | -| `updatedAtUtc` | `String` | Yes | - -### 16.3 COMMUNITY-COMMENT-03 · Comment Create - -`AdminCommunityCommentCreateRequest` - -```json -{ - "content": "댓글 내용", - "parentCommentId": null, - "isSecret": false -} -``` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `content` | `String` | Yes | - | -| `parentCommentId` | `Long` | No | `null` | -| `isSecret` | `Boolean` | No | `false` | - -작성자는 선택 AI 캐릭터의 `creatorMember`로 고정한다. 부모 댓글은 같은 `postId`에 속하고 활성 상태이며 `parentCommentId=null`인 최상위 댓글이어야 한다. 답글의 답글은 허용하지 않는다. - -Response Data는 `AdminMutationResponse`다. - -### 16.4 COMMUNITY-COMMENT-04 · Comment Update - -`AdminCommentUpdateRequest` - -```json -{ - "content": "수정한 댓글" -} -``` - -| Field | Type | Required | -|---|---|---:| -| `content` | `String` | Yes | - -AI 캐릭터의 `creatorMember`가 작성한 댓글만 본문을 수정할 수 있다. - -`parentCommentId`와 `isSecret`은 등록 후 변경할 수 없다. - -Response Data는 `AdminMutationResponse`다. - -### 16.5 COMMUNITY-COMMENT-05 · Comment Delete - -- Request: Path `characterId`, `postId`, `commentId`; Body 없음 -- Response Data: `{ "id": 4101, "isActive": false }` -- AI 캐릭터가 작성한 댓글은 작성자 권한으로 논리 삭제할 수 있다. -- 선택 AI 캐릭터 소유 게시글에 달린 댓글은 다른 사용자가 작성했어도 게시글 소유자 권한으로 논리 삭제할 수 있다. -- 다른 크리에이터의 게시글에 달린 댓글은 이 관리자 경로에서 삭제할 수 없다. - -## 17. FanTalk API - -Base path는 `/admin/ai-characters/{characterId}/fan-talks`다. - -### 17.1 Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `FAN-TALK-01` | `GET` | `/admin/ai-characters/{characterId}/fan-talks` | Path: `characterId`; Query: `page`, `size` | `AdminFanTalkPageResponse` | -| `FAN-TALK-02` | `POST` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | Path: `characterId`, `fanTalkId`; JSON: `AdminFanTalkReplyCreateRequest` | `AdminFanTalkReplyResponse` | -| `FAN-TALK-03` | `PUT` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | Path: `characterId`, `fanTalkId`, `replyId`; JSON: `AdminFanTalkReplyUpdateRequest` | `AdminFanTalkReplyResponse` | -| `FAN-TALK-04` | `DELETE` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | Path: `characterId`, `fanTalkId`, `replyId`; Body 없음 | `AdminMutationResponse` | -| `FAN-TALK-05` | `DELETE` | `/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` | Path: `characterId`, `fanTalkId`; Body 없음 | `AdminMutationResponse` | - -답글 전용 `GET` Endpoint는 만들지 않는다. FanTalk 목록 조회 시 각 루트 항목의 `creatorReplies`에 활성 AI 캐릭터 답글을 함께 반환한다. - -### 17.2 FAN-TALK-01 · FanTalk List - -#### Request - -| Field | In | Type | Required | Description | -|---|---|---|---:|---| -| `page` | Query | `Int` | No | 기본 0 | -| `size` | Query | `Int` | No | 기본 20, 최대 50 | - -활성 최상위 FanTalk만 `createdAtUtc DESC, fanTalkId DESC`로 반환한다. 중첩 AI 캐릭터 답글은 활성 직접 답글만 `createdAtUtc ASC, replyId ASC`로 정렬한다. - -#### Response Data - -기존 v2 FanTalk domain/query port는 활용할 수 있지만 공개 채널 API DTO를 직접 반환하지 않는다. 관리자 API 전용 `AdminFanTalkPageResponse`로 변환한다. - -| Field | Type | Nullable | -|---|---|---:| -| `items` | `List` | No | -| `page` | `Int` | No | -| `size` | `Int` | No | -| `totalCount` | `Long` | No | -| `hasNext` | `Boolean` | No | - -`AdminFanTalkResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `fanTalkId` | `Long` | No | -| `writerId` | `Long` | No | -| `writerNickname` | `String` | No | -| `writerProfileImageUrl` | `String` | No | -| `content` | `String` | No | -| `createdAtUtc` | `String` | No | -| `creatorReplies` | `List` | No | - -중첩 `AdminFanTalkReplyResponse`는 다음 필드를 반환한다. - -| Field | Type | Nullable | -|---|---|---:| -| `replyId` | `Long` | No | -| `fanTalkId` | `Long` | No | -| `writerId` | `Long` | No | -| `writerNickname` | `String` | No | -| `writerProfileImageUrl` | `String` | No | -| `content` | `String` | No | -| `isActive` | `Boolean` | No | -| `createdAtUtc` | `String` | No | -| `updatedAtUtc` | `String` | Yes | - -프로필 이미지가 없는 작성자와 AI 캐릭터에는 기존 CDN 기본 프로필 이미지 URL을 적용하므로 `writerProfileImageUrl`은 null이 아니다. - -### 17.3 FAN-TALK-02 · FanTalk Reply Create - -#### Request - -`AdminFanTalkReplyCreateRequest` - -```json -{ - "content": "AI 캐릭터 답글", - "languageCode": "ko" -} -``` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `content` | `String` | Yes | - | -| `languageCode` | `String` | No | `null` | - -루트 `fanTalkId`가 선택 AI 캐릭터를 대상으로 한 활성 FanTalk인지 검증한다. 작성자는 선택 AI 캐릭터의 `creatorMember`로 고정한다. - -#### Response Data - -`AdminFanTalkReplyResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `replyId` | `Long` | No | -| `fanTalkId` | `Long` | No | -| `writerId` | `Long` | No | -| `writerNickname` | `String` | No | -| `writerProfileImageUrl` | `String` | No | -| `content` | `String` | No | -| `isActive` | `Boolean` | No | -| `createdAtUtc` | `String` | No | -| `updatedAtUtc` | `String` | Yes | - -### 17.4 FAN-TALK-03 · FanTalk Reply Update - -#### Request - -`AdminFanTalkReplyUpdateRequest` - -```json -{ - "content": "수정한 AI 캐릭터 답글" -} -``` - -| Field | Type | Required | -|---|---|---:| -| `content` | `String` | Yes | - -선택 AI 캐릭터의 `creatorMember`가 작성했고 해당 루트 FanTalk의 답글인 경우에만 수정한다. - -`fanTalkId`와 `languageCode`는 등록 후 변경할 수 없다. - -#### Response Data - -`AdminFanTalkReplyResponse` - -### 17.5 FAN-TALK-04 · FanTalk Reply Delete - -- Request: Path `characterId`, `fanTalkId`, `replyId`; Body 없음 -- Response: `{ "id": 5002, "isActive": false }` -- AI 캐릭터가 작성한 답글만 논리 삭제할 수 있다. -- 팬이 작성한 루트 FanTalk는 답글 삭제 Endpoint에서 함께 삭제하지 않는다. - -### 17.6 FAN-TALK-05 · FanTalk Root Moderation - -선택 AI 캐릭터를 대상으로 작성된 루트 FanTalk는 크리에이터 소유자 권한으로 논리 삭제할 수 있다. - -- Request: Path `characterId`, `fanTalkId`; Body 없음 -- Response: `{ "id": 5001, "isActive": false }` -- 팬이 작성한 본문을 수정할 수는 없다. -- 루트 FanTalk 삭제는 하위 AI 답글을 물리 삭제하지 않지만 공개 조회에서 루트와 답글을 함께 제외한다. - -## 18. Content Category and Creator Channel Settings API - -### 18.1 Content Category Endpoint Summary - -Base path는 `/admin/ai-characters/{characterId}/content-categories`다. - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `CATEGORY-01` | `GET` | `/admin/ai-characters/{characterId}/content-categories` | Path: `characterId`; Query: `page`, `size`, `isActive` | `AdminPageResponse` | -| `CATEGORY-02` | `POST` | `/admin/ai-characters/{characterId}/content-categories` | Path: `characterId`; JSON: `AdminContentCategoryCreateRequest` | `AdminContentCategoryMutationResponse` | -| `CATEGORY-03` | `PUT` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}` | Path: `characterId`, `categoryId`; JSON: `AdminContentCategoryUpdateRequest` | `AdminContentCategoryMutationResponse` | -| `CATEGORY-04` | `DELETE` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}` | Path: `characterId`, `categoryId`; Body 없음 | `AdminContentCategoryMutationResponse` | -| `CATEGORY-05` | `PUT` | `/admin/ai-characters/{characterId}/content-categories/orders` | Path: `characterId`; JSON: `AdminContentCategoryOrderRequest` | `AdminContentCategoryOrderResponse` | -| `CATEGORY-06` | `GET` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | Path: `characterId`, `categoryId`; Query: `page`, `size` | `AdminPageResponse` | -| `CATEGORY-07` | `GET` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents` | Path: `characterId`, `categoryId`; Query: `page`, `size`, `search` | `AdminPageResponse` | -| `CATEGORY-08` | `POST` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | Path: `characterId`, `categoryId`; JSON: `AdminCategoryContentsAddRequest` | `AdminCategoryContentsMutationResponse` | -| `CATEGORY-09` | `DELETE` | `/admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId}` | Path: `characterId`, `categoryId`, `contentId`; Body 없음 | `AdminCategoryContentsMutationResponse` | - -### 18.2 CATEGORY-01 · Content Category List - -목록 Request는 공통 `page`, `size`와 선택 `isActive`를 받는다. `isActive=null`이면 활성·비활성 카테고리를 모두 반환한다. - -`AdminContentCategoryResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `categoryId` | `Long` | No | -| `title` | `String` | No | -| `order` | `Int` | No | -| `contentCount` | `Int` | No | -| `isActive` | `Boolean` | No | - -기본 정렬은 `order ASC, categoryId ASC`다. - -### 18.3 CATEGORY-02 · Content Category Create - -#### Request - -`AdminContentCategoryCreateRequest` - -```json -{ - "title": "ASMR", - "contentIds": [2001, 2002] -} -``` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `title` | `String` | Yes | - | -| `contentIds` | `List` | No | `[]` | - -#### Response Data - -`AdminContentCategoryMutationResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `categoryId` | `Long` | No | -| `isActive` | `Boolean` | No | - -```json -{ - "categoryId": 6001, - "isActive": true -} -``` - -모든 `contentIds`는 중복이 없어야 하며 선택 AI 캐릭터가 소유한 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 카테고리를 생성하지 않는다. -`title`은 trim 후 2자 이상이어야 하며 같은 캐릭터의 활성 카테고리 제목과 중복될 수 없다. - -### 18.4 CATEGORY-03 · Content Category Update - -#### Request - -`AdminContentCategoryUpdateRequest` - -```json -{ - "title": "수면 ASMR" -} -``` - -| Field | Type | Required | Default | -|---|---|---:|---| -| `title` | `String` | Yes | - | - -등록과 동일하게 trim 후 2자 이상 및 같은 캐릭터의 활성 카테고리 제목 중복 금지 규칙을 적용한다. - -#### Response Data - -`AdminContentCategoryMutationResponse` - -### 18.5 CATEGORY-04 · Content Category Delete - -- Request: Path `characterId`, `categoryId`; Body 없음 -- Response: `{ "categoryId": 6001, "isActive": false }` -- 카테고리와 콘텐츠 연결만 비활성화하고 콘텐츠는 삭제하지 않는다. - -### 18.6 CATEGORY-05 · Content Category Order - -#### Request - -`AdminContentCategoryOrderRequest` - -```json -{ - "categoryIds": [6003, 6001, 6002] -} -``` - -| Field | Type | Required | -|---|---|---:| -| `categoryIds` | `List` | Yes | - -#### Response Data - -`AdminContentCategoryOrderResponse` - -```json -{ - "categoryIds": [6003, 6001, 6002] -} -``` - -`categoryIds`는 선택 캐릭터가 소유한 활성 카테고리 전체를 중복·누락 없이 정확히 한 번씩 포함해야 한다. 조건을 충족하지 않으면 400을 반환하고 순서를 변경하지 않는다. - -### 18.7 CATEGORY-06~09 · Category Contents - -`AdminCategoryContentResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `contentId` | `Long` | No | -| `title` | `String` | No | -| `coverImageUrl` | `String` | Yes | -| `isActive` | `Boolean` | No | - -#### CATEGORY-06 · Included Content List - -- Request: Path `characterId`, `categoryId`; Query `page`, `size` -- Response Data: `AdminPageResponse` -- 활성 연결과 활성 콘텐츠만 대상으로 `CategoryContent.orders ASC, contentId ASC`로 정렬한다. - -#### CATEGORY-07 · Available Content List - -- Request: Path `characterId`, `categoryId`; Query `page`, `size`, 선택 `search` -- Response Data: `AdminPageResponse` -- 활성 콘텐츠 중 해당 카테고리에 포함되지 않은 콘텐츠를 `createdAtUtc DESC, contentId DESC`로 정렬한다. - -#### CATEGORY-08 · Add Contents - -Request `AdminCategoryContentsAddRequest`: - -```json -{ - "contentIds": [2001, 2002] -} -``` - -| Field | Type | Required | -|---|---|---:| -| `contentIds` | `List` | Yes | - -Response Data `AdminCategoryContentsMutationResponse`: - -```json -{ - "categoryId": 6001, - "affectedContentIds": [2001, 2002] -} -``` - -추가 대상은 중복이 없어야 하며 모두 선택 캐릭터 소유의 활성 콘텐츠여야 한다. 하나라도 조건을 충족하지 않으면 전체 요청을 rollback한다. - -기존 비활성 연결이 있으면 새 row를 만들지 않고 다시 활성화해 현재 마지막 `orders` 뒤에 배치한다. 이미 활성인 콘텐츠의 중복 추가는 409다. - -#### CATEGORY-09 · Remove Content - -- Request: Path `characterId`, `categoryId`, `contentId`; Body 없음 -- Response Data: `{ "categoryId": 6001, "affectedContentIds": [2001] }` -- 제거 대상 연결이 존재하면 `CategoryContent.isActive=false`로 논리 삭제한다. -- 같은 제거 요청을 반복하면 `affectedContentIds=[]`인 200을 반환한다. - -### 18.8 Channel Notice Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `NOTICE-01` | `GET` | `/admin/ai-characters/{characterId}/channel-notice` | Path: `characterId` | `AdminChannelNoticeResponse` | -| `NOTICE-02` | `PUT` | `/admin/ai-characters/{characterId}/channel-notice` | Path: `characterId`; JSON: `AdminChannelNoticeUpsertRequest` | `AdminChannelNoticeResponse` | - -별도 등록 Endpoint를 만들지 않고 `PUT`을 upsert로 사용한다. - -### 18.9 NOTICE-01 · Channel Notice Read - -#### Request - -- Path `characterId: Long` -- Query와 Body 없음 - -#### Response Data - -`AdminChannelNoticeResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `characterId` | `Long` | No | -| `creatorId` | `Long` | No | -| `notice` | `String` | No | -| `updatedAtUtc` | `String` | Yes | - -저장된 공지가 없으면 `notice=""`, `updatedAtUtc=null`을 반환한다. - -### 18.10 NOTICE-02 · Channel Notice Upsert - -#### Request - -`AdminChannelNoticeUpsertRequest` - -```json -{ - "notice": "새 콘텐츠는 매주 금요일 공개됩니다." -} -``` - -| Field | Type | Required | Description | -|---|---|---:|---| -| `notice` | `String` | Yes | 빈 문자열은 공지 내용 지우기 | - -#### Response Data - -변경 후 `AdminChannelNoticeResponse`를 반환한다. - -- 선택 AI 캐릭터의 `creatorMemberId`로 공지를 조회·저장한다. -- 값이 실제로 변경된 경우에만 v2 notification port를 통해 기존 구독자 알림 이벤트와 동등한 알림을 발행한다. -- legacy `ExplorerService.saveNotice`를 호출하지 않는다. - -### 18.11 Channel Profile and Creator Tag Endpoint Summary - -| Operation ID | Method | Endpoint | Request | Response Data | -|---|---|---|---|---| -| `CREATOR-TAG-01` | `GET` | `/admin/ai-characters/metadata/creator-tags` | 없음 | `List` | -| `CHANNEL-PROFILE-01` | `GET` | `/admin/ai-characters/{characterId}/channel-profile` | Path: `characterId` | `AdminChannelProfileResponse` | -| `CHANNEL-PROFILE-02` | `PUT` | `/admin/ai-characters/{characterId}/channel-profile` | Path: `characterId`; JSON: `AdminChannelProfileUpdateRequest` | `AdminChannelProfileResponse` | - -### 18.12 CREATOR-TAG-01, CHANNEL-PROFILE-01/02 · Channel Profile and Creator Tags - -#### CREATOR-TAG-01 · Creator Tag Metadata - -`AdminCreatorTagResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `tagId` | `Long` | No | -| `name` | `String` | No | -| `imageUrl` | `String` | Yes | -| `isAdult` | `Boolean` | No | - -creator tag metadata는 활성 태그 전체를 `orders ASC, tagId ASC`로 반환하는 비페이징 기준정보다. - -#### CHANNEL-PROFILE-01 · Channel Profile Read - -- Request: Path `characterId`; Query와 Body 없음 -- Response Data: `AdminChannelProfileResponse` - -`AdminChannelProfileResponse` - -| Field | Type | Nullable | -|---|---|---:| -| `characterId` | `Long` | No | -| `creatorId` | `Long` | No | -| `instagramUrl` | `String` | No | -| `fancimmUrl` | `String` | No | -| `xUrl` | `String` | No | -| `youtubeUrl` | `String` | No | -| `kakaoOpenChatUrl` | `String` | No | -| `creatorTags` | `List` | No | -| `isVisibleDonationRank` | `Boolean` | No | -| `donationRankingPeriod` | `String` | Yes | -| `updatedAtUtc` | `String` | Yes | - -#### CHANNEL-PROFILE-02 · Channel Profile Update - -`AdminChannelProfileUpdateRequest` - -| Field | Type | Required | -|---|---|---:| -| `instagramUrl` | `String` | Yes | -| `fancimmUrl` | `String` | Yes | -| `xUrl` | `String` | Yes | -| `youtubeUrl` | `String` | Yes | -| `kakaoOpenChatUrl` | `String` | Yes | -| `tagIds` | `List` | Yes | -| `isVisibleDonationRank` | `Boolean` | Yes | -| `donationRankingPeriod` | `String?` | Yes | - -```json -{ - "instagramUrl": "https://instagram.com/example", - "fancimmUrl": "", - "xUrl": "", - "youtubeUrl": "https://youtube.com/@example", - "kakaoOpenChatUrl": "", - "tagIds": [11, 14], - "isVisibleDonationRank": true, - "donationRankingPeriod": "CUMULATIVE" -} -``` - -- Response Data는 변경 후 `AdminChannelProfileResponse`다. -- 빈 문자열은 해당 URL 지우기다. -- URL 값은 빈 문자열 또는 `http`/`https` 절대 URL이어야 한다. -- `donationRankingPeriod`는 `WEEKLY`, `CUMULATIVE`, `null`만 허용한다. -- `tagIds`는 중복 없이 활성 creator tag만 포함해야 하며 AI creator Member의 태그를 전체 교체한다. 빈 배열은 모든 creator tag 제거를 의미하며 일부 크리에이터 탐색 목록에서 해당 채널이 제외될 수 있다. -- `ChatCharacter.tags`는 캐릭터 대화·검색용 별도 모델이므로 creator Member 태그와 자동 동기화하지 않는다. -- 이름, 프로필 이미지와 소개는 이 API에서 변경하지 않고 캐릭터 수정의 동기화 결과만 사용한다. - -## 19. Authorization and Ownership Rules - -요청군별 검증 경계는 다음과 같다. - -| Request group | Authentication | Target validation | -|---|---|---| -| `GET/POST /admin/ai-characters` | `ROLE_ADMIN` | 목록은 target 없음, 등록은 신규 character·creator 생성 규칙 적용 | -| `/admin/ai-characters/original-works/**` | `ROLE_ADMIN` | global 원작 상태와 캐릭터 배정 집합을 검증하며 사전 character 선택은 없음 | -| `/admin/ai-characters/{characterId}/**` 및 캐릭터 상세·수정·삭제 | `ROLE_ADMIN` | `characterId`와 연결 AI creator를 해석한 뒤 소유권·활성 상태 검증 | -| `/admin/ai-characters/metadata/**` | `ROLE_ADMIN` | character target 없이 활성 기준정보만 조회 | - -캐릭터 범위 요청은 다음 순서로 검증한다. - -1. Bearer JWT와 `ROLE_ADMIN`을 확인한다. -2. `characterId`에 해당하는 `ChatCharacter`와 연결 Member가 존재하고 `role=CREATOR`, `memberKind=AI_CHARACTER`인지 확인한다. -3. 대상 콘텐츠, 시리즈, 카테고리 또는 게시글이 연결 `creatorMember.id` 소유인지 확인한다. -4. 댓글·답글은 부모와 루트 리소스의 귀속까지 확인한다. -5. 생성·수정·구성 변경에는 캐릭터, 연결 Member 및 대상 부모 리소스가 모두 활성 상태인지 확인한다. -6. 검증이 끝난 뒤 해당 v2 도메인 use case를 호출한다. - -global 원작 변경은 사람 관리자만 인증한 뒤 `originalWorkId`, `isDeleted`와 제목 충돌을 검증한다. 배정·해제는 Request의 모든 `characterIds`를 한 번에 검증하고, 배정에서는 활성 AI 캐릭터인지, 해제에서는 Path 원작에 실제 연결되어 있는지 확인한 뒤 하나의 transaction으로 변경한다. 캐릭터를 먼저 선택하거나 관리자 principal을 AI 캐릭터 Member로 바꾸지 않는다. - -논리 삭제된 콘텐츠, 시리즈, 카테고리, 게시글, 댓글 또는 답글에는 수정·고정·순서/구성 변경·하위 리소스 생성을 허용하지 않고 409를 반환한다. 조회 API에서 명시한 상태 필터 조회와 `DELETE` 재시도만 허용한다. 캐릭터 자체 및 기존 리소스의 논리 삭제는 비활성 캐릭터에서도 허용하며 반복 호출은 같은 비활성 결과를 반환한다. - -관리자 principal을 AI 캐릭터 Member로 교체하지 않는다. - -```text -ADMIN JWT - -> v2 admin web adapter - -> v2 AI character target query - -> characterId / creatorMemberId 해석 - -> 해당 v2 domain use case - -> v2 persistence / external-system port -``` - -## 20. Error Contract - -다음 표의 역할 규칙은 `/admin/ai-characters/**`에 적용한다. - -| Situation | HTTP Status | `success` | `errorProperty` | -|---|---:|---:|---| -| JWT 없음 또는 유효하지 않음 | `401` | `false` | `null` | -| 인증되었지만 `ROLE_ADMIN` 아님 | `403` | `false` | `null` | -| 캐릭터 미존재 | `404` | `false` | `characterId` | -| `originalWorkId` 또는 원작 API Path ID가 0 이하 | `400` | `false` | `originalWorkId` | -| 원작 미존재 또는 삭제된 원작 조회 | `404` | `false` | `originalWorkId` | -| 원작 수정·삭제·배정·해제의 미존재 양수 Path ID | `404` | `false` | `originalWorkId` | -| 캐릭터 생성·수정의 미존재 양수 `originalWorkId` | `404` | `false` | `originalWorkId` | -| 캐릭터 생성·수정의 삭제된 `originalWorkId` | `409` | `false` | `originalWorkId` | -| 삭제된 원작 수정·배정·해제 | `409` | `false` | `originalWorkId` | -| 연결 캐릭터가 남은 원작 삭제 | `409` | `false` | `originalWorkId` | -| 원작 배정의 미존재·중복·비AI 캐릭터 또는 해제 귀속 불일치 | `400` | `false` | `characterIds` | -| 원작 배정 대상 캐릭터 비활성 | `409` | `false` | `characterIds` | -| 대상 리소스 미존재 또는 다른 캐릭터 소유 | `404` | `false` | 해당 resource ID field | -| 비활성 캐릭터에 대한 `DELETE` 외 변경 요청 | `409` | `false` | `characterId` | -| 논리 삭제된 리소스에 대한 삭제 외 변경 요청 | `409` | `false` | 해당 resource ID field | -| 잘못된 필드·부모 귀속·페이지 요청 | `400` | `false` | 해당 field | -| 동일 이름 등 현재 상태와 충돌 | `409` | `false` | 충돌 field | -| 외부 캐릭터 API 또는 파일 저장 실패 | `502` | `false` | `null` | -| 원작 이미지 저장 후 비재시도 DB 실패 또는 재시도 소진 | `500` | `false` | `null` | -| Signed URL 생성 또는 저장된 output key 무결성 검증 실패 | `500` | `false` | `null` | - -신규 Endpoint는 기존 일부 legacy handler의 `HTTP 200 + success=false` 관례를 답습하지 않고 위 HTTP status를 계약으로 사용한다. 오류 본문은 기존 `ApiResponse.error(...)` 형식을 유지한다. - -이미 삭제된 원작의 반복 DELETE는 과거 불일치 연결이 남아 있어도 성공 200이므로 위 “연결 캐릭터가 남은 원작 삭제” 409보다 먼저 판정한다. 원작 이미지 보상 삭제가 실패해도 이미 발생한 로컬 transaction 실패의 500을 다른 성공이나 502로 바꾸지 않고 orphan 운영 로그를 남긴다. - -기존 `/audio-content/upload-complete`는 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. 기존 Request와 `ADMIN`/`BOT` 성공 Response는 이번 범위에서 변경하지 않으며, 관리자 콘텐츠 조회에서 수행하는 canonical output key 검증도 callback의 Request·성공 Response를 변경하지 않는다. - -## 21. Technical Requirements - -### 21.1 Module Boundary - -- 관리자 inbound adapter와 DTO는 `kr.co.vividnext.sodalive.v2.admin.aicharacter` 아래에 둔다. -- 신규 worker callback Controller, 전용 Request/Response, V1/V2 dispatcher 또는 v2 completion input port를 만들지 않는다. 기존 `/audio-content/upload-complete`의 Controller와 Service 흐름을 유지한다. -- 비즈니스 기능은 관리자 패키지 한 곳에 모으지 않고 다음 v2 도메인이 소유한다. - -| 기능 | v2 소유 패키지 | -|---|---| -| AI 캐릭터 CRUD와 관리자 대상 해석 | `kr.co.vividnext.sodalive.v2.aicharacter` | -| 원작 CRUD·검색과 캐릭터 배정 | `kr.co.vividnext.sodalive.v2.originalwork` | -| 콘텐츠·콘텐츠 댓글·콘텐츠 카테고리·콘텐츠 기준정보 | `kr.co.vividnext.sodalive.v2.content` 하위 기능 패키지 | -| 시리즈·장르·시리즈 구성 | `kr.co.vividnext.sodalive.v2.creator.channel.series` | -| 커뮤니티 게시글·댓글 | `kr.co.vividnext.sodalive.v2.creator.channel.community` | -| FanTalk 조회·답글 | `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` | -| 크리에이터 채널 공지 | `kr.co.vividnext.sodalive.v2.creator.channel.notice` | -| 크리에이터 채널 프로필·creator tag 설정 | `kr.co.vividnext.sodalive.v2.creator.channel.profile` | - -- 각 도메인은 필요한 `domain`, `application`, `port/out`, `adapter/out` 계층을 기존 v2 구조에 맞춰 둔다. -- admin web adapter는 Request 변환, `Member` principal에서 `adminMemberId` 추출, Response 변환만 담당한다. -- admin application 조정 계층은 character-scoped 요청의 `characterId -> creatorMemberId` 해석 또는 global 원작 요청의 원작 use case 호출과 Response 변환만 담당한다. -- 업무 규칙, 소유권 검증 및 상태 전이는 각 v2 도메인이 소유한다. -- legacy Controller, Service, Request/Response DTO 및 Repository를 호출하지 않는다. -- 호환용 legacy 원작 관리자 web adapter와 legacy 캐릭터 등록·수정 adapter는 각각 v2 원작·캐릭터 input command를 호출할 수 있다. 의존 방향은 `legacy inbound -> v2 application` 단방향이며, 기존 Method·Path·Request·성공 Response와 legacy patch 의미 변환만 legacy 계층이 담당한다. -- Controller에서 다른 Controller를 호출하지 않고 기존 API를 내부 HTTP로 호출하지 않는다. -- 단일 기능을 위한 범용 impersonation 프레임워크를 만들지 않는다. - -### 21.2 Target Resolver - -v2 AI character domain의 공통 대상 query는 entity가 아닌 다음 값 객체를 반환한다. - -```text -AiCharacterAdminTarget( - characterId, - creatorMemberId, - characterIsActive, - creatorMemberIsActive, - creatorRole, - memberKind -) -``` - -대상 query는 `creatorRole=CREATOR`, `memberKind=AI_CHARACTER`를 만족하지 않으면 대행 대상으로 반환하지 않는다. 생성·수정 작업에는 캐릭터와 연결 Member가 모두 활성 상태여야 한다. 모든 character-scoped 하위 도메인 use case는 이 해석 결과를 사용해 body의 `creatorId` 주입 가능성을 제거한다. 각 도메인은 전달된 `creatorMemberId`와 대상 리소스의 실제 소유자가 같은지도 자체 persistence port로 다시 검증한다. global 원작 CRUD에는 이 resolver를 호출하지 않는다. 원작 배정은 각 character ID의 활성 AI 캐릭터 유효성을 일괄 검증하고, 해제는 legacy 불일치 관계 정리를 위해 resolver 대신 기존 `ChatCharacter`의 존재와 Path 원작 귀속만 일괄 검증한다. - -### 21.3 v2 Domain Implementation - -- legacy 구현은 데이터 의미와 회귀 시나리오를 파악하는 참고 자료로만 사용한다. -- v2 도메인 규칙을 legacy service에 위임하지 않는다. -- 기존 v2 query use case와 port가 이 문서의 계약을 충족하면 같은 v2 도메인 안에서 재사용하거나 확장할 수 있다. -- command use case가 없는 콘텐츠·댓글·시리즈·커뮤니티·FanTalk 답글은 각 v2 도메인에 별도로 구현한다. -- 관리자 조회에는 구매 여부, 성인 선호도 또는 차단 관계에 따른 소비자용 마스킹을 적용하지 않고 관리자 전용 query projection을 사용한다. -- 다음 규칙은 legacy 동작을 복사하지 않고 v2 도메인 정책으로 명시적으로 구현한다. - - 캐릭터 활성 상태와 연결 AI creator 유효성 - - 원작의 `isDeleted=false`, 제목 중복, 전체 교체, 연결 캐릭터가 없는 삭제 조건 - - 원작 캐릭터 배정·해제의 전건 검증, 원자성, 이동 및 현재 원작 귀속 - - 콘텐츠·시리즈·게시글 소유권 - - 콘텐츠 카테고리와 포함 콘텐츠의 동일 소유권 - - 콘텐츠 댓글과 커뮤니티 댓글의 부모·루트 귀속 - - 작성자만 본문 수정 가능 - - 작성자 또는 소유자만 댓글 논리 삭제 가능 - - 시리즈 순서 변경 대상 전체의 동일 소유자 검증 - - FanTalk 루트 대상과 AI 답글 작성자 검증 - - 채널 공지의 creator 소유권과 변경 알림 - - 채널 프로필·creator tag 설정의 creator 소유권과 활성 tag 검증 -- 캐릭터 등록·수정의 외부 API와 파일 저장, 원작 이미지 저장은 v2 outbound port로 정의하고 v2 infrastructure adapter에서 구현한다. - -legacy Service를 호출하지 않더라도 다음 외부 관찰 가능 동작은 v2 outbound port와 after-commit event로 보존한다. - -| Trigger | Required side effect | -|---|---| -| 캐릭터 등록 | description 기반 언어 감지 작업 예약 | -| 캐릭터 수정 | 캐릭터 번역 갱신 작업 예약 | -| 원작 등록 | title·contentType·category·description 기반 언어 감지 작업 예약 | -| 원작 수정 | 정규화된 title·contentType·category·description·tags 중 하나 이상이 실제 변경된 경우 원작 번역 갱신 작업 예약 | -| 콘텐츠 등록·수정 | 언어 코드가 없으면 언어 감지, 있으면 번역 작업 예약 | -| 기존 callback 또는 예약 공개가 콘텐츠를 최초 공개 | 구독자 콘텐츠 공개 알림과 v2 following/home news 발행 | -| 콘텐츠 댓글 등록 | 대상 콘텐츠 알림, 언어 코드가 없으면 언어 감지 | -| 시리즈 등록·수정 | 언어 감지 또는 번역 작업 예약 | -| 콘텐츠 카테고리 등록·제목 수정 | 언어 감지 또는 번역 작업 예약 | -| 커뮤니티 게시글 등록 | 구독자 알림, 무료 게시글이면 v2 following/home news 발행 | -| FanTalk 답글 등록 | 언어 코드가 없으면 언어 감지 | -| 채널 공지 변경 | 구독자 공지 변경 알림 | -| 캐릭터 삭제 | commit 후 공개 콘텐츠·추천·랭킹·인기 캐릭터 cache 무효화 | - -트랜잭션 rollback 시 알림·home news·번역 요청을 발행하지 않는다. 같은 공개 처리 또는 같은 command의 멱등 재시도에서 중복 발행하지 않는다. - -원작 event adapter는 legacy 언어 감지 listener가 감지 transaction 안에서 후속 번역 event를 즉시 발행할 수 있다는 점을 그대로 노출하지 않는다. 원작 생성·수정 transaction이 commit된 뒤에만 감지 또는 번역 시작 event를 한 번 전달하고, 감지 결과를 사용하는 후속 번역도 해당 감지 결과 commit 이후 실행되도록 adapter test로 고정한다. - -### 21.4 Persistence and Shared Infrastructure - -- 기존 DB 테이블을 그대로 사용하며 같은 테이블을 위한 v2 전용 JPA entity를 중복 생성하지 않는다. -- v2 domain/application은 legacy JPA entity나 QueryDSL Q type을 참조하지 않는다. -- v2 persistence adapter만 기존 JPA entity 또는 QueryDSL Q type을 사용해 기존 테이블에 접근할 수 있다. -- adapter는 persistence 결과를 v2 port record 또는 v2 domain model로 변환한다. -- v2 application과 v2 persistence adapter 모두 legacy Repository를 주입하거나 호출하지 않는다. 필요한 query/command는 v2가 소유한 port와 persistence 구현으로 정의하고, adapter에서 `EntityManager`, QueryDSL 또는 v2 전용 repository 구현을 사용해 기존 entity/table에 접근한다. -- original-work persistence adapter는 기존 `OriginalWork`, `OriginalWorkLink`, `OriginalWorkTag`, `OriginalWorkTagMapping` entity와 `ChatCharacter.originalWork` 관계를 사용하되 v2 domain에 이를 노출하지 않는다. 같은 테이블을 위한 v2 `@Entity` 또는 relation mapping을 새로 만들지 않는다. -- 원작 link와 tag-mapping 조회는 각 mapping ID 오름차순을 query에 명시하고, 변경 시 정규화된 요청 순서대로 mapping을 다시 만든다. 순서 보존을 위해 기존 entity에 `@OrderColumn`을 추가하지 않는다. -- 원작 삭제·배정·해제와 캐릭터 원작 연결 변경은 v2 persistence port의 잠금 query를 사용한다. 양수 추가 연결 대상 또는 원작 Path가 있으면 해당 원작 row를 `PESSIMISTIC_WRITE`로 먼저 잠그고 batch 캐릭터 row를 ID 오름차순으로 잠근 뒤 `isDeleted`, 활성 AI 여부와 현재 귀속을 다시 검증한다. `CHAR-04` null과 legacy 수정 0의 해제는 target 원작 없이 캐릭터 row만 잠그고 현재 귀속을 재검증한다. -- 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` 격리에서 중복 조회와 write를 같은 transaction으로 처리한다. deadlock·serialization 실패는 새 transaction에서 최대 한 번 재시도하며, 중복 재조회 결과가 없는 잠금 실패를 제목 충돌 409로 오인하지 않는다. -- 공통 JWT 인증, `ApiResponse`, 파일 저장 client, CDN URL 정책 및 외부 캐릭터 API client는 플랫폼·인프라 기능이므로 port 경계 뒤에서 재사용할 수 있다. -- 이번 기능을 위해 DB 테이블·컬럼·인덱스를 추가하거나 기존 JPA entity mapping을 변경하지 않는다. `alter-existing-tables.sql`, lifecycle backfill, upload pipeline 구분 컬럼과 v2 전용 콘텐츠 테이블도 만들지 않는다. -- content persistence adapter는 기존 `content` row의 `isActive`, `releaseDate`, `duration`, `content` 경로와 연결 creator 활성 상태를 반환한다. v2 query application은 12.2의 우선순위로 `status`를 계산하며 계산값을 DB에 다시 저장하지 않는다. -- CONTENT-01의 `status` 필터도 같은 기존 컬럼 조건과 하나의 UTC 기준 시각을 사용한다. 별도 status 컬럼이나 status 전용 인덱스는 실제 성능 근거 없이 추가하지 않는다. -- 콘텐츠 생성 Request의 `previewStartTime`, `previewEndTime`은 S3 metadata 전달용이며 DB에 저장하지 않는다. 따라서 목록·상세 persistence projection과 Response에도 포함하지 않는다. -- v2 content query application은 요청마다 기준 `Instant`를 한 번 얻고 `(duration의 HH 부분 + 2)시간`을 더한 절대 `expiresAt`을 계산한다. -- Signed URL outbound port는 canonical output key와 절대 `expiresAt`을 받아 URL policy에 정확히 같은 만료 시각을 사용하고 `SignedAudioUrl(url, expiresAt)`을 반환한다. 현재 `AudioContentCloudFront`의 상대 TTL 호출과 별도로 응답 만료 시각을 계산해 두 값이 어긋나는 구현은 허용하지 않는다. -- v2 content query application은 위 port를 통해 `CONTENT-01`, `CONTENT-02` 응답마다 Signed URL과 `contentUrlExpiresAtUtc`를 함께 생성한다. -- Signed URL 생성 실패 시 persistence 경로나 비서명 오디오 URL을 응답으로 노출하지 않는다. -- v2 콘텐츠 생성은 기존 row와 같은 방식으로 `isActive=false`, `duration=null`, `content=input/{contentId}/{contentId}-content-...`로 시작한다. object basename은 기존 생성 규칙을 유지해 worker가 만든 output basename이 callback의 content ID 검증을 통과하게 한다. `releaseAtUtc=null`이면 현재 UTC 시각을 `releaseDate`에 저장해 callback 완료 후 즉시 공개 조건을 표현한다. -- 콘텐츠 논리 삭제는 기존 의미대로 `isActive=false`, `releaseDate=null`로 기록한다. callback은 가공 결과 경로와 duration을 기록할 수 있지만 이 row를 다시 활성화하지 않는다. -- 캐릭터 삭제 cascade는 소유 콘텐츠 row의 raw `isActive`만 `false`로 전환하고 `releaseDate`, `duration`, `content`와 구매 이력을 보존한다. 연결 creator 비활성 상태를 포함해 계산한 유효 `isActive`는 `false`이고 기존 미삭제 콘텐츠의 `status`는 `SUSPENDED`다. 이 상태 변경은 기존 컬럼을 사용하며 schema나 JPA mapping 변경을 요구하지 않는다. -- 기존 비활성 캐릭터 row를 일괄 보정하는 데이터 migration도 현재 근거 없이 선제 수행하지 않는다. 실제 운영 불일치가 확인되면 대상·영향 건수를 먼저 조사하고 별도 승인과 migration 계획을 작성한다. - -### 21.5 Transactions and External Systems - -- DB 안에서 끝나는 변경은 하나의 transaction으로 처리한다. -- 캐릭터 등록·수정은 외부 캐릭터 API, 이미지 저장, DB 변경의 실패 지점을 구분해 오류를 반환한다. -- 원작 생성·이미지 교체 command도 서버에서 `requestId`를 생성한다. 이는 파일 key·보상·구조화 로그 상관관계용이며 클라이언트 HTTP 재시도 멱등성 key는 아니다. -- `OriginalWorkImageStoragePort`는 `store(originalWorkId, validatedImage, requestId, attemptNumber)`와 새로 저장한 object의 `delete(objectKey)`를 제공한다. `store`는 `originals/{originalWorkId}/...` 아래 attempt별 고유 key를 만들고 정확한 `objectKey`를 반환한다. 공통 `S3Uploader`에는 존재 확인 없이 정확한 bucket·object key를 `deleteObject`하는 최소 메서드만 추가하고, v2 S3 adapter가 이를 port 뒤에서 사용해 application에 AWS type을 노출하지 않는다. -- 원작 생성·이미지 교체는 DB와 새 이미지의 부분 성공을 반환하지 않는다. DB commit 전에 `originals/{originalWorkId}/...`에 저장한 새 이미지가 이후 실패하면 같은 request의 object key만 삭제 보상한다. 기존 이미지와 논리 삭제된 원작의 이미지는 보존하며 일반 이미지 정리 기능을 추가하지 않는다. -- 원작 이미지 orchestration은 `@Transactional` proxy method 내부의 `try/catch`에 commit 예외가 잡힌다고 가정하지 않는다. application의 비transactional 진입점이 attempt마다 하나의 `REQUIRES_NEW` `TransactionTemplate.execute` 전체를 감싸 DB flush·commit 예외까지 받은 뒤, callback 밖에서 확인한 해당 attempt의 새 object key만 보상한다. outer transaction에 join하거나 `REQUIRED`와 `SERIALIZABLE` template을 중첩하지 않는다. -- 원작 이미지 저장 자체가 실패하면 DB 변경을 rollback하고 502를 반환한다. 이미지 저장 뒤 retry 가능한 deadlock·serialization 실패가 발생하면 아직 HTTP 오류로 매핑하지 않고 해당 attempt object를 삭제한 다음, 보상이 성공한 경우에만 새 transaction과 새 key로 한 번 재시도한다. 재조회에서 실제 제목 중복이 확인되면 409를 반환한다. 비재시도 DB 실패 또는 재시도 소진은 attempt object 삭제 보상 후 500을 반환한다. 보상 삭제가 실패하면 재시도를 중단하고 원래 500을 유지하며 `requestId`, attempt number, object key와 실패 단계를 orphan 로그 및 운영 알림에 남긴다. -- 원작 배정·해제는 Request의 모든 캐릭터를 검증한 뒤 한 transaction에서 변경한다. 누락·비활성·귀속 불일치 ID를 조용히 건너뛰는 부분 성공을 허용하지 않는다. -- 캐릭터 변경 command마다 `requestId`를 생성하고 외부 캐릭터 API와 파일 저장 port에 idempotency key로 전달한다. -- 외부 캐릭터 또는 새 파일 생성 후 DB 작업이 실패하면 생성된 외부 리소스의 삭제·비활성화 보상을 즉시 시도한다. -- 외부 캐릭터 수정 성공 후 DB 작업이 실패하면 command 시작 전에 읽은 remote 표현으로 compensating update를 시도한다. 원상 복구를 지원하지 않거나 실패하면 local/remote 차이와 외부 resource ID를 divergence 로그와 운영 재처리 대상으로 남긴다. -- 외부 시스템이 보상 작업을 지원하지 않거나 보상이 실패하면 `requestId`, 외부 리소스 ID 또는 파일 경로, 실패 단계를 구조화 orphan 로그로 남기고 운영 알림을 발행한다. 같은 `requestId`의 재처리는 기존 외부 리소스를 확인해 중복 생성하지 않는다. -- 이 문서의 “운영 알림”은 기존 로그 수집·경보가 감지하는 구조화 `ERROR` log를 의미한다. 이번 범위에 별도 알림 outbound port, 메시지 채널 또는 범용 운영 알림 시스템을 추가하지 않는다. -- 외부 작업이나 보상 결과와 관계없이 DB 변경까지 완료되지 않은 요청은 성공으로 응답하지 않는다. -- 파일 업로드 실패 시 부분 DB 리소스를 성공으로 반환하지 않는다. -- v2 업로드 요청은 기존 worker가 이미 처리하는 S3 bucket, `input/{contentId}/{contentId}-content-...` key와 metadata 계약을 그대로 사용하며 callback URL, pipeline version 또는 새 분기 정보를 worker에 전달하지 않는다. -- 기존 `/audio-content/upload-complete`의 Method, Path, Request와 `ADMIN`/`BOT` 성공 Response가 바뀌지 않고, 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`인지 contract test로 검증한다. -- v2에서 생성한 기존 형식의 content row도 현재 `AudioContentService.uploadComplete`와 예약 공개 흐름이 처리하는지 통합 검증한다. -- callback은 `releaseDate=null` 또는 연결 creator 비활성인 콘텐츠를 공개하지 않고, 기존 예약 공개 query는 활성 creator만 선택하도록 최소 안전 조건을 보강한다. `AudioContentReleaseScheduledTask`의 cron·lock과 worker 코드는 수정하지 않는다. -- 캐릭터 삭제 시 raw `content.isActive=false`가 되므로 일반 사용자용 목록·검색·추천은 기존 공개 조건으로 이를 제외한다. 직접 상세 조회는 비활성 creator 또는 비활성 콘텐츠를 미구매 사용자에게 반환하지 않되, 기존 주문을 확인한 `KEEP`·`RENTAL` 구매자의 재생 경로는 유지한다. -- content/creator ranking의 latest/previous visible snapshot query는 snapshot 생성 당시 값만 신뢰하지 않고 현재 `content.isActive`와 creator Member의 `isActive`를 확인한다. snapshot row 자체는 변경하거나 backfill하지 않는다. -- snapshot 외 legacy creator ranking query도 현재 Member의 `isActive=true`를 요구한다. -- 캐릭터·콘텐츠·시리즈의 공개 언어별 banner query는 banner 자체의 활성 상태뿐 아니라 연결 대상의 현재 활성 상태를 확인한다. banner row와 관리자용 전체 목록은 변경하지 않는다. -- 비활성 creator 콘텐츠의 댓글·답글 공개 조회와 신규 등록·본문 수정·재활성화는 거부하고 권한 있는 기존 댓글 논리 삭제만 허용한다. 구매자는 재생에 필요한 상세와 Signed URL만 유지하며 댓글·관련 콘텐츠 같은 공개 상호작용은 제공하지 않는다. -- 현재 v2 FanTalk 탭은 활성 creator 조회 조건을 유지하고, legacy FanTalk 목록과 신규 원문 등록도 대상 creator의 활성 상태를 확인한다. 비활성 creator의 FanTalk 원문·답글 row는 변경하지 않는다. -- 캐릭터 삭제 event는 transaction 안에서 상태가 실제 변경된 경우에만 1회 publish한다. AFTER_COMMIT listener는 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale` cache 전체를 clear해 삭제 전 materialized DTO를 제거한다. rollback과 멱등 재시도에서는 clear하지 않으며 cache TTL이나 Redis 설정은 변경하지 않는다. -- worker와 AWS Trigger 구현은 이 저장소 밖에 있으므로 배포 전 staging에서 v2 원본 업로드부터 기존 callback 완료까지 E2E를 1회 수행한다. S3 input 저장 시점과 DB transaction commit 사이에 callback이 도착할 가능성과 worker의 조회 실패 retry 여부도 이 검증에서 확인하며, 확인되지 않은 retry 동작을 Backend 보장으로 가정하지 않는다. - -### 21.6 Audit Log - -`/admin/ai-characters/**`의 사람 관리자 변경 작업은 최소 다음 구조화 로그를 남긴다. - -| Field | Description | -|---|---| -| `adminMemberId` | 실제 인증된 사람 관리자 | -| `characterId` | 선택 AI 캐릭터. global 원작 CRUD에서는 `null`, 배정·해제에서는 캐릭터별 로그에 값 기록 | -| `creatorMemberId` | 연결 크리에이터 Member. global 원작 CRUD에서는 `null`, 배정에서는 캐릭터별 값, 해제에서는 해당 Member ID를 확인할 수 있으면 값이고 과거 불일치로 연결 정보가 없거나 해석할 수 없을 때만 `null` 가능 | -| `action` | CREATE, UPDATE, DELETE, ASSIGN, UNASSIGN, PIN 등 | -| `resourceType` | CHARACTER, ORIGINAL_WORK, ORIGINAL_WORK_CHARACTER, CONTENT, CONTENT_COMMENT, CONTENT_CATEGORY, SERIES, COMMUNITY_POST, COMMUNITY_COMMENT, FAN_TALK, FAN_TALK_REPLY, CHANNEL_NOTICE, CHANNEL_PROFILE | -| `resourceId` | 변경 대상 ID | -| `result` | SUCCESS 또는 FAILURE | - -원작 배정·해제는 변경 캐릭터마다 `resourceType=ORIGINAL_WORK_CHARACTER`, `resourceId=originalWorkId`, 해당 `characterId`와 가능한 경우 `creatorMemberId`를 남긴다. 원작 생성·수정·삭제는 `resourceType=ORIGINAL_WORK`이며 두 캐릭터 field가 `null`이다. 정상 해제와 과거 불일치 해제를 별도 command나 audit factory로 구분하지 않는다. 해제 호출자는 캐릭터에서 `creatorMemberId`를 확인할 수 있으면 반드시 전달하고, 연결 정보가 없거나 해석할 수 없는 과거 불일치 상태에서만 `null`을 전달한다. - -영속 audit table 도입은 이번 범위가 아니며 구조화 application log를 요구한다. 비밀번호, JWT, system prompt 전체, 댓글 본문 또는 업로드 파일 내용은 로그에 기록하지 않는다. - -### 21.7 Security - -- `/admin/ai-characters/**` Controller는 class level에서 `hasRole('ADMIN')`을 선언한다. -- 기존 `/audio-content/upload-complete`의 `hasAnyRole('BOT', 'ADMIN')`을 유지한다. 인증 정보가 없거나 JWT가 유효하지 않으면 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`이며, `ADMIN`/`BOT`의 기존 성공 응답 계약은 변경하지 않는다. -- `/admin/ai-characters/**` RequestMatcher에만 적용되는 authentication entry point, access-denied handler 및 exception response 경계를 두어 legacy API의 HTTP status를 변경하지 않고 20장의 status와 `ApiResponse` 계약을 보장한다. -- 클라이언트 메뉴·route guard 테스트와 Backend API 인가 테스트를 별도로 작성한다. -- Multipart JSON string part는 단일 JSON root만 허용하고 뒤에 이어진 추가 root나 garbage를 400으로 거부한다. -- Multipart 요청은 애플리케이션의 `max-file-size=1024MB`, `max-request-size=1024MB` 상한을 적용한다. 이미지 part는 공통 이미지 검증기를 v2 web adapter에서 사용해 bytes 적재 전에 10MB 초과를 거부하고, 실제 MIME type이 `image/jpeg`, `image/png`, `image/gif` 중 하나인지 검증한다. 실제 format 확인 decode는 출력 영역을 1x1로 제한하되 내부 decoder row/loop 때문에 한 변은 최대 20,000px, 총 픽셀은 최대 40,000,000 pixels로 제한한다. PNG는 ancillary payload 합계 1MB와 chunk 4,096개를 상한으로 두고 ImageIO 입력을 `ignoreMetadata=true`로 설정해 metadata를 읽지 않는다. GIF는 최대 500 frame, extension 1,024개, extension당 sub-block 64개, extension payload 합계 1MB, 모든 frame의 누적 40,000,000 pixels를 상한으로 둔다. GIF logical canvas와 모든 frame header에도 같은 dimension 상한을 적용하고 각 frame의 LZW 출력 pixel 수가 선언된 width와 height의 곱과 정확히 일치해야 한다. 조기 EOI·연속 clear·EOI 뒤 data가 있는 LZW와 첫 frame이 정상이지만 후속 frame decode가 손상된 입력은 거부한다. 첫 frame뿐 아니라 모든 frame을 각각 1x1 출력 영역으로 decode해 malformed header/frame을 400으로 거부한다. GIF는 API에 별도 허용 조건이 있는 유료 커뮤니티 게시글 이미지만 허용하고, 원작·캐릭터·콘텐츠 커버·시리즈 이미지를 포함한 나머지 image part에서는 거부한다. -- 콘텐츠 원본 오디오는 빈 파일을 거부하고 worker에 전달한다. 지원 codec과 재생 가능 여부는 worker가 검증하며 실패한 콘텐츠를 `PUBLISHED`로 전환하지 않는다. -- 관리자 응답에서 system prompt는 캐릭터 상세에만 포함하며 목록에는 포함하지 않는다. -- `CONTENT-01`, `CONTENT-02`는 Signed URL이 포함될 수 있으므로 `Cache-Control: private, no-store`를 반환한다. - -## 22. Acceptance Criteria - -아래 체크박스는 사용자가 추가로 결정할 항목이 아니다. 후속 `plan-task.md`, 구현 및 테스트 단계에서 이 PRD의 충족 여부를 추적하기 위한 검증 목록이며, 아직 구현하지 않았으므로 모두 미체크 상태로 둔다. 제품 결정이 필요한 항목은 24장에 기록하고 미결정 사항은 25장에만 기록한다. - -### Authentication and Menu - -- [ ] 기존 `POST /admin/member/login`으로 로그인한 `ADMIN`이 신규 API를 호출할 수 있다. -- [ ] 별도 AI 캐릭터 관리자 로그인 Endpoint가 추가되지 않는다. -- [ ] 비로그인 요청은 401을 반환한다. -- [ ] `/admin/ai-characters/**`에 대한 `CONTENT_MANAGER`, `CREATOR`, `AGENT`, `USER` 요청은 403을 반환한다. -- [ ] 기존 `/audio-content/upload-complete`의 `BOT` 또는 `ADMIN` 인가와 외부 계약이 변경되지 않는다. -- [ ] Backend는 v2 AI 캐릭터 관리자용 menu Endpoint를 추가하지 않는다. -- [ ] 클라이언트 메뉴 또는 route guard와 관계없이 Backend API 인가가 독립적으로 동작한다. - -### Character - -- [ ] 활성·비활성 상태 및 이름으로 AI 캐릭터를 조회할 수 있다. -- [ ] 캐릭터 등록 시 연결 AI `creatorMember`가 생성된다. -- [ ] 캐릭터 표시 정보 수정 시 연결 Member가 동기화된다. -- [ ] 캐릭터 삭제 시 캐릭터와 연결 Member가 비활성화되고 공개 리소스가 노출되지 않으며 어떤 데이터도 물리 삭제되지 않는다. -- [ ] 캐릭터 삭제 시 소유 콘텐츠 row의 raw `isActive`만 `false`로 전환되고 `releaseDate`, `content`, `duration`과 구매 이력은 보존된다. 기존 미삭제 콘텐츠는 계산 상태 `SUSPENDED`, 기존 삭제 콘텐츠는 `DELETED`로 반환된다. -- [ ] 삭제 캐릭터의 콘텐츠는 일반 사용자용 목록·검색·추천·크리에이터 채널과 미구매 상세에서 노출되지 않지만, 기존 `KEEP`·`RENTAL` 구매자는 주문 이력 기반 재생을 유지한다. -- [ ] content/creator ranking의 latest/previous snapshot 조회도 현재 콘텐츠·creator 활성 상태를 적용하고, 기존 snapshot row를 삭제·수정·재생성하지 않는다. -- [ ] snapshot 외 legacy creator ranking도 비활성 creator를 반환하지 않는다. -- [ ] 공개 캐릭터·콘텐츠·시리즈 banner는 비활성 연결 대상을 반환하지 않지만 기존 banner row와 관리자용 목록은 보존된다. -- [ ] 삭제 캐릭터 콘텐츠의 공개 댓글·답글 조회와 신규 등록·본문 수정·재활성화는 구매 여부와 관계없이 차단되고, 권한 있는 기존 댓글 논리 삭제만 허용된다. -- [ ] 삭제 캐릭터의 FanTalk 원문·답글 row와 관리자 조회·논리 삭제 기능은 보존되지만, 일반 사용자용 FanTalk 조회와 신규 FanTalk 원문 등록은 차단된다. -- [ ] cache를 미리 채운 뒤 캐릭터 삭제가 commit되면 `default`, `cache_ttl_3_days`, `popularCharacters_24h_locale`의 stale 공개 응답이 제거되고, rollback·반복 삭제에서는 cache clear가 발생하지 않는다. -- [ ] legacy 캐릭터 비활성화 경로도 같은 v2 삭제 cascade를 사용하고 비활성 캐릭터 재활성화는 거부한다. -- [ ] 비활성 캐릭터의 신규 발행·수정 작업은 409를 반환한다. - -### Original Work - -- [ ] `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08`의 method와 path가 11.7과 정확히 일치한다. -- [ ] 원작 목록이 공통 페이징과 `search` Query를 사용하고 삭제 원작을 제외하며 별도 비페이징 `/search` Endpoint를 만들지 않는다. -- [ ] 원작 상세·등록·수정·논리 삭제와 연결 캐릭터 목록을 사용할 수 있다. -- [ ] 생성·수정 이미지의 실제 MIME을 검증하고 GIF를 거부하며 이미지 또는 DB 실패를 부분 성공으로 반환하지 않는다. -- [ ] 원작 수정은 nullable field의 명시적 `null`과 빈 목록 전체 삭제를 포함한 전체 교체 계약을 지킨다. -- [ ] 생성과 수정 모두 삭제되지 않은 동일 제목 충돌을 409로 반환하고, 동시 요청에서도 MySQL `SERIALIZABLE` transaction과 한 번의 재시도로 중복 활성 제목을 만들지 않는다. -- [ ] 신규 `CHAR-03`·`CHAR-04`의 양수 `originalWorkId`는 `isDeleted=false` 원작만 허용하고, `null`은 각각 미연결과 연결 해제를 의미하며 `0` sentinel을 허용하지 않는다. legacy 호환 adapter는 기존 등록의 `0`을 미연결로, 수정의 `0`만 해제로 변환한다. -- [ ] 원작 배정은 모든 캐릭터가 활성 AI 캐릭터인지 먼저 검증하고 다른 원작 연결을 새 원작으로 원자적으로 이동한다. -- [ ] 원작 해제는 활성·비활성 및 연결 creator 상태와 관계없이 기존 캐릭터를 허용하되 모든 캐릭터가 Path 원작에 실제 연결되어 있는지 먼저 검증한다. -- [ ] 배정·해제에서 누락 ID를 조용히 무시하거나 일부만 성공하지 않고, 잘못된 ID가 하나라도 있으면 전체를 rollback한다. -- [ ] 원작 삭제·배정·해제와 양수 캐릭터 원작 연결 변경은 원작/캐릭터 잠금 순서를 사용하고, 대상 원작 없는 해제는 캐릭터만 잠가 동시 요청에서도 삭제 원작 참조를 만들지 않는다. -- [ ] 활성·비활성 연결 캐릭터가 하나라도 남은 원작 삭제는 409이며 관계를 암묵적으로 해제하지 않는다. -- [ ] 원작 삭제는 `isDeleted=true`만 변경하고 링크·태그·이미지·번역 이력을 물리 삭제하지 않으며, 이미 삭제된 원작은 연결 수보다 먼저 판정해 반복 삭제가 멱등하다. -- [ ] 원작 생성 언어 감지와 `title`, `contentType`, `category`, `description`, `tags`가 실제 바뀐 수정의 번역 갱신은 commit 후 한 번만 실행되고 다른 field 변경·배정·해제에서는 실행되지 않는다. -- [ ] legacy `/admin/chat/original/**`와 legacy 캐릭터 등록·수정의 Method·Path·Request·성공 Response는 유지되고, 원작 mutation 5개와 캐릭터 mutation 2개 전체가 각각 같은 v2 원작·캐릭터 command로 수렴한다. -- [ ] 배포 전 `isDeleted=true` 원작을 참조하는 캐릭터가 0건임을 확인하며, 1건 이상이면 자동 migration 대신 별도 승인된 데이터 보정을 완료한 뒤 mutation 전환을 활성화한다. - -### Creator Operations - -- [ ] 선택 AI 캐릭터의 콘텐츠 CRUD와 고정 상태 변경이 가능하다. -- [ ] 선택 AI 캐릭터 명의로 콘텐츠 댓글·답글 CRUD가 가능하다. -- [ ] 선택 AI 캐릭터 소유 콘텐츠에 달린 타인의 댓글을 삭제할 수 있다. -- [ ] 선택 AI 캐릭터의 콘텐츠 카테고리 CRUD, 콘텐츠 구성 및 카테고리 순서 변경이 가능하다. -- [ ] 선택 AI 캐릭터의 시리즈 CRUD, 콘텐츠 구성 및 순서 변경이 가능하다. -- [ ] 선택 AI 캐릭터의 커뮤니티 게시글 CRUD와 고정 상태 변경이 가능하다. -- [ ] 선택 AI 캐릭터 명의로 커뮤니티 댓글·답글 CRUD가 가능하다. -- [ ] 선택 AI 캐릭터 소유 게시글에 달린 타인의 댓글을 삭제할 수 있다. -- [ ] 선택 AI 캐릭터의 FanTalk를 조회하고 AI 캐릭터 답글을 등록·수정·삭제할 수 있다. -- [ ] FanTalk 목록의 각 항목에 `replyId`가 있는 AI 캐릭터 답글이 함께 반환되고 별도 답글 조회 API는 없다. -- [ ] 선택 AI 캐릭터를 대상으로 한 FanTalk 원문을 소유자 권한으로 논리 삭제할 수 있다. -- [ ] 선택 AI 캐릭터의 채널 공지를 조회하고 upsert할 수 있다. -- [ ] 선택 AI 캐릭터의 채널 SNS URL, creator tag와 후원 랭킹 공개 설정을 조회·수정할 수 있다. -- [ ] 다른 캐릭터 소유 리소스는 조회·변경할 수 없다. -- [ ] 댓글과 FanTalk의 부모·루트 귀속을 우회할 수 없다. - -### API Contract - -- [ ] 모든 신규 Endpoint가 이 문서의 Path, Request, Response 계약을 따른다. -- [ ] 신규 관리자 Operation 66개가 route inventory에 중복·누락·추가 없이 존재한다. -- [ ] 등록·수정·삭제 응답이 변경된 resource ID와 상태를 반환한다. -- [ ] 페이징 대상 목록 API가 동일한 페이징 규칙을 사용하고, 콘텐츠 테마·시리즈 장르·creator tag 기준정보만 명시된 비페이징 예외로 동작한다. -- [ ] 신규 오류 응답이 정의된 HTTP status와 `ApiResponse` body를 사용한다. -- [ ] 실제 관리자와 대행 AI 캐릭터를 구분하는 구조화 로그가 남는다. -- [ ] v2 비즈니스 로직과 persistence adapter가 legacy Controller, Service, Repository 또는 web DTO를 호출하지 않는다. -- [ ] 신규 upload-complete Endpoint를 만들지 않고 기존 AWS S3 Trigger worker의 코드·스케줄·설정을 변경하지 않는다. -- [ ] 기존 `/audio-content/upload-complete`의 Method, Path, Request와 `ADMIN`/`BOT` 성공 Response가 유지되고 인증 정보 없음·유효하지 않은 JWT는 `401`, 인증됐지만 `ADMIN`/`BOT`이 아니면 `403`을 반환한다. -- [ ] v2 콘텐츠 생성이 기존 `content` row와 S3 key·metadata 계약을 따르고 별도 V1/V2 callback 분기 없이 기존 callback과 예약 공개 흐름으로 처리된다. -- [ ] CONTENT-03 원본 object가 `input/{contentId}/{contentId}-content-...` key를 사용하고 worker 결과 basename이 기존 callback의 content ID 검증을 통과한다. -- [ ] staging에서 v2 S3 input 저장부터 기존 callback 완료까지 E2E가 통과하고 DB commit 전 callback 도착 시 worker retry 동작이 확인된다. -- [ ] 이번 기능을 위한 DB 컬럼·인덱스·테이블·DDL·backfill·데이터 migration이나 기존 JPA mapping 변경을 추가하지 않고 v2 전용 예약 공개 scheduler도 만들지 않는다. -- [ ] 콘텐츠 생성은 기존 초기 필드에서 계산한 `PROCESSING`으로 응답하고 목록·상세의 `status`와 `isActive` 필터도 기존 필드와 creator 활성 상태에서 계산한다. -- [ ] `previewStartTime`, `previewEndTime`은 생성 시 S3 metadata로만 전달되고 콘텐츠 목록·상세 Response에는 포함되지 않는다. -- [ ] 콘텐츠 목록과 상세가 계산 상태 `SCHEDULED`, `PUBLISHED`이고 canonical output key와 duration이 있는 가공 완료 오디오에만 Signed URL을 반환한다. -- [ ] Signed URL의 TTL이 legacy와 같은 `(duration의 HH 부분 + 2)시간`이고 URL policy 만료 시각과 `contentUrlExpiresAtUtc`가 동일한 기준 시각에서 계산되어 일치한다. -- [ ] `coverImageUrl`은 일반 CDN 절대 URL이며 Signed URL이나 저장 경로가 아니다. -- [ ] 관리자 `CONTENT-01`, `CONTENT-02`에서 계산 상태가 `PROCESSING`, `SUSPENDED`, `DELETED`이거나 creator가 비활성이면 원본 업로드 경로나 오디오 URL이 노출되지 않는다. 기존 구매자의 소비자용 재생 예외는 이 관리자 응답 계약에 적용하지 않는다. -- [ ] 콘텐츠 목록과 상세 재조회 시 Signed URL이 갱신되고 별도 브라우저용 URL 갱신 Endpoint는 없다. -- [ ] Signed URL 생성 또는 output key 무결성 검증 실패 시 500으로 실패하고 raw path, 비서명 오디오 URL 또는 빈 문자열로 fallback하지 않는다. -- [ ] `CONTENT-01`, `CONTENT-02` 응답에 `Cache-Control: private, no-store`가 포함된다. -- [ ] 처리 중 삭제되어 `releaseDate=null`이 된 콘텐츠는 늦은 callback 이후에도 `isActive=false`를 유지하고 공개 side effect를 발생시키지 않는다. -- [ ] 비활성 creator의 콘텐츠는 늦은 callback 이후에도 raw `content.isActive=false`를 유지하며, 과거 불일치 row는 `false`로 보정되고 공개 side effect를 발생시키지 않는다. -- [ ] 기존 예약 공개 query는 활성 creator의 공개 시각이 지난 가공 완료 콘텐츠만 선택하며 scheduler component의 cron·lock은 변경하지 않는다. - -### Frontend Handoff - -다음 체크박스는 Backend 구현 완료 조건이 아니라 27장의 프론트엔드 개발 프롬프트와 함께 전달할 클라이언트 검증 기준이다. - -- [ ] `/ai-characters` 메뉴와 하위 route/tab을 typed static configuration으로 관리하고 legacy `GET /menu`를 호출하지 않는다. -- [ ] 로그인 응답의 `{token, role}`을 `sessionStorage`에서 함께 복원하고 `role=ADMIN`으로 화면 진입을 제어하되 이를 Backend API 인가의 대체 수단으로 취급하지 않는다. -- [ ] child resource 등록·수정 전에 사용자가 캐릭터를 명시적으로 선택하고 URL Path의 `characterId`를 source of truth로 사용한다. -- [ ] child resource form에서 캐릭터를 다시 선택하게 하거나 Request body에 `characterId`, `creatorId`, writer ID를 추가하지 않는다. -- [ ] 27.4의 기준에 따라 주요 목록·상세·등록·수정은 Page, 부모 문맥 안의 짧은 입력·선택·확인은 Dialog 또는 inline UI로 구현한다. -- [ ] 선택 AI 캐릭터가 작성한 댓글에만 수정 action을 표시하고 선택 AI 캐릭터 소유 부모의 댓글에는 writer와 관계없이 삭제 action을 제공한다. -- [ ] 브라우저용 `AUTH-01`과 신규 관리자 Operation 58개를 API client와 화면에 필요한 범위로 연결한다. -- [ ] 기존 27.8의 58개 baseline 구현은 유지하고, `frontend-original-work-prompt.md`의 원작 Operation 8개를 추가해 브라우저용 신규 관리자 Operation 66개를 연결한다. -- [ ] `contentUrl`을 영속 저장하지 않고 `contentUrlExpiresAtUtc` 기준으로 player 진입·만료 임박 시 `CONTENT-02`를 재조회하며 TTL을 재계산하거나 raw 업로드 경로를 조합하지 않는다. -- [ ] character-scoped query key와 캐릭터 목록·기준정보용 global query key를 분리한다. -- [ ] 공통 UI는 실제 반복 사용되는 단위로 component화하고 도메인별 validation과 form을 범용 CRUD 설정 하나로 합치지 않는다. -- [ ] 27.2의 선택 stack과 제외 목록을 지키고 lockfile, typecheck, ESLint, unit/UI test와 production build 검증을 통과한다. -- [ ] 현재 비어 있는 작업 디렉터리를 frontend project root로 사용하고 그 아래에 프로젝트 디렉터리를 다시 중첩 생성하지 않는다. -- [ ] Backend DTO/data class 이름을 전제로 하지 않고 27.8의 Operation별 실제 Request/Response JSON만으로 type, API client와 mock을 구현한다. -- [ ] `.env.development`와 `.env.production`에서 동일한 `VITE_API_BASE_URL` key에 서로 다른 dev·production API Base URL을 설정하고 source code에 두 URL을 하드코딩하지 않는다. -- [ ] `packageManager`를 `pnpm@11.15.0`으로 고정하고 Jenkins가 `pnpm install --frozen-lockfile`, `pnpm run ci:prod` 순서로 typecheck, lint, unit test와 production build를 검증해 `dist/`를 생성한다. -- [ ] mutation 성공과 일시적 오류는 Sonner, field/form 오류는 inline, 최초 조회 실패는 `ErrorState`, 파괴적 작업의 사전 확인은 `AlertDialog`로 분리하고 중복 알림이나 별도 notification center를 만들지 않는다. -- [ ] API의 절대 날짜·시간을 UTC `Z`로 보관·비교하고 `Asia/Seoul`로만 표시하며, KST 입력은 전송 직전에 UTC `Z`로 변환한다. duration과 preview offset은 timezone 변환하지 않는다. -- [ ] production host가 app route를 `index.html`로 rewrite하고 정적 asset과 절대 API Base URL 요청에는 SPA fallback을 적용하지 않는지 deep-link 새로고침으로 검증한다. -- [ ] 환경 책임자의 edge 접근 제어, HTML meta `noindex`, 응답 `X-Robots-Tag`를 적용하고 `robots.txt Disallow: /`로 noindex 확인을 차단하지 않는다. -- [ ] 승인된 production host/edge와 설정 책임자가 없으면 production 배포 완료로 판단하지 않는다. - -## 23. Metrics - -- 권한 없는 신규 API 접근 성공 건수: 0 -- 다른 AI 캐릭터 소유 리소스 변경 성공 건수: 0 -- 캐릭터 삭제로 인한 연관 리소스 물리 삭제 건수: 0 -- 원작 삭제로 인한 원작·링크·태그·이미지·캐릭터 관계 물리 삭제 건수: 0 -- 원작 배정·해제 Request의 부분 성공 건수: 0 -- 삭제된 원작을 새로 참조하는 캐릭터 관계 건수: 0 -- 삭제되지 않은 동일 제목 원작의 동시 생성 건수: 0 -- 신규 변경 API 구조화 audit log 누락 건수: 0 -- 신규 API에서 parent/root 귀속 검증 우회 성공 건수: 0 - -## 24. Decisions - -- 관리자 로그인은 기존 `POST /admin/member/login`을 재사용한다. -- AI 캐릭터 관리자 전용 로그인과 AI 캐릭터 사칭 토큰은 만들지 않는다. -- 1차 접근 권한은 `ROLE_ADMIN`으로 제한한다. -- v2 AI 캐릭터 관리자 메뉴는 클라이언트가 소유하며 legacy `GET /menu`를 재사용하지 않는다. -- 신규 menu Endpoint 또는 capability Endpoint는 이번 범위에 만들지 않는다. -- FanTalk 답글은 FanTalk 목록 응답에 포함하고 별도 답글 조회 Endpoint는 만들지 않는다. -- 신규 API는 v2 패키지에 두고 `/admin/ai-characters`를 base path로 사용한다. -- 원작 CRUD·검색·캐릭터 배정은 global `/admin/ai-characters/original-works` 아래 8개 Operation으로 v2에 이관하고 legacy `/admin/chat/original/**`는 호환을 위해 유지한다. -- legacy 원작 mutation 5개와 legacy 캐릭터 등록·수정 2개 전체는 기존 외부 계약과 patch 의미를 유지한 채 같은 v2 원작·캐릭터 command로 위임해 공존 중 정책 우회와 외부 작업 뒤 원작 연결 실패의 부분 성공을 막는다. legacy 원작 조회와 일반 사용자용 원작 조회는 기존 흐름을 유지한다. -- legacy 원작 목록과 검색은 `ORIGINAL-WORK-01`의 페이징 `search` Query로 합치고 별도 v2 `/search` Endpoint는 만들지 않는다. -- 원작 도메인은 `v2.originalwork`가 소유하고 관리자 HTTP 계층만 `v2.admin.aicharacter`에 둔다. -- 원작에 연결된 캐릭터가 하나라도 있으면 삭제를 409로 거부하고 명시적 해제를 요구한다. -- 이미 삭제된 원작의 반복 DELETE는 연결 수보다 먼저 판정해 멱등 성공한다. 배포 전 삭제 원작 연결 불일치가 1건 이상이면 별도 승인된 보정을 완료하기 전 mutation 전환을 활성화하지 않는다. -- 원작 배정은 다른 원작의 활성 AI 캐릭터를 새 원작으로 이동하며, 해제는 Path 원작 귀속을 전건 검증한다. 어느 작업도 부분 성공하지 않는다. -- 원작 관계 mutation은 양수 연결 대상 또는 원작 Path가 있으면 원작 row, 캐릭터 row 순서의 pessimistic lock을 사용하고, `CHAR-04`의 `null`과 legacy 수정의 `0` 해제는 캐릭터 row만 잠근다. 원작 생성과 제목이 실제 바뀌는 수정은 MySQL `SERIALIZABLE` transaction과 1회 재시도로 동시성 불변식을 보장한다. -- 신규 `CHAR-03`의 `originalWorkId=null`은 미연결, `CHAR-04`의 명시적 `null`은 해제이며 신규 계약은 legacy의 `0` sentinel을 사용하지 않는다. legacy 호환 adapter는 기존 등록의 `0`을 미연결로, 수정의 `0`만 해제로 변환한다. -- v2 Kotlin package와 HTTP URL version은 별개이므로 기존 v2 관리자 관례에 없는 `/admin/v2` 또는 `/v2/admin` prefix를 추가하지 않는다. -- 관리자 principal은 실제 `ADMIN`으로 유지하고 선택 캐릭터의 `creatorMember`만 도메인 작업 주체로 전달한다. -- legacy Controller, Service, Repository 및 Request/Response DTO를 신규 v2 비즈니스 로직에서 재사용하지 않는다. -- AWS S3 Trigger worker의 기존 `PUT /audio-content/upload-complete` 계약과 처리 흐름을 유지한다. v2 콘텐츠도 기존 row·S3 계약으로 처리하며 V1/V2 dispatcher 또는 v2 completion use case를 추가하지 않는다. -- 신규 upload-complete Endpoint는 실제 AWS 연동 전환 일정과 호출 주체가 확정될 때 별도 PRD에서 정의하며 이번 범위에는 선제 구현하지 않는다. -- 기존 오디오 가공 worker의 코드·스케줄·AWS Trigger 설정은 변경하지 않는다. -- v2 전용 예약 공개 scheduler를 만들지 않고 기존 scheduler component의 cron·lock을 유지한다. 삭제 콘텐츠와 비활성 creator를 공개하지 않는 callback·조회 조건만 최소 보강한다. -- 캐릭터 삭제 시 소유 콘텐츠의 기존 raw `isActive`만 `false`로 전환하고 나머지 콘텐츠 필드와 구매 이력을 보존한다. 일반 공개 탐색과 미구매 상세는 차단하고 기존 구매 재생은 유지한다. -- 콘텐츠·creator ranking snapshot은 현재 콘텐츠·creator 활성 상태를 visible query에 적용해 stale 노출만 차단하고 snapshot row는 보존한다. -- legacy creator ranking과 공개 캐릭터·콘텐츠·시리즈 banner도 연결 대상의 현재 활성 상태를 확인하며 원본 ranking/banner row는 보존한다. -- 삭제 성공 commit 후 영향받는 기존 cache namespace 3개만 clear하며 별도 범용 cache invalidation framework나 Redis key scan은 만들지 않는다. -- 필요한 command/query 기능은 콘텐츠, 댓글, 시리즈, 커뮤니티, FanTalk 등 v2 각 도메인 패키지에 구현한다. -- 기존 v2 domain/application/port는 계약이 맞는 경우 v2 내부에서 재사용하거나 확장한다. -- 기존 DB 스키마와 JPA 매핑은 변경하거나 복제하지 않고 v2 persistence adapter 뒤에서 연결한다. 이번 기능을 위한 DDL·backfill·데이터 migration은 만들지 않는다. -- 기존 `OriginalWork` 관련 entity와 `ChatCharacter.originalWork` 관계도 v2 persistence adapter에서 그대로 사용하며 원작용 schema·JPA mapping·data migration을 추가하지 않는다. -- 콘텐츠 상태는 기존 `isActive`, `releaseDate`, `duration`과 연결 creator 활성 상태로 계산하고, 저장된 `content` 경로는 Signed URL 발급 전 canonical output key인지 검증한다. -- 콘텐츠 생성의 `previewStartTime`, `previewEndTime`은 기존 worker용 S3 metadata로만 전달하고 DB나 목록·상세 Response에 저장하지 않는다. -- 공통 인증, `ApiResponse` 및 외부 인프라 client는 v2 port 또는 web adapter 경계에서 재사용한다. -- 콘텐츠 목록·상세의 가공 오디오는 legacy와 같은 TTL의 CloudFront Signed URL로 반환하고 커버 이미지는 비서명 CDN URL로 반환한다. -- Controller 간 호출과 내부 HTTP 호출은 금지한다. -- 독립 업무 리소스 삭제는 논리 삭제로 통일하고, 시리즈-콘텐츠 및 creator-Member-tag 연결 해제만 join row 물리 제거 예외로 둔다. -- legacy 캐릭터 비활성화 경로는 v2 삭제 cascade를 사용한다. 기존 비활성 AI 캐릭터 데이터의 선제 migration은 하지 않으며 실제 불일치가 확인되면 별도 승인 범위로 다룬다. -- 댓글·FanTalk의 타인 작성 본문 수정은 금지하고, 선택 캐릭터 소유 리소스에 달린 댓글의 삭제만 허용한다. -- 요구 목록에서 빠진 콘텐츠 고정, 콘텐츠 카테고리, 시리즈 구성·순서, 커뮤니티 고정, 기준정보 조회, FanTalk 원문 moderation, 채널 공지, 채널 프로필과 creator tag 설정을 이번 범위에 포함한다. -- 기존 27.8 Frontend 프롬프트는 적용된 baseline이므로 수정하지 않고, 원작 화면·선택 UI와 8개 JSON 계약은 별도 `frontend-original-work-prompt.md`로 추가한다. -- 라이브, 정산 및 시그니처 후원은 별도 제품 범위로 둔다. - -## 25. Open Questions - -- 제품·API·UX 미결정 사항은 없다. 요구사항이 바뀌면 구현 전에 이 PRD와 후속 `plan-task.md`를 먼저 갱신한다. -- dev·production API Base URL 실제 값과 production static host/edge 접근 제어 설정은 배포 전에 환경 책임자가 제공해야 한다. 값이 없으면 예시 URL로 배포하지 않고 production 배포를 차단 상태로 보고한다. - -## 26. Related Documents and Code Evidence - -### Documents - -- `AGENTS.md` -- `docs/agent-guides/작업절차.md` -- `docs/agent-guides/문서유지보수.md` -- `docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` -- `docs/20260611_AI캐릭터_크리에이터기능_최소연결/prd.md` -- `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md` -- `docs/20260709_팬톡_작성수정_응답보강/prd.md` -- `docs/20260706_커뮤니티_게시물_상세_API/prd.md` - -### Authentication and Menu - -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuRepository.kt` - -### AI Character - -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt` - -### Original Work - -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` -- `src/main/resources/application.yml` -- `src/test/resources/application.yml` - -### Creator Operations - -- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt` -- `src/main/resources/application.yml` - -### v2 Admin URL, Existing AWS Callback, and Content Signed URL - -- `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobController.kt` -- `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/ranking/creator/AdminCreatorRankingSnapshotJobControllerTest.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentRepository.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/GetCreatorAdminContentListResponse.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/content/UploadCompleteRequest.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt` -- `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt` -- `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - -## 27. Web Frontend Client Development Handoff - -### 27.1 Purpose and Scope - -이 장은 AI 캐릭터 관리자 웹 클라이언트를 구현할 때 사용할 기술·UX 결정과 복사 가능한 개발 프롬프트다. -27장 전체는 프론트엔드 전용 handoff appendix이며 Backend 구현 범위나 Backend 완료 조건이 아니다. -기준일은 2026-07-20이며, 구현을 시작할 때는 아래 major/minor 범위 안의 최신 보안 patch를 확인한 뒤 lockfile로 고정한다. - -- 브라우저가 호출하는 Operation은 기존 로그인 `AUTH-01`과 신규 관리자 Operation 58개다. -- 프론트엔드 에이전트는 Backend DTO/data class를 볼 수 없다고 가정한다. 27.8의 Operation별 실제 Request/Response JSON만으로 type, API client와 mock을 구현할 수 있어야 한다. -- Request/Response의 canonical contract는 9~18장이며, Operation ID로 화면·API 함수·테스트를 연결한다. 27.8 JSON과 본문 계약이 다르면 구현하지 않고 계약 불일치로 보고한다. -- 이미 생성되어 있는 빈 작업 디렉터리 자체를 frontend project root로 사용하며 하위에 프로젝트 디렉터리를 다시 만들지 않는다. -- production 배포 인프라는 25장의 외부 prerequisite다. 프론트엔드 구현자는 승인되지 않은 hosting 또는 edge 제품을 임의 도입하지 않는다. - -### 27.2 Selected Frontend Stack - -| Area | Decision | Reason | -|---|---|---| -| Build/CI tooling runtime | Node.js 24 LTS, pnpm 11.15.0, `packageManager`와 lockfile 고정 | local과 Jenkins가 같은 package manager version과 dependency graph를 사용함 | -| UI runtime | React 19.2 stable line | 현재 공식 안정 React major/minor | -| Build | Vite 8.1 stable line, React TypeScript template | 별도 Spring API를 호출하는 내부 SPA이며 SSR, RSC, BFF가 필요하지 않음 | -| Language | TypeScript 6.0 stable line, `strict=true` | TypeScript 7 생태계 전환 비용 없이 최신 안정 도구와 호환되는 기준 | -| Routing | React Router 8.2 Declarative Mode | TanStack Query가 data layer를 소유하므로 loader/action을 중복하지 않음 | -| Styling | Tailwind CSS 4.3 + `@tailwindcss/vite` | Vite 공식 통합 방식 | -| Components | shadcn/ui latest stable CLI + Base UI primitive | 신규 shadcn 프로젝트의 기본 primitive이며 필요한 component source만 가져옴 | -| UI feedback | shadcn/ui Sonner + inline field/form error + `ErrorState` | 성공·일시 오류, 입력 오류, 조회 오류를 서로 다른 수명과 위치로 표시함 | -| Server state | TanStack Query v5 | 조회, mutation, cache invalidation과 loading/error 상태를 관리함 | -| Tables | TanStack Table v8 | 서버 페이징 목록에만 `manualPagination`으로 사용함 | -| Forms | TanStack Form v1 + Zod 4 | 중첩 캐릭터 field array와 multipart form을 type-safe하게 관리함 | -| HTTP | Browser `fetch` 기반 얇은 wrapper | Axios와 별도 API framework 없이 Bearer, JSON, multipart, `ApiResponse`만 공통 처리함 | -| Date/time | Native `Date`, epoch millisecond, `Intl.DateTimeFormat` | API UTC instant를 유지하고 별도 date library 없이 KST 표시만 수행함 | -| Lint | Vite React TypeScript template의 ESLint + `typescript-eslint` | 별도 formatter/linter 경쟁 구성을 추가하지 않고 template 기준을 유지함 | -| Unit/UI test | Vitest 4.1 + `jsdom` + Testing Library + `user-event` + `jest-dom` | browser DOM 환경에서 사용자 동작 중심으로 검증함 | -| E2E | Playwright latest stable patch | 로그인, route guard, 캐릭터 scope, CRUD 핵심 흐름만 검증함 | - -Node.js와 `vite preview`는 production application server가 아니다. `vite build`의 `dist`를 25장에서 환경 책임자가 정한 static host/edge가 제공한다. - -다음은 이번 범위에서 사용하지 않는다. - -- Next.js: SSR, Server Component, Server Action 또는 BFF 요구가 없고 공개 검색 노출도 금지한다. -- Redux, Zustand: 서버 상태는 TanStack Query, 선택 캐릭터와 필터는 URL이 source of truth다. -- React Hook Form: 폼 라이브러리는 TanStack Form 하나로 통일한다. -- Axios: `fetch` wrapper로 필요한 계약을 충족한다. -- OpenAPI code generator: 실제 OpenAPI 문서가 발행되기 전에는 수기 PRD와 생성 결과가 어긋날 수 있다. -- 범용 CRUD engine: 도메인별 validation과 화면 차이를 거대한 설정 객체 하나로 숨기지 않는다. -- prerelease, beta, RC package: 운영 관리자 페이지에 사용하지 않는다. - -### 27.3 Menu, Character Selection, and Routes - -메뉴는 Backend에서 받지 않고 클라이언트의 typed static configuration으로 관리한다. - -```ts -type AdminMenuItem = { - key: string - label: string - to: string - scope: "GLOBAL" | "CHARACTER" -} -``` - -전역 메뉴는 `AI 캐릭터 관리 -> /ai-characters` 하나다. 캐릭터를 선택한 뒤에만 다음 character scope 메뉴를 표시한다. - -- 캐릭터 정보 -- 콘텐츠 -- 콘텐츠 카테고리 -- 시리즈 -- 커뮤니티 -- FanTalk -- 채널 설정 - -콘텐츠 댓글과 커뮤니티 댓글은 부모 리소스의 목록·상세 화면에서 진입하며 sidebar 최상위 메뉴로 만들지 않는다. - -등록·수정 화면의 캐릭터 선택 정책은 다음과 같다. - -- AI 캐릭터 자체 등록은 선택할 기존 캐릭터가 없으므로 `/ai-characters/new`에서 시작한다. -- 콘텐츠, 카테고리, 시리즈, 커뮤니티, FanTalk 답글 및 채널 설정은 먼저 캐릭터를 선택한 뒤 접근한다. -- 선택한 `characterId`는 전역 memory가 아니라 URL Path가 source of truth다. -- child resource Request body에 `characterId` 또는 `creatorId`를 추가하지 않는다. PRD의 Path parameter만 사용한다. -- deep link로 진입하면 URL의 `characterId`로 `CHAR-02`를 조회해 `CharacterContextBar`를 복원한다. -- 캐릭터를 임의로 자동 선택하지 않는다. 전환은 사용자의 명시적 선택으로만 수행한다. -- dirty form에서 캐릭터를 전환하거나 route를 이탈하면 확인한다. -- 비활성 캐릭터는 조회와 허용된 삭제만 제공하고 그 밖의 mutation control을 disabled 처리한다. Backend 409도 그대로 처리한다. - -권장 route는 다음과 같다. - -| Route | UI | -|---|---| -| `/login` | 관리자 로그인 | -| `/ai-characters` | 캐릭터 목록과 명시적 선택 | -| `/ai-characters/new` | 캐릭터 등록 Page | -| `/ai-characters/:characterId` | 캐릭터 상세 | -| `/ai-characters/:characterId/edit` | 캐릭터 수정 Page | -| `/ai-characters/:characterId/contents` | 콘텐츠 목록 | -| `/ai-characters/:characterId/contents/new` | 콘텐츠 등록 Page | -| `/ai-characters/:characterId/contents/:contentId` | 콘텐츠 상세와 Signed URL player | -| `/ai-characters/:characterId/contents/:contentId/edit` | 콘텐츠 수정 Page | -| `/ai-characters/:characterId/contents/:contentId/comments` | 콘텐츠 댓글 moderation | -| `/ai-characters/:characterId/content-categories` | 콘텐츠 카테고리 목록 | -| `/ai-characters/:characterId/content-categories/:categoryId/contents` | 카테고리 콘텐츠 구성 | -| `/ai-characters/:characterId/series` | 시리즈 목록 | -| `/ai-characters/:characterId/series/new` | 시리즈 등록 Page | -| `/ai-characters/:characterId/series/:seriesId` | 시리즈 상세와 콘텐츠 구성 | -| `/ai-characters/:characterId/series/:seriesId/edit` | 시리즈 수정 Page | -| `/ai-characters/:characterId/community-posts` | 커뮤니티 게시글 목록 | -| `/ai-characters/:characterId/community-posts/new` | 커뮤니티 게시글 등록 Page | -| `/ai-characters/:characterId/community-posts/:postId` | 게시글 상세와 댓글 진입 | -| `/ai-characters/:characterId/community-posts/:postId/edit` | 게시글 수정 Page | -| `/ai-characters/:characterId/community-posts/:postId/comments` | 커뮤니티 댓글 moderation | -| `/ai-characters/:characterId/fan-talks` | FanTalk 목록과 embedded 답글 | -| `/ai-characters/:characterId/channel-settings` | 공지·프로필·creator tag 설정 | - -BrowserRouter deep link와 새로고침을 위해 production static host/edge는 `/login`, `/ai-characters`, `/ai-characters/**`의 파일이 아닌 GET 요청을 `index.html`로 rewrite한다. -정적 asset 요청에는 SPA fallback을 적용하지 않는다. API 요청은 현재 Vite mode의 `VITE_API_BASE_URL` 절대 URL로 보내므로 frontend route rewrite 대상이 아니다. - -### 27.4 Page, Dialog, and Inline UI Rule - -URL 복원, 새로고침, 서버 페이징, 복잡한 validation 또는 감사 대상 문맥이 필요한 primary resource는 Page로 만든다. -부모 화면 안에서 끝나는 짧은 입력·선택·확인만 Dialog 또는 inline UI로 만든다. - -| Use case | UI | Reason | -|---|---|---| -| 캐릭터 목록·상세·등록·수정 | Page | 중첩 관계·성격·배경·기억과 이미지 form이 큼 | -| 콘텐츠 목록·상세·등록·수정 | Page | multipart, 계산 status, player, 다양한 validation이 있음 | -| 콘텐츠·커뮤니티 댓글 목록 | Page | 루트·답글 서버 페이징과 moderation 문맥이 필요함 | -| 콘텐츠·커뮤니티 댓글 작성·수정 | inline 또는 작은 Dialog | 짧은 본문 입력이며 부모 목록을 벗어날 필요가 없음 | -| 시리즈 목록·상세·등록·수정·구성 | Page | 콘텐츠 검색·구성과 순서 관리가 있음 | -| 커뮤니티 게시글 목록·상세·등록·수정 | Page | multipart와 댓글 진입 문맥이 있음 | -| FanTalk 목록 | Page | 답글이 목록 응답에 포함되고 root 문맥이 필요함 | -| FanTalk 답글 등록·수정 | inline 또는 작은 Dialog | 별도 조회 Endpoint 없이 선택 root 안에서 완료됨 | -| 콘텐츠 카테고리 목록 | Page | 서버 페이징과 순서 관리가 있음 | -| 카테고리 생성·이름 수정 | Dialog | 짧은 단일 작업 | -| 시리즈·카테고리 available content 선택 | Dialog | 부모 구성 작업을 위한 검색·다중 선택 | -| creator tag 선택 | Dialog 또는 Combobox | 채널 설정 form의 종속 선택 | -| 채널 공지·프로필 | 하나의 Page 안 section 또는 tab | 같은 캐릭터의 채널 설정 문맥을 공유함 | -| 삭제·비활성화·고정 해제 | AlertDialog | 파괴적 또는 노출 상태를 바꾸는 작업 | -| 캐릭터 전환 | Command/Dialog | 현재 character scope를 명시적으로 바꿈 | - -Dialog가 여러 tab, 중첩 form, browser history 또는 독립적인 서버 페이징 URL을 요구하기 시작하면 Page로 승격한다. - -댓글 row action은 권한 계약을 UI에도 반영한다. - -- 선택 AI 캐릭터가 작성한 댓글과 답글만 수정 action을 표시한다. -- 선택 AI 캐릭터 소유 콘텐츠·게시글에 달린 댓글은 writer와 관계없이 삭제 action을 표시할 수 있다. -- 다른 캐릭터 소유 부모의 댓글은 route에 진입시키지 않고, Backend의 동일 소유권 검증도 유지한다. - -### 27.5 Reusable Component Boundary - -아래 경로는 현재 비어 있는 작업 디렉터리를 그대로 사용하는 project root 기준이다. - -shadcn source component는 `src/components/ui`, 두 개 이상의 실제 화면에서 반복되는 조합은 `src/components/shared`, -도메인별 화면·schema·column은 `src/features/{domain}`에 둔다. - -최초 공통 component 후보는 다음으로 제한한다. - -- `AppShell` -- `AppSidebar` -- `CharacterContextBar` -- `PageHeader` -- `SearchFilterBar` -- `ServerDataTable` -- `ServerPagination` -- `StatusBadge` -- `EmptyState` -- `ErrorState` -- `AppToaster` -- `FormErrorSummary` -- `ConfirmDeleteDialog` -- `FormActions` -- `ImageUploadField` -- `UtcDateTime` - -두 번째 실제 사용처가 생기기 전에는 도메인 component를 공통 component로 승격하지 않는다. - -권장 feature 경계는 다음과 같다. - -```text -src/ - app/ - routes/ - components/ui/ - components/shared/ - features/auth/ - features/ai-characters/ - features/contents/ - features/content-comments/ - features/content-categories/ - features/series/ - features/community-posts/ - features/community-comments/ - features/fan-talks/ - features/channel-settings/ - lib/api/ - lib/query/ - lib/routes/ - test/ -``` - -### 27.6 Search Indexing Prohibition and Security Layers - -`robots.txt`나 `noindex`만으로 “절대 검색 노출 금지”를 보장할 수 없다. 접근 제어를 1차 경계로 두고 다음 책임을 분리한다. - -| Owner | Required Contract | -|---|---| -| 환경·플랫폼 책임자 | production HTML 앞에 조직 승인 IAP, Zero Trust, VPN, IP allowlist 또는 edge authentication을 적용하고 비인가 요청을 차단한다. 제품별 redirect 또는 401/403 동작은 배포 검증에 기록한다. | -| 환경·플랫폼 책임자 | HTML과 비인가 응답에 `X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex`, HTML에 `Cache-Control: no-store`를 적용한다. | -| 환경·플랫폼 책임자 | 27.3의 SPA rewrite를 구성하고 dev·production API Base URL 실제 값을 각 환경에 제공한다. 정적 asset에는 SPA fallback을 적용하지 않는다. | -| 프론트엔드 | 공통 `index.html`에 ``를 둔다. | -| 프론트엔드 | `AUTH-01`로 로그인하고 `role=ADMIN`만 route에 진입시킨다. route guard를 Backend `ROLE_ADMIN` 검사의 대체 수단으로 사용하지 않는다. | -| 프론트엔드 | sitemap, prerender, SSR, 공개 marketing page를 만들지 않고 `VITE_*`에는 API base URL 같은 공개 설정만 둔다. JWT, API key, private key 또는 다른 secret을 build-time 변수에 넣지 않는다. | - -`robots.txt`에 `Disallow: /`를 두면 crawler가 meta 또는 HTTP `noindex`를 읽지 못해 URL만 검색 결과에 남을 수 있으므로 이 방식은 사용하지 않는다. -`robots.txt`는 보안 경계가 아니며 생략하거나 noindex 확인을 막지 않는 형태로만 제공한다. - -25장의 승인된 production host/edge와 설정 책임자가 제공되지 않으면 프론트엔드 에이전트는 production 인프라를 임의 선택하지 않는다. -local production build와 설정 요구사항 문서까지만 만들고 배포는 차단 상태로 보고한다. - -### 27.7 Frontend Runtime, Build, Feedback, Date, and API Rules - -#### 27.7.1 Environment and API Base URL - -Vite mode별로 같은 key에 다른 API Base URL을 주입한다. 아래 host는 형식 설명용 예시이며 실제 배포 값이 아니다. - -`.env.development` - -```dotenv -VITE_API_BASE_URL=https://dev-api.example.com -``` - -`.env.production` - -```dotenv -VITE_API_BASE_URL=https://api.example.com -``` - -- source code에 dev·production URL을 동시에 하드코딩하거나 runtime host를 보고 추측하지 않는다. -- `ImportMetaEnv`를 선언해 `VITE_API_BASE_URL`을 필수 string으로 취급한다. -- 앱 bootstrap에서 값의 존재, `http:` 또는 `https:` 절대 URL 여부를 검증하고 trailing slash는 한 곳에서만 제거한다. 누락·예시 값·잘못된 URL이면 시작 또는 build를 실패시킨다. -- 모든 API URL은 검증된 Base URL과 이 PRD의 `/admin/...` Path를 결합해 만든다. -- `pnpm dev`는 development mode, `pnpm run build:prod`는 production mode를 사용한다. -- `VITE_*` 값은 browser bundle에 노출되므로 API Base URL 같은 공개 설정만 넣고 secret, JWT 또는 API key를 넣지 않는다. -- API가 cross-origin이면 허용 origin, method, header와 credential 정책은 환경·Backend 책임자가 명시적으로 구성한다. 프론트엔드는 Vite proxy나 same-origin reverse proxy가 있다고 가정하지 않는다. - -#### 27.7.2 Package Scripts and Jenkins Build - -`package.json`에 `"packageManager": "pnpm@11.15.0"`을 선언하고 `pnpm-lock.yaml`을 commit한다. script의 canonical contract는 다음과 같다. - -```json -{ - "scripts": { - "dev": "vite --mode development", - "typecheck": "tsc -b --pretty false", - "lint": "eslint . --max-warnings=0", - "test:run": "vitest run", - "build:dev": "vite build --mode development", - "build:prod": "vite build --mode production", - "ci:prod": "pnpm run typecheck && pnpm run lint && pnpm run test:run && pnpm run build:prod" - }, - "packageManager": "pnpm@11.15.0" -} -``` - -Jenkins의 install·검증·build 명령은 다음 순서를 기준으로 한다. - -```sh -npm install --global corepack@latest -corepack enable -corepack prepare pnpm@11.15.0 --activate -pnpm --version -pnpm install --frozen-lockfile -pnpm run ci:prod -``` - -- `pnpm --version` 결과가 `11.15.0`이 아니면 pipeline을 실패시킨다. -- production `VITE_API_BASE_URL`은 승인된 Jenkins environment 또는 workspace의 `.env.production`으로 제공한다. 값이 없거나 `example.com`이면 build를 실패시킨다. -- `pnpm run ci:prod`가 성공한 뒤 생성된 `dist/`만 정적 배포 artifact로 보관한다. -- `vite preview`는 production server로 사용하지 않는다. - -#### 27.7.3 In-Page Feedback and Error Display - -이 관리자 페이지의 “내부 알림”은 별도 알림함이나 실시간 알림 시스템이 아니라 현재 사용자 작업 결과를 알려 주는 UI feedback이다. - -- root에 Sonner `Toaster`를 하나만 두고 `position="top-right"`, `richColors`, `closeButton`, 기본 표시 시간 4초를 적용한다. -- mutation 성공과 짧게 확인하면 되는 background 오류는 Sonner에 표시한다. 성공 toast는 HTTP 성공 응답 뒤에만 표시하고 optimistic success toast는 사용하지 않는다. -- `errorProperty`가 특정 field를 가리키면 해당 field 아래 inline error에 연결한다. field에 귀속되지 않는 form 오류는 `FormErrorSummary`에 표시하며 같은 오류를 toast로 중복 표시하지 않는다. -- 최초 목록·상세 조회 실패는 해당 content 영역의 `ErrorState`와 재시도 action으로 표시한다. -- background refetch 또는 field에 귀속되지 않는 mutation 실패만 중복을 제거한 error toast로 표시한다. -- 401은 session을 정리하고 `/login`으로 이동한 뒤 “세션이 만료되었습니다”를 한 번 표시한다. 403은 권한 없음 Page를 표시한다. -- 삭제·비활성화·고정 해제는 호출 전에 `AlertDialog`로 확인한다. 처리 결과는 toast 또는 inline error로 표시하며 AlertDialog를 결과 알림으로 재사용하지 않는다. -- 이번 범위에는 notification center, 읽음 상태, WebSocket/SSE, push 알림 또는 알림 영속 저장을 추가하지 않는다. - -#### 27.7.4 UTC Storage and KST Display - -- Request와 Response의 절대 날짜·시간은 ISO-8601 UTC `Z` 문자열을 사용한다. 필드 이름은 `createdAtUtc`, `updatedAtUtc`, `releaseAtUtc`, `contentUrlExpiresAtUtc`처럼 `*AtUtc`를 사용한다. -- API 원문, query cache와 비교 로직은 UTC 문자열 또는 epoch millisecond를 유지한다. browser·운영체제 timezone을 저장 기준으로 사용하지 않는다. -- 화면 표시는 `Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", ... })`로 KST 변환하고 날짜·시간을 표시하는 곳에 `KST`를 명시한다. -- 목록은 분 단위, 상세·tooltip은 초 단위로 표시하고 `hourCycle: "h23"`을 사용한다. 상대 시간만 단독으로 표시하지 않는다. -- `datetime-local` 입력값은 KST wall-clock으로 해석하고 전송 직전에 명시적 `+09:00` instant로 만든 뒤 `toISOString()`의 UTC `Z` 값으로 변환한다. 입력 문자열 뒤에 `Z`만 붙이지 않는다. -- 공통 utility는 최소한 `parseUtcInstant`, `formatUtcInKst`, `kstInputToUtcIso`, `utcIsoToKstInput`으로 제한하고 invalid 또는 UTC `Z`가 아닌 API instant는 계약 오류로 처리한다. -- nullable 날짜는 조회 화면에서 `-`, form에서 빈 값으로 표시한다. -- `duration`, `previewStartTime`, `previewEndTime` 같은 `HH:mm:ss` offset은 절대 시각이 아니므로 timezone 변환하지 않는다. -- 자정·연말·월말 경계, nullable 값, invalid 값과 KST 입력→UTC→KST round trip을 unit test로 검증한다. - -#### 27.7.5 API Client and Cache - -- 27.8의 실제 JSON에서 공통 응답 envelope와 page shape를 TypeScript generic으로 추출할 수 있지만 Backend DTO/data class 이름에 의존하지 않는다. -- `AUTH-01` 성공 시 `{ token, role }`만 `sessionStorage`에 보관하고 새로고침 시 함께 복원한다. `localStorage`, URL, log 또는 build-time 환경변수에는 저장하지 않는다. -- 모든 관리자 API에 `Authorization: Bearer `을 추가한다. 복원된 `role`이 `ADMIN`이 아니거나 값이 손상되면 session을 지우고 로그인으로 이동한다. -- Query parameter는 `URLSearchParams`로 만들고 null, undefined와 빈 검색어는 보내지 않는다. -- JSON mutation에는 `Content-Type: application/json`을 명시한다. -- multipart는 API별 정확한 file part 이름을 지키고 `request` part에는 27.8에 표시된 Request JSON을 `JSON.stringify`한 문자열을 넣는다. 브라우저가 boundary를 만들게 하므로 multipart 전체 `Content-Type`을 직접 지정하지 않는다. -- child resource body에 `characterId`, `creatorId`, writer ID를 추가하지 않는다. -- character-scoped query key는 `["ai-character", characterId, domain, ...]`로 시작하고 캐릭터 전환 시 이전 캐릭터 mutation을 재사용하지 않는다. -- `CHAR-01`과 content theme, series genre, creator tag metadata는 `characterId`가 없는 별도 global key factory를 사용한다. -- 목록은 응답의 `page`, `size`, `totalCount`, `hasNext`를 사용하고 TanStack Table은 `manualPagination=true`로 둔다. -- mutation 성공 후 해당 character와 parent resource 범위의 query만 invalidate한다. -- `CONTENT-01`, `CONTENT-02`의 `contentUrl`과 전체 응답을 persistent storage에 보관하지 않는다. -- player 진입 시 `CONTENT-02`를 조회한다. duration으로 TTL을 재계산하지 않고 `contentUrlExpiresAtUtc`만 기준으로 만료 또는 만료 임박 여부를 판단해 상세를 한 번 재조회한다. -- `contentUrl=null`이면 player를 숨기고 API가 반환한 계산 status를 표시한다. raw path나 preview path를 조합하지 않는다. - -### 27.8 Copy-Paste Frontend Development Prompt - -아래 블록은 이 PRD 전체와 함께 프론트엔드 구현 에이전트에 제공한다. 프론트엔드 구현자는 별도 구현 타입 정보 없이 -문서의 HTTP 계약만 사용한다. 각 Operation의 Request JSON과 Response JSON은 클라이언트 type·API client·mock을 -만들 수 있는 self-contained 예시이며, validation과 권한의 최종 기준은 9~20장이다. - -```text -당신은 운영용 AI 캐릭터 관리자 웹 클라이언트를 구현한다. - -[작업 위치] -- 현재 비어 있는 작업 디렉터리 자체를 project root로 사용하고 하위에 별도 프로젝트 디렉터리를 만들지 않는다. -- 이 프롬프트만으로 독립 실행 가능한 frontend project를 만들고 문서에 없는 외부 구현 타입을 찾거나 전제하지 않는다. -- production host/edge, 접근 제어 제품과 설정 책임자는 환경 책임자가 제공한다. 값이 없으면 local production - build와 배포 요구사항 문서까지만 만들고 production 배포를 완료했다고 말하지 않는다. - -[목표] -- 기존 POST /admin/member/login으로 로그인한 ADMIN만 접근하는 SPA를 만든다. -- 관리자가 캐릭터를 명시적으로 선택한 뒤 해당 characterId scope에서 콘텐츠, 댓글, 카테고리, - 시리즈, 커뮤니티, FanTalk, 채널 설정을 관리하게 한다. -- 아래 HTTP Endpoint와 JSON 계약을 임의 변경하지 않는다. -- legacy GET /menu를 호출하지 않고 메뉴를 클라이언트 typed static config로 제공한다. - -[기술 스택] -- Node.js 24 LTS, pnpm 11.15.0, packageManager와 pnpm-lock.yaml 고정 -- React 19.2 stable, Vite 8.1 stable, TypeScript 6.0 strict -- React Router 8.2 Declarative Mode -- Tailwind CSS 4.3, @tailwindcss/vite -- shadcn/ui latest stable CLI, Base UI primitive -- shadcn/ui Sonner, inline field/form error, 조회 ErrorState -- TanStack Query v5, TanStack Table v8, TanStack Form v1, Zod 4 -- fetch wrapper -- Vite React TypeScript template의 ESLint와 typescript-eslint -- Vitest 4.1, jsdom, Testing Library, user-event, jest-dom, Playwright -- beta, RC, prerelease package를 사용하지 않는다. -- Next.js, Redux, Zustand, React Hook Form, Axios, OpenAPI generator를 추가하지 않는다. -- Node나 vite preview를 production server로 사용하지 않는다. vite build의 dist는 승인된 static host/edge가 제공한다. - -[환경별 API Base URL] -- .env.development: VITE_API_BASE_URL=https://dev-api.example.com -- .env.production: VITE_API_BASE_URL=https://api.example.com -- 위 URL은 예시다. 실제 dev·production 값이 제공되기 전에는 배포하지 않는다. -- 같은 VITE_API_BASE_URL key를 Vite mode별로 다르게 설정하고 source code에 두 URL을 하드코딩하지 않는다. -- 값을 필수 typed env로 선언하고 누락, example.com, http/https가 아닌 값이면 시작 또는 build를 실패시킨다. -- API URL은 검증된 Base URL과 아래 절대 Path를 결합한다. Vite proxy나 same-origin reverse proxy를 가정하지 않는다. -- VITE_*에는 browser에 노출해도 되는 값만 넣고 secret, JWT와 API key를 넣지 않는다. - -[package.json scripts와 Jenkins] -- package.json에 "packageManager": "pnpm@11.15.0"을 기록한다. -- scripts는 다음 명령을 정확히 제공한다. - dev = vite --mode development - typecheck = tsc -b --pretty false - lint = eslint . --max-warnings=0 - test:run = vitest run - build:dev = vite build --mode development - build:prod = vite build --mode production - ci:prod = pnpm run typecheck && pnpm run lint && pnpm run test:run && pnpm run build:prod -- Jenkins는 Node.js 24 LTS agent에서 다음 순서로 실행한다. - npm install --global corepack@latest - corepack enable - corepack prepare pnpm@11.15.0 --activate - pnpm --version - pnpm install --frozen-lockfile - pnpm run ci:prod -- pnpm --version이 11.15.0이 아니면 실패한다. 성공 후 dist/만 배포 artifact로 보관한다. -- production VITE_API_BASE_URL이 없거나 example.com이면 Jenkins build를 실패시킨다. - -[공통 API 계약] -- 각 Operation의 Response JSON 전체가 실제 envelope 예시다. 문서에 없는 Response type이나 class를 추측하지 않는다. -- 성공 envelope key는 success, message, data, errorProperty다. -- 목록 data key는 items, page, size, totalCount, hasNext다. -- 공통 오류 Response JSON: - {"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":"characterId"} -- Bearer JWT를 사용한다. -- 로그인 성공의 {token, role}만 sessionStorage에 함께 저장·복원하고 localStorage, URL, log에는 저장하지 않는다. -- 401 또는 ADMIN이 아닌 복원 role은 sessionStorage 삭제 후 /login, 403은 권한 없음, - 400/409는 errorProperty를 form 오류에 연결한다. -- Path의 characterId가 작업 대상이다. body에 creatorId, characterId, writerId를 추가하지 않는다. -- multipart의 request part는 각 Operation에 표시된 Request JSON을 JSON.stringify한 문자열이다. - 브라우저가 boundary를 생성하도록 multipart 전체 Content-Type을 직접 지정하지 않는다. -- character-scoped query key는 ["ai-character", characterId, domain, ...]를 사용한다. -- CHAR-01, content theme, series genre, creator tag는 characterId가 없는 global query key를 사용한다. - -[날짜·시간] -- 모든 *AtUtc 절대 시각 Request/Response는 ISO-8601 UTC Z 문자열이다. -- API 원문, query cache와 비교는 UTC 문자열 또는 epoch millisecond를 유지하고 표시에만 Asia/Seoul을 적용한다. -- Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", ... })을 사용하고 화면에 KST를 표시한다. -- 목록은 분 단위, 상세와 tooltip은 초 단위, hourCycle은 h23으로 표시하며 상대 시간만 단독 표시하지 않는다. -- datetime-local 입력은 KST로 해석해 명시적 +09:00 instant로 만든 뒤 toISOString() UTC Z 값으로 전송한다. - 입력 문자열에 Z만 붙이지 않는다. -- parseUtcInstant, formatUtcInKst, kstInputToUtcIso, utcIsoToKstInput 공통 utility를 만들고 경계값을 test한다. -- nullable 날짜는 조회에서 -, form에서 빈 값으로 표시한다. -- duration, previewStartTime, previewEndTime은 HH:mm:ss offset이므로 timezone 변환하지 않는다. - -[화면 내부 알림] -- root에 Sonner Toaster를 하나만 두고 top-right, richColors, closeButton, 기본 4초로 표시한다. -- mutation 성공은 HTTP 성공 응답 뒤 Sonner success toast로 표시한다. -- errorProperty가 가리키는 field 오류는 field 아래, 나머지 form 오류는 FormErrorSummary에 표시하고 toast와 중복하지 않는다. -- 최초 조회 실패는 content 영역의 ErrorState와 재시도 action으로 표시한다. -- background refetch 또는 field에 귀속되지 않는 mutation 오류만 중복 제거한 Sonner error toast로 표시한다. -- 401은 session을 지우고 /login으로 이동한 뒤 세션 만료 toast를 한 번 표시한다. 403은 권한 없음 Page다. -- AlertDialog는 삭제·비활성화·고정 해제 호출 전 확인에만 사용한다. -- notification center, 읽음 상태, WebSocket/SSE, push 또는 알림 영속 저장은 만들지 않는다. - -[Operation Catalog: Authentication and Character] -AUTH-01 POST /admin/member/login - Request JSON: - {"email":"admin@example.com","password":"password"} - Response JSON: - {"success":true,"message":null,"data":{"token":"","role":"ADMIN"},"errorProperty":null} - -CHAR-01 GET /admin/ai-characters - Path: 없음 - Query: page=0&size=20&search=루나&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "characterId": 101, - "creatorId": 10001, - "name": "루나", - "imageUrl": "https://cdn.example.com/characters/101.webp", - "description": "달빛 라디오 DJ", - "gender": "FEMALE", - "age": 24, - "mbti": "INFP", - "region": "KR", - "tags": ["라디오", "힐링"], - "isActive": true, - "createdAtUtc": "2026-07-20T09:00:00Z", - "updatedAtUtc": "2026-07-20T10:00:00Z" - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -CHAR-02 GET /admin/ai-characters/{characterId} - Path: characterId=101 - Query: 없음 - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "characterId": 101, - "creatorId": 10001, - "characterUuid": "018f4dc0-9c49-7d15-9f73-3f573e52a871", - "name": "루나", - "imageUrl": "https://cdn.example.com/characters/101.webp", - "description": "달빛 라디오 DJ", - "systemPrompt": "차분하고 다정한 말투로 대화한다.", - "characterType": "Character", - "age": 24, - "gender": "FEMALE", - "mbti": "INFP", - "speechPattern": "문장 끝을 부드럽게 맺는다.", - "speechStyle": "차분함", - "appearance": "은빛 단발", - "region": "KR", - "isActive": true, - "tags": ["라디오", "힐링"], - "hobbies": ["음악 감상"], - "values": ["공감"], - "goals": ["팬의 편안한 밤 돕기"], - "relationships": [{ - "personName": "별이", - "relationshipName": "친구", - "description": "오랜 친구", - "importance": 5, - "relationshipType": "FRIEND", - "currentStatus": "CLOSE" - }], - "personalities": [{"trait":"다정함","description":"상대의 감정을 먼저 살핀다."}], - "backgrounds": [{"topic":"직업","description":"심야 라디오 DJ다."}], - "memories": [{"title":"첫 방송","content":"첫 생방송을 성공했다.","emotion":"기쁨"}], - "originalWork": {"id":71,"imageUrl":"https://cdn.example.com/works/71.webp","title":"달빛 방송국"}, - "createdAtUtc": "2026-07-20T09:00:00Z", - "updatedAtUtc": "2026-07-20T10:00:00Z" - }, - "errorProperty": null - } - -CHAR-03 POST /admin/ai-characters - Parts: image=, request= - request Part JSON: - { - "name": "루나", - "systemPrompt": "차분하고 다정한 말투로 대화한다.", - "description": "달빛 라디오 DJ", - "age": 24, - "gender": "FEMALE", - "mbti": "INFP", - "speechPattern": "문장 끝을 부드럽게 맺는다.", - "speechStyle": "차분함", - "appearance": "은빛 단발", - "region": "KR", - "originalWorkId": 71, - "characterType": "Character", - "tags": ["라디오", "힐링"], - "hobbies": ["음악 감상"], - "values": ["공감"], - "goals": ["팬의 편안한 밤 돕기"], - "relationships": [{ - "personName": "별이", - "relationshipName": "친구", - "description": "오랜 친구", - "importance": 5, - "relationshipType": "FRIEND", - "currentStatus": "CLOSE" - }], - "personalities": [{"trait":"다정함","description":"상대의 감정을 먼저 살핀다."}], - "backgrounds": [{"topic":"직업","description":"심야 라디오 DJ다."}], - "memories": [{"title":"첫 방송","content":"첫 생방송을 성공했다.","emotion":"기쁨"}] - } - Response JSON: - {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"isActive":true},"errorProperty":null} - -CHAR-04 PUT /admin/ai-characters/{characterId} - Path: characterId=101 - Parts: image=, request= - request Part JSON: - { - "name": "루나", - "systemPrompt": "차분하고 다정한 말투로 대화한다.", - "description": "달빛 라디오 DJ", - "age": 25, - "gender": "FEMALE", - "mbti": "INFP", - "speechPattern": "문장 끝을 부드럽게 맺는다.", - "speechStyle": "차분함", - "appearance": "은빛 단발", - "originalWorkId": 71, - "characterType": "Character", - "tags": ["라디오", "힐링"], - "hobbies": ["음악 감상"], - "values": ["공감"], - "goals": ["팬의 편안한 밤 돕기"], - "relationships": [], - "personalities": [], - "backgrounds": [], - "memories": [] - } - Response JSON: - {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"isActive":true},"errorProperty":null} - -CHAR-05 DELETE /admin/ai-characters/{characterId} - Path: characterId=101 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"characterIsActive":false,"creatorIsActive":false},"errorProperty":null} - -[Operation Catalog: Content] -CONTENT-01 GET /admin/ai-characters/{characterId}/contents - Path: characterId=101 - Query: page=0&size=20&search=비&isActive=true&status=PUBLISHED - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "contentId": 2001, - "title": "비 오는 밤", - "detail": "수면을 위한 빗소리", - "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp", - "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...", - "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z", - "themeId": 1, - "theme": "ASMR", - "price": 0, - "purchaseOption": "BOTH", - "limited": null, - "totalContentCount": null, - "remainingContentCount": null, - "isAdult": false, - "isActive": true, - "isPointAvailable": false, - "isCommentAvailable": true, - "isGeneratePreview": false, - "isOnlyRental": false, - "isFullDetailVisible": true, - "languageCode": "ko", - "isPinned": false, - "status": "PUBLISHED", - "duration": "00:10:30", - "releaseAtUtc": "2026-07-20T12:00:00Z", - "tags": ["수면", "빗소리"], - "createdAtUtc": "2026-07-20T11:00:00Z", - "updatedAtUtc": "2026-07-20T12:00:00Z" - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -CONTENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId} - Path: characterId=101, contentId=2001 - Query: 없음 - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "contentId": 2001, - "title": "비 오는 밤", - "detail": "수면을 위한 빗소리", - "coverImageUrl": "https://cdn.example.com/audio_content_cover/2001/cover.webp", - "contentUrl": "https://audio.example.com/output/2001/audio.m4a?Expires=...", - "contentUrlExpiresAtUtc": "2026-07-20T14:00:00Z", - "themeId": 1, - "theme": "ASMR", - "price": 0, - "purchaseOption": "BOTH", - "limited": null, - "totalContentCount": null, - "remainingContentCount": null, - "isAdult": false, - "isActive": true, - "isPointAvailable": false, - "isCommentAvailable": true, - "isGeneratePreview": false, - "isOnlyRental": false, - "isFullDetailVisible": true, - "languageCode": "ko", - "isPinned": false, - "status": "PUBLISHED", - "duration": "00:10:30", - "releaseAtUtc": "2026-07-20T12:00:00Z", - "tags": ["수면", "빗소리"], - "createdAtUtc": "2026-07-20T11:00:00Z", - "updatedAtUtc": "2026-07-20T12:00:00Z" - }, - "errorProperty": null - } - -CONTENT-03 POST /admin/ai-characters/{characterId}/contents - Path: characterId=101 - Parts: contentFile=, coverImage=, request= - request Part JSON: - { - "title": "비 오는 밤", - "detail": "수면을 위한 빗소리", - "tags": ["수면", "빗소리"], - "price": 0, - "purchaseOption": "BOTH", - "limited": null, - "releaseAtUtc": "2026-07-20T12:00:00Z", - "themeId": 1, - "isAdult": false, - "isGeneratePreview": false, - "isOnlyRental": false, - "isPointAvailable": false, - "isCommentAvailable": true, - "isFullDetailVisible": true, - "previewStartTime": null, - "previewEndTime": null, - "languageCode": "ko" - } - Response JSON: - {"success":true,"message":null,"data":{"contentId":2001,"isActive":false,"status":"PROCESSING"},"errorProperty":null} - -CONTENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId} - Path: characterId=101, contentId=2001 - Parts: coverImage=, request= - request Part JSON: - {"title":"비 오는 밤","detail":"수면을 위한 빗소리","tags":["수면","빗소리"],"price":0,"isAdult":false,"isPointAvailable":false,"isCommentAvailable":true} - Response JSON: - {"success":true,"message":null,"data":{"id":2001,"isActive":true},"errorProperty":null} - -CONTENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId} - Path: characterId=101, contentId=2001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":2001,"isActive":false},"errorProperty":null} - -CONTENT-06 PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin - Path: characterId=101, contentId=2001 - Request JSON: - {"isPinned":true} - Response JSON: - {"success":true,"message":null,"data":{"contentId":2001,"isPinned":true,"replacedContentId":null},"errorProperty":null} - -CONTENT-07 GET /admin/ai-characters/metadata/content-themes - Path, Query, Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":[{"themeId":1,"name":"ASMR","imageUrl":"https://cdn.example.com/themes/1.webp"}],"errorProperty":null} - -[Operation Catalog: Content Comment] -CONTENT-COMMENT-01 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments - Path: characterId=101, contentId=2001 - Query: page=0&size=20&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "commentId": 2101, - "parentCommentId": null, - "writerId": 9001, - "writerNickname": "팬A", - "writerProfileImageUrl": null, - "content": "잘 들었습니다.", - "languageCode": "ko", - "donationCan": 0, - "isSecret": false, - "isActive": true, - "replyCount": 1, - "createdAtUtc": "2026-07-20T12:10:00Z", - "updatedAtUtc": null - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -CONTENT-COMMENT-02 GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies - Path: characterId=101, contentId=2001, commentId=2101 - Query: page=0&size=20&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "commentId": 2102, - "parentCommentId": 2101, - "writerId": 10001, - "writerNickname": "루나", - "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp", - "content": "들어주셔서 고마워요.", - "languageCode": "ko", - "donationCan": 0, - "isSecret": false, - "isActive": true, - "replyCount": 0, - "createdAtUtc": "2026-07-20T12:20:00Z", - "updatedAtUtc": null - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -CONTENT-COMMENT-03 POST /admin/ai-characters/{characterId}/contents/{contentId}/comments - Path: characterId=101, contentId=2001 - Request JSON: - {"content":"들어주셔서 고마워요.","parentCommentId":2101,"isSecret":false,"languageCode":"ko"} - Response JSON: - {"success":true,"message":null,"data":{"id":2102,"isActive":true},"errorProperty":null} - -CONTENT-COMMENT-04 PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} - Path: characterId=101, contentId=2001, commentId=2102 - Request JSON: - {"content":"정말 고마워요."} - Response JSON: - {"success":true,"message":null,"data":{"id":2102,"isActive":true},"errorProperty":null} - -CONTENT-COMMENT-05 DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId} - Path: characterId=101, contentId=2001, commentId=2101 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":2101,"isActive":false},"errorProperty":null} - -[Operation Catalog: Series] -SERIES-01 GET /admin/ai-characters/{characterId}/series - Path: characterId=101 - Query: page=0&size=20&search=밤&isActive=true&state=PROCEEDING - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "seriesId": 3001, - "title": "루나의 밤", - "introduction": "매주 금요일 밤의 이야기", - "coverImageUrl": "https://cdn.example.com/series/3001.webp", - "publishedDaysOfWeek": ["FRI"], - "genreId": 3, - "isAdult": false, - "state": "PROCEEDING", - "isActive": true, - "writer": "루나", - "studio": null - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -SERIES-02 GET /admin/ai-characters/{characterId}/series/{seriesId} - Path: characterId=101, seriesId=3001 - Query: 없음 - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "seriesId": 3001, - "title": "루나의 밤", - "introduction": "매주 금요일 밤의 이야기", - "coverImageUrl": "https://cdn.example.com/series/3001.webp", - "publishedDaysOfWeek": ["FRI"], - "genreId": 3, - "isAdult": false, - "state": "PROCEEDING", - "isActive": true, - "writer": "루나", - "studio": null, - "genre": "힐링", - "keyword": "수면,라디오", - "createdAtUtc": "2026-07-20T09:00:00Z", - "updatedAtUtc": "2026-07-20T10:00:00Z" - }, - "errorProperty": null - } - -SERIES-03 POST /admin/ai-characters/{characterId}/series - Path: characterId=101 - Parts: image=, request= - request Part JSON: - {"title":"루나의 밤","introduction":"매주 금요일 밤의 이야기","publishedDaysOfWeek":["FRI"],"keyword":"수면,라디오","genreId":3,"isAdult":false,"writer":"루나","studio":null} - Response JSON: - {"success":true,"message":null,"data":{"id":3001,"isActive":true},"errorProperty":null} - -SERIES-04 PUT /admin/ai-characters/{characterId}/series/{seriesId} - Path: characterId=101, seriesId=3001 - Parts: image=, request= - request Part JSON: - {"title":"루나의 밤","introduction":"매주 금요일 밤의 이야기","keyword":"수면,라디오","publishedDaysOfWeek":["FRI"],"genreId":3,"isAdult":false,"state":"PROCEEDING","writer":"루나","studio":null} - Response JSON: - {"success":true,"message":null,"data":{"id":3001,"isActive":true},"errorProperty":null} - -SERIES-05 DELETE /admin/ai-characters/{characterId}/series/{seriesId} - Path: characterId=101, seriesId=3001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":3001,"isActive":false},"errorProperty":null} - -SERIES-06 GET /admin/ai-characters/{characterId}/series/{seriesId}/contents - Path: characterId=101, seriesId=3001 - Query: page=0&size=20 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"items":[{"contentId":2001,"title":"비 오는 밤","coverImageUrl":"https://cdn.example.com/audio_content_cover/2001/cover.webp","isActive":true,"order":0}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} - -SERIES-07 GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents - Path: characterId=101, seriesId=3001 - Query: page=0&size=20&search=파도 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"items":[{"contentId":2002,"title":"잔잔한 파도","coverImageUrl":null,"isActive":true,"order":null}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} - -SERIES-08 POST /admin/ai-characters/{characterId}/series/{seriesId}/contents - Path: characterId=101, seriesId=3001 - Request JSON: - {"contentIds":[2002]} - Response JSON: - {"success":true,"message":null,"data":{"seriesId":3001,"affectedContentIds":[2002]},"errorProperty":null} - -SERIES-09 DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId} - Path: characterId=101, seriesId=3001, contentId=2001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"seriesId":3001,"affectedContentIds":[2001]},"errorProperty":null} - -SERIES-10 PUT /admin/ai-characters/{characterId}/series/orders - Path: characterId=101 - Request JSON: - {"seriesIds":[3003,3001,3002]} - Response JSON: - {"success":true,"message":null,"data":{"seriesIds":[3003,3001,3002]},"errorProperty":null} - -SERIES-11 GET /admin/ai-characters/metadata/series-genres - Path, Query, Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":[{"genreId":3,"name":"힐링"}],"errorProperty":null} - -[Operation Catalog: Community Post and Comment] -COMMUNITY-POST-01 GET /admin/ai-characters/{characterId}/community-posts - Path: characterId=101 - Query: page=0&size=20&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "postId": 4001, - "creatorId": 10001, - "creatorNickname": "루나", - "creatorProfileImageUrl": "https://cdn.example.com/characters/101.webp", - "imageUrl": "https://cdn.example.com/community/4001.webp", - "audioUrl": null, - "content": "오늘 밤 10시에 만나요.", - "price": 0, - "isCommentAvailable": true, - "isAdult": false, - "isFixed": true, - "isActive": true, - "likeCount": 12, - "commentCount": 3, - "createdAtUtc": "2026-07-20T08:00:00Z", - "updatedAtUtc": null - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -COMMUNITY-POST-02 GET /admin/ai-characters/{characterId}/community-posts/{postId} - Path: characterId=101, postId=4001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"postId":4001,"creatorId":10001,"creatorNickname":"루나","creatorProfileImageUrl":"https://cdn.example.com/characters/101.webp","imageUrl":"https://cdn.example.com/community/4001.webp","audioUrl":null,"content":"오늘 밤 10시에 만나요.","price":0,"isCommentAvailable":true,"isAdult":false,"isFixed":true,"isActive":true,"likeCount":12,"commentCount":3,"createdAtUtc":"2026-07-20T08:00:00Z","updatedAtUtc":null},"errorProperty":null} - -COMMUNITY-POST-03 POST /admin/ai-characters/{characterId}/community-posts - Path: characterId=101 - Parts: audioFile=, postImage=, request= - request Part JSON: - {"content":"오늘 밤 10시에 만나요.","isCommentAvailable":true,"isAdult":false,"price":0} - Response JSON: - {"success":true,"message":null,"data":{"id":4001,"isActive":true},"errorProperty":null} - -COMMUNITY-POST-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId} - Path: characterId=101, postId=4001 - Parts: postImage=, request= - request Part JSON: - {"content":"오늘 밤 10시에 꼭 만나요.","isCommentAvailable":true,"isAdult":false} - Response JSON: - {"success":true,"message":null,"data":{"id":4001,"isActive":true},"errorProperty":null} - -COMMUNITY-POST-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId} - Path: characterId=101, postId=4001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":4001,"isActive":false},"errorProperty":null} - -COMMUNITY-POST-06 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed - Path: characterId=101, postId=4001 - Request JSON: - {"isFixed":true} - Response JSON: - {"success":true,"message":null,"data":{"postId":4001,"isFixed":true},"errorProperty":null} - -COMMUNITY-COMMENT-01 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments - Path: characterId=101, postId=4001 - Query: page=0&size=20&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "commentId": 4101, - "parentCommentId": null, - "writerId": 9001, - "writerNickname": "팬A", - "writerProfileImageUrl": null, - "content": "기대할게요.", - "isSecret": false, - "isActive": true, - "replyCount": 1, - "createdAtUtc": "2026-07-20T08:10:00Z", - "updatedAtUtc": null - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -COMMUNITY-COMMENT-02 GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies - Path: characterId=101, postId=4001, commentId=4101 - Query: page=0&size=20&isActive=true - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "commentId": 4102, - "parentCommentId": 4101, - "writerId": 10001, - "writerNickname": "루나", - "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp", - "content": "조금 뒤에 만나요.", - "isSecret": false, - "isActive": true, - "replyCount": 0, - "createdAtUtc": "2026-07-20T08:20:00Z", - "updatedAtUtc": null - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -COMMUNITY-COMMENT-03 POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments - Path: characterId=101, postId=4001 - Request JSON: - {"content":"조금 뒤에 만나요.","parentCommentId":4101,"isSecret":false} - Response JSON: - {"success":true,"message":null,"data":{"id":4102,"isActive":true},"errorProperty":null} - -COMMUNITY-COMMENT-04 PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} - Path: characterId=101, postId=4001, commentId=4102 - Request JSON: - {"content":"곧 만나요."} - Response JSON: - {"success":true,"message":null,"data":{"id":4102,"isActive":true},"errorProperty":null} - -COMMUNITY-COMMENT-05 DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId} - Path: characterId=101, postId=4001, commentId=4101 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":4101,"isActive":false},"errorProperty":null} - -[Operation Catalog: FanTalk] -FAN-TALK-01 GET /admin/ai-characters/{characterId}/fan-talks - Path: characterId=101 - Query: page=0&size=20 - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "items": [{ - "fanTalkId": 5001, - "writerId": 9001, - "writerNickname": "팬A", - "writerProfileImageUrl": "https://cdn.example.com/default-profile.webp", - "content": "오늘도 힘내세요.", - "createdAtUtc": "2026-07-20T07:00:00Z", - "creatorReplies": [{ - "replyId": 5002, - "fanTalkId": 5001, - "writerId": 10001, - "writerNickname": "루나", - "writerProfileImageUrl": "https://cdn.example.com/characters/101.webp", - "content": "응원 고마워요.", - "isActive": true, - "createdAtUtc": "2026-07-20T07:10:00Z", - "updatedAtUtc": null - }] - }], - "page": 0, - "size": 20, - "totalCount": 1, - "hasNext": false - }, - "errorProperty": null - } - -FAN-TALK-02 POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies - Path: characterId=101, fanTalkId=5001 - Request JSON: - {"content":"응원 고마워요.","languageCode":"ko"} - Response JSON: - {"success":true,"message":null,"data":{"replyId":5002,"fanTalkId":5001,"writerId":10001,"writerNickname":"루나","writerProfileImageUrl":"https://cdn.example.com/characters/101.webp","content":"응원 고마워요.","isActive":true,"createdAtUtc":"2026-07-20T07:10:00Z","updatedAtUtc":null},"errorProperty":null} - -FAN-TALK-03 PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} - Path: characterId=101, fanTalkId=5001, replyId=5002 - Request JSON: - {"content":"늘 응원해 줘서 고마워요."} - Response JSON: - {"success":true,"message":null,"data":{"replyId":5002,"fanTalkId":5001,"writerId":10001,"writerNickname":"루나","writerProfileImageUrl":"https://cdn.example.com/characters/101.webp","content":"늘 응원해 줘서 고마워요.","isActive":true,"createdAtUtc":"2026-07-20T07:10:00Z","updatedAtUtc":"2026-07-20T07:20:00Z"},"errorProperty":null} - -FAN-TALK-04 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId} - Path: characterId=101, fanTalkId=5001, replyId=5002 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":5002,"isActive":false},"errorProperty":null} - -FAN-TALK-05 DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId} - Path: characterId=101, fanTalkId=5001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"id":5001,"isActive":false},"errorProperty":null} - -- 답글 조회 API를 추가하지 않는다. FAN-TALK-01 Response JSON의 creatorReplies를 사용한다. - -[Operation Catalog: Content Category and Channel Settings] -CATEGORY-01 GET /admin/ai-characters/{characterId}/content-categories - Path: characterId=101 - Query: page=0&size=20&isActive=true - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"items":[{"categoryId":6001,"title":"ASMR","order":0,"contentCount":2,"isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} - -CATEGORY-02 POST /admin/ai-characters/{characterId}/content-categories - Path: characterId=101 - Request JSON: - {"title":"ASMR","contentIds":[2001,2002]} - Response JSON: - {"success":true,"message":null,"data":{"categoryId":6001,"isActive":true},"errorProperty":null} - -CATEGORY-03 PUT /admin/ai-characters/{characterId}/content-categories/{categoryId} - Path: characterId=101, categoryId=6001 - Request JSON: - {"title":"수면 ASMR"} - Response JSON: - {"success":true,"message":null,"data":{"categoryId":6001,"isActive":true},"errorProperty":null} - -CATEGORY-04 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId} - Path: characterId=101, categoryId=6001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"categoryId":6001,"isActive":false},"errorProperty":null} - -CATEGORY-05 PUT /admin/ai-characters/{characterId}/content-categories/orders - Path: characterId=101 - Request JSON: - {"categoryIds":[6003,6001,6002]} - Response JSON: - {"success":true,"message":null,"data":{"categoryIds":[6003,6001,6002]},"errorProperty":null} - -CATEGORY-06 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents - Path: characterId=101, categoryId=6001 - Query: page=0&size=20 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"items":[{"contentId":2001,"title":"비 오는 밤","coverImageUrl":"https://cdn.example.com/audio_content_cover/2001/cover.webp","isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} - -CATEGORY-07 GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents - Path: characterId=101, categoryId=6001 - Query: page=0&size=20&search=파도 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"items":[{"contentId":2002,"title":"잔잔한 파도","coverImageUrl":null,"isActive":true}],"page":0,"size":20,"totalCount":1,"hasNext":false},"errorProperty":null} - -CATEGORY-08 POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents - Path: characterId=101, categoryId=6001 - Request JSON: - {"contentIds":[2002]} - Response JSON: - {"success":true,"message":null,"data":{"categoryId":6001,"affectedContentIds":[2002]},"errorProperty":null} - -CATEGORY-09 DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId} - Path: characterId=101, categoryId=6001, contentId=2001 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"categoryId":6001,"affectedContentIds":[2001]},"errorProperty":null} - -NOTICE-01 GET /admin/ai-characters/{characterId}/channel-notice - Path: characterId=101 - Query: 없음 - Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"notice":"새 콘텐츠는 매주 금요일 공개됩니다.","updatedAtUtc":"2026-07-20T10:00:00Z"},"errorProperty":null} - -NOTICE-02 PUT /admin/ai-characters/{characterId}/channel-notice - Path: characterId=101 - Request JSON: - {"notice":"새 콘텐츠는 매주 금요일 공개됩니다."} - Response JSON: - {"success":true,"message":null,"data":{"characterId":101,"creatorId":10001,"notice":"새 콘텐츠는 매주 금요일 공개됩니다.","updatedAtUtc":"2026-07-20T10:00:00Z"},"errorProperty":null} - -CREATOR-TAG-01 GET /admin/ai-characters/metadata/creator-tags - Path, Query, Request JSON: 없음 - Response JSON: - {"success":true,"message":null,"data":[{"tagId":11,"name":"ASMR","imageUrl":"https://cdn.example.com/creator-tags/11.webp","isAdult":false}],"errorProperty":null} - -CHANNEL-PROFILE-01 GET /admin/ai-characters/{characterId}/channel-profile - Path: characterId=101 - Query: 없음 - Request JSON: 없음 - Response JSON: - { - "success": true, - "message": null, - "data": { - "characterId": 101, - "creatorId": 10001, - "instagramUrl": "https://instagram.com/example", - "fancimmUrl": "", - "xUrl": "", - "youtubeUrl": "https://youtube.com/@example", - "kakaoOpenChatUrl": "", - "creatorTags": [{ - "tagId": 11, - "name": "ASMR", - "imageUrl": "https://cdn.example.com/creator-tags/11.webp", - "isAdult": false - }], - "isVisibleDonationRank": true, - "donationRankingPeriod": "CUMULATIVE", - "updatedAtUtc": "2026-07-20T10:00:00Z" - }, - "errorProperty": null - } - -CHANNEL-PROFILE-02 PUT /admin/ai-characters/{characterId}/channel-profile - Path: characterId=101 - Request JSON: - {"instagramUrl":"https://instagram.com/example","fancimmUrl":"","xUrl":"","youtubeUrl":"https://youtube.com/@example","kakaoOpenChatUrl":"","tagIds":[11,14],"isVisibleDonationRank":true,"donationRankingPeriod":"CUMULATIVE"} - Response JSON: - { - "success": true, - "message": null, - "data": { - "characterId": 101, - "creatorId": 10001, - "instagramUrl": "https://instagram.com/example", - "fancimmUrl": "", - "xUrl": "", - "youtubeUrl": "https://youtube.com/@example", - "kakaoOpenChatUrl": "", - "creatorTags": [ - {"tagId":11,"name":"ASMR","imageUrl":"https://cdn.example.com/creator-tags/11.webp","isAdult":false}, - {"tagId":14,"name":"힐링","imageUrl":null,"isAdult":false} - ], - "isVisibleDonationRank": true, - "donationRankingPeriod": "CUMULATIVE", - "updatedAtUtc": "2026-07-20T10:05:00Z" - }, - "errorProperty": null - } - -[Signed URL] -- CONTENT-01과 CONTENT-02 Response JSON의 contentUrl은 가공 완료된 전체 오디오 CloudFront Signed URL이다. -- coverImageUrl은 Signed URL이 아닌 일반 CDN 절대 URL이다. -- 기존 DB 필드와 creator 활성 상태에서 계산한 status가 SCHEDULED/PUBLISHED이고 canonical output/{contentId}/... key와 duration이 있을 때만 URL이 있다. -- 계산 status가 PROCESSING/SUSPENDED/DELETED이면 contentUrl과 contentUrlExpiresAtUtc는 null이다. -- URL TTL은 (duration HH + 2)시간이며 URL policy 만료와 contentUrlExpiresAtUtc가 일치한다. -- 클라이언트는 duration으로 TTL을 계산하지 않고 contentUrlExpiresAtUtc만 사용한다. -- CONTENT-01/02는 응답마다 새 URL을 반환하며 API response Cache-Control은 private, no-store다. -- URL과 응답을 persistent storage에 저장하지 않는다. -- 만료 또는 만료 임박 시 CONTENT-02를 한 번 재조회한다. -- raw input path, DB path, preview path를 조합하거나 서명 실패 시 fallback하지 않는다. - -[Menu and Route] -- 메뉴는 클라이언트 typed static config다. GET /menu를 호출하지 않는다. -- /ai-characters에서 캐릭터를 먼저 명시적으로 선택한다. -- 선택 characterId는 /ai-characters/:characterId/** URL Path가 source of truth다. -- 상단 CharacterContextBar에 avatar, name, characterId, active status, 전환 action을 표시한다. -- child resource 등록·수정 화면 안에서 다시 캐릭터를 선택하게 하지 않는다. -- 캐릭터 자체 등록만 /ai-characters/new에서 selection 없이 수행한다. -- production host는 /login과 /ai-characters/**의 파일이 아닌 GET을 index.html로 rewrite한다. -- 정적 asset과 VITE_API_BASE_URL로 보내는 API 요청에는 SPA fallback을 적용하지 않는다. - -[Page vs Dialog] -- 캐릭터, 콘텐츠, 시리즈, 커뮤니티의 목록·상세·등록·수정은 Page다. -- 댓글 moderation, FanTalk 목록, 카테고리 구성은 Page다. -- 짧은 댓글·답글 입력, 카테고리 이름, available content 선택, creator tag 선택은 Dialog 또는 inline이다. -- 삭제·비활성화·고정 해제는 AlertDialog다. -- 선택 AI 캐릭터가 작성한 댓글에만 edit action을 표시한다. -- 선택 AI 캐릭터 소유 콘텐츠·게시글의 댓글은 writer와 관계없이 delete action을 표시할 수 있다. -- Dialog에 독립 URL, 여러 tab, 중첩 form 또는 복잡한 서버 페이징이 필요해지면 Page로 바꾼다. - -[Component Reuse] -- shadcn 원시는 components/ui에 둔다. -- 두 개 이상의 실제 화면에서 반복되는 조합만 components/shared로 올린다. -- AppShell, AppSidebar, CharacterContextBar, PageHeader, SearchFilterBar, ServerDataTable, - ServerPagination, StatusBadge, EmptyState, ErrorState, AppToaster, FormErrorSummary, - ConfirmDeleteDialog, FormActions, ImageUploadField, UtcDateTime을 최초 공통 후보로 한다. -- 도메인별 schema, column, form은 features/{domain}에 유지한다. -- 범용 CRUD engine을 만들지 않는다. - -[검색 노출 금지와 보안] -- 환경 책임자가 production HTML 앞에 조직 승인 edge 접근 제어를 적용한다. -- 승인된 host/edge와 설정 책임자가 없으면 임의 선택하거나 production 배포 완료로 판단하지 않는다. -- index.html에 noindex,nofollow,noarchive,nosnippet,noimageindex meta를 둔다. -- 환경 책임자는 HTML과 비인가 응답에 같은 X-Robots-Tag를, HTML에 Cache-Control: no-store를 적용한다. -- robots.txt Disallow: /는 crawler가 noindex를 읽지 못하게 할 수 있으므로 사용하지 않는다. -- robots.txt는 생략하거나 noindex 확인을 막지 않는 형태로만 제공하며 보안 경계로 간주하지 않는다. -- sitemap, SSR, prerender, 공개 marketing page를 만들지 않는다. -- VITE_* 환경변수에 secret이나 JWT를 넣지 않는다. -- 클라이언트 role guard는 화면 진입 UX를 위한 것이며 HTTP 401/403 처리를 생략하는 근거가 아니다. - -[테스트] -- AUTH-01 성공/실패, token과 role의 sessionStorage 복원, ADMIN 외 role 차단, 401 session 정리, 403 화면을 검증한다. -- 캐릭터를 자동 선택하지 않는지, 선택 후 URL과 CharacterContextBar가 일치하는지 검증한다. -- deep link 새로고침에서 SPA rewrite 후 CHAR-02로 context가 복원되고 API/asset path가 index.html로 rewrite되지 않는지 검증한다. -- development와 production mode가 서로 다른 VITE_API_BASE_URL을 사용하고 누락·예시 값이면 실패하는지 검증한다. -- child mutation body에 creatorId/characterId/writerId가 들어가지 않는지 검증한다. -- JSON과 multipart part 이름 및 Query 직렬화를 Operation별로 검증한다. -- 서버 페이징, 빈 상태, 오류 상태, mutation 후 좁은 query invalidation을 검증한다. -- global query key와 character-scoped query key가 섞이지 않는지 검증한다. -- 타인 댓글에는 edit가 없고 선택 캐릭터 소유 부모의 댓글에는 delete가 표시되는지 검증한다. -- FanTalk 답글을 별도 GET 없이 creatorReplies로 렌더링하는지 검증한다. -- Signed URL null 상태, contentUrlExpiresAtUtc 기준 만료 임박 상세 재조회, TTL 미재계산, raw path 미사용을 검증한다. -- destructive action은 AlertDialog 확인 전 호출되지 않는지 검증한다. -- mutation success toast, inline field/form 오류, 조회 ErrorState, 401 session 만료 toast와 중복 알림 방지를 검증한다. -- UTC Z 검증, KST 표시, KST 입력의 UTC 변환, 자정·월말·연말 경계와 HH:mm:ss 미변환을 검증한다. -- meta robots, restrictive robots.txt 미사용, X-Robots-Tag와 환경별 edge 비인가 차단 동작을 배포 검증에 포함한다. -- Jenkins와 같은 pnpm install --frozen-lockfile 및 pnpm run ci:prod가 통과하고 dist/를 생성해야 완료다. -- 로그인, character scope와 주요 CRUD의 핵심 Playwright E2E가 통과해야 완료다. - -[완료 산출물] -- 실행 가능한 Vite SPA -- typed route/menu config -- Operation ID 기준 typed API module -- 공통 layout/table/pagination/form/error component -- 각 도메인 Page/Dialog -- unit/UI test와 핵심 E2E -- README에 실행 방법, dev·production 환경변수, Jenkins 명령, UTC/KST 규칙, - 화면 feedback 규칙, production 접근 제어 및 noindex 검증 방법 -- 구현하지 않은 API나 화면이 있으면 숨기지 말고 Operation ID와 이유를 명시한다. -``` - -### 27.9 Official Frontend References - -- [React versions](https://react.dev/versions) -- [Vite 8.1 release](https://vite.dev/blog/announcing-vite8-1) -- [Vite env files and modes](https://vite.dev/guide/env-and-mode) -- [Vite static deployment](https://vite.dev/guide/static-deploy.html) -- [Node.js release status](https://nodejs.org/en/about/previous-releases) -- [pnpm package versions](https://www.npmjs.com/package/pnpm?activeTab=versions) -- [pnpm continuous integration with Jenkins](https://pnpm.io/continuous-integration#jenkins) -- [Jenkins Pipeline](https://www.jenkins.io/doc/book/pipeline/) -- [TypeScript 6.0 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html) -- [React Router current changelog](https://reactrouter.com/start/start/changelog) -- [React Router mode selection](https://reactrouter.com/start/modes) -- [React Router SPA deployment](https://reactrouter.com/how-to/spa) -- [Tailwind CSS with Vite](https://tailwindcss.com/docs/installation/using-vite) -- [shadcn/ui Vite installation](https://ui.shadcn.com/docs/installation/vite) -- [shadcn/ui Base UI decision](https://ui.shadcn.com/docs/changelog) -- [shadcn/ui TanStack Form guide](https://ui.shadcn.com/docs/forms/tanstack-form) -- [shadcn/ui Sonner](https://ui.shadcn.com/docs/components/radix/sonner) -- [TanStack Query v5](https://tanstack.com/query/v5/docs/framework/react/overview) -- [TanStack Table v8](https://tanstack.com/table/v8/docs/introduction) -- [Zod 4](https://zod.dev/) -- [Vitest](https://vitest.dev/guide/) -- [Playwright](https://playwright.dev/docs/intro) -- [MDN Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) -- [MDN Date.prototype.toISOString](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) -- [Google noindex and X-Robots-Tag](https://developers.google.com/search/docs/crawling-indexing/block-indexing) -- [Google robots.txt limitations](https://developers.google.com/search/docs/crawling-indexing/robots/intro) - -## 28. Original Work Frontend Add-on - -27.8은 이미 적용된 58개 Operation의 baseline 프롬프트이므로 내용을 수정하지 않는다. 원작 관리 8개 Operation과 캐릭터 등록·수정의 원작 검색·선택 UI는 다음 별도 delta 프롬프트를 기존 frontend 프로젝트에 추가 적용한다. - -- Prompt: `docs/20260720_AI캐릭터_관리자기능/frontend-original-work-prompt.md` -- 적용 후 브라우저용 계약: 기존 로그인 `AUTH-01` + 신규 관리자 Operation 66개 -- 원작 추가 Operation: `ORIGINAL-WORK-01`~`ORIGINAL-WORK-08` -- 기존 stack, 환경별 `VITE_API_BASE_URL`, Jenkins 명령, 인증·세션, noindex, UTC/KST와 feedback 규칙은 변경하지 않는다. -- 27.8 fenced prompt의 변경 전 SHA-256은 `5956ddc152c026937728381d625859bdea65b9a2f16a39b200f0d6b3660a73e1`이며 문서 보강 후에도 같아야 한다. - -## 29. PRD-Plan Synchronization and Legacy API Mapping - -### 29.1 Synchronization Check - -- 이 PRD와 `plan-task.md`는 신규 관리자 Operation 66개를 같은 범위로 추적한다. -- `plan-task.md`의 Task 1.1~11.4는 PRD의 인증, 메뉴, 원작, 캐릭터, 콘텐츠, 댓글, 카테고리, 시리즈, 커뮤니티, FanTalk, 채널 설정, 삭제 cascade, legacy 호환, callback 유지 및 경계 검증을 모두 포함한다. -- 기존 `AUTH-01`, legacy `/admin/chat/**`, 기존 `/audio-content/upload-complete`는 신규 Operation 수에 포함하지 않는다는 점도 두 문서가 일치한다. -- Frontend baseline 58개와 원작 8개 추가로 브라우저용 신규 관리자 Operation 66개가 된다는 설명도 두 문서가 일치한다. - -### 29.2 Mapping Rule - -- 아래 표는 신규 v2 관리자 API가 이관·대체·참고하는 legacy HTTP API를 추적하기 위한 문서다. -- `없음`은 기존에 같은 목적의 HTTP API가 없거나, 기존 서비스 내부 기능만 있었음을 의미한다. -- `부분 대응`은 legacy API가 데이터 조회나 일부 동작의 근거만 제공하며 신규 v2 계약을 그대로 대체하지 못한다는 의미다. -- 이 표는 구현 의존성 허용 목록이 아니다. v2 신규 비즈니스 로직은 PRD 7.6과 21장의 경계대로 legacy Controller, Service, Repository, Request/Response DTO를 호출하지 않는다. - -### 29.3 Authentication, Character, Original Work - -| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | -|---|---|---|---| -| `AUTH-01` | `POST /admin/member/login` | 동일 | 기존 로그인 재사용 | -| `CHAR-01` | `GET /admin/ai-characters` | `GET /admin/chat/character/list`, `GET /admin/chat/character/search` | 목록·검색 통합 | -| `CHAR-02` | `GET /admin/ai-characters/{characterId}` | `GET /admin/chat/character/{characterId}` | 상세 이관 | -| `CHAR-03` | `POST /admin/ai-characters` | `POST /admin/chat/character/register` | 등록 이관, legacy 성공 응답은 유지 | -| `CHAR-04` | `PUT /admin/ai-characters/{characterId}` | `PUT /admin/chat/character/update` | 수정 이관, legacy nullable patch 의미는 adapter에서 보존 | -| `CHAR-05` | `DELETE /admin/ai-characters/{characterId}` | `PUT /admin/chat/character/update` with `isActive=false` | 명시 DELETE 없음, 비활성화 경로를 v2 delete cascade로 수렴 | -| `ORIGINAL-WORK-01` | `GET /admin/ai-characters/original-works` | `GET /admin/chat/original/list`, `GET /admin/chat/original/search` | 목록·검색 통합 | -| `ORIGINAL-WORK-02` | `GET /admin/ai-characters/original-works/{originalWorkId}` | `GET /admin/chat/original/{id}` | 상세 이관 | -| `ORIGINAL-WORK-03` | `POST /admin/ai-characters/original-works` | `POST /admin/chat/original/register` | 등록 이관, legacy 성공 응답은 유지 | -| `ORIGINAL-WORK-04` | `PUT /admin/ai-characters/original-works/{originalWorkId}` | `PUT /admin/chat/original/update` | 수정 이관, legacy nullable patch 의미는 adapter에서 보존 | -| `ORIGINAL-WORK-05` | `DELETE /admin/ai-characters/original-works/{originalWorkId}` | `DELETE /admin/chat/original/{id}` | 삭제 이관, 연결 존재 시 신규 정책 적용 | -| `ORIGINAL-WORK-06` | `GET /admin/ai-characters/original-works/{originalWorkId}/characters` | `GET /admin/chat/original/{id}/characters` | 연결 캐릭터 목록 이관 | -| `ORIGINAL-WORK-07` | `POST /admin/ai-characters/original-works/{originalWorkId}/characters` | `POST /admin/chat/original/{id}/assign-characters` | 배정 이관, 부분 성공 금지 | -| `ORIGINAL-WORK-08` | `DELETE /admin/ai-characters/original-works/{originalWorkId}/characters` | `POST /admin/chat/original/{id}/unassign-characters` | 해제 이관, 신규 API는 DELETE body 사용 | - -### 29.4 Content and Content Comment - -| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | -|---|---|---|---| -| `CONTENT-01` | `GET /admin/ai-characters/{characterId}/contents` | `GET /creator-admin/audio-content/list`, `GET /creator-admin/audio-content/search` | 목록·검색과 관리자 projection 재구성 | -| `CONTENT-02` | `GET /admin/ai-characters/{characterId}/contents/{contentId}` | `GET /audio-content/{id}` | 부분 대응, 관리자 상세·Signed URL 계약은 v2에서 새로 정의 | -| `CONTENT-03` | `POST /admin/ai-characters/{characterId}/contents` | `POST /audio-content` | 생성 이관, 기존 callback 계약 유지 | -| `CONTENT-04` | `PUT /admin/ai-characters/{characterId}/contents/{contentId}` | `PUT /creator-admin/audio-content`, `PUT /audio-content` | 부분 대응, 수정 가능 field를 v2에서 제한 | -| `CONTENT-05` | `DELETE /admin/ai-characters/{characterId}/contents/{contentId}` | `DELETE /audio-content/{id}` | 논리 삭제 이관 | -| `CONTENT-06` | `PUT /admin/ai-characters/{characterId}/contents/{contentId}/pin` | `POST /audio-content/pin-to-the-top/{id}`, `PUT /audio-content/unpin-at-the-top/{id}` | 고정·해제 통합 | -| `CONTENT-07` | `GET /admin/ai-characters/metadata/content-themes` | `GET /audio-content/theme`, `GET /audio-content/theme/active` | 기준정보 조회 이관 | -| `CONTENT-COMMENT-01` | `GET /admin/ai-characters/{characterId}/contents/{contentId}/comments` | `GET /audio-content/{id}/comment` | 루트 댓글 목록 이관 | -| `CONTENT-COMMENT-02` | `GET /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}/replies` | `GET /audio-content/comment/{id}` | 답글 목록 이관 | -| `CONTENT-COMMENT-03` | `POST /admin/ai-characters/{characterId}/contents/{contentId}/comments` | `POST /audio-content/comment` | 작성 이관, writer는 AI creator로 고정 | -| `CONTENT-COMMENT-04` | `PUT /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | `PUT /audio-content/comment` | 수정 이관, AI 작성자만 허용 | -| `CONTENT-COMMENT-05` | `DELETE /admin/ai-characters/{characterId}/contents/{contentId}/comments/{commentId}` | `PUT /audio-content/comment` | 명시 DELETE 없음, 논리 삭제 동작만 v2로 분리 | - -### 29.5 Category and Series - -| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | -|---|---|---|---| -| `CATEGORY-01` | `GET /admin/ai-characters/{characterId}/content-categories` | `GET /category` | 목록 이관 | -| `CATEGORY-02` | `POST /admin/ai-characters/{characterId}/content-categories` | `POST /category` | 생성 이관 | -| `CATEGORY-03` | `PUT /admin/ai-characters/{characterId}/content-categories/{categoryId}` | `PUT /category` | 수정 이관 | -| `CATEGORY-04` | `DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}` | `DELETE /category/{id}` | 논리 삭제 이관 | -| `CATEGORY-05` | `PUT /admin/ai-characters/{characterId}/content-categories/orders` | `PUT /category/orders` | 순서 변경 이관, 소유자 전체 집합 검증 추가 | -| `CATEGORY-06` | `GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | `GET /creator-admin/content-category` | 포함 콘텐츠 목록 이관 | -| `CATEGORY-07` | `GET /admin/ai-characters/{characterId}/content-categories/{categoryId}/available-contents` | `GET /creator-admin/content-category/search` | 추가 가능 콘텐츠 검색 이관 | -| `CATEGORY-08` | `POST /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents` | 없음 | 카테고리 생성·수정 내부 구성 기능을 명시 API로 분리 | -| `CATEGORY-09` | `DELETE /admin/ai-characters/{characterId}/content-categories/{categoryId}/contents/{contentId}` | 없음 | 카테고리-콘텐츠 연결 해제를 명시 API로 신설 | -| `SERIES-01` | `GET /admin/ai-characters/{characterId}/series` | `GET /creator-admin/audio-content/series` | 목록 이관 | -| `SERIES-02` | `GET /admin/ai-characters/{characterId}/series/{seriesId}` | `GET /creator-admin/audio-content/series/{id}` | 상세 이관 | -| `SERIES-03` | `POST /admin/ai-characters/{characterId}/series` | `POST /creator-admin/audio-content/series` | 등록 이관 | -| `SERIES-04` | `PUT /admin/ai-characters/{characterId}/series/{seriesId}` | `PUT /creator-admin/audio-content/series` | 수정 이관 | -| `SERIES-05` | `DELETE /admin/ai-characters/{characterId}/series/{seriesId}` | 없음 | 명시 삭제 API 신설 | -| `SERIES-06` | `GET /admin/ai-characters/{characterId}/series/{seriesId}/contents` | `GET /creator-admin/audio-content/series/{id}/content` | 포함 콘텐츠 목록 이관 | -| `SERIES-07` | `GET /admin/ai-characters/{characterId}/series/{seriesId}/available-contents` | `GET /creator-admin/audio-content/series/content/search` | 추가 가능 콘텐츠 검색 이관 | -| `SERIES-08` | `POST /admin/ai-characters/{characterId}/series/{seriesId}/contents` | `POST /creator-admin/audio-content/series/add/content` | 콘텐츠 추가 이관 | -| `SERIES-09` | `DELETE /admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | `PUT /creator-admin/audio-content/series/remove/content` | 신규 API는 DELETE로 관계 해제 표현 | -| `SERIES-10` | `PUT /admin/ai-characters/{characterId}/series/orders` | `PUT /creator-admin/audio-content/series/orders` | 순서 변경 이관, 소유자 전체 집합 검증 추가 | -| `SERIES-11` | `GET /admin/ai-characters/metadata/series-genres` | `GET /creator-admin/audio-content/series/genre` | 기준정보 조회 이관 | - -### 29.6 Community, FanTalk, Channel Settings - -| Operation ID | 신규 API | 대응 legacy API | 매핑 판단 | -|---|---|---|---| -| `COMMUNITY-POST-01` | `GET /admin/ai-characters/{characterId}/community-posts` | `GET /creator-community` | 목록 이관, 관리자 projection 사용 | -| `COMMUNITY-POST-02` | `GET /admin/ai-characters/{characterId}/community-posts/{postId}` | `GET /creator-community/{id}` | 상세 이관 | -| `COMMUNITY-POST-03` | `POST /admin/ai-characters/{characterId}/community-posts` | `POST /creator-community` | 등록 이관 | -| `COMMUNITY-POST-04` | `PUT /admin/ai-characters/{characterId}/community-posts/{postId}` | `PUT /creator-community` | 수정 이관 | -| `COMMUNITY-POST-05` | `DELETE /admin/ai-characters/{characterId}/community-posts/{postId}` | 없음 | 명시 삭제 API 신설 | -| `COMMUNITY-POST-06` | `PUT /admin/ai-characters/{characterId}/community-posts/{postId}/fixed` | `PUT /creator-community/fixed` | 고정 상태 변경 이관 | -| `COMMUNITY-COMMENT-01` | `GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `GET /creator-community/{id}/comment` | 루트 댓글 목록 이관 | -| `COMMUNITY-COMMENT-02` | `GET /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | `GET /creator-community/comment/{id}` | 답글 목록 이관 | -| `COMMUNITY-COMMENT-03` | `POST /admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `POST /creator-community/comment` | 작성 이관, writer는 AI creator로 고정 | -| `COMMUNITY-COMMENT-04` | `PUT /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | `PUT /creator-community/comment` | 수정 이관, AI 작성자만 허용 | -| `COMMUNITY-COMMENT-05` | `DELETE /admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | `PUT /creator-community/comment` | 명시 DELETE 없음, 논리 삭제 동작만 v2로 분리 | -| `FAN-TALK-01` | `GET /admin/ai-characters/{characterId}/fan-talks` | `GET /api/v2/creator-channels/{creatorId}/fan-talks`, `GET /explorer/profile/{id}/cheers` | 기존 v2 query와 legacy FanTalk 조회를 관리자 projection으로 확장 | -| `FAN-TALK-02` | `POST /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | `POST /explorer/profile/cheers` | 부분 대응, AI 답글 생성 정책을 v2에서 분리 | -| `FAN-TALK-03` | `PUT /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | `PUT /explorer/profile/cheers` | 부분 대응, AI 답글 수정만 허용 | -| `FAN-TALK-04` | `DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | `PUT /explorer/profile/cheers` | 명시 DELETE 없음, AI 답글 논리 삭제를 v2로 분리 | -| `FAN-TALK-05` | `DELETE /admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` | `PUT /explorer/profile/cheers` | 명시 DELETE 없음, 루트 moderation을 v2로 분리 | -| `NOTICE-01` | `GET /admin/ai-characters/{characterId}/channel-notice` | `GET /explorer/profile/{id}/detail` | 부분 대응, 공지 전용 조회 API 신설 | -| `NOTICE-02` | `PUT /admin/ai-characters/{characterId}/channel-notice` | `POST /explorer/profile/notice` | 공지 저장 이관, 신규 API는 PUT upsert | -| `CREATOR-TAG-01` | `GET /admin/ai-characters/metadata/creator-tags` | `GET /member/tag` | 기준정보 조회 이관 | -| `CHANNEL-PROFILE-01` | `GET /admin/ai-characters/{characterId}/channel-profile` | `GET /member/info`, `GET /explorer/profile/{id}/detail` | 부분 대응, 관리자 설정 projection 신설 | -| `CHANNEL-PROFILE-02` | `PUT /admin/ai-characters/{characterId}/channel-profile` | `PUT /member` | 채널 SNS·태그·후원 랭킹 설정 이관 | - -### 29.7 Existing API Kept Outside New Operation Count - -| Existing API | 신규 Operation 포함 여부 | 처리 | -|---|---:|---| -| `GET /menu` | No | v2 AI 캐릭터 관리자는 호출하지 않음, 메뉴는 클라이언트 정적 설정 | -| `PUT /audio-content/upload-complete` | No | 기존 AWS worker callback 계약 유지, v2 콘텐츠도 같은 row·S3 metadata 계약으로 처리 | -| `GET /api/chat/original/**` | No | 일반 사용자용 원작 조회 계약 유지 | diff --git a/docs/20260724_AI캐릭터_관리자_API/api-contract.md b/docs/20260724_AI캐릭터_관리자_API/api-contract.md new file mode 100644 index 00000000..31aebd53 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/api-contract.md @@ -0,0 +1,296 @@ +# AI 캐릭터 관리자 API Contract + +## 1. 문서 목적 + +클라이언트 개발과 서버 계약 테스트가 같은 스키마를 사용하도록 AI 캐릭터 관리자 API의 전체 request/response를 +OpenAPI 3.1 JSON으로 고정한다. + +- 정식 계약: `api-contract.openapi.json` +- endpoint: 37개 +- 현재 route 구현: 37개 +- 현재 계약과 일치하는 구현 완료: 37개 +- 계약 정합화 필요: 0개 +- 구현 예정: 0개 +- 공통 envelope: `ApiResponse` +- 인증: JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 동시 충족 + +`x-implementation-status`의 의미는 다음과 같다. + +- `implemented`: route가 구현되어 있고 확정 계약과 후속 리뷰 Gate를 통과했다. +- `alignment-required`: route는 구현되어 있으나 승인된 최신 계약에 맞춘 request/response 정합화가 필요하다. +- `planned`: 계약과 구현 Task는 확정됐으나 route가 아직 구현되지 않았다. + +## 2. 계약 결정 + +1. 신규 endpoint와 `characterId` 기반 관리자 target 경계는 유지한다. +2. JSON 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태는 레거시 API를 유지한다. +3. 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`만 request body에서 중복 + 제거한다. +4. 레거시 mutation이 `ApiResponse.ok(null)`이면 신규 endpoint도 `data: null`을 반환한다. +5. 오디오 콘텐츠 생성은 레거시 `CreateAudioContentResponse(contentId)`를 반환한다. +6. FanTalk 답변 작성만 신규 계획의 축약 응답 + `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. +7. FanTalk 목록은 공개 v2 `CreatorChannelFanTalkTabResponse`의 필드 형태를 유지하는 관리자 전용 endpoint로 추가한다. +8. 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 response shape가 달라 별도 endpoint로 분리한다. +9. 시리즈 상세 `data`는 배열 wrapper 없이 시리즈 목록 `items`의 단일 객체와 동일한 필드·타입을 사용한다. +10. 오디오 콘텐츠와 커뮤니티 게시글 댓글의 원댓글/답글 조회는 레거시 응답을 유지하고, 작성은 target AI 캐릭터 + 명의로 수행한다. +11. 댓글 수정은 target AI 캐릭터가 작성한 댓글/답글만 허용한다. 삭제는 target 소유 리소스에 달린 댓글/답글이면 + 작성자와 관계없이 해당 row만 soft delete하며, 이미 비활성인 row의 삭제는 성공 no-op이다. +12. 댓글 생성의 optional `parentId`가 없으면 원댓글, 있으면 같은 리소스의 활성 원댓글에 대한 답글이다. +13. 팬이 작성한 FanTalk 원글 삭제는 target 채널의 root만 soft delete하고 이미 비활성이면 성공 no-op이며 연결된 creator reply row는 유지한다. +14. FanTalk 답변 수정은 레거시 `PutWriteCheersRequest`에서 path `replyId`로 이동한 `cheersId`만 제거하고 + optional/nullable `content`, `isActive`, 빈 객체 no-op과 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. +15. 캐릭터에 직접 달리는 레거시 댓글 삭제 API는 v2 전환 후 사용하지 않으므로 계약과 구현 범위에서 제외한다. +16. 캐릭터 등록용 원작 검색과 시리즈 장르 목록은 기존 관리자 조회 규칙과 전체 response 필드를 재사용한다. +17. AI 캐릭터 관리자 오디오·커뮤니티 API는 `timezone` query/body를 받지 않는다. 오디오 생성의 nullable + `releaseDate`는 클라이언트가 ISO-8601 UTC(`Z`)로 변환해 보내며, 오디오 상세 `releaseDate`와 오디오·커뮤니티 + 댓글 `date`도 기존 필드명과 null/노출 조건을 유지한 채 ISO-8601 UTC(`Z`)로 반환한다. + +## 3. endpoint와 레거시 근거 + +| 상태 | Method | Endpoint | request 근거 | response `data` 근거 | +|---|---|---|---|---| +| 구현 완료 | GET | `/api/v2/admin/ai-characters` | `searchTerm?`, `page`, `size` | `ChatCharacterListPageResponse` / `ChatCharacterSearchListPageResponse` | +| 구현 완료 | POST | `/api/v2/admin/ai-characters` | `ChatCharacterRegisterRequest`, 필수 `image` | `null` | +| 구현 완료 | GET | `/api/v2/admin/ai-characters/original-works/search` | 필수 `searchTerm` | `List` | +| 구현 완료 | 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 해석 + +- 객체의 `required` 배열에 포함된 필드는 JSON key가 필수다. +- `required`에 없으면 optional이며 key를 생략할 수 있다. +- `type: ["string", "null"]`, 다른 union의 `type: "null"`은 명시적 `null`을 허용한다. +- response DTO의 nullable 생성자 필드는 key는 존재하고 값이 `null`일 수 있으므로 `required`와 nullable을 함께 사용한다. +- request의 optional nullable 필드는 key 생략과 명시적 `null`을 모두 허용한다. +- request는 `additionalProperties: false`이므로 정의되지 않은 이름은 계약 위반이다. +- 모든 DB ID는 JSON integer, OpenAPI `int64`다. +- 승인된 UTC 예외인 오디오 생성·상세 `releaseDate`, 오디오·커뮤니티 댓글 `date`, FanTalk `createdAtUtc`는 + OpenAPI `date-time`이다. 그 밖의 레거시 날짜 문자열은 기존 format을 유지한다. +- enum은 대소문자를 구분하며 Kotlin enum 이름을 그대로 사용한다. +- `Accept-Language`는 `ko`, `en`, `ja` 외 문자열도 전송할 수 있고 서버가 KO로 fallback하므로 enum으로 제한하지 않는다. + +### multipart + +multipart API의 `request` part는 schema상 JSON 객체다. + +- part name: `request` +- part Content-Type: `application/json` +- Character 생성: `image`, `request` 필수 +- Character 수정: `image` optional, `request` 필수 +- AudioContent 생성: `contentFile`, `coverImage`, `request` 필수 +- AudioContent 수정: `coverImage` optional, `request` 필수 +- Series 생성: `image`, `request` 필수 +- Series 수정: `image` optional, `request` 필수 +- Community 생성: `audioFile`, `postImage` optional, `request` 필수 +- Community 수정/고정: `postImage` optional, `request` 필수 + +## 5. domain별 전체 필드 근거 + +### Character + +- request: + `ChatCharacterRegisterRequest`, `ChatCharacterUpdateRequest`, + `ChatCharacterRelationshipRequest`, `ChatCharacterPersonalityRequest`, + `ChatCharacterBackgroundRequest`, `ChatCharacterMemoryRequest` +- response: + `ChatCharacterListResponse`, `ChatCharacterListPageResponse`, + `ChatCharacterSearchListPageResponse`, `ChatCharacterDetailResponse`, + `RelationshipResponse`, `PersonalityResponse`, `BackgroundResponse`, + `MemoryResponse`, `OriginalWorkBriefResponse`, `OriginalWorkResponse` +- 목록 response는 `totalCount`, `content`이며 `items/page/size/hasNext`로 바꾸지 않는다. +- 상세에는 `systemPrompt`, 캐릭터 속성 배열과 `originalWork`를 포함한다. +- 생성과 수정의 성공 `data`는 상세가 아니라 `null`이다. +- 캐릭터 등록용 원작 검색은 필수 `searchTerm`으로 제목·콘텐츠 타입·카테고리를 부분 검색하고, soft delete 원작을 + 제외한 `OriginalWorkResponse` 전체 필드의 직접 배열을 반환한다. 별도 pagination은 두지 않는다. +- 수정은 `isActive=false`와 다른 optional field의 동시 입력도 레거시 request처럼 허용한다. 이 경우 레거시 service 의미대로 + 비활성화만 반영하고 나머지 JSON field는 적용하지 않는다. + +### AudioContent + +- request: + `AudioContentCreateRequest`, `UpdateCreatorAdminContentRequest`, + `RegisterCommentRequest`, `ModifyCommentRequest` +- response: + `GetAudioContentThemeResponse`, `GetCreatorAdminContentListResponse`, + `GetCreatorAdminContentListItem`, `CreateAudioContentResponse`, + `GetAudioContentDetailResponse`, `OtherContentResponse`, + `AudioContentCreator`, `ContentBuyer`, + `GetAudioContentCommentListItem`, `TranslatedContent` +- `detail`, `releaseDate`, `contentFile`, `id/theme/image`, + `audioContentId`, `contentUrl`, `tags`를 레거시 이름 그대로 사용한다. +- 현재 v2 전용 `description`, `releaseDateUtc`, `audioSignedUrl`, `status`, + `seriesIds`, `themeName`, `imageUrl` 별칭은 정식 계약에 포함하지 않는다. +- 생성 request는 `timezone`을 받지 않으며 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받는다. 로컬 시각과 + timezone을 함께 받는 레거시 생성 형식은 신규 관리자 endpoint에서 지원하지 않는다. +- 상세 response의 nullable `releaseDate`는 기존 null/노출 조건을 유지하고, 값이 있으면 ISO-8601 UTC(`Z`)로 반환한다. +- 댓글/답글 목록은 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고, 각 `date`를 + ISO-8601 UTC(`Z`)로 반환한다. +- 댓글 생성은 target AI 캐릭터 명의로 수행한다. `parentId`는 optional/nullable, `isSecret` 기본값은 `false`, + `languageCode`는 optional/nullable이다. +- 댓글 수정 request는 `comment`만 받으며 target AI 캐릭터가 작성한 활성 댓글/답글만 수정한다. +- 삭제는 target 소유 활성 오디오 콘텐츠의 댓글/답글 row 하나만 `isActive=false`로 변경한다. 하위 답글을 + cascade 삭제하지 않으며 mutation 성공 `data`는 `null`이다. + +### Series + +- request: + `CreateSeriesRequest`, `ModifySeriesRequest`, + `AddingContentToTheSeriesRequest`, `RemoveContentToTheSeriesRequest`, + `UpdateOrdersRequest` +- response: + `GetCreatorAdminContentSeriesListResponse`, + `GetCreatorAdminContentSeriesContentResponse`, + `SearchContentNotInSeriesResponse`, `GetSeriesGenreListResponse` +- `publishedDaysOfWeek` enum은 `SUN..SAT`, `RANDOM`이며 state는 + `PROCEEDING`, `SUSPEND`, `COMPLETE`다. +- 상세 `data`는 목록 `items`의 단일 객체와 동일하게 + `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`, + `state`, `isActive`, `writer`, `studio`를 반환한다. 별도 `genre`, `keywords`는 반환하지 않는다. +- 등록용 장르 목록은 활성 장르를 `orders` 오름차순으로 조회하고 `id`, `genre`, `isAdult`의 직접 배열을 반환한다. +- 연결 request는 `contentIdList`, 순서 request는 `ids`다. + +### Community + +- request: + `CreateCommunityPostRequest`, `ModifyCommunityPostRequest`, + `UpdateCommunityPostFixedRequest`, `CreateCommunityPostCommentRequest`, + `ModifyCommunityPostCommentRequest` +- response: + `GetCommunityPostListResponse`, `GetCommunityPostCommentListResponse`, + `GetCommunityPostCommentListItem` +- 2026-07-29 사용자 확정에 따라 목록은 레거시 직접 배열의 예외다. `timezone` query를 제거하고 `data`를 + `AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`로 반환한다. +- `totalCount`는 target creatorMember 소유 active 게시글 전체 개수이고, `items`는 기존 + `GetCommunityPostListResponse` 필드·고정 우선 정렬을 유지한다. `hasNext`는 현재 page 뒤에 active owner 게시글이 + 더 있는지를 나타낸다. +- 생성 part 이름은 `postImage`, `audioFile`이다. +- 수정 endpoint는 기존 본문 수정과 고정/해제를 합치므로 ID를 제외한 + `content`, `isCommentAvailable`, `isAdult`, `isActive`, `isFixed`를 받는다. +- 레거시에 없는 수정 `price`, `audioFile`은 포함하지 않는다. +- 댓글/답글 목록은 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고, 각 `date`를 + ISO-8601 UTC(`Z`)로 반환한다. +- 댓글 생성은 target AI 캐릭터 명의로 수행하고 optional/nullable `parentId`와 기본값 `false`인 `isSecret`을 + 받는다. +- 댓글 수정 request는 `comment`만 받으며 target AI 캐릭터가 작성한 활성 댓글/답글만 수정한다. +- 삭제는 target 소유 활성 게시글의 댓글/답글 row 하나만 `isActive=false`로 변경한다. 하위 답글을 cascade + 삭제하지 않으며 mutation 성공 `data`는 `null`이다. + +### FanTalk + +- 목록 response: + `CreatorChannelFanTalkTabResponse`, `CreatorChannelFanTalkResponse`, + `CreatorChannelFanTalkReplyResponse` +- 공개 v2 endpoint를 직접 사용하지 않는다. 공개 v2는 `creatorId`, viewer 인증과 block filter, 다른 CORS 경계를 사용하기 + 때문이다. +- 신규 관리자 목록은 `characterId`로 creator를 해석하고 공개 v2 response field만 유지한다. +- 답변 작성 request는 path로 이동한 `creatorId`, `parentId`를 제외하고 `content`만 받는다. +- 답변 작성 response는 사용자 승인 예외인 신규 축약 형태다. +- 답변 수정 request는 레거시 `PutWriteCheersRequest`에서 path로 이동한 `cheersId`만 제외하고 optional/nullable + `content`, `isActive`를 받는다. 두 필드를 함께 입력할 수 있고, 모두 생략하거나 `null`이면 성공 no-op이다. +- 답변 수정 대상은 target AI가 writer이자 creator이고 path의 활성 root에 직접 연결된 reply로 한정한다. reply 자체가 + 비활성이어도 `isActive=true`로 재활성화할 수 있으며, 다른 target/root·팬 작성 row·nested mismatch는 400이다. +- 답변 수정은 non-null field만 반영하고 기존 `languageCode`를 변경하거나 언어 감지·이벤트를 발생시키지 않는다. +- 답변 수정 response `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태다. `fanTalkId`는 수정한 reply row ID이고 + `creatorReplies`는 빈 배열이다. +- 삭제는 target 채널에 속한 팬 작성 원글만 허용하고 해당 root row만 `isActive=false`로 변경한다. 연결된 creator reply + row는 유지되며 목록에서 root가 제외되므로 함께 노출되지 않는다. +- 이미 비활성인 원글 삭제는 성공 no-op이고 성공 `data`는 `null`이다. + +## 6. 공통 응답과 오류 + +일반 조회 성공: + +```json +{ + "success": true, + "message": null, + "data": {}, + "errorProperty": null +} +``` + +레거시 mutation 성공: + +```json +{ + "success": true, + "message": null, + "data": null, + "errorProperty": null +} +``` + +오류: + +```json +{ + "success": false, + "message": "현지화된 오류 메시지", + "data": null, + "errorProperty": null +} +``` + +주요 status는 400, 401, 403, 404, 405, 406, 415, 500이다. 405는 `Allow`, 415는 `Accept` header를 유지한다. +Spring CORS 계층이 차단한 미허용 Origin의 403 body는 이 envelope 계약 대상이 아니다. + +## 7. 기계 검증과 클라이언트 생성 + +```bash +jq empty docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json + +npx --yes @redocly/cli lint --skip-rule info-license \ + docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json + +npx --yes @openapitools/openapi-generator-cli validate \ + -i docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json + +npx --yes @openapitools/openapi-generator-cli generate \ + -i docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json \ + -g typescript-fetch \ + -o /tmp/ai-character-admin-typescript-client + +tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM \ + /tmp/ai-character-admin-typescript-client/index.ts +``` + +`info-license`만 제외하는 이유는 저장소 라이선스 값을 추측해 계약에 추가하지 않기 위해서다. +생성 클라이언트에는 계약의 37개 operation이 모두 포함된다. 37개 모두 실제 route가 구현되어 있고 `implemented` 상태다. + +OpenAPI의 `/` server URL은 현재 host를 의미하지만 OpenAPI Generator 7.24.0의 `typescript-fetch` runtime 기본값은 +`http://localhost`다. 실제 클라이언트는 배포 환경의 API origin을 `new Configuration({ basePath: "..." })`로 반드시 +지정한다. diff --git a/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json b/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json new file mode 100644 index 00000000..dcd25519 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json @@ -0,0 +1,1695 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "AI 캐릭터 관리자 API", + "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 작성·수정·팬 원글 삭제"} + ], + "paths": { + "/api/v2/admin/ai-characters": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["Character"], + "summary": "AI 캐릭터 목록/검색", + "operationId": "listAiCharacters", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminChatCharacterController.getCharacterList", "AdminChatCharacterController.searchCharacters", "ChatCharacterListPageResponse", "ChatCharacterSearchListPageResponse"], + "parameters": [ + {"name": "searchTerm", "in": "query", "required": false, "description": "생략하면 활성 목록, 지정하면 레거시 검색을 수행한다.", "schema": {"type": "string"}}, + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CharacterListSuccess"}, + "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": ["Character"], + "summary": "AI 캐릭터 생성", + "operationId": "createAiCharacter", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminChatCharacterController.registerCharacter", "ChatCharacterRegisterRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CharacterCreateMultipart"}, + "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/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", + "x-legacy-sources": ["AdminChatCharacterController.getCharacterDetail", "ChatCharacterDetailResponse"], + "responses": { + "200": {"$ref": "#/components/responses/CharacterDetailSuccess"}, + "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"} + } + }, + "put": { + "tags": ["Character"], + "summary": "AI 캐릭터 수정/soft delete", + "description": "레거시 ChatCharacterUpdateRequest의 id만 path characterId로 이동한다.", + "operationId": "updateAiCharacter", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminChatCharacterController.updateCharacter", "ChatCharacterUpdateRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CharacterUpdateMultipart"}, + "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/audio-content-themes": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["AudioContent"], + "summary": "활성 오디오 콘텐츠 테마 목록", + "operationId": "listAiCharacterAudioContentThemes", + "x-implementation-status": "implemented", + "x-legacy-sources": ["GetAudioContentThemeResponse"], + "responses": { + "200": {"$ref": "#/components/responses/AudioThemeListSuccess"}, + "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": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 목록/검색", + "operationId": "listAiCharacterAudioContents", + "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"}}, + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentListSuccess"}, + "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": "오디오 콘텐츠 생성", + "operationId": "createAiCharacterAudioContent", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentController.createAudioContent", "CreateAudioContentRequest", "CreateAudioContentResponse"], + "requestBody": {"$ref": "#/components/requestBodies/AudioContentCreateMultipart"}, + "responses": { + "200": {"$ref": "#/components/responses/AudioContentCreateSuccess"}, + "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}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/ContentId"}], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 상세", + "operationId": "getAiCharacterAudioContent", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentController.getDetail", "GetAudioContentDetailResponse"], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentDetailSuccess"}, + "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"} + } + }, + "put": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 수정/soft delete", + "description": "레거시 UpdateCreatorAdminContentRequest의 id만 path contentId로 이동한다.", + "operationId": "updateAiCharacterAudioContent", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentController.modifyAudioContent", "UpdateCreatorAdminContentRequest"], + "requestBody": {"$ref": "#/components/requestBodies/AudioContentUpdateMultipart"}, + "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": { + "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": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListResponse"], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "responses": { + "200": {"$ref": "#/components/responses/SeriesListSuccess"}, + "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": ["Series"], + "summary": "시리즈 생성", + "operationId": "createAiCharacterSeries", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.createSeries", "CreateSeriesRequest"], + "requestBody": {"$ref": "#/components/requestBodies/SeriesCreateMultipart"}, + "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}/series/orders": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "put": { + "tags": ["Series"], + "summary": "시리즈 순서 변경", + "operationId": "reorderAiCharacterSeries", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.updateSeriesOrders", "UpdateOrdersRequest"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesOrderUpdateRequest"}}}}, + "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}/series/{seriesId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 상세", + "description": "성공 data는 시리즈 목록 items의 단일 항목과 동일한 schema를 사용한다.", + "operationId": "getAiCharacterSeries", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListItem"], + "responses": { + "200": {"$ref": "#/components/responses/SeriesDetailSuccess"}, + "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"} + } + }, + "put": { + "tags": ["Series"], + "summary": "시리즈 수정/soft delete", + "description": "레거시 ModifySeriesRequest의 seriesId만 path로 이동한다.", + "operationId": "updateAiCharacterSeries", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.modifySeries", "ModifySeriesRequest"], + "requestBody": {"$ref": "#/components/requestBodies/SeriesUpdateMultipart"}, + "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}/series/{seriesId}/contents": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 연결 콘텐츠 목록", + "operationId": "listAiCharacterSeriesContents", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesContent", "GetCreatorAdminContentSeriesContentResponse"], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "responses": { + "200": {"$ref": "#/components/responses/SeriesContentListSuccess"}, + "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": ["Series"], + "summary": "시리즈 콘텐츠 연결", + "description": "레거시 AddingContentToTheSeriesRequest의 seriesId만 path로 이동한다.", + "operationId": "addAiCharacterSeriesContents", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.addingContentToTheSeries", "AddingContentToTheSeriesRequest"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentAddRequest"}}}}, + "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}/series/{seriesId}/contents/search": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 미연결 콘텐츠 검색", + "operationId": "searchAiCharacterContentsNotInSeries", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.searchContentNotInSeries", "SearchContentNotInSeriesResponse"], + "parameters": [{"name": "search_word", "in": "query", "required": true, "schema": {"type": "string"}}], + "responses": { + "200": {"$ref": "#/components/responses/SeriesContentSearchSuccess"}, + "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/{seriesId}/contents/{contentId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/SeriesId"}, {"$ref": "#/components/parameters/ContentId"}], + "delete": { + "tags": ["Series"], + "summary": "시리즈 콘텐츠 연결 해제", + "description": "레거시 RemoveContentToTheSeriesRequest의 seriesId와 contentId를 path로 이동해 body가 없다.", + "operationId": "removeAiCharacterSeriesContent", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.removeContentInTheSeries", "RemoveContentToTheSeriesRequest"], + "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": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], + "get": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 목록", + "operationId": "listAiCharacterCommunityPosts", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.getCommunityPostList", "GetCommunityPostListResponse"], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "responses": { + "200": {"$ref": "#/components/responses/CommunityPostListSuccess"}, + "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": "커뮤니티 게시글 등록", + "operationId": "createAiCharacterCommunityPost", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.createCommunityPost", "CreateCommunityPostRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CommunityPostCreateMultipart"}, + "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}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/PostId"}], + "put": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 수정/고정/soft delete", + "description": "ModifyCommunityPostRequest의 creatorCommunityId와 UpdateCommunityPostFixedRequest의 postId를 path로 이동하고 나머지 레거시 필드를 하나의 request에 합친다.", + "operationId": "updateAiCharacterCommunityPost", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.modifyCommunityPost", "CreatorCommunityController.updateCommunityPostFixed", "ModifyCommunityPostRequest", "UpdateCommunityPostFixedRequest"], + "requestBody": {"$ref": "#/components/requestBodies/CommunityPostUpdateMultipart"}, + "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": { + "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": { + "tags": ["FanTalk"], + "summary": "FanTalk 관리자 목록", + "description": "공개 v2 응답 필드 형태를 유지하되 관리자 target/ownership 정책을 사용하고 viewer/block 필터를 적용하지 않는다.", + "operationId": "listAiCharacterFanTalks", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorChannelFanTalkController.getFanTalkTab", "CreatorChannelFanTalkTabResponse"], + "parameters": [{"$ref": "#/components/parameters/FanTalkPage"}, {"$ref": "#/components/parameters/FanTalkSize"}], + "responses": { + "200": {"$ref": "#/components/responses/FanTalkListSuccess"}, + "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}": { + "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": { + "tags": ["FanTalk"], + "summary": "FanTalk 답변 작성", + "description": "사용자가 승인한 예외로 신규 관리자 축약 응답을 반환한다.", + "operationId": "createAiCharacterFanTalkReply", + "x-implementation-status": "implemented", + "x-legacy-sources": ["ExplorerController.writeCheers", "PostWriteCheersRequest", "CreatorChannelFanTalkReplyResponse"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyCreateRequest"}}}}, + "responses": { + "200": {"$ref": "#/components/responses/FanTalkReplySuccess"}, + "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}/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": { + "securitySchemes": { + "bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + }, + "parameters": { + "AcceptLanguage": { + "name": "Accept-Language", + "in": "header", + "required": false, + "description": "ko, en, ja는 해당 언어를 사용한다. header가 없거나 그 밖의 문자열이면 KO로 fallback한다.", + "schema": {"type": "string", "default": "ko", "examples": ["ko", "en", "ja"]} + }, + "CharacterId": {"name": "characterId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "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}} + }, + "requestBodies": { + "CharacterCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CharacterCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "CharacterUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CharacterUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "AudioContentCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/AudioContentCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "AudioContentUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/AudioContentUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "SeriesCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/SeriesCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "SeriesUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/SeriesUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "CommunityPostCreateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CommunityPostCreateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + }, + "CommunityPostUpdateMultipart": { + "required": true, + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CommunityPostUpdateMultipart"}, "encoding": {"request": {"contentType": "application/json"}}}} + } + }, + "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"}}}}, + "Forbidden": {"description": "인가 실패", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "NotFound": {"description": "미매핑 경로", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "MethodNotAllowed": {"description": "지원하지 않는 method", "headers": {"Allow": {"schema": {"type": "string"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "NotAcceptable": {"description": "지원하지 않는 응답 media type", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "UnsupportedMediaType": {"description": "지원하지 않는 요청 media type", "headers": {"Accept": {"schema": {"type": "string"}}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, + "InternalServerError": {"description": "서버 오류", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}} + }, + "schemas": { + "NullableString": {"type": ["string", "null"]}, + "NullableInt32": {"type": ["integer", "null"], "format": "int32"}, + "NullableInt64": {"type": ["integer", "null"], "format": "int64"}, + "NullableBoolean": {"type": ["boolean", "null"]}, + "BinaryFile": {"type": "string", "format": "binary"}, + "PurchaseOption": {"type": "string", "enum": ["BOTH", "BUY_ONLY", "RENT_ONLY"]}, + "OrderType": {"type": "string", "enum": ["RENTAL", "KEEP"]}, + "CharacterType": {"type": "string", "enum": ["Clone", "Character"]}, + "SeriesPublishedDaysOfWeek": {"type": "string", "enum": ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "RANDOM"]}, + "SeriesState": {"type": "string", "enum": ["PROCEEDING", "SUSPEND", "COMPLETE"]}, + + "ApiSuccessBase": { + "type": "object", + "required": ["success"], + "properties": { + "success": {"type": "boolean", "const": true} + } + }, + "ApiErrorResponse": { + "type": "object", + "required": ["success", "message", "data", "errorProperty"], + "properties": { + "success": {"type": "boolean", "const": false}, + "message": {"type": "string"}, + "data": {"type": ["object", "null"], "const": null}, + "errorProperty": {"type": ["string", "null"]} + } + }, + "NullSuccessResponse": { + "allOf": [ + {"$ref": "#/components/schemas/ApiSuccessBase"}, + { + "type": "object", + "required": ["message", "data", "errorProperty"], + "properties": { + "message": {"type": ["string", "null"]}, + "data": {"type": ["object", "null"], "const": null}, + "errorProperty": {"type": ["string", "null"], "const": null} + } + } + ] + }, + + "CharacterRelationship": { + "type": "object", + "additionalProperties": false, + "required": ["personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"], + "properties": { + "personName": {"type": "string"}, + "relationshipName": {"type": "string"}, + "description": {"type": "string"}, + "importance": {"type": "integer", "format": "int32"}, + "relationshipType": {"type": "string"}, + "currentStatus": {"type": "string"} + } + }, + "CharacterPersonality": { + "type": "object", + "additionalProperties": false, + "required": ["trait", "description"], + "properties": {"trait": {"type": "string"}, "description": {"type": "string"}} + }, + "CharacterBackground": { + "type": "object", + "additionalProperties": false, + "required": ["topic", "description"], + "properties": {"topic": {"type": "string"}, "description": {"type": "string"}} + }, + "CharacterMemory": { + "type": "object", + "additionalProperties": false, + "required": ["title", "content", "emotion"], + "properties": {"title": {"type": "string"}, "content": {"type": "string"}, "emotion": {"type": "string"}} + }, + "OriginalWorkBrief": { + "type": "object", + "additionalProperties": false, + "required": ["id", "imageUrl", "title"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "title": {"type": "string"} + } + }, + "CharacterCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["name", "systemPrompt", "description"], + "properties": { + "name": {"type": "string"}, + "systemPrompt": {"type": "string"}, + "description": {"type": "string"}, + "age": {"$ref": "#/components/schemas/NullableString"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "appearance": {"$ref": "#/components/schemas/NullableString"}, + "region": {"type": "string", "default": "KR"}, + "originalTitle": {"$ref": "#/components/schemas/NullableString"}, + "originalLink": {"$ref": "#/components/schemas/NullableString"}, + "originalWorkId": {"$ref": "#/components/schemas/NullableInt64"}, + "characterType": {"$ref": "#/components/schemas/NullableString"}, + "tags": {"type": "array", "items": {"type": "string"}, "default": []}, + "hobbies": {"type": "array", "items": {"type": "string"}, "default": []}, + "values": {"type": "array", "items": {"type": "string"}, "default": []}, + "goals": {"type": "array", "items": {"type": "string"}, "default": []}, + "relationships": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterRelationship"}, "default": []}, + "personalities": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterPersonality"}, "default": []}, + "backgrounds": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterBackground"}, "default": []}, + "memories": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterMemory"}, "default": []} + } + }, + "CharacterUpdateRequest": { + "type": "object", + "additionalProperties": false, + "description": "ChatCharacterUpdateRequest에서 path로 이동한 id만 제거한 형태다. isActive=false와 다른 필드를 함께 보내는 것도 레거시 request 계약상 허용되며, 이 경우 레거시 service는 비활성화만 반영하고 나머지 JSON 필드는 적용하지 않는다.", + "properties": { + "name": {"$ref": "#/components/schemas/NullableString"}, + "systemPrompt": {"$ref": "#/components/schemas/NullableString"}, + "description": {"$ref": "#/components/schemas/NullableString"}, + "age": {"$ref": "#/components/schemas/NullableString"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "appearance": {"$ref": "#/components/schemas/NullableString"}, + "originalTitle": {"$ref": "#/components/schemas/NullableString"}, + "originalLink": {"$ref": "#/components/schemas/NullableString"}, + "originalWorkId": {"$ref": "#/components/schemas/NullableInt64"}, + "characterType": {"$ref": "#/components/schemas/NullableString"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "tags": {"type": ["array", "null"], "items": {"type": "string"}}, + "hobbies": {"type": ["array", "null"], "items": {"type": "string"}}, + "values": {"type": ["array", "null"], "items": {"type": "string"}}, + "goals": {"type": ["array", "null"], "items": {"type": "string"}}, + "relationships": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterRelationship"}}, + "personalities": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterPersonality"}}, + "backgrounds": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterBackground"}}, + "memories": {"type": ["array", "null"], "items": {"$ref": "#/components/schemas/CharacterMemory"}} + } + }, + "CharacterCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["image", "request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/CharacterCreateRequest"}} + }, + "CharacterUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/CharacterUpdateRequest"}} + }, + "CharacterListItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "imageUrl", "description", "gender", "age", "mbti", "speechStyle", "speechPattern", "region", "tags", "createdAt", "updatedAt"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "name": {"type": "string"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "description": {"type": "string"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "age": {"$ref": "#/components/schemas/NullableInt32"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "region": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "createdAt": {"type": ["string", "null"], "description": "Asia/Seoul yyyy-MM-dd HH:mm:ss"}, + "updatedAt": {"type": ["string", "null"], "description": "Asia/Seoul yyyy-MM-dd HH:mm:ss"} + } + }, + "CharacterListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "content"], + "properties": { + "totalCount": {"type": "integer", "format": "int64"}, + "content": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterListItem"}} + } + }, + "CharacterDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": ["id", "characterUUID", "name", "imageUrl", "description", "systemPrompt", "characterType", "age", "gender", "mbti", "speechPattern", "speechStyle", "appearance", "region", "isActive", "tags", "hobbies", "values", "goals", "relationships", "personalities", "backgrounds", "memories", "originalWork"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "characterUUID": {"type": "string"}, + "name": {"type": "string"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "description": {"type": "string"}, + "systemPrompt": {"type": "string"}, + "characterType": {"$ref": "#/components/schemas/CharacterType"}, + "age": {"$ref": "#/components/schemas/NullableInt32"}, + "gender": {"$ref": "#/components/schemas/NullableString"}, + "mbti": {"$ref": "#/components/schemas/NullableString"}, + "speechPattern": {"$ref": "#/components/schemas/NullableString"}, + "speechStyle": {"$ref": "#/components/schemas/NullableString"}, + "appearance": {"$ref": "#/components/schemas/NullableString"}, + "region": {"type": "string"}, + "isActive": {"type": "boolean"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "hobbies": {"type": "array", "items": {"type": "string"}}, + "values": {"type": "array", "items": {"type": "string"}}, + "goals": {"type": "array", "items": {"type": "string"}}, + "relationships": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterRelationship"}}, + "personalities": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterPersonality"}}, + "backgrounds": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterBackground"}}, + "memories": {"type": "array", "items": {"$ref": "#/components/schemas/CharacterMemory"}}, + "originalWork": {"oneOf": [{"$ref": "#/components/schemas/OriginalWorkBrief"}, {"type": "null"}]} + } + }, + "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", + "additionalProperties": false, + "required": ["id", "theme", "image"], + "properties": {"id": {"type": "integer", "format": "int64"}, "theme": {"type": "string"}, "image": {"type": "string"}} + }, + "AudioThemeListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentTheme"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "AudioContentCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["title", "detail", "tags", "price"], + "properties": { + "title": {"type": "string"}, + "detail": {"type": "string"}, + "tags": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "purchaseOption": {"$ref": "#/components/schemas/PurchaseOption", "default": "BOTH"}, + "limited": {"$ref": "#/components/schemas/NullableInt32"}, + "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}, + "isOnlyRental": {"type": "boolean", "default": false}, + "isPointAvailable": {"type": "boolean", "default": false}, + "isCommentAvailable": {"type": "boolean", "default": false}, + "isFullDetailVisible": {"type": "boolean", "default": true}, + "previewStartTime": {"type": ["string", "null"], "description": "HH:mm:ss"}, + "previewEndTime": {"type": ["string", "null"], "description": "HH:mm:ss"}, + "languageCode": {"$ref": "#/components/schemas/NullableString"} + } + }, + "AudioContentUpdateRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"$ref": "#/components/schemas/NullableString"}, + "detail": {"$ref": "#/components/schemas/NullableString"}, + "tags": {"$ref": "#/components/schemas/NullableString"}, + "price": {"$ref": "#/components/schemas/NullableInt32"}, + "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "isPointAvailable": {"$ref": "#/components/schemas/NullableBoolean"}, + "isCommentAvailable": {"$ref": "#/components/schemas/NullableBoolean"} + } + }, + "AudioContentCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["contentFile", "coverImage", "request"], + "properties": { + "contentFile": {"$ref": "#/components/schemas/BinaryFile"}, + "coverImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/AudioContentCreateRequest"} + } + }, + "AudioContentUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": { + "coverImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/AudioContentUpdateRequest"} + } + }, + "AudioContentListItem": { + "type": "object", + "additionalProperties": false, + "required": ["audioContentId", "title", "detail", "coverImageUrl", "creatorNickname", "theme", "price", "totalContentCount", "remainingContentCount", "isAdult", "isPointAvailable", "isCommentAvailable", "remainingTime", "contentUrl", "date", "releaseDate", "tags"], + "properties": { + "audioContentId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "detail": {"type": "string"}, + "coverImageUrl": {"type": "string"}, + "creatorNickname": {"type": "string"}, + "theme": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "totalContentCount": {"$ref": "#/components/schemas/NullableInt32"}, + "remainingContentCount": {"$ref": "#/components/schemas/NullableInt32"}, + "isAdult": {"type": "boolean"}, + "isPointAvailable": {"type": "boolean"}, + "isCommentAvailable": {"type": "boolean"}, + "remainingTime": {"type": "string"}, + "contentUrl": {"type": "string", "description": "signed URL"}, + "date": {"type": "string"}, + "releaseDate": {"$ref": "#/components/schemas/NullableString"}, + "tags": {"type": "string"} + } + }, + "AudioContentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentListItem"}}} + }, + "AudioContentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "AudioContentCreateResponse": { + "type": "object", + "additionalProperties": false, + "required": ["contentId"], + "properties": {"contentId": {"type": "integer", "format": "int64"}} + }, + "AudioContentCreateApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentCreateResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "OtherContentResponse": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "title", "coverUrl"], + "properties": {"contentId": {"type": "integer", "format": "int64"}, "title": {"type": "string"}, "coverUrl": {"type": "string"}} + }, + "AudioContentCreator": { + "type": "object", + "additionalProperties": false, + "required": ["creatorId", "nickname", "profileImageUrl", "isFollowing", "isFollow", "isNotify"], + "properties": { + "creatorId": {"type": "integer", "format": "int64"}, + "nickname": {"type": "string"}, + "profileImageUrl": {"type": "string"}, + "isFollowing": {"type": "boolean"}, + "isFollow": {"type": "boolean"}, + "isNotify": {"type": "boolean"} + } + }, + "ContentBuyer": { + "type": "object", + "additionalProperties": false, + "required": ["nickname", "profileImageUrl"], + "properties": {"nickname": {"type": "string"}, "profileImageUrl": {"type": "string"}} + }, + "AudioContentComment": { + "type": "object", + "additionalProperties": false, + "required": ["id", "writerId", "nickname", "profileUrl", "comment", "languageCode", "isSecret", "donationCan", "date", "replyCount"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "nickname": {"type": "string"}, + "profileUrl": {"type": "string"}, + "comment": {"type": "string"}, + "languageCode": {"$ref": "#/components/schemas/NullableString"}, + "isSecret": {"type": "boolean"}, + "donationCan": {"type": "integer", "format": "int32"}, + "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, + "required": ["title", "detail", "tags"], + "properties": {"title": {"$ref": "#/components/schemas/NullableString"}, "detail": {"$ref": "#/components/schemas/NullableString"}, "tags": {"$ref": "#/components/schemas/NullableString"}} + }, + "AudioContentDetailResponse": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "title", "detail", "languageCode", "coverImageUrl", "contentUrl", "themeStr", "tag", "price", "duration", "releaseDate", "totalContentCount", "remainingContentCount", "orderSequence", "isActivePreview", "isAdult", "isMosaic", "isOnlyRental", "existOrdered", "purchaseOption", "orderType", "remainingTime", "creatorOtherContentList", "sameThemeOtherContentList", "isCommentAvailable", "isLike", "likeCount", "commentList", "commentCount", "isPin", "isAvailablePin", "creator", "previousContent", "nextContent", "buyerList", "isAvailableUsePoint", "translated"], + "properties": { + "contentId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "detail": {"type": "string"}, + "languageCode": {"$ref": "#/components/schemas/NullableString"}, + "coverImageUrl": {"type": "string"}, + "contentUrl": {"type": "string"}, + "themeStr": {"type": "string"}, + "tag": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "duration": {"type": "string"}, + "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"}, + "isActivePreview": {"type": "boolean"}, + "isAdult": {"type": "boolean"}, + "isMosaic": {"type": "boolean"}, + "isOnlyRental": {"type": "boolean"}, + "existOrdered": {"type": "boolean"}, + "purchaseOption": {"$ref": "#/components/schemas/PurchaseOption"}, + "orderType": {"oneOf": [{"$ref": "#/components/schemas/OrderType"}, {"type": "null"}]}, + "remainingTime": {"$ref": "#/components/schemas/NullableString"}, + "creatorOtherContentList": {"type": "array", "items": {"$ref": "#/components/schemas/OtherContentResponse"}}, + "sameThemeOtherContentList": {"type": "array", "items": {"$ref": "#/components/schemas/OtherContentResponse"}}, + "isCommentAvailable": {"type": "boolean"}, + "isLike": {"type": "boolean"}, + "likeCount": {"type": "integer", "format": "int32"}, + "commentList": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentComment"}}, + "commentCount": {"type": "integer", "format": "int32"}, + "isPin": {"type": "boolean"}, + "isAvailablePin": {"type": "boolean"}, + "creator": {"$ref": "#/components/schemas/AudioContentCreator"}, + "previousContent": {"oneOf": [{"$ref": "#/components/schemas/OtherContentResponse"}, {"type": "null"}]}, + "nextContent": {"oneOf": [{"$ref": "#/components/schemas/OtherContentResponse"}, {"type": "null"}]}, + "buyerList": {"type": "array", "items": {"$ref": "#/components/schemas/ContentBuyer"}}, + "isAvailableUsePoint": {"type": "boolean"}, + "translated": {"oneOf": [{"$ref": "#/components/schemas/TranslatedContent"}, {"type": "null"}]} + } + }, + "AudioContentDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + + "SeriesCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["title", "introduction", "publishedDaysOfWeek", "keyword"], + "properties": { + "title": {"type": "string"}, + "introduction": {"type": "string"}, + "publishedDaysOfWeek": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/components/schemas/SeriesPublishedDaysOfWeek"}}, + "keyword": {"type": "string"}, + "genreId": {"type": "integer", "format": "int64", "default": 0, "description": "0은 binding 기본값이며 domain validation에서 유효하지 않다."}, + "isAdult": {"type": "boolean", "default": false}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesUpdateRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"$ref": "#/components/schemas/NullableString"}, + "introduction": {"$ref": "#/components/schemas/NullableString"}, + "publishedDaysOfWeek": {"type": ["array", "null"], "uniqueItems": true, "items": {"$ref": "#/components/schemas/SeriesPublishedDaysOfWeek"}}, + "genreId": {"$ref": "#/components/schemas/NullableInt64"}, + "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, + "state": {"oneOf": [{"$ref": "#/components/schemas/SeriesState"}, {"type": "null"}]}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["image", "request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/SeriesCreateRequest"}} + }, + "SeriesUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": {"image": {"$ref": "#/components/schemas/BinaryFile"}, "request": {"$ref": "#/components/schemas/SeriesUpdateRequest"}} + }, + "SeriesListItem": { + "type": "object", + "additionalProperties": false, + "required": ["seriesId", "title", "introduction", "coverImageUrl", "publishedDaysOfWeek", "genreId", "isAdult", "state", "isActive", "writer", "studio"], + "properties": { + "seriesId": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "introduction": {"type": "string"}, + "coverImageUrl": {"type": "string"}, + "publishedDaysOfWeek": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesPublishedDaysOfWeek"}}, + "genreId": {"type": "integer", "format": "int64"}, + "isAdult": {"type": "boolean"}, + "state": {"$ref": "#/components/schemas/SeriesState"}, + "isActive": {"type": "boolean"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"} + } + }, + "SeriesListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesListItem"}}} + }, + "SeriesGenreItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "genre", "isAdult"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "genre": {"type": "string"}, + "isAdult": {"type": "boolean"} + } + }, + "SeriesContentListItem": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "coverImage", "title", "isAdult"], + "properties": {"contentId": {"type": "integer", "format": "int64"}, "coverImage": {"type": "string"}, "title": {"type": "string"}, "isAdult": {"type": "boolean"}} + }, + "SeriesContentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesContentListItem"}}} + }, + "SeriesContentSearchItem": { + "type": "object", + "additionalProperties": false, + "required": ["contentId", "title", "coverImage"], + "properties": {"contentId": {"type": "integer", "format": "int64"}, "title": {"type": "string"}, "coverImage": {"type": "string"}} + }, + "SeriesContentAddRequest": { + "type": "object", + "additionalProperties": false, + "required": ["contentIdList"], + "properties": {"contentIdList": {"type": "array", "items": {"type": "integer", "format": "int64"}}} + }, + "SeriesOrderUpdateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["ids"], + "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/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}}}]}, + + "CommunityPostComment": { + "type": "object", + "additionalProperties": false, + "required": ["id", "writerId", "nickname", "profileUrl", "comment", "isSecret", "date", "replyCount"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "nickname": {"type": "string"}, + "profileUrl": {"type": "string"}, + "comment": {"type": "string"}, + "isSecret": {"type": "boolean"}, + "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, + "required": ["postId", "creatorId", "creatorNickname", "creatorProfileUrl", "imageUrl", "audioUrl", "content", "price", "date", "dateUtc", "isCommentAvailable", "isAdult", "isFixed", "isLike", "existOrdered", "likeCount", "commentCount", "firstComment"], + "properties": { + "postId": {"type": "integer", "format": "int64"}, + "creatorId": {"type": "integer", "format": "int64"}, + "creatorNickname": {"type": "string"}, + "creatorProfileUrl": {"type": "string"}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"}, + "audioUrl": {"$ref": "#/components/schemas/NullableString"}, + "content": {"type": "string"}, + "price": {"type": "integer", "format": "int32"}, + "date": {"type": "string"}, + "dateUtc": {"type": "string"}, + "isCommentAvailable": {"type": "boolean"}, + "isAdult": {"type": "boolean"}, + "isFixed": {"type": "boolean"}, + "isLike": {"type": "boolean"}, + "existOrdered": {"type": "boolean"}, + "likeCount": {"type": "integer", "format": "int32"}, + "commentCount": {"type": "integer", "format": "int32"}, + "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, + "required": ["content", "isCommentAvailable", "isAdult"], + "properties": { + "content": {"type": "string"}, + "isCommentAvailable": {"type": "boolean"}, + "isAdult": {"type": "boolean"}, + "price": {"type": "integer", "format": "int32", "default": 0} + } + }, + "CommunityPostUpdateRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": {"$ref": "#/components/schemas/NullableString"}, + "isCommentAvailable": {"$ref": "#/components/schemas/NullableBoolean"}, + "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, + "isFixed": {"type": "boolean", "description": "레거시 UpdateCommunityPostFixedRequest에서 ID만 path로 이동한 필드"} + } + }, + "CommunityPostCreateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": { + "audioFile": {"$ref": "#/components/schemas/BinaryFile"}, + "postImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/CommunityPostCreateRequest"} + } + }, + "CommunityPostUpdateMultipart": { + "type": "object", + "additionalProperties": false, + "required": ["request"], + "properties": { + "postImage": {"$ref": "#/components/schemas/BinaryFile"}, + "request": {"$ref": "#/components/schemas/CommunityPostUpdateRequest"} + } + }, + "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", + "additionalProperties": false, + "required": ["fanTalkId", "writerId", "writerNickname", "writerProfileImageUrl", "content", "createdAtUtc"], + "properties": { + "fanTalkId": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "writerNickname": {"type": "string"}, + "writerProfileImageUrl": {"type": "string"}, + "content": {"type": "string"}, + "createdAtUtc": {"type": "string", "format": "date-time"} + } + }, + "FanTalkListItem": { + "type": "object", + "additionalProperties": false, + "required": ["fanTalkId", "writerId", "writerNickname", "writerProfileImageUrl", "content", "createdAtUtc", "creatorReplies"], + "properties": { + "fanTalkId": {"type": "integer", "format": "int64"}, + "writerId": {"type": "integer", "format": "int64"}, + "writerNickname": {"type": "string"}, + "writerProfileImageUrl": {"type": "string"}, + "content": {"type": "string"}, + "createdAtUtc": {"type": "string", "format": "date-time"}, + "creatorReplies": {"type": "array", "items": {"$ref": "#/components/schemas/FanTalkCreatorReply"}} + } + }, + "FanTalkListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["fanTalkCount", "fanTalks", "page", "size", "hasNext"], + "properties": { + "fanTalkCount": {"type": "integer", "format": "int32"}, + "fanTalks": {"type": "array", "items": {"$ref": "#/components/schemas/FanTalkListItem"}}, + "page": {"type": "integer", "format": "int32"}, + "size": {"type": "integer", "format": "int32"}, + "hasNext": {"type": "boolean"} + } + }, + "FanTalkReplyCreateRequest": { + "type": "object", + "additionalProperties": false, + "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, + "required": ["fanTalkId", "replyId", "creatorMemberId", "content", "createdAtUtc"], + "properties": { + "fanTalkId": {"type": "integer", "format": "int64"}, + "replyId": {"type": "integer", "format": "int64"}, + "creatorMemberId": {"type": "integer", "format": "int64"}, + "content": {"type": "string"}, + "createdAtUtc": {"type": "string", "format": "date-time"} + } + }, + "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}}}]}, + "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 new file mode 100644 index 00000000..ce798856 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/plan-task.md @@ -0,0 +1,7763 @@ +# AI 캐릭터 관리자 API Implementation Plan + +> **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를 구현한다. + +**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 컴포넌트를 테스트로 고정해 선택적으로 재사용한다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Security, JPA/Hibernate, QueryDSL, MySQL, Gradle Wrapper, JUnit5. + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-07-24 | +| 요구사항 기준 | `docs/20260724_AI캐릭터_관리자_API/prd.md` | +| API 기준 | `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` | +| 현재 Phase | 완료 | +| 현재 활성 Goal | 완료 | + +## 현재 상태 + +| Phase | 상태 | 완료/전체 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 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을 설정하지 않는다. +- 기존 `[x]` Task와 검증 기록은 당시 완료 이력으로 보존한다. 후속 리뷰에서 발견한 문제는 기존 Task를 다시 열지 않고 새 review/fix + Goal로 처리한다. +- 각 Goal은 체크박스, focused test, 완료 증거와 Progress 기록이 모두 충족된 뒤에만 `complete`로 갱신한다. +- 검증은 focused test와 영향받는 slice/legacy 회귀를 우선한다. 전체 `./gradlew test`는 공통 경계·여러 Phase 영향, targeted + 결과만으로 영향 범위를 판단할 수 없는 실패 또는 최종 release 판정에 실제로 필요하다고 기록한 경우에만 실행한다. +- 전체 회귀를 생략하면 생략 근거와 대신 실행한 focused/영향 범위 회귀 명령을 Progress와 검증 기록에 남긴다. +- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고, 문서화나 독립 작업도 불가능할 때만 `blocked`로 갱신한다. + +--- + +## Source of Truth +- 요구사항 원본: `.omx/specs/deep-interview-ai-character-admin-api.md` +- 2026-07-24 후속 확정 정책: 신규 prefix는 JWT `ROLE_ADMIN` + 현재 DB `Member.role == ADMIN` 이중 인가를 적용하고, + stale ADMIN claim은 403으로 거부한다. 신규 prefix의 API application/controller/security filter 오류는 정확한 비2xx status + + `ApiResponse.error` + `Accept-Language` 기반 KO/EN/JA message를 반환한다. 이 후속 정책이 원본과 충돌하면 후속 정책을 + 우선한다. +- 2026-07-25 후속 확정 정책: 신규 prefix의 CORS는 현재 코드에 정의된 캐릭터 관리자 frontend Origin + `http://localhost:8888`, `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 + 허용한다. 기존 범용 관리자 frontend와 creator frontend Origin은 허용하지 않는다. +- 2026-07-25 2차 리뷰 후속 확정 정책: 공유 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 관리자 + Origin의 합집합을 path-specific으로 허용한다. CORS 정책 거부 403 body는 API 오류 envelope 계약에서 제외한다. 신규 prefix의 + 406은 `common.error.invalid_request`, `MissingPathVariableException`은 500 `common.error.unknown`으로 처리하고, 405 `Allow`와 + 415 `Accept` 표준 header를 유지한다. +- 문서 작성 규칙: `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/테스트스타일.md` +- 기존 AI 캐릭터 연결 문서: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/{prd.md,plan-task.md}` +- 2026-07-28 후속 확정 정책: 신규 endpoint의 JSON request/response는 레거시 필드명·타입·optional/nullable·기본값과 + 성공 `data` 형태를 그대로 유지한다. path로 이동한 ID만 body에서 제거한다. FanTalk 답변 작성만 계획의 축약 응답을 + 유지하고, 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` +- 계약 근거와 예외 설명: + `docs/20260724_AI캐릭터_관리자_API/api-contract.md` + +## Endpoint Contract Summary + +신규 API prefix는 `/api/v2/admin/ai-characters`로 한다. 기존 `/admin/*`, `/creator-admin/*`, 공개 +`/api/v2/creator-channels/*`의 성공·오류 status/body/message 계약은 변경하지 않는다. + +모든 성공 응답은 기존 관례처럼 `ApiResponse.ok(...)` wrapper를 사용한다. API application/controller/security filter 오류는 +오류 의미에 맞는 HTTP status와 `ApiResponse.error(...)` wrapper를 사용한다. + +`characterId`는 target resource endpoint의 외부 대상 식별자다. 캐릭터 목록/검색은 선택된 target이 없어 `characterId`를 받지 않고, 캐릭터 생성은 새 `ChatCharacter`를 만드는 endpoint라 path `characterId`를 받지 않는다. + +목록/검색 endpoint의 query와 pagination은 `api-contract.openapi.json`에 명시한 각 레거시 API를 따른다. FanTalk 관리자 +목록만 공개 v2 query policy의 `page` 기본값 0, `size` 기본값 20, 최소 20, 최대 50 보정을 적용한다. + +정식 endpoint, request/response schema, 타입, required/optional/nullable, 기본값과 multipart encoding은 +`api-contract.openapi.json`만 기준으로 사용한다. 사람이 읽는 레거시 근거와 생성 방법은 `api-contract.md`를 참고한다. + +| Domain | operation | 구현 상태와 소유 Goal | +|---|---:|---| +| 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`, `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 예외를 유지한다. +- 기존 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`에서 전체 문서 상태를 통합 재판정한다. + +--- + +## 과거 구현 계약 이력 (비규범) + +아래 축약 예시는 2026-07-28 레거시 계약 확정 전 Phase 2·3 구현과 계획 변경 이력을 보존하기 위한 자료다. +클라이언트 개발, 신규 구현, 테스트의 계약으로 사용하지 않으며 위 Endpoint Contract Summary와 +`api-contract.openapi.json`이 항상 우선한다. + +```json +{ + "success": true, + "message": null, + "data": {} +} +``` + +공통 API 오류 응답은 `success=false`, 현지화된 `message`, `data=null`, `errorProperty=null`을 포함하고 2xx로 normalize하지 않는다. +`Accept-Language: ko|en|ja`에 따라 KO/EN/JA를 반환하며, 없거나 지원하지 않는 언어는 KO로 fallback한다. security filter 단계도 +MVC interceptor에 의존하지 않고 header를 직접 해석한다. + +| 오류 | HTTP status | message key | +|---|---:|---| +| JWT 없음·잘못됨·만료·폐기 | 401 | `common.error.bad_credentials` | +| JWT role 비ADMIN | 403 | `common.error.access_denied` | +| JWT ADMIN + 현재 DB role 비ADMIN stale claim | 403 | `common.error.access_denied` | +| request binding·target 미존재·creatorMember 누락·role/memberKind 불변식 위반 | 400 | `common.error.invalid_request` | +| 신규 prefix 미매핑 경로 | 404 | `common.error.invalid_request` | +| 지원하지 않는 HTTP method | 405 | `common.error.invalid_request` | +| 지원하지 않는 응답 media type | 406 | `common.error.invalid_request` | +| 지원하지 않는 요청 media type | 415 | `common.error.invalid_request` | +| `MissingPathVariableException`·예상하지 못한 서버 오류 | 500 | `common.error.unknown` | + +405 응답은 표준 `Allow` header를, 415 응답은 표준 `Accept` header를 유지한다. + +```json +{ + "success": false, + "message": "Invalid request.", + "data": null, + "errorProperty": null +} +``` + +신규 prefix는 캐릭터 관리자 Origin `http://localhost:8888`, `https://test-character-admin.sodalive.net`, +`https://character-admin.sodalive.net`만 허용한다. 공유 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 +관리자 Origin의 합집합만 path-specific으로 허용하며, 다른 legacy/public 경로의 허용 범위는 변경하지 않는다. 허용되지 않은 +Origin, method 또는 header가 Spring CORS 계층에서 403으로 정책 거부되면 handler 진입 전 종료되는 브라우저 보안 경계이므로 +그 응답의 body, content type, 현지화 및 `ApiResponse.error` envelope는 외부 계약으로 고정하지 않는다. + +Phase 2~6에서 추가되는 domain/client/server 오류는 구현 전에 각 Task에서 정확한 비2xx status와 KO/EN/JA message key를 +고정하고 같은 envelope를 적용한다. 신규 prefix 전용 오류 처리는 legacy/public endpoint에 적용하지 않는다. + +#### 캐릭터 목록/검색 + +`GET /api/v2/admin/ai-characters?search=루나&page=0&size=20` + +Query parameters: + +```json +{ + "search": "루나", + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "characterId": 101, + "name": "루나", + "description": "달빛을 좋아하는 AI 캐릭터", + "imageUrl": "https://cdn.example.com/characters/luna.png", + "creatorMemberId": 9001, + "creatorNickname": "루나", + "originalWorkId": 31, + "externalCharacterId": "ext-luna-001", + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z" + } + ] +} +``` + +#### 캐릭터 상세 + +`GET /api/v2/admin/ai-characters/{characterId}` + +Response `data`: + +```json +{ + "characterId": 101, + "name": "루나", + "description": "달빛을 좋아하는 AI 캐릭터", + "imageUrl": "https://cdn.example.com/characters/luna.png", + "creatorMemberId": 9001, + "creatorNickname": "루나", + "creatorProfileImageUrl": "https://cdn.example.com/characters/luna.png", + "creatorIntroduce": "달빛을 좋아하는 AI 캐릭터", + "originalWorkId": 31, + "externalCharacterId": "ext-luna-001", + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z", + "updatedAtUtc": "2026-07-24T00:00:00Z" +} +``` + +#### 캐릭터 생성 + +`POST /api/v2/admin/ai-characters` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "name": "루나", + "systemPrompt": "루나는 달빛을 좋아하는 AI 캐릭터입니다.", + "description": "달빛을 좋아하는 AI 캐릭터", + "originalWorkId": 31 + } +} +``` + +Response `data`: 캐릭터 상세와 동일하다. + +#### 캐릭터 수정/비활성화 + +`PUT /api/v2/admin/ai-characters/{characterId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "name": "루나", + "description": "수정된 소개", + "originalWorkId": 31 + } +} +``` + +`externalCharacterId`는 response 전용이며 request에 포함하면 400이다. 일반 수정은 `image`와 함께 보낼 수 있으며 image가 없으면 기존 +이미지를 유지한다. soft delete는 `request: {"isActive": false}`만 허용하고 일반 수정 field 또는 image와 혼합하면 400이다. + +Response `data`: 캐릭터 상세와 동일하다. `isActive=false`는 soft delete 의미다. + +#### 오디오 콘텐츠 목록/검색 + +`GET /api/v2/admin/ai-characters/{characterId}/audio-contents?search=밤&status=OPEN&page=0&size=20` + +Query parameters: + +```json +{ + "search": "밤", + "status": "OPEN | SCHEDULED", + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "contentId": 501, + "title": "밤 산책", + "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", + "audioSignedUrl": "https://cdn.example.com/signed/audio/501.m4a?Expires=...", + "price": 1000, + "isAdult": false, + "isActive": true, + "releaseDateUtc": "2026-07-25T00:00:00Z", + "status": "OPEN" + } + ] +} +``` + +#### 오디오 콘텐츠 상세 + +`GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` + +Response `data`: + +```json +{ + "contentId": 501, + "title": "밤 산책", + "description": "조용한 밤 산책 오디오", + "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", + "audioSignedUrl": "https://cdn.example.com/signed/audio/501.m4a?Expires=...", + "price": 1000, + "isAdult": false, + "isActive": true, + "releaseDateUtc": "2026-07-25T00:00:00Z", + "status": "OPEN", + "seriesIds": [701], + "createdAtUtc": "2026-07-24T00:00:00Z", + "updatedAtUtc": "2026-07-24T00:00:00Z" +} +``` + +#### 오디오 콘텐츠 생성 + +`POST /api/v2/admin/ai-characters/{characterId}/audio-contents` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "coverImage": "File", + "audioFile": "File", + "request": { + "title": "밤 산책", + "description": "조용한 밤 산책 오디오", + "price": 1000, + "isAdult": false, + "isActive": true, + "themeId": 11, + "releaseDateUtc": "2026-07-25T00:00:00Z", + "seriesIds": [701] + } +} +``` + +Response `data`: 오디오 콘텐츠 상세와 동일하다. + +#### 오디오 콘텐츠 수정/soft delete + +`PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "coverImage": "File | optional", + "audioFile": "미지원", + "request": { + "title": "밤 산책 수정", + "description": "수정된 설명", + "price": 1200, + "isAdult": false, + "isActive": false, + "releaseDateUtc": null, + "seriesIds": [701] + } +} +``` + +Response `data`: 오디오 콘텐츠 상세와 동일하다. + +#### 시리즈 목록 + +`GET /api/v2/admin/ai-characters/{characterId}/series?page=0&size=20` + +Query parameters: + +```json +{ + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "seriesId": 701, + "title": "루나의 밤", + "introduction": "밤을 주제로 한 시리즈", + "coverImageUrl": "https://cdn.example.com/series/701.png", + "genreId": 3, + "isAdult": false, + "state": "OPEN", + "isActive": true, + "orders": 1 + } + ] +} +``` + +#### 시리즈 상세 + +`GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` + +Response `data`: + +```json +{ + "seriesId": 701, + "title": "루나의 밤", + "introduction": "밤을 주제로 한 시리즈", + "coverImageUrl": "https://cdn.example.com/series/701.png", + "publishedDaysOfWeek": ["MONDAY", "WEDNESDAY"], + "genreId": 3, + "keywords": ["밤", "산책"], + "isAdult": false, + "state": "OPEN", + "isActive": true, + "writer": "루나", + "studio": "소다라이브", + "orders": 1 +} +``` + +#### 시리즈 생성 + +`POST /api/v2/admin/ai-characters/{characterId}/series` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "title": "루나의 밤", + "introduction": "밤을 주제로 한 시리즈", + "publishedDaysOfWeek": ["MONDAY", "WEDNESDAY"], + "genreId": 3, + "keywords": ["밤", "산책"], + "isAdult": false, + "state": "OPEN", + "writer": "루나", + "studio": "소다라이브" + } +} +``` + +Response `data`: 시리즈 상세와 동일하다. + +#### 시리즈 수정/soft delete + +`PUT /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "title": "루나의 밤 수정", + "introduction": "수정된 소개", + "publishedDaysOfWeek": ["FRIDAY"], + "genreId": 3, + "keywords": ["밤"], + "isAdult": false, + "state": "OPEN", + "isActive": false, + "writer": "루나", + "studio": "소다라이브" + } +} +``` + +Response `data`: 시리즈 상세와 동일하다. + +#### 시리즈 콘텐츠 조회 + +`GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents?search=밤&page=0&size=20` + +Query parameters: + +```json +{ + "search": "밤", + "page": 0, + "size": 20 +} +``` + +#### 시리즈 콘텐츠 연결 해제 + +`DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` + +Request body: 없음. `RemoveContentToTheSeriesRequest`의 `seriesId`, `contentId`는 path variable로 이동한다. + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "contentId": 501, + "title": "밤 산책", + "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", + "isAdult": false, + "orders": 1 + } + ] +} +``` + +#### 시리즈 콘텐츠 연결 + +`POST /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` + +Request body: + +```json +{ + "contentIdList": [501, 502] +} +``` + +Response `data`: `null` + +#### 시리즈 콘텐츠 연결 해제 + +`DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` + +Request body: + +```json +{ + "contentId": 501 +} +``` + +Response `data`: `null` + +#### 시리즈 순서 변경 + +`PUT /api/v2/admin/ai-characters/{characterId}/series/orders` + +Request body: + +```json +{ + "seriesIds": [701, 702, 703] +} +``` + +Response `data`: 시리즈 목록과 동일하다. + +#### 커뮤니티 게시글 목록 + +`GET /api/v2/admin/ai-characters/{characterId}/community-posts?page=0&size=20` + +Query parameters: + +```json +{ + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "postId": 801, + "content": "오늘의 소식입니다.", + "imageUrl": "https://cdn.example.com/community/801.png", + "audioSignedUrl": null, + "price": 0, + "isAdult": false, + "isFixed": true, + "fixedAtUtc": "2026-07-24T00:00:00Z", + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z" + } + ] +} +``` + +#### 커뮤니티 게시글 등록 + +`POST /api/v2/admin/ai-characters/{characterId}/community-posts` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "audioFile": "File | optional", + "request": { + "content": "오늘의 소식입니다.", + "price": 0, + "isAdult": false, + "isFixed": false, + "isActive": true + } +} +``` + +Response `data`: + +```json +{ + "postId": 801, + "content": "오늘의 소식입니다.", + "imageUrl": "https://cdn.example.com/community/801.png", + "audioSignedUrl": null, + "price": 0, + "isAdult": false, + "isFixed": false, + "fixedAtUtc": null, + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z", + "updatedAtUtc": "2026-07-24T00:00:00Z" +} +``` + +#### 커뮤니티 게시글 수정/고정/soft delete + +`PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "audioFile": "File | optional", + "request": { + "content": "수정된 소식입니다.", + "price": 0, + "isAdult": false, + "isFixed": false, + "isActive": false + } +} +``` + +Response `data`: 커뮤니티 게시글 등록 응답과 동일하다. `isActive=false`이면 `isFixed=false`, `fixedAtUtc=null`이어야 한다. + +#### FanTalk 답변 작성 + +`POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` + +Request body: + +```json +{ + "content": "응원해줘서 고마워요!" +} +``` + +Response `data`: + +```json +{ + "fanTalkId": 901, + "replyId": 902, + "creatorMemberId": 9001, + "content": "응원해줘서 고마워요!", + "createdAtUtc": "2026-07-24T00:00:00Z" +} +``` + +--- + +## 구현 Phase + +### Phase 1: 공통 ADMIN 인증과 AI 캐릭터 target resolver 기반 + +#### 공통 Task 실행 규칙 +- 각 구현 Task는 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다. +- 테스트 작성이 현실적으로 불가능한 검증 전용 Task는 `TDD 예외 사유`와 `대체 검증 방법`을 Task에 명시한다. +- 기존 business method 재사용 전 특성화/회귀 테스트는 신규 v2 use-case RED 테스트와 분리한다. 특성화 테스트는 기존 legacy/creator-admin 구현을 대상으로 먼저 통과해 baseline을 고정하고, 그 결과를 신규 v2 RED 기대값으로 옮긴다. +- Phase 2~6의 모든 신규 오류 RED는 정확한 비2xx status, `ApiResponse.error` shape, `Accept-Language` KO/EN/JA message를 + 함께 검증한다. 새 오류 분기는 status, message key, 3개 언어 message, 테스트가 모두 정해지기 전 완료 처리하지 않는다. + +#### 목표 +모든 신규 API가 공유할 JWT ADMIN + 현재 DB ADMIN 이중 인가, prefix 전용 오류 envelope/i18n, `characterId` 기반 target 해석, +`CREATOR + AI_CHARACTER` 불변식, ownership no-side-effect 검증 기반을 만든다. + +#### 범위와 비범위 +- 포함: 신규 v2 admin package 골격, JWT role + 현재 DB role 이중 인가, prefix 전용 security/application 오류 처리, 공통 target + resolver/use-case, no-side-effect 테스트 fixture. +- 제외: 캐릭터/콘텐츠/시리즈/커뮤니티/FanTalk 실제 domain 기능 구현. + +#### 선행 Phase 및 의존성 +- 선행 Phase 없음. +- 기존 `ChatCharacter.creatorMember`, `MemberRole.CREATOR`, `MemberKind.AI_CHARACTER`가 존재해야 한다. + +#### API endpoint와 request/response contract +- 모든 후속 endpoint에 공통 적용한다. +- target resource request는 JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 모두 만족하는 principal과 path/query/body의 + `characterId`를 받는다. 캐릭터 목록/검색과 생성은 Endpoint Contract Summary의 예외를 따른다. +- resolver output은 내부 전용 `AiCharacterAdminTarget(characterId, chatCharacter, creatorMember)`로 계획한다. +- Phase 1 API 실패 응답은 Endpoint Contract Summary의 400/401/403/404/405/406/415/500과 + `ApiResponse.error`/KO·EN·JA 계약을 따르며 domain side effect가 없어야 한다. Spring CORS 정책 거부 403 body는 해당 + envelope 계약의 예외다. + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `ChatCharacterRepository`에 `characterId`로 `creatorMember`를 `LEFT JOIN FETCH`하는 query를 추가해 반환 직후 + 연관 객체가 초기화되도록 한다. +- Service/Application: `AiCharacterAdminTargetResolver` 생성. +- Controller/Facade: `kr.co.vividnext.sodalive.v2.api.admin.aicharacter` 하위 신규 controller/facade 패키지 생성. + +#### DB migration +- 없음. 신규 DDL, 신규 migration 파일을 만들지 않는다. + +#### transaction과 concurrency 고려사항 +- target resolver는 read-only transaction으로 동작한다. +- 후속 write use-case는 target 검증을 write transaction 시작부에서 먼저 수행한다. +- target 검증 실패는 transaction rollback-only가 필요 없는 사전 실패로 끝나야 한다. + +#### 보안 및 개인정보 위험 +- JWT `ROLE_ADMIN`만 신뢰하지 않고 `TokenProvider`가 이미 조회한 `MemberAdapter.member.role`을 신규 prefix 인가에서 함께 + 확인한다. `TokenProvider`의 전역 authority 계산은 변경하지 않는다. +- JWT ADMIN + 현재 DB 비ADMIN stale claim과 `MemberAdapter`가 아닌 principal은 target resolver 실행 전에 403으로 거부한다. +- `creatorMember`를 인증 principal로 교체하지 않는다. +- `creatorMemberId`를 관리자 입력값으로 신뢰하지 않는다. +- Phase 1 resolver의 잘못된 target 요청은 Hibernate 통계로 DB insert/update/delete 0건을 검증한다. resolver는 S3, 외부 API, + 이벤트 발행 의존성을 갖지 않으며 Phase 2~6 write slice에서 각 외부 부작용 0건을 별도 검증한다. + +#### acceptance criteria +- JWT ADMIN + 현재 DB ADMIN 요청만 유효한 AI character target을 resolver로 해석할 수 있다. +- JWT 없음·잘못됨·만료·폐기는 401, JWT 비ADMIN 또는 현재 DB 비ADMIN은 403, character 미존재·creatorMember 미존재·target + role/memberKind 불일치는 400이다. +- 위 API 오류는 모두 `ApiResponse.error`와 `Accept-Language`에 따른 KO/EN/JA message를 반환한다. Spring CORS 정책 거부 403 + body는 해당 envelope 계약의 예외다. +- 405는 `Allow`, 415는 `Accept` header를 유지하고, 지원하지 않는 응답 media type은 406 + `common.error.invalid_request`, `MissingPathVariableException`은 500 `common.error.unknown`으로 반환한다. +- Phase 1 resolver 실패는 DB insert/update/delete가 0건이다. S3, 외부 API, 이벤트 부작용은 해당 의존성이 처음 도입되는 + Phase 2~6 write slice에서 검증한다. + +#### targeted test +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt` +- Integration Test: + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt` +- RED: resolver 미구현 상태에서 `shouldResolveAiCharacterCreatorMemberForAdminTarget`, `shouldRejectMissingCharacterWithoutSideEffect`, `shouldRejectHumanCreatorMemberWithoutSideEffect` 테스트를 작성해 실패를 확인한다. +- GREEN: resolver와 최소 repository query를 구현해 resolver 테스트를 통과시킨다. +- Controller 권한 테스트: `AiCharacterAdminAuthorizationTest`에서 JWT role × 현재 DB role 매트릭스와 stale claim을 고정하고, + Phase 2~6 controller test에서 신규 endpoint 전체가 같은 이중 인가를 공유하는지 검증한다. +- 오류 계약 테스트: `AiCharacterAdminErrorContractTest`에서 400/401/403/404/405/406/415/500, 405 `Allow`, 415 `Accept`, JWT + filter 예외, KO/EN/JA body와 legacy fallback을 검증한다. CORS는 미매핑 fallback뿐 아니라 실제 mapped endpoint와 + `/admin/member/login`, `/member/logout`의 허용·거부 Origin/preflight를 검증하고, 정책 거부 403 body에는 envelope를 요구하지 + 않는다. +- Run: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` + +#### 전체 회귀 테스트 영향 +- 기존 endpoint를 건드리지 않아야 한다. +- 신규 package 추가로 component scan과 security 설정 충돌이 없어야 한다. + +#### rollback 전략 +- 신규 v2 admin controller/facade/resolver/error/security package와 관련 테스트를 제거한다. +- `SecurityConfig.kt`, `WebConfig.kt`, `ExceptionHandlerFilter.kt`, `TokenProvider.kt`, `ChatCharacterRepository.kt`의 Phase 1 변경을 + 함께 되돌린다. +- DB rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin target resolver` + +- [x] **Task 1.1: resolver RED 테스트 작성** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt` + - RED: 유효 target, missing character, wrong role, wrong memberKind, missing creatorMember, cross-owner fixture를 먼저 작성하고 실패를 확인한다. + - GREEN: 구현 전 Task라 production code를 변경하지 않는다. + - REFACTOR: fixture 중복만 정리하고 테스트 의미는 약화하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` + - 검증 기록: 무엇: resolver RED 테스트. 왜: resolver와 `findByIdWithCreatorMember` 미구현을 실제 실패로 고정하기 위해. 어떻게: + 위 명령을 실행했다. 결과: `compileTestKotlin`이 두 미구현 항목으로 실패했다. + +- [x] **Task 1.2: resolver 최소 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt` + - RED: Task 1.1 실패 테스트가 같은 실패 이유로 남아 있음을 확인한다. + - GREEN: `characterId`로 `ChatCharacter`와 `creatorMember`를 조회하고 strict validation을 적용한다. + - REFACTOR: resolver/repository naming과 예외 메시지를 인접 v2 관례에 맞추고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest` + - 검증 기록: 무엇: resolver 최소 구현과 repository/ownership 통합 검증. 왜: mock 기반 테스트만으로 실제 조회 동작을 확인할 수 + 없었기 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest`와 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest`를 실행했다. + 결과: 두 실행 모두 `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.3: ADMIN 권한 controller smoke 테스트** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` + - RED: 최소 sample controller 또는 Phase 2 첫 controller 기준 anonymous/non-admin 접근 실패와 admin 접근 성공 테스트를 먼저 작성해 실패를 확인한다. + - GREEN: 공통 security 설정 또는 controller annotation을 최소 구현해 테스트를 통과시킨다. + - REFACTOR: Phase 2~6의 모든 신규 endpoint controller test가 같은 권한 매트릭스를 따르도록 test helper를 정리한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest` + - 검증 기록: 무엇: sample route ADMIN 권한 smoke. 왜: 신규 prefix의 ADMIN rule 적용 전후를 확인하기 위해. 어떻게: 위 명령으로 + 권한 테스트를 실행했다. 결과: 적용 전 권한 실패를 확인했고, `/api/v2/admin/ai-characters/**` ADMIN rule 적용 후 + `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.4: JWT claim + 현재 DB ADMIN 이중 인가** + - Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt` + - RED: `UsernamePasswordAuthenticationToken(MemberAdapter(currentMember), token, jwtAuthorities)` fixture로 JWT ADMIN + DB ADMIN + 200, JWT 비ADMIN + DB ADMIN 403, JWT ADMIN + DB 비ADMIN stale claim 403, ADMIN authority + 비`MemberAdapter` principal + 403을 작성한다. stale claim은 현재 구현에서 200이므로 이 실패를 확인한다. + - GREEN: 신규 prefix의 matcher 하나에서 아래 세 조건을 AND로 묶고 `MemberAdapter`, legacy matcher는 변경하지 않는다. + `TokenProvider`는 JWT subject 누락/비숫자 값이 500으로 누수되지 않도록 `common.error.bad_credentials`로만 보정하며, + 전역 authority 계산과 token 저장소 검증 의미는 변경하지 않는다. + + ```kotlin + .antMatchers("/api/v2/admin/ai-characters/**") + .access( + "hasRole('ADMIN') and " + + "principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " + + "principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN" + ) + ``` + + - REFACTOR: production `SecurityConfig` matcher의 비확산은 full-context `AiCharacterAdminAuthorizationTest`의 + `/phase1-legacy-sample`로 확인한다. 기존 `AdminAgentReadControllerSecurityTest`, `AdminContentControllerSecurityTest`는 각자의 + 자체 security chain을 사용하는 controller 회귀 증거로 구분하며 production matcher 비확산의 증거로 해석하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest` + - 검증 기록: 무엇: JWT claim과 현재 DB ADMIN 이중 인가. 왜: stale ADMIN claim과 비`MemberAdapter` principal이 허용되면 안 + 되기 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest`를 + 실행했다. 결과: 6개 중 해당 2개가 403 기대 대비 200으로 실패한 RED를 확인했고, 세 조건을 AND로 적용한 뒤 같은 명령이 + `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.5: 신규 prefix 오류 envelope/status/i18n 계약** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt` + - Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt` + - RED: legacy anonymous/revoked-token/지원하지 않는 method 오류의 현재 status/body를 먼저 통과하는 특성화 baseline으로 + 고정한다. 신규 prefix의 anonymous·잘못된 JWT·폐기 JWT 401, JWT/DB role 불충족 403, invalid request/target 400, 미매핑 경로 + 404, 지원하지 않는 method 405, 지원하지 않는 media type 415, 예상하지 못한 controller/JWT filter 오류 500을 KO/EN/JA로 + parameterized 검증한다. 확정된 캐릭터 관리자 Origin의 404/405/415와 실제 JWT header를 요청하는 미매핑 경로 + preflight에 CORS 응답 header가 적용되는지 확인한다. 허용된 Origin의 API 오류는 `success=false`, localized `message`, JSON + content type을 확인하고, 기존 범용 관리자와 creator frontend Origin의 CORS 정책 거부는 403과 CORS 허용 header 부재만 + 확인한다. 현재 `sendError`, hardcoded KO, 기본 Spring error body 때문에 실패하는 것을 확인한다. + - GREEN: `AiCharacterAdminErrorResponseWriter`가 `Lang.fromAcceptLanguage`와 `SodaMessageSource`로 `ApiResponse.error`를 만들고 + JSON을 기록하게 한다. `AiCharacterAdminSecurityErrorHandler`는 401 `common.error.bad_credentials`와 403 + `common.error.access_denied`를 위 writer에 위임한다. `SecurityConfig`는 신규 prefix matcher에만 이 handler를 선택하고 기존 + `JwtAuthenticationEntryPoint`/`JwtAccessDeniedHandler`를 fallback으로 유지한다. `ExceptionHandlerFilter`도 신규 prefix에서 잡은 + JWT 예외만 신규 401 handler로 위임하고 legacy branch는 그대로 둔다. + - GREEN: target resolver는 400 + `common.error.invalid_request`를 가진 `AiCharacterAdminApiException`을 던진다. URI matcher로 신규 + prefix에만 적용되는 `AiCharacterAdminExceptionHandler`는 controller 선택 전 오류까지 처리해 명시적 API 예외, request binding + 400, method 405, media type 415, controller `AccessDeniedException` 403, 예상하지 못한 오류 500을 각각 정확한 status와 + localized `ApiResponse.error`로 반환한다. + 낮은 우선순위의 prefix fallback handler는 미매핑 경로를 404로 반환하고 캐릭터 관리자 Origin 전용 CORS 설정을 적용한다. + 단순 + `SodaException` 교체나 전역 + `SodaExceptionHandler` 변경은 하지 않는다. + - GREEN: `ExceptionHandlerFilter`가 잡은 폐기 JWT 등 알려진 인증 실패만 401로 보내고, JWT 처리 중 예상하지 못한 예외는 위 + prefix exception handler에 위임해 500 `common.error.unknown`으로 반환한다. + - REFACTOR: raw message key 노출, MVC `LangInterceptor` 의존, legacy 오류 응답 변경이 없는지 확인하고 신규/legacy contract + 테스트를 함께 재실행한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAccessDeniedErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` + - 검증 기록: 무엇: 신규 prefix 오류 envelope/status/i18n 계약. 왜: 신규 401/403/400/500 응답이 기존 body·message와 달랐기 + 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`를 + 실행했다. 결과: 최초 15개 중 13개의 status/content type/message 불일치 RED를 확인했고, prefix 기반 handler와 writer 적용 후 + 44개 invocation이 모두 통과해 `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.6: Phase 1 코드 리뷰 후속 보완** + - Modify: `docs/20260724_AI캐릭터_관리자_API/prd.md`, + `docs/20260724_AI캐릭터_관리자_API/plan-task.md`, + `src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt` + - RED: 서명과 subject는 유효하지만 `auth` claim이 누락, 공백 또는 빈 분할 항목을 포함한 JWT가 + `common.error.bad_credentials`로 거부되지 않는 테스트를 작성하고 실패를 확인한다. + - GREEN: `TokenProvider`가 `auth` claim을 authority로 변환하기 전에 문자열 타입, 공백 여부와 각 분할 항목을 검증하고 + 잘못된 claim은 `common.error.bad_credentials`로 거부하는 최소 구현을 적용한다. + - REFACTOR: Phase 1 테스트 전용 controller를 각 테스트 클래스 내부 nested class로 이동하고 `@TestComponent`로 + component scan에서 제외한 뒤 해당 테스트 context에만 명시적으로 import한다. 테스트 fixture 범위만 바꾸는 구조 + 정리이므로 별도 동작 RED 대신 targeted/full-context 회귀와 application context 시작 성공으로 검증한다. + - CORS: 현재 코드의 캐릭터 관리자 Origin `http://localhost:8888`, + `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 허용하고 기존 범용 + 관리자/creator Origin은 거부하는 정책으로 PRD/plan과 CORS 계약 테스트를 동기화한다. + - 2차 리뷰 RED: 신규 prefix의 실제 mapped endpoint와 공유 `/admin/member/login`, `/member/logout`에서 캐릭터 관리자 Origin + 요청/preflight가 허용되지 않는 실패를 확인한다. 응답 media type 협상 실패 406, 405 `Allow` header, 415 `Accept` header, + `MissingPathVariableException` 500 계약 테스트를 추가해 현재 동작과의 불일치를 확인한다. + - 2차 리뷰 GREEN: 신규 prefix는 캐릭터 관리자 Origin만 허용하는 기존 정책을 유지하고, 두 공유 인증 경로에만 기존 전역 + Origin과 캐릭터 관리자 Origin의 합집합을 적용한다. `AiCharacterAdminExceptionHandler`는 406을 + `common.error.invalid_request`, `MissingPathVariableException`을 500 `common.error.unknown`으로 분류하고 405/415 표준 + header를 보존한다. Spring CORS 정책 거부 403 body는 localized `ApiResponse.error` envelope 계약에서 제외한다. + - 2차 리뷰 REFACTOR: 실제 mapped endpoint, 두 공유 인증 경로와 미매핑 fallback의 허용·거부 Origin/preflight를 함께 + 회귀하고, path-specific CORS 확장이 다른 legacy/public 경로로 확산되지 않았는지 확인한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` + - 검증 기록: 무엇: malformed `auth` claim, CORS, HTTP 오류, 테스트 fixture 격리 후속 보완. 왜: claim 누수와 실제 mapped/shared + path CORS·405/406/415/500 계약 누락을 해소하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest`를 실행했다. 결과: malformed claim 7개 invocation RED 후 + 9개가 통과했고, fallback 보완 후 targeted+legacy 136개가 모두 통과해 `BUILD SUCCESSFUL`이었다. 추가 + 로그인/로그아웃 CORS 보완 뒤 `AiCharacterAdminLoginJwtIntegrationTest`도 `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.7: Phase 1 후속 리뷰 전체 반영** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt` + - RED: full-context `AiCharacterAdminLoginJwtIntegrationTest`에 허용 Origin의 `PROPFIND` 400/i18n/CORS, raw double slash 400/CORS, + 미허용 Origin 403/`Access-Control-Allow-Origin` 부재와 legacy `RequestRejectedException` 유지 계약을 추가하고, 신규 prefix 세 + 요청이 `RequestRejectedException`으로 실패하며 legacy fallback은 통과하는 production-before RED를 확인한다. + - GREEN: `SecurityConfig`가 기존 `AiCharacterAdminSecurityErrorHandler`를 global `RequestRejectedHandler`로 등록하고, handler는 + 신규 prefix에만 400/CORS 계약을 적용한다. `setUnsafeAllowAnyHttpMethod(true)` 없이 + 허용된 캐릭터 관리자 Origin에는 CORS header를 포함한 400 `common.error.invalid_request`와 현지화된 `ApiResponse.error`를, + 미허용 Origin에는 기존 Spring CORS 정책과 같은 body 계약 없는 403을 반환한다. legacy/public은 + `DefaultRequestRejectedHandler`에 위임해 기존 `RequestRejectedException` 동작을 유지한다. Spring 5.3의 비표준 method enum + 한계는 CORS 검사 request에만 `GET` wrapper를 사용해 우회하고 실제 firewall method 허용 범위는 확장하지 않는다. + - REFACTOR: `AiCharacterAdminErrorContractTest`, `AiCharacterAdminAuthorizationTest`, + `AiCharacterAdminAccessDeniedErrorContractTest`를 production `@SpringBootTest` + MockMvc + EmbeddedRedis full context로 전환한다. + 기존 ErrorContract의 표준 `POST` -> GET-only mapping 405, `Allow: GET`, CORS 계약도 full context에서 회귀한다. + `AiCharacterAdminLoginJwtIntegrationTest`는 `MemberTokenRepository.deleteAll()`을 `@AfterEach`에 실행해 Redis token fixture를 + cleanup한다. `AiCharacterAdminTargetResolverIntegrationTest`는 repository 조회 직후 + `Hibernate.isInitialized(found.creatorMember)`를 단언해 production `LEFT JOIN FETCH`가 실제 회귀 방지에 필요함을 고정한다. + Phase 1 production에는 Bean Validation provider를 추가하지 않고, `MethodArgumentNotValidException`은 test-only endpoint에서 + 의존성 없이 직접 던져 handler 분기를 검증한다. 이 Task의 실행 명령과 결과는 먼저 이 Task 아래에 기록하고, phase/전체 + aggregate만 문서 하단 검증 기록에 누적한다. + - Verify: `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` + - 검증 기록(RED): 무엇: 신규 prefix firewall와 legacy fallback 계약. 왜: production firewall 거부가 신규 API 오류 계약 밖으로 + 탈출하는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest`를 + 실행했다. 결과: 17개 중 신규 prefix 3개가 `RequestRejectedException`으로 실패했고 legacy fallback 테스트는 통과했다. + - 검증 기록(GREEN): 무엇: prefix-aware global `RequestRejectedHandler`. 왜: 신규 prefix만 400/i18n/CORS로 변환하고 legacy/public + 동작을 보존하기 위해. 어떻게: RED와 동일한 명령을 실행했다. 결과: 17/17, `BUILD SUCCESSFUL`을 확인했다. + - 검증 기록(REFACTOR): 무엇: 네 core controller security/error 클래스의 production full-context 계약과 Redis fixture 격리. + 왜: slice 설정이 아닌 실제 security/CORS/filter 구성을 검증하기 위해. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAccessDeniedErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest`를 + 실행했다. 결과: 115/115, failure/error 0, `BUILD SUCCESSFUL`을 확인했다. + - 검증 기록(FETCH JOIN): 무엇: repository 조회 직후 `creatorMember` 초기화의 non-vacuous 회귀 계약. 왜: resolver transaction + 내부 접근만으로 fetch join 누락이 가려지는 것을 막기 위해. 어떻게: production query의 `LEFT JOIN FETCH`를 임시로 + `LEFT JOIN`으로 바꾸고 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest`를 실행했다. + 결과: 4개 중 fetch 테스트 1개가 line 66에서 실패해 `BUILD FAILED`(38초)을 확인했다. 즉시 `LEFT JOIN FETCH`를 복원했고, + 복원 상태는 하단 최신 canonical 154/154에 포함되어 통과했다. + - 검증 기록(직접 400 분기): 무엇: malformed JSON의 `HttpMessageNotReadableException`, test-only endpoint에서 의존성 없이 직접 + 던진 `MethodArgumentNotValidException`, 실제 multipart 필수 part 누락의 `MissingServletRequestPartException` 각 KO/EN/JA 총 + 9 invocation. 왜: exact `resolvedException` 타입과 localized 400 envelope를 각 handler 분기에서 직접 고정하기 위해. 어떻게: + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest' --rerun-tasks`를 실행했다. + 결과: 98/98, failure/error/skipped 0, `BUILD SUCCESSFUL`을 확인했고 production/build dependency 변경은 없었다. + +--- + +### Phase 2: AI 캐릭터 관리 vertical slice + +#### 목표 +AI 캐릭터 목록/검색/상세/생성/수정/비활성화와 등록용 원작 검색을 신규 ADMIN v2 API로 제공하고 레거시 관리자 동작 +parity를 고정한다. + +#### 범위와 비범위 +- 포함: character CRUD API, 등록용 원작 검색, 외부 캐릭터 API 연동, 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, + creatorMember 생성/표시 정보 동기화 parity. +- 제외: hard delete, cascade delete, 기존 legacy admin endpoint 변경. + +#### 선행 Phase 및 의존성 +- Phase 1 resolver와 ADMIN 권한 기반이 선행되어야 한다. +- 기존 `ChatCharacterService`, `ChatCharacterCreatorMemberService`, image/S3/event 관련 컴포넌트 동작을 특성화해야 한다. + +#### API endpoint와 request/response contract +- 정식 schema는 `api-contract.openapi.json`의 Character operation을 따른다. +- `GET /api/v2/admin/ai-characters?searchTerm=&page=&size=` -> + `ChatCharacterListPageResponse(totalCount, content)` 또는 동일 필드의 검색 response. +- `GET /api/v2/admin/ai-characters/{characterId}` -> nested 필드 전체를 포함한 `ChatCharacterDetailResponse`. +- `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 변경 +- Entity: 변경 없음. +- Repository: character 목록/검색용 query adapter 추가 가능. +- Service: 신규 `AiCharacterAdminCharacterFacade/ApplicationService`에서 기존 하위 service를 선택적으로 재사용한다. +- DTO: 신규 admin v2 전용 request/response DTO 생성. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- 생성/수정은 단일 transaction에서 character, relation, creatorMember 표시 정보 동기화를 완료한다. +- 외부 API/S3/event 순서는 기존 레거시 동작 특성화 결과를 따른다. +- 중복 이름 검증은 기존 정책을 유지하며 동시 생성 시 DB/서비스 레벨 실패가 부분 저장을 남기지 않아야 한다. + +#### 보안 및 개인정보 위험 +- 목록/상세 응답에 AI creatorMember 로그인 credential, token, private storage path를 노출하지 않는다. +- ADMIN 외 접근을 허용하지 않는다. + +#### acceptance criteria +- 목록/검색/상세는 AI 캐릭터 관리자 화면에 필요한 필드를 반환한다. +- 생성/수정은 레거시 관리자와 동일한 business side effect를 만든다. +- 비활성화는 `isActive=false`이며 row와 연결 Member/콘텐츠를 삭제하지 않는다. + +#### targeted test +- Characterization: `LegacyChatCharacterAdminCharacterizationTest`에서 기존 character admin create/update/soft delete 결과와 외부 API·S3·event failure order, transaction/compensation 계약을 통과 상태로 고정한다. +- V2 RED/GREEN: `AiCharacterAdminCharacterControllerTest`, `AiCharacterAdminCharacterServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'` + +#### 전체 회귀 테스트 영향 +- 기존 `AdminChatCharacterController` 및 public character 조회 응답이 변하지 않아야 한다. +- creatorMember 동기화 기존 테스트가 계속 통과해야 한다. + +#### rollback 전략 +- 신규 character admin v2 route/facade만 제거한다. +- 이미 생성/수정된 정상 데이터는 기존 관리자와 같은 domain 데이터라 별도 schema rollback이 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin character slice` + +- [x] **Task 2.1: 기존 character parity 특성화 baseline 고정** + - **Goal 이력 `P2-H1`:** 기존 character admin 동작을 신규 v2 구현의 비교 기준으로 고정했다. + - **완료 증거:** 아래 특성화 테스트 RED/GREEN/REFACTOR 기록과 실제 명령 결과. + - **범위 밖:** 신규 v2 endpoint 구현. + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt` + - CHARACTERIZE: 기존 character admin 구현을 대상으로 중복 이름, 외부 API·S3·event 호출/실패 순서와 transaction/compensation, original work 연결, 언어 감지/번역 이벤트, creatorMember 표시 정보 동기화, `isActive=false` 및 연결 Member/콘텐츠 미삭제 결과를 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: fixture와 assertion naming만 정리하고 parity baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest` + - 검증 기록(RED): 무엇: 기존 character admin baseline 특성화 테스트. 왜: 신규 v2 production code 전에 기존 생성/수정/비활성화/원작/언어 이벤트/creatorMember·콘텐츠 보존 계약을 고정하기 위해. 어떻게: 위 테스트를 추가하고 동일 명령을 실행했다. 결과: 이벤트 baseline 추가 직후 Mockito/S3 stub과 request JSON 누락으로 2회 실패해 RED를 확인했다. + - 검증 기록(GREEN): 무엇: Task 2.1 baseline 통과. 왜: 기존 구현이 현재 특성화 계약을 만족하는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest`를 실행했다. 결과: `BUILD SUCCESSFUL`을 확인했다. + - 검증 기록(REFACTOR): 무엇: Kotlin style과 리뷰 gate. 왜: 테스트-only baseline의 품질과 계획 준수 여부를 확인하기 위해. 어떻게: `./gradlew ktlintCheck`와 spec/code-quality read-only review를 실행했다. 결과: `BUILD SUCCESSFUL`, 두 리뷰 모두 `APPROVED`였다. + +- [x] **Task 2.2: character controller/facade/DTO 구현** + - **Goal 이력 `P2-H2`:** 캐릭터 목록·상세·생성·수정의 최초 v2 API 구현을 제공했다. + - **완료 증거:** 아래 controller test와 `ktlintCheck` 기록. 이 이력만으로 Phase 2 Gate 통과를 의미하지 않는다. + - **범위 밖:** 후속 심층 리뷰에서 확정되는 누락·회귀 보완. + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/*` + - RED: Task 2.1 baseline에서 옮긴 `AiCharacterAdminCharacterServiceTest`와 controller 권한/페이지네이션 경계 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - RED: 현재 신규 dependency 금지 제약에 따라 기존 Spring binding 또는 수동 validation 전략을 우선하고, 실제 DTO의 invalid + 요청 통합 계약을 추가한다. Bean Validation provider가 반드시 필요하면 구현 전에 PRD/계획과 dependency 허용 범위를 + 명시적으로 변경하고 승인을 받는다. + - GREEN: endpoint contract summary의 character endpoint를 구현하고 목록/검색 `page/size` 기본값·최소·최대 보정을 적용한다. + - REFACTOR: 신규 DTO가 legacy/public DTO를 외부 계약으로 재노출하지 않는지 확인하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'` + - 검증 기록(RED): 무엇: v2 character controller 계약 테스트 7개. 왜: 신규 route가 목록/검색/상세/생성/수정과 ADMIN 경계를 제공하지 않음을 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerTest`를 실행했다. 결과: 모든 기대 endpoint가 미구현 route의 404를 반환해 7개가 실패했다. + - 검증 기록(GREEN): 무엇: 목록/검색 page-size 보정, target resolver 상세 거부, multipart 생성/수정, AI creatorMember 동기화, soft delete, invalid JSON, ADMIN 인가. 왜: Task 2.2 API 계약과 레거시 부작용 재사용을 확인하기 위해. 어떻게: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'`를 실행했다. 결과: controller 8개와 Task 2.1 baseline 7개가 모두 통과했다. + - 검증 기록(REFACTOR): 무엇: 신규 v2 DTO 경계와 Kotlin style. 왜: legacy/public DTO 비노출과 코드 스타일을 확인하기 위해. 어떻게: `./gradlew ktlintCheck`를 실행했다. 결과: `BUILD SUCCESSFUL`을 확인했다. + +- [x] **Task 2.3: Phase 2 요구사항·계약·코드 리뷰** + +**Goal 실행 `P2-R1`:** PRD Feature B와 Endpoint Contract Summary를 Phase 2 코드·테스트에 추적해 확정된 누락만 후속 Goal로 전환한다. + +- **추적 review ID:** `REV-001`, `REV-002`, `REV-003`, `REV-007`, `REV-008`. +- **시작 조건:** `P2-H1`, `P2-H2` 산출물과 검증 기록 존재. +- **완료 증거:** `docs/sample/sample-review.md` 형식의 리뷰 문서, endpoint/side-effect 추적표, 모든 후보의 확정·오탐·보류 판정, + 후속 Goal 연결과 Progress 기록. +- **범위 밖:** 리뷰 도중 production code 수정, Phase 3 이후 기능 검토. +- **TDD 예외 사유:** 구현이 아닌 read-only 리뷰 Task다. +- **대체 검증 방법:** PRD·계약·production·test를 대조하고 현재 focused test를 실행해 관찰 결과를 리뷰 문서에 기록한다. + +**Files:** + +- Create: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Confirm: `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/AiCharacterAdminCharacterDto.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` + +- [x] 목록·검색·상세·생성·수정·비활성화 endpoint와 DTO 필드를 PRD/계약에 1:1로 추적한다. +- [x] 중복 이름, 외부 API, S3, 원작, 언어 이벤트, creatorMember 동기화와 실패 순서를 코드·test에 추적한다. +- [x] ADMIN 이중 인가, pagination, multipart/binding, KO/EN/JA 오류, private 정보 비노출 계약을 확인한다. +- [x] `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'`의 실제 결과를 기록한다. +- [x] 후보를 확정·오탐·보류로 판정하고 확정 항목을 아래 세부 Goal에 연결하거나 새 회귀 수정 Goal을 계획에 먼저 추가한다. + - 검증 기록: 무엇: `P2-R1` Phase 2 character slice read-only 리뷰. 왜: `P2-H1`, `P2-H2` 완료 이력만으로 Phase 2 Gate를 통과할 수 있는지 판정하기 위해. 어떻게: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md`를 작성하고 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'`를 실행했다. 결과: focused test는 `BUILD SUCCESSFUL in 51s`였고, `REV-001`~`REV-003`, `REV-007`, `REV-008`은 기존 `P2-T3`~`P2-T6` 보완 Goal에 연결된 확정 finding으로 판정했다. + +- [x] **Task 2.4: 캐릭터 목록·검색·상세 보완** + +**Goal 실행 `P2-T3`:** 캐릭터 조회 API의 검색·pagination·응답·target 계약을 독립적으로 검증하고 확정된 누락을 최소 수정한다. + +- **추적 review ID:** `REV-007` 중 목록 응답 field set과 DTO 경계. +- **시작 조건:** `P2-R1` 완료와 관련 review ID 확정. 확정 finding이 없으면 Decision Log에 `해당 없음` 근거를 남긴다. +- **완료 증거:** 조회 전용 RED/GREEN, focused test, DTO 비노출 점검과 Progress 기록. +- **범위 밖:** 생성, 수정, 외부 API·S3 mutation. + +**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` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt` + +- [x] **RED:** 목록 item의 exact JSON key를 고정하고 상세 전용 `creatorProfileImageUrl`, `creatorIntroduce`, `updatedAtUtc`가 노출되는 현재 동작을 실패로 재현한다. +- [x] **RED 확인:** `page=0`, `size` 기본 20·최소 20·최대 50, 검색·`hasNext`와 상세 target 불변식의 경계 test를 실행해 의도한 assertion 실패를 확인한다. +- [x] **GREEN:** 목록 전용 DTO와 mapper를 최소 구현해 계약 field만 반환하고 credential·token·private path를 노출하지 않는다. +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 exact 목록 field set과 pagination·target 계약이 모두 통과하는지 확인한다. +- [x] **REFACTOR:** 상세 UTC field 계약을 유지하면서 목록/상세 DTO 의존 방향을 점검하고 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: 목록 item의 상세 전용 field 비노출 계약. 왜: 목록 응답이 상세 DTO를 재사용해 `creatorProfileImageUrl`, `creatorIntroduce`, `updatedAtUtc`를 노출했기 때문이다. 어떻게: `AiCharacterAdminCharacterControllerTest`에 `doesNotExist()` assertion 3개를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerTest`를 실행했다. 결과: `목록은 음수 page와 최소 미만 size를 기본값으로 보정한다`가 line 66에서 실패해 RED를 확인했다. + - 검증 기록(GREEN/REFACTOR): 무엇: 목록 전용 DTO와 mapper 분리. 왜: 상세 응답 계약을 유지하면서 목록 field set만 Endpoint Contract Summary에 맞추기 위해. 어떻게: `AiCharacterAdminCharacterListItemResponse`와 `toListItemResponse`를 추가하고 목록 mapping만 교체한 뒤 같은 focused test와 `./gradlew ktlintCheck`를 실행했다. 결과: 둘 다 `BUILD SUCCESSFUL`이었다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerTest` + +- [x] **Task 2.5: 캐릭터 생성 흐름 보완** + +**Goal 실행 `P2-T4`:** 캐릭터 생성의 중복 검증, 외부 API, 이미지, 원작, creatorMember와 이벤트 흐름을 parity 기준으로 완결한다. + +- **추적 review ID:** `REV-002`, `REV-003`, `REV-007` 중 생성 request·`characterType` 계약. +- **시작 조건:** `P2-T3` 완료와 생성 관련 확정 review ID. +- **완료 증거:** 정상 생성 및 실패 지점별 RED/GREEN, DB/S3/외부 API/event 결과, focused/legacy test와 Progress 기록. +- **범위 밖:** 기존 external character API 계약 변경, 캐릭터 수정·비활성화. + +**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/AiCharacterAdminCharacterDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt` + +- [x] `systemPrompt`, `externalCharacterId`, 생성 `isActive`와 invalid `characterType`의 canonical request 계약을 Endpoint Contract Summary·legacy 특성화 결과로 확정하고 충돌 시 코드 수정 전에 Decision Log를 갱신한다. +- [x] **RED:** 확정된 문서 JSON의 역직렬화·반영, 중복 이름, 외부 API 실패, S3 실패와 존재하지 않는 `originalWorkId` 실패를 각각 재현한다. +- [x] **RED 확인:** 실패 지점별 DB row·creatorMember·원작 연결·S3 객체·외부 캐릭터·event 결과와 호출 순서를 단언해 현재 부분 저장 또는 고아 부작용을 확인한다. +- [x] **GREEN:** 모든 DB 참조를 외부 부작용 전에 검증하고, legacy parity에 맞는 최소 보상/정리 경계로 정상 생성과 실패 원자성을 통과시킨다. +- [x] **GREEN 확인:** 같은 생성 focused/characterization test를 다시 실행해 정상 결과와 실패 지점별 잔존 상태가 확정 계약과 일치하는지 확인한다. +- [x] **REFACTOR:** creatorMember 표시 정보와 언어 이벤트를 포함한 focused/legacy characterization test 및 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: v2 생성 request와 실패 순서 계약. 왜: 외부 ID·생성 활성 상태가 무시되고, 존재하지 않는 원작이 외부 생성 뒤에 실패했기 때문이다. 어떻게: `AiCharacterAdminCharacterControllerMutationTest`에 서버 소유 field, invalid `characterType`, 중복 이름, 원작, 외부 API, S3 실패와 creatorMember·언어 이벤트 assertion을 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`를 실행했다. 결과: 서버 소유 field는 400 기대 대비 200, 원작 실패는 외부 요청 0 기대 대비 1로 실패했다. S3 DB assertion의 최초 실패는 class-level test transaction 관찰 오류였으므로 해당 test만 transaction 밖에서 재실행해 DB rollback을 확인했다. + - 검증 기록(GREEN): 무엇: 서버 소유 create field 거부와 원작 선검증. 왜: `externalCharacterId`/생성 `isActive`를 client 입력으로 받지 않고, 존재하지 않는 원작에서 외부 캐릭터를 만들지 않기 위해. 어떻게: create DTO의 수신 field를 명시적으로 거부하고 facade에서 원작을 외부 호출 전에 조회한 뒤 같은 focused test를 실행했다. 결과: 10개 test가 `BUILD SUCCESSFUL`이었다. + - 검증 기록(REFACTOR): 무엇: 생성 focused/legacy parity 회귀와 formatting. 왜: 정상 원작 연결, AI creatorMember 표시 정보, 언어 감지 event, duplicate·외부 API·S3 실패의 결과를 legacy 특성화와 함께 유지하기 위해. 어떻게: 아래 Verify 명령과 `./gradlew ktlintCheck`를 실행했다. 결과: focused/legacy 명령은 `BUILD SUCCESSFUL in 40s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 11s`였다. 전체 `./gradlew test`는 task 범위가 character create slice이고 focused/legacy 명령으로 직접 영향 범위를 확인하므로 실행하지 않았다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest` + +- [x] **Task 2.6: 캐릭터 수정·비활성화 흐름 보완** + +**Goal 실행 `P2-T5`:** 캐릭터 수정과 `isActive=false`가 표시 정보를 동기화하고 연결 Member·콘텐츠를 보존하도록 완결한다. + +- **추적 review ID:** `REV-002`, `REV-003`, `REV-007` 중 수정 request·응답·soft-delete parity. +- **시작 조건:** `P2-T4` 완료와 수정·비활성화 관련 확정 review ID. +- **완료 증거:** 수정·이미지 유지/교체·soft delete RED/GREEN, 보존/no-partial-update 검증과 Progress 기록. +- **범위 밖:** hard delete, cascade delete, 복원 API. + +**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/AiCharacterAdminCharacterDto.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.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` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt` + +- [x] `externalCharacterId`, 일반 수정과 `isActive=false` 혼합 요청, invalid `characterType`의 canonical update 계약을 확정하고 충돌 시 Decision Log를 먼저 갱신한다. +- [x] **RED:** 문서 PUT JSON의 field 반영, soft delete와 일반 수정 혼합, image 동시 요청, 외부 수정 성공 후 S3/DB 실패, `updatedAtUtc`의 flush 전 mapping을 각각 재현한다. +- [x] **RED 확인:** soft delete 성공·실패에서 row·Member·콘텐츠 보존, 미참조 S3 객체 0건, 외부/DB 상태 일치와 응답 timestamp가 후속 GET과 같은지 확인한다. +- [x] **GREEN:** 확정 계약에 맞춰 혼합 요청을 명시적으로 처리하고, 불필요한 upload를 차단하며 외부/S3/DB 보상 경계와 flush 후 response mapping을 최소 구현한다. +- [x] **GREEN 확인:** 같은 수정 focused/characterization test를 다시 실행해 field 반영, 보상 결과, soft-delete 보존과 timestamp가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** creatorMember 표시 정보·번역 event와 legacy `characterType` 동작을 포함한 focused/characterization test 및 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: PUT의 서버 소유 external ID, `isActive=false` 혼합 image, 존재하지 않는 원작과 flush 전 timestamp 계약. 왜: 기존 구현이 client external ID를 무시하고 soft delete 전에 image를 업로드하며, 원작 검증 후 외부 수정과 이전 `updatedAtUtc`를 반환했기 때문이다. 어떻게: `AiCharacterAdminCharacterControllerMutationTest`에 해당 회귀, image 유지·교체, S3 실패 경계, creatorMember·콘텐츠 보존과 번역 event test를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`를 실행했다. 결과: 16건 중 server-owned ID, mixed soft delete, missing original work, post-flush timestamp 4건이 의도대로 실패했다. + - 검증 기록(GREEN/REFACTOR): 무엇: canonical update 계약과 post-flush 응답. 왜: soft delete의 고아 image를 막고 정상 수정의 creatorMember 동기화·번역 event·timestamp 및 외부/S3/DB 실패 경계를 고정하기 위해. 어떻게: `externalCharacterId` 명시 거부, `isActive=false` 혼합 거부, 원작 선검증, `flush()` 후 response mapping을 적용한 뒤 아래 Verify 명령과 `./gradlew ktlintCheck`를 실행했다. 결과: 모두 `BUILD SUCCESSFUL`이었다. 전체 `./gradlew test`는 변경 범위가 character update slice이고 지정 focused/legacy 회귀가 직접 영향 범위를 포함하므로 실행하지 않았다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest` + +- [x] **Task 2.7: Phase 2 보안·오류·회귀 보완** + +**Goal 실행 `P2-T6`:** Phase 2의 모든 endpoint가 공통 ADMIN·오류·CORS 계약을 공유하고 legacy/public 계약을 회귀시키지 않음을 고정한다. + +- **추적 review ID:** `REV-001`, `REV-007`, `REV-008`. +- **시작 조건:** `P2-T5` 완료 또는 앞선 Goal의 근거 있는 `해당 없음` 판정. +- **완료 증거:** endpoint 권한 매트릭스, 정확한 오류 status/key/KO·EN·JA, legacy 회귀와 Progress 기록. +- **범위 밖:** Phase 1 공통 security/error 구조 재설계, Phase 3 기능. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **RED:** `/api/v2/admin/ai-characters/unmapped-path`가 detail `Long` binding에 잡혀 404 대신 400이 되는 KO/EN/JA·허용 Origin CORS 4건을 현재 Phase 1 오류 계약 test로 재현한다. +- [x] **RED:** 목록·상세·생성·수정 각각의 JWT role × DB role, stale ADMIN claim과 binding·multipart·domain/client/server 오류의 exact status/key/KO·EN·JA를 parameterized test로 고정한다. +- [x] **RED 확인:** 오류 계약과 실제 endpoint matrix를 실행해 404 회귀 4건과 누락된 인가·i18n assertion이 의도대로 실패하는지 확인한다. +- [x] **GREEN:** numeric `characterId`만 resource handler에 매핑되도록 최소 수정하고, Phase 2 오류 의미를 확정된 message key와 `ApiResponse.error`로 반환한다. +- [x] **GREEN 확인:** 같은 오류·인가 focused test를 다시 실행해 실제 endpoint의 status/header/envelope와 KO/EN/JA가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** 실제 test 파일 목록과 targeted 명령을 대조해 존재하지 않는 `AiCharacterAdminCharacterServiceTest` 참조 및 과거 test 수 기록은 삭제하지 않고 정정 기록을 누적한다. +- [x] 기존 admin/public character contract, 신규 DTO 의존 방향과 Phase 2 focused test·`ktlintCheck` 결과를 Progress에 기록한다. + - 정정 기록: `AiCharacterAdminCharacterServiceTest`는 현재 존재하지 않는 과거 계획 참조다. P2-T6의 실제 범위는 `AiCharacterAdminCharacterControllerTest`, `AiCharacterAdminCharacterControllerMutationTest`, 공통 `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`이며 기존 완료 이력과 과거 test 수 기록은 삭제하지 않았다. + - 검증 기록(RED): 무엇: 실제 character controller가 문자열 미매핑 경로를 404 fallback으로 넘기는지와 endpoint matrix. 왜: `/{characterId}`의 `Long` binding이 fallback 404 계약을 400으로 바꾸고 있었기 때문이다. 어떻게: 아래 Verify 명령을 production 변경 전 실행했다. 결과: 전체 146건 중 `unmapped-path` KO/EN/JA와 허용 Origin CORS 4건만 404 기대 대비 400으로 실패했고, 새 목록 binding·상세 target·생성/수정 multipart i18n 및 실제 endpoint non-ADMIN/CORS assertion은 통과했다. + - 검증 기록(GREEN/REFACTOR): 무엇: numeric path 제약과 Phase 2 ADMIN/error/CORS/legacy 회귀. 왜: 문자열 segment는 fallback 404로, 숫자 resource와 기존 public/legacy는 기존 계약으로 유지해야 하기 때문이다. 어떻게: controller의 GET/PUT path를 `[0-9]+`로 제한한 뒤 아래 Verify 명령과 `./gradlew ktlintCheck`를 실행했다. 결과: focused command는 `BUILD SUCCESSFUL in 1m 26s`, ktlint는 `BUILD SUCCESSFUL in 34s`였고, P2-GATE는 이 Task 범위 밖으로 미완료 상태를 유지한다. +- Verify: `./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` + +#### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** Phase 2 캐릭터 관리의 PRD 추적성, 정상·실패 흐름과 legacy 회귀를 최종 판정한다. + +- [x] **`P2-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. + +- **시작 조건:** `P2-R1`, `P2-T3`~`P2-T6` 완료 또는 근거 있는 `해당 없음` 판정. +- **완료 증거:** 아래 명령 성공, review 후보 0건, 확정 finding 처리 완료와 Progress 기록. +- **범위 밖:** Gate 통과를 위한 test 삭제·완화, Phase 3 기능 수정. + +- [x] `REV-001`~`REV-003`, `REV-007`, `REV-008`의 계약 결정·failure matrix·수정 test와 실제 결과가 각 소유 Goal의 Progress에 연결됐다. +- [x] 캐릭터 목록 exact key, 문서 mutation JSON, 동시 중복 결과, 외부/S3/DB 보상, post-flush `updatedAtUtc`와 실제 endpoint 권한·i18n matrix에 미결정 항목이 없다. +- [x] 완료 이력의 누락 test 파일·test 수·failure-order 증거는 원문을 삭제하지 않고 최신 정정 기록으로 재현 가능하게 남겼다. + - 검증 기록: 무엇: `P2-GATE` Phase 2 최종 판정. 왜: `P2-R1`, `P2-T3`~`P2-T6`의 확정 finding 처리와 Gate 명령 성공을 확인하기 위해. 어떻게: 아래 세 Gate 명령을 실행했다. 결과: character focused 명령은 최초 병렬 실행 중 XML test result write 충돌로 실패했으나 동일 명령 단독 재실행은 `BUILD SUCCESSFUL in 1m 11s`였다. authorization/error 명령은 `BUILD SUCCESSFUL in 1m 30s`, `ktlintCheck`는 `BUILD SUCCESSFUL`이었다. `git diff --check`도 통과했다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*' +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +**Expected:** 모든 명령 exit code 0, Phase 2 review의 후보·확정 미처리 항목 0건, 관련 legacy/public 계약 diff 없음. + +#### Phase 2 후속 리뷰 보완 + +- [x] **Task 2.8: 실제 character endpoint 보안·오류·실패 경계 증거 보강** + +**Goal 실행 `P2-R2`:** `REV-009`에서 확인한 실제 character endpoint별 인가·CORS·오류와 mutation 실패 경계를 non-vacuous 회귀 test로 고정한다. + +- **추적 review ID:** `REV-009`. +- **시작 조건:** 기존 `P2-GATE` 완료 이력과 `phase2-character-review.md` 2차 리뷰 판정 존재. +- **완료 증거:** 아래 실제 endpoint test, 공통 authorization/error 회귀, `ktlintCheck`와 Progress 기록. +- **범위 밖:** 공통 Phase 1 security/error 재설계, external API 보상 endpoint·신규 DDL 추가, Phase 3 기능. +- **TDD 예외 사유:** 현재 production 실패가 아니라 완료 기록 대비 직접 검증 증거 누락이 확정된 test 보강 Task다. +- **대체 검증 방법:** 실제 endpoint test를 먼저 추가하고, 현재 동작이 계약을 만족하면 production code 변경 없이 통과 증거를 기록한다. 계약 불일치가 재현될 때만 해당 assertion의 RED를 확인하고 최소 수정한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt` +- 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/AiCharacterAdminAuthorizationTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **CONTRACT TEST:** 목록·상세·생성·수정 실제 경로에서 JWT role × 현재 DB role과 stale ADMIN claim을 parameterized test로 고정한다. +- [x] **CONTRACT TEST:** 실제 GET/POST/PUT의 허용·거부 Origin/preflight와 대표 binding·domain·client·server 오류의 exact status/key/KO·EN·JA envelope를 고정한다. +- [x] **FAILURE TEST:** 중복·원작·external API·S3·DB 실패에서 DB/creatorMember/originalWork/S3/external/event 결과를 직접 단언하고 기존 non-compensated external 경계를 유지한다. +- [x] **GREEN:** 새 test가 현재 계약 불일치를 재현할 때만 가장 작은 production 수정으로 통과시키고, 이미 통과하면 production code를 변경하지 않는다. +- [x] **REFACTOR:** character focused와 legacy characterization, 공통 authorization/error 및 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록: 무엇: `REV-009`의 실제 character endpoint 증거 보강. 왜: 기존 Gate 기록이 네 endpoint의 stale claim, allow/deny CORS preflight, KO/EN/JA 실패 envelope를 직접 매트릭스로 고정했다는 증거가 부족했기 때문이다. 어떻게: `AiCharacterAdminCharacterControllerTest`에 목록·상세·생성·수정 실제 경로의 stale ADMIN claim 403과 허용/거부 Origin preflight를 추가하고, `AiCharacterAdminCharacterControllerMutationTest`의 external API 실패, 생성 S3 실패, 수정 S3 실패를 KO/EN/JA envelope와 잔존 DB/S3/external 상태 단언으로 확장했다. 결과: production code 변경 없이 아래 focused 명령이 `BUILD SUCCESSFUL in 1m 47s`였다. + +```bash +./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 +``` + +#### Phase 2 후속 리뷰 Gate + +**Goal 실행 `P2-R2-GATE`:** `REV-009`의 직접 증거를 재검토하고 Phase 2 후속 리뷰를 종결한다. + +- [x] **`P2-R2-GATE` 완료:** `P2-R2` 완료 후 fresh 검증과 리뷰 문서 수정 후 기록을 남긴다. +- **시작 조건:** `P2-R2` 완료. +- **완료 증거:** `REV-009` 수정 완료, 위 두 명령 성공, `phase2-character-review.md` 최신 결론과 Progress 동기화. +- **범위 밖:** 기존 `P2-GATE` 이력 수정, Phase 3 production 변경. + - 검증 기록: 무엇: `P2-R2-GATE` 후속 리뷰 종결. 왜: `REV-009`의 직접 증거가 추가됐고 Phase 3 후속 보완으로 넘어갈 수 있는지 판정하기 위해. 어떻게: `phase2-character-review.md`에 3차 후속 검증 기록을 누적하고 위 focused 명령을 fresh 실행했다. 결과: `BUILD SUCCESSFUL in 1m 47s`였고, `REV-009`는 처리 완료로 판정했다. `ktlintCheck`는 Phase 3 후속 보완까지 완료한 뒤 공통으로 실행해 전체 후속 범위 검증 기록에 남긴다. + +#### Phase 2 4차 리뷰 보완 + +- [x] **Task 2.9: character DB·event 실패 경계 증거 보강** + +**Goal 실행 `P2-R3`:** `REV-012`에서 남은 character 생성·수정의 DB/event 실패 후 내부·외부 부작용 경계를 실제 흐름으로 고정한다. + +- **추적 review ID:** `REV-012`. +- **시작 조건:** 기존 `P2-R2-GATE` 완료 이력과 `phase2-character-review.md` 4차 리뷰 판정 존재. +- **완료 증거:** 실제 mutation failure test, character/common 회귀, `ktlintCheck`와 Progress 기록. +- **범위 밖:** external character API 보상 endpoint 추가, 신규 DDL, Phase 3 이후 production 변경. +- **TDD 예외 사유:** 현재 production 실패가 아니라 `Task 2.8` 완료 기록 대비 직접 검증 증거 누락이 확정된 test 보강 Task다. +- **대체 검증 방법:** 실제 DB flush/save 또는 event publish 실패를 먼저 재현하고, 현재 transaction·비보상 경계가 계약과 일치하면 production code 변경 없이 관찰 결과를 고정한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- 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` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **FAILURE CHARACTERIZATION:** 생성·수정의 DB flush/save와 event publish 실패를 실제 transaction 경계에서 재현한다. +- [x] **CONTRACT TEST:** 각 실패 뒤 ChatCharacter·creatorMember·originalWork·event와 이미 발생한 external/S3 결과를 직접 단언한다. +- [x] **CONTRACT TEST:** 대표 실패의 exact HTTP status와 KO/EN/JA `ApiResponse.error`를 실제 mutation endpoint에서 확인한다. +- [x] **GREEN:** 현재 계약 위반이 재현될 때만 최소 production 수정으로 통과시키고, 기존 비보상 경계와 일치하면 test-only로 종료한다. +- [x] **REFACTOR:** character/common focused 회귀와 `ktlintCheck` 결과를 Progress와 리뷰 수정 후 기록에 누적한다. + - 검증 기록: 무엇: `REV-012`의 character 생성·수정 실패 경계 증거를 보강했다. 왜: 기존 완료 기록이 external/S3 실패는 확인했지만 event publish 실패와 KO/EN/JA 대표 실패 경계를 직접 고정하지 않았기 때문이다. 어떻게: `AiCharacterAdminCharacterControllerMutationTest`에 external/S3 실패 locale matrix와 facade 직접 event 실패 특성화를 추가했다. 결과: production code 변경 없이 mutation focused 명령은 `BUILD SUCCESSFUL in 1m 1s`였다. + +```bash +./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 +``` + +#### Phase 2 4차 리뷰 Gate + +**Goal 실행 `P2-R3-GATE`:** `REV-012`의 DB/event 실패 경계 증거를 재검토하고 Phase 2 4차 리뷰를 종결한다. + +- [x] **`P2-R3-GATE` 완료:** `P2-R3` 완료 후 fresh 검증과 리뷰 문서 수정 후 기록을 남긴다. +- **시작 조건:** `P2-R3` 완료. +- **완료 증거:** `REV-012` 처리 완료, 위 두 명령 성공, `phase2-character-review.md` 최신 결론과 Progress 동기화. +- **범위 밖:** 기존 Phase 2 완료 이력 수정, Phase 3 production 변경. + - 검증 기록: 무엇: Phase 2 4차 리뷰의 `REV-012` 처리를 종결했다. 왜: Phase 3 4차 보완으로 넘어가기 전 character failure evidence 완료 여부를 판정하기 위해. 어떻게: character mutation focused test와 최종 `ktlintCheck`를 실행하고 `phase2-character-review.md`를 처리 완료로 갱신했다. 결과: mutation focused 명령은 `BUILD SUCCESSFUL in 1m 1s`, 최종 `ktlintCheck`는 `BUILD SUCCESSFUL in 17s`였다. + +#### Phase 2 5차 리뷰 보완 + +- [x] **Task 2.10: character 실제 transaction DB·event 실패 경계 완결** + +**Goal 실행 `P2-R4`:** `REV-015`의 생성·수정 persistence/event 실패를 actual endpoint와 Spring transaction 경계에서 재현하고 내부 rollback·외부 비보상 결과를 고정한다. + +- **추적 review ID:** `REV-015`. +- **시작 조건:** 기존 `P2-R3-GATE` 완료 이력과 `phase2-character-review.md` 5차 리뷰 판정 존재. +- **완료 증거:** actual POST/PUT failure test, transaction 종료 뒤 DB 재조회, character/common 회귀와 `ktlintCheck` 결과 및 Progress 기록. +- **범위 밖:** external character API 보상 endpoint, S3 object 정리 정책, 신규 DDL, Phase 3 이후 production 변경. +- **TDD 예외 사유:** 현재 production 결함보다 기존 `REV-012` 완료 기록의 transaction/rollback 직접 증거 누락이 확정된 회귀 검증 Task다. +- **대체 검증 방법:** test transaction 밖 actual endpoint와 repository/publisher failure injection으로 production proxy를 통과시키고, 요청 종료 뒤 내부 DB와 외부 interaction을 재조회한다. 계약 위반이 재현될 때만 최소 production 수정으로 전환한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` +- 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/AiCharacterAdminErrorContractTest.kt` + +- [x] **FAILURE CHARACTERIZATION:** `@Transactional(propagation = Propagation.NOT_SUPPORTED)` actual POST/PUT에서 `ApplicationEventPublisher` 실패를 주입하고 500 `common.error.unknown` envelope을 확인한다. +- [x] **PERSISTENCE FAILURE:** `@SpyBean ChatCharacterRepository`로 생성 `save`와 수정 `flush()` 실패를 각각 주입해 external 호출 뒤 transaction rollback 순서를 재현한다. +- [x] **CONTRACT TEST:** 생성 실패 뒤 ChatCharacter·creatorMember·originalWork 부재, 수정 실패 뒤 기존 character·creatorMember·originalWork 상태 유지, S3/event interaction과 external 호출 횟수를 직접 단언한다. +- [x] **CONTRACT TEST:** 대표 event/persistence 실패의 KO/EN/JA exact HTTP status/message를 actual mutation endpoint에서 확인한다. +- [x] **GREEN:** 현재 transaction 계약 위반이 재현될 때만 가장 작은 production 수정으로 통과시키고, 기존 rollback·비보상 경계와 일치하면 test-only로 종료한다. +- [x] **REFACTOR:** direct `createFacade` event failure test를 actual endpoint 증거로 대체하거나 역할을 명확히 축소하고 character/common 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./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 +``` + +- 검증 기록: 무엇: `REV-015`의 생성·수정 event/save/flush 실패가 실제 MockMvc POST/PUT와 facade transaction 경계를 통과해 내부 DB rollback과 외부 비보상 호출을 보이는지 고정했다. 왜: direct facade 호출은 Spring transaction proxy 및 요청 종료 뒤 DB 상태를 증명하지 못했기 때문이다. 어떻게: `NOT_SUPPORTED` test에서 facade proxy target의 publisher mock과 `@SpyBean ChatCharacterRepository` failure를 주입하고, `TransactionTemplate` 재조회로 상태를 확인했다. 결과: 초기 RED는 `@MockBean`이 이미 생성된 facade field를 대체하지 못해 실제 event listener가 실행되고 200이 반환된 것으로 확인됐으며, 실제 proxy target에 같은 mock을 교체한 뒤 KO/EN/JA 500 envelope, 생성 내부 state 부재, 수정 기존 state 유지, 외부 호출 1회와 image 없는 S3 미호출이 통과했다. production 변경은 없었다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`는 `BUILD SUCCESSFUL in 1m 8s`, 영향 범위 character/auth/error 회귀는 `BUILD SUCCESSFUL in 1m 59s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 42s`였다. + +#### Phase 2 5차 리뷰 Gate + +**Goal 실행 `P2-R4-GATE`:** `REV-015`의 actual transaction·rollback 증거를 재검토하고 Phase 2 5차 리뷰를 종결한다. + +- [x] **`P2-R4-GATE` 완료:** `P2-R4` 완료 후 위 명령을 fresh 실행하고 리뷰 문서·Progress를 갱신한다. +- **시작 조건:** `P2-R4` 완료. +- **완료 증거:** `REV-015` 수정 완료, actual endpoint transaction evidence, focused/영향 범위 회귀와 lint 성공. +- **범위 밖:** 기존 Phase 2 완료 이력 수정, Phase 3 production 변경. + - 진행 기록: `P2-R4` 구현과 fresh 검증을 완료했고, 5차 재리뷰에서 `REV-015` 보완 완료와 Phase 2 Gate 종료를 확인했다. + +#### Phase 2 6차 리뷰 보완 + +- [x] **Task 2.11: 캐릭터 생성 Endpoint Contract Summary 동기화** + +**Goal 실행 `P2-R5`:** `REV-018`의 캐릭터 생성 예시를 `DEC-P2-T4-001` 및 production request 계약과 일치시키고 +actual endpoint 회귀로 확인한다. + +- **추적 review ID:** `REV-018`. +- **시작 조건:** 기존 `P2-R4-GATE` 완료 이력과 `phase2-character-review.md` 6차 리뷰 판정 존재. +- **완료 증거:** Endpoint Contract Summary 생성 JSON 정정, 정상 생성·서버 소유 field 거부 actual endpoint 회귀, + `git diff --check`와 Progress 기록. +- **범위 밖:** production DTO/facade 변경, 캐릭터 생성 behavior 변경, 외부 API 계약 변경, Phase 3 이후 production 변경. +- **TDD 예외 사유:** production과 기존 actual endpoint test는 확정 계약을 충족하고 문서 예시만 반대로 남은 문서 정합성 + 수정 Task다. +- **대체 검증 방법:** 생성 예시의 exact field를 문자열 검색으로 확인하고 기존 정상 생성·서버 소유 field 거부 actual endpoint + test를 재실행한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt` + +- [x] **CONTRACT DOC:** POST 생성 예시에 필수 `systemPrompt`를 추가하고 request의 `externalCharacterId`, `isActive`를 + 제거한다. +- [x] **CONTRACT DOC 확인:** 생성 예시와 `DEC-P2-T4-001`을 대조해 request field와 response 전용 field가 일치하는지 + 확인한다. +- [x] **CONTRACT TEST:** `systemPrompt`를 포함하고 서버 소유 field를 제외한 actual POST가 성공하며 response에 외부 API가 + 반환한 `externalCharacterId`와 서버 생성 `isActive=true`가 있는지 확인한다. +- [x] **REJECTION TEST:** `externalCharacterId` 또는 생성 `isActive`가 포함된 actual POST가 외부/S3/DB/event 부작용 전 + 400으로 거부되는 기존 회귀를 확인한다. +- [x] **REFACTOR:** production 변경 없이 문서 diff와 focused test 결과를 Progress에 기록한다. + - 검증 기록: 무엇: `REV-018`의 캐릭터 생성 Endpoint Contract Summary 예시를 production 생성 request 계약과 동기화했다. 왜: 예시가 필수 `systemPrompt`를 누락하고 서버 소유 `externalCharacterId`, `isActive`를 포함했기 때문이다. 어떻게: `rg -n -A 12 'POST /api/v2/admin/ai-characters' docs/20260724_AI캐릭터_관리자_API/plan-task.md`로 예시 field를 확인하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest`를 실행했다. 결과: 예시는 `systemPrompt` 포함 및 서버 소유 field 제외로 확인됐고 focused test는 `BUILD SUCCESSFUL in 30s`였다. + +```bash +rg -n -A 12 'POST /api/v2/admin/ai-characters' docs/20260724_AI캐릭터_관리자_API/plan-task.md +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest +git diff --check +``` + +#### Phase 2 6차 리뷰 Gate + +**Goal 실행 `P2-R5-GATE`:** `REV-018`의 문서 계약 정합성을 재검토하고 Phase 2 6차 리뷰를 종결한다. + +- [x] **`P2-R5-GATE` 완료:** `P2-R5` 완료 후 위 문서/focused 검증을 fresh 실행하고 리뷰 문서·Progress를 갱신한다. +- **시작 조건:** `P2-R5` 완료. +- **완료 증거:** `REV-018` 처리 완료, Endpoint Contract Summary·Decision Log·production 계약 일치, focused test와 + 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 + +#### 목표 +선택한 AI 캐릭터 소유 오디오 콘텐츠 목록/검색/상세/생성/수정/soft delete, 댓글 CRUD와 관리자 재생용 signed URL을 +제공한다. + +#### 범위와 비범위 +- 포함: 콘텐츠 owner 검증, 기존 파일 처리/가격/공개/예약/번역/알림 parity, 댓글 root/reply 조회·작성·수정·soft + delete, `AudioContentCloudFront` 재사용, private path 비노출. +- 제외: 콘텐츠 구매/좋아요, 캐릭터 직접 댓글, 댓글 hard delete·cascade, content upload/processing pipeline 변경, + community audio 30분 정책 통합. + +#### 선행 Phase 및 의존성 +- Phase 1 target resolver. +- 콘텐츠 생성/수정/delete 기존 동작 특성화 테스트. + +#### API endpoint와 request/response contract +- `GET /api/v2/admin/ai-characters/audio-content-themes` + - Request: query/body 없음. + - Response: `List`. +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents?search_word=&page=&size=` -> + `GetCreatorAdminContentListResponse` 전체 필드. +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}?timezone=Asia/Seoul` -> + `GetAudioContentDetailResponse` 전체 nested 필드. +- `POST /api/v2/admin/ai-characters/{characterId}/audio-contents` + - multipart 필수 `contentFile`, `coverImage`, `request: CreateAudioContentRequest`. + - Response: `CreateAudioContentResponse(contentId)`. +- `PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` + - 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`에서 레거시 계약으로 정합화한다. + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `AudioContent` owner-scoped query adapter 추가 가능. +- Service: 신규 content admin application service에서 기존 creator/admin content service를 테스트로 고정 후 선택 위임 또는 최소 추출한다. +- CloudFront: 기존 `AudioContentCloudFront` 그대로 주입해 사용한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- write transaction 시작 직후 target과 `content.member.id == creatorMember.id`를 검증한다. +- S3 업로드, 이벤트 발행 순서는 기존 동작 parity를 따른다. +- soft delete는 기존 콘텐츠 삭제 동작처럼 `isActive=false`, 필요한 경우 `releaseDate=null`을 유지한다. + +#### 보안 및 개인정보 위험 +- private S3 object path, signed key material을 응답하지 않는다. +- 다른 캐릭터 content ID 접근은 4xx와 no side effect다. + +#### acceptance criteria +- target 캐릭터 소유 콘텐츠만 조회/변경된다. +- signed URL 만료 계산이 기존 creator admin policy와 동일하다는 테스트가 있다. +- signed URL 만료 계산·path 처리에서 실제 기존 구현에서 관찰되는 edge case가 특성화 테스트로 고정된다. +- invalid target/ownership 실패 시 DB/S3/event side effect가 없다. + +#### targeted test +- Characterization: `LegacyCreatorAdminAudioContentCharacterizationTest`, `AudioContentCloudFrontCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminAudioContentServiceTest`, `AiCharacterAdminAudioSignedUrlTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'` + +#### 전체 회귀 테스트 영향 +- 기존 `CreatorAdminContentController`, `AdminContentController`, public content 조회 테스트가 통과해야 한다. + +#### rollback 전략 +- 신규 content v2 admin route/facade를 제거한다. +- 신규 DDL이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin content slice` + +- [x] **Task 3.1: 기존 콘텐츠와 signed URL 특성화 baseline 고정** + - **Goal 이력 `P3-H1`:** 기존 creator/admin 콘텐츠와 signed URL 동작을 신규 v2 비교 기준으로 고정했다. + - **완료 증거:** 아래 특성화 테스트와 `ktlintCheck` 실행 기록. + - **범위 밖:** 신규 v2 content endpoint 구현. + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt` + - CHARACTERIZE: 기존 creator/admin content 구현을 대상으로 검증, 파일 처리, 가격, 공개/예약, 번역/알림, soft delete, upload/processing pipeline 결과를 고정한다. + - CHARACTERIZE: 기존 creator admin signed URL 만료 계산식과 만료 계산·path 처리에서 실제로 관찰되는 edge case, private path 비노출 계약을 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 두 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: 테스트 fixture만 정리하고 content/signed URL parity baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*CharacterizationTest'` + - 검증 기록 (2026-07-26): 위 특성화 테스트 6건과 `./gradlew ktlintCheck`가 모두 통과했다. + +- [x] **Task 3.2: content controller/facade/DTO 구현** + - **Goal 이력 `P3-H2`:** 콘텐츠 조회·생성·수정의 최초 v2 API 구현을 제공했다. + - **완료 증거:** 아래 RED/GREEN 및 후속 보완 기록. 이 이력만으로 Phase 3 Gate 통과를 의미하지 않는다. + - **범위 밖:** 후속 심층 리뷰에서 확정되는 누락·회귀 보완. + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/*` + - RED: Task 3.1 baseline에서 옮긴 content parity, cross-character content 접근, signed URL/private path 계약 테스트와 controller 권한/페이지네이션 경계 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - GREEN: content endpoint와 owner-scoped query/write를 구현하고 목록/검색 `page/size` 기본값·최소·최대 보정을 적용한다. + - REFACTOR: signed URL/private path mapping과 기존 pipeline 재사용 경계를 정리하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'` + - 구현 기록(2026-07-26): `GET` 목록/검색·상세와 `PUT` 수정/soft delete를 target resolver와 owner-scoped query로 구현했다. 응답은 v2 DTO만 사용하며 private content path 필드를 반환하지 않는다. + - 구현 보완(2026-07-27): `POST` 생성을 기존 `AudioContentService.createAudioContent` 재사용 방식으로 제공한다. v2 adapter에서 `themeId`, ISO-8601 `releaseDateUtc`, owner-scoped `seriesIds` 연결을 처리하고, 업로드 완료 전 `isActive` 직접 활성화는 기존 processing pipeline parity를 위해 수행하지 않는다. + - 검증 기록(RED, 2026-07-26): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'`를 실행했고, 신규 route 부재로 목록은 404, 상세와 PUT은 404/405여서 의도대로 실패함을 확인했다. + - 검증 기록(GREEN, 2026-07-26): `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'`와 `./gradlew ktlintCheck`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. + - 검토 보완(2026-07-26): 상세 응답에 target owner 범위의 활성 series ID 목록을 추가하고, 다른 캐릭터 콘텐츠 PUT이 DB를 변경하지 않는 통합 테스트를 추가했다. + - 재검증(2026-07-26): `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'`를 실행해 `BUILD SUCCESSFUL`을 확인했다. + - 검토 보완(2026-07-27): 생성/수정의 `seriesIds` owner-scoped 연결, 생성의 ISO-8601 `releaseDateUtc` 변환, POST 제공 문서 불일치를 보완했다. 수정 `audioFile` 교체는 기존 creator/admin 수정 pipeline에 없어 Phase 3에서 미지원으로 명시했다. + +- [x] **Task 3.3: Phase 3 요구사항·계약·코드 리뷰** + +**Goal 실행 `P3-R1`:** PRD Feature C와 Endpoint Contract Summary를 콘텐츠 코드·테스트에 추적해 확정된 누락만 후속 Goal로 전환한다. + +- **추적 review ID:** `REV-004`, `REV-005`, `REV-006`, `REV-007`, `REV-008`. +- **시작 조건:** `P2-GATE`, `P3-H1`, `P3-H2` 완료 증거 존재. +- **완료 증거:** `docs/sample/sample-review.md` 형식의 리뷰 문서, endpoint·pipeline·side-effect 추적표, 모든 후보 판정과 Progress 기록. +- **범위 밖:** 리뷰 도중 production code 수정, series/community 기능 검토. +- **TDD 예외 사유:** 구현이 아닌 read-only 리뷰 Task다. +- **대체 검증 방법:** PRD·계약·production·test를 대조하고 focused test를 실행해 실제 결과를 리뷰 문서에 기록한다. + +**Files:** + +- Create: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt` +- Confirm: `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/AiCharacterAdminAudioContentControllerTest.kt` + +- [x] 테마·목록·검색·상세·생성·수정·soft delete endpoint와 DTO 필드를 PRD/계약에 추적한다. +- [x] signed URL TTL/path, private path 비노출, viewer 상태 기본값을 production·test에 추적한다. +- [x] 생성/update pipeline, 파일, 가격, 공개·예약, 번역·알림, `seriesIds`, 날짜 변환과 실패 순서를 확인한다. +- [x] owner 검증, no-side-effect, ADMIN 인가, 오류 i18n, pagination/multipart 계약을 확인한다. +- [x] `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'` 결과와 후보 판정을 리뷰 문서에 기록한다. + - 검증 기록: 무엇: Phase 3 오디오 콘텐츠 slice의 read-only 요구사항·계약·코드 리뷰. 왜: `P3-H1`, `P3-H2` 완료 이력 이후 `REV-004`~`REV-008`의 실제 확정 여부와 후속 소유 Goal을 고정하기 위해. 어떻게: `docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md`에 PRD Feature C, Endpoint Contract Summary, production/test 대조표와 발견 사항을 기록하고 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'`를 실행했다. 결과: focused test는 `BUILD SUCCESSFUL in 2s`였고, `REV-004`~`REV-008`은 각각 `P3-T3`~`P3-T7`의 기존 소유 Goal로 연결했다. 리뷰 Task이므로 production code는 수정하지 않았다. + +- [x] **Task 3.4: 활성 콘텐츠 테마 API 보완** + +**Goal 실행 `P3-T3`:** 활성 콘텐츠 테마를 전용 `themeId/themeName/imageUrl` DTO로 반환하는 관리자 API를 완결한다. + +- **추적 review ID:** `REV-007`, `REV-008` 중 v2 DTO 경계와 endpoint 인가. +- **시작 조건:** `P3-R1` 완료와 테마 endpoint review 판정. +- **완료 증거:** request body 없는 GET, 활성 필터, DTO field contract RED/GREEN과 Progress 기록. +- **범위 밖:** 테마 CRUD, legacy DTO 외부 노출. + +**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` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt` + +- [x] **RED:** 테마 endpoint 부재 또는 계약 불일치와 legacy field 노출을 재현하는 가장 작은 실패 test를 작성한다. +- [x] **RED 확인:** focused test를 실행해 의도한 route·field assertion 실패를 확인한다. +- [x] **GREEN:** 활성 테마만 `themeId`, `themeName`, `imageUrl`로 반환하는 최소 구현을 작성한다. +- [x] **GREEN 확인:** request body 없음, exact field set과 ADMIN 이중 인가를 포함한 focused test 성공을 확인한다. +- [x] **REFACTOR:** v2 DTO 경계만 정리하고 테마 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록: 무엇: `P3-T3`의 활성 콘텐츠 테마 endpoint 계약 증거를 전용 focused test로 분리했다. 왜: production 동작은 이미 `AiCharacterAdminAudioContentController`/`Facade`/DTO에서 충족하고 있었지만, `REV-007`, `REV-008` 기준 Gate 증거가 단일 대형 controller test에 섞여 있었기 때문이다. 어떻게: `AiCharacterAdminAudioContentThemeControllerTest`를 추가해 활성 필터, orders 정렬, `themeId/themeName/imageUrl` exact field, legacy `id/theme/image` 비노출과 anonymous 401을 검증하고 기존 controller test의 중복 테마 케이스를 제거했다. 결과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentThemeControllerTest`는 `BUILD SUCCESSFUL in 35s`, 테마+기존 controller focused 회귀는 `BUILD SUCCESSFUL in 48s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 11s`였다. production code는 추가하지 않았다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentThemeControllerTest` + +- [x] **Task 3.5: 콘텐츠 목록·상세·signed URL 보완** + +**Goal 실행 `P3-T4`:** owner-scoped 콘텐츠 조회와 signed URL·상세 DTO 계약을 독립적으로 완결한다. + +- **추적 review ID:** `REV-005`, `REV-007` 중 상세 parity와 v2 전용 DTO 경계. +- **시작 조건:** `P3-T3` 완료와 조회/signed URL 관련 review 판정. +- **완료 증거:** 검색·status·pagination·상세·TTL/path/private 정보 RED/GREEN과 Progress 기록. +- **범위 밖:** 콘텐츠 생성·수정, community audio 30분 정책. + +**Files:** + +- 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/AiCharacterAdminAudioContentDto.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` +- 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/AudioContentCloudFrontCharacterizationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` + +- [x] **RED:** `purchaseOption=RENT_ONLY`, entity `isOnlyRental=true`, 미래·과거 `releaseDate` 조합에서 legacy 상세와 다른 `isOnlyRental`, `purchaseOption`, `releaseDate`를 재현한다. +- [x] **RED:** 응답의 creator·buyer·other content·comment·translation 중첩 타입이 legacy/public DTO package에 직접 의존하는 현재 경계를 검출하고 exact JSON key를 고정한다. +- [x] **RED 확인:** query/legacy baseline test를 실행해 세 compatibility field와 금지 DTO 의존이 의도대로 실패하는지 확인한다. +- [x] **GREEN:** legacy 파생 규칙과 현지화된 `releaseDate` 의미를 유지하고 UTC 원본은 `releaseDateUtc`에만 반환하며, 동일 JSON을 v2 전용 중첩 DTO로 최소 매핑한다. +- [x] **GREEN 확인:** 같은 query/legacy baseline test를 다시 실행해 legacy compatibility field, `releaseDateUtc`와 exact JSON schema가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** owner·검색·status·pagination, 활성 owner-scoped `seriesIds`, viewer 기본값, signed URL TTL/path와 private 정보 비노출을 함께 회귀한다. +- [x] 조회/signed URL focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: 상세 응답의 legacy `releaseDate` 의미, RENT_ONLY 파생값과 v2 전용 중첩 DTO 경계. 왜: `REV-005`, `REV-007`에서 상세 DTO가 legacy/public 중첩 DTO에 직접 의존하고, 과거 공개일을 legacy `releaseDate`에도 노출하고 있었기 때문이다. 어떻게: `AiCharacterAdminAudioContentQueryTest`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest`를 실행했다. 결과: 2건이 의도대로 실패했다. 하나는 `releaseDate`가 존재해서 실패했고, 하나는 legacy nested DTO package 누출 assertion으로 실패했다. + - 검증 기록(GREEN/REFACTOR): 무엇: 상세 응답의 `releaseDateUtc` 전용 노출, RENT_ONLY 파생 규칙, v2 전용 중첩 DTO. 왜: 관리자 상세는 UTC 원본을 `releaseDateUtc`에만 고정하고, response DTO는 legacy/public DTO 타입을 외부 계약으로 재노출하지 않아야 하기 때문이다. 어떻게: `AiCharacterAdminAudioContentDto`에 v2 중첩 response DTO를 추가하고, `AiCharacterAdminAudioContentMapper`의 `releaseDate`, `isOnlyRental`, `purchaseOption`, `creator` mapping만 최소 수정했다. 결과: `AiCharacterAdminAudioContentQueryTest`는 `BUILD SUCCESSFUL in 29s`, 계획서 Verify 묶음은 `BUILD SUCCESSFUL in 41s`, `./gradlew ktlintCheck`는 최초 unused import 2건으로 실패 후 정리 재실행에서 `BUILD SUCCESSFUL in 10s`였다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AudioContentCloudFrontCharacterizationTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest` + +- [x] **Task 3.6: 콘텐츠 생성 pipeline 보완** + +**Goal 실행 `P3-T5`:** 콘텐츠 생성의 multipart 입력, legacy field 변환, 파일·processing·series 연결과 side effect parity를 완결한다. + +- **추적 review ID:** `REV-006`, `REV-007`, `REV-008` 중 생성 binding·field·pipeline 특성화. +- **시작 조건:** `P3-T4` 완료와 생성 pipeline 관련 review 판정. +- **완료 증거:** 전체 생성 field·파일·날짜·series RED/GREEN, 실패 순서와 focused/legacy test 기록. +- **범위 밖:** upload/processing pipeline 정책 변경, 오디오 파일 교체. + +**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` +- 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/AiCharacterAdminAudioContentCreateTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] `tags` 필수 여부와 생성 `isActive=false`의 canonical 계약을 legacy pipeline·Endpoint Contract Summary로 확정하고 충돌 시 코드 수정 전에 Decision Log를 갱신한다. +- [x] **RED:** `coverImage`, `audioFile`, `request` 각 part 누락에서 Kotlin nullable 때문에 `MissingServletRequestPartException`이 발생하지 않는 현재 binding과 KO/EN/JA envelope 차이, facade·DB·S3·event 호출 0건 기대를 재현한다. +- [x] **RED:** 생성 request 전체 field, `description/releaseDateUtc` 변환, `tags` 누락, `isActive=false`, target·theme·`seriesIds` 오류와 S3/processing/event 실패 순서를 각각 고정한다. +- [x] **RED 확인:** create/error/legacy characterization test를 실행해 part별 exception·field 계약·failure order가 의도대로 실패하는지 확인한다. +- [x] **GREEN:** 필수 file part를 non-null binding으로 만들고 확정된 field 계약, 외부 부작용 전 참조 검증과 legacy upload/processing parity를 최소 구현한다. +- [x] **GREEN 확인:** 같은 test를 다시 실행해 part별 400/i18n, 정상 생성과 실패 후 DB/S3/event 결과가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** cover/audio upload, 가격·공개·예약·번역·알림 및 실패 후 DB/S3/event 결과를 characterization/focused test로 회귀하고 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: 콘텐츠 생성의 필수 multipart part와 legacy 기본 계약. 왜: `REV-006`에서 생성 binding·field·failure-order 증거가 분리되지 않았고, `tags` 누락과 `isActive=true` 요청의 canonical 동작을 확정해야 했기 때문이다. 어떻게: `AiCharacterAdminAudioContentCreateTest`를 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest`를 실행했다. 결과: 정상 생성 케이스가 200 기대 대비 500으로 실패했고, 원인은 production 계약이 아니라 test fixture의 `AmazonS3Client.getUrl(String, String)` 미설정으로 `S3Uploader.putS3`에서 null URL이 발생한 것이었다. + - 검증 기록(GREEN/REFACTOR): 무엇: 생성 필수 part 400, 업로드 전 S3 0회, `tags` 누락 허용, `isActive=true` 요청의 legacy processing 기본값. 왜: 신규 v2 생성은 기존 upload/processing pipeline을 바꾸지 않고 adapter 계약만 고정해야 하기 때문이다. 어떻게: test fixture에 `amazonS3Client.getUrl(...)` mock만 추가하고 production code는 변경하지 않았다. 결과: `AiCharacterAdminAudioContentCreateTest`는 `BUILD SUCCESSFUL`, create+legacy+error contract 회귀는 `BUILD SUCCESSFUL`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 10s`였다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.LegacyCreatorAdminAudioContentCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` + +- [x] **Task 3.7: 콘텐츠 수정·soft delete 보완** + +**Goal 실행 `P3-T6`:** 콘텐츠 수정, cover 유지/교체, series 재연결과 soft delete를 owner-safe하게 완결한다. + +- **추적 review ID:** `REV-004`. +- **시작 조건:** `P3-T5` 완료와 수정·삭제 관련 review 판정. +- **완료 증거:** 수정·soft delete·audioFile 미지원·cross-owner RED/GREEN과 Progress 기록. +- **범위 밖:** audio file 교체, hard delete. + +**Files:** + +- 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` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/SeriesContent.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt` + +- [x] **RED:** 기존 `SeriesContent.orders`, row ID, `createdAt`이 있는 콘텐츠에 동일 `seriesIds`를 PUT했을 때 전부 삭제·재생성되는 현재 동작을 실패 test로 고정한다. +- [x] **RED 확인:** 동일 ID, 추가 ID, 제거 ID를 각각 요청해 교집합 metadata 보존과 차집합만 insert/delete한다는 기대가 현재 실패하는지 확인한다. +- [x] **GREEN:** 기존 연결과 요청 ID의 차집합만 변경하고 교집합 row의 ID·`orders`·`createdAt`을 보존하는 최소 구현을 작성한다. +- [x] **GREEN 확인:** 같은 update test를 다시 실행해 동일 집합 no-op, 교집합 metadata 보존과 차집합 변경만 발생하는지 확인한다. +- [x] **REFACTOR:** cover 유지/교체, 날짜, `audioFile` 미지원, `isActive=false`와 cross-owner/invalid series의 DB/S3/event no-side-effect를 회귀한다. +- [x] 수정 focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: 동일 `seriesIds` 수정 시 기존 `SeriesContent` metadata 보존. 왜: `REV-004`에서 기존 구현이 연결을 모두 삭제·재생성해 row ID, `orders`, `createdAt`을 소실했기 때문이다. 어떻게: `AiCharacterAdminAudioContentUpdateTest`를 추가하고 focused 실행했다. 결과: 최초 focused test는 200 기대 대비 500으로 실패했고, 원인은 응답 매핑의 CloudFront private key fixture 문제임을 로그로 확인한 뒤 test fixture에 `AudioContentCloudFront` mock을 추가했다. + - 검증 기록(GREEN/REFACTOR): 무엇: `replaceSeriesIds`가 요청 ID와 기존 연결의 차집합만 변경하고 교집합 row를 보존하도록 수정했다. 왜: 동일 series 연결의 metadata를 유지하면서 제거·추가만 반영해야 하기 때문이다. 어떻게: `requestedIds`, 기존 연결 ID set을 비교해 삭제 대상만 remove하고 신규 ID만 persist했다. 결과: `AiCharacterAdminAudioContentUpdateTest`는 `BUILD SUCCESSFUL in 46s`, update+controller+legacy+error contract 회귀는 `BUILD SUCCESSFUL in 1m 21s`였다. 기존 controller 회귀 2건은 앞선 `P3-T4` 확정 계약(`releaseDate` 미노출, `RENT_ONLY` 파생값)에 맞춰 기대값만 갱신했다. +- Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest` + +- [x] **Task 3.8: 콘텐츠 ownership·오류·legacy 회귀 보완** + +**Goal 실행 `P3-T7`:** Phase 3 모든 endpoint의 ownership·ADMIN·오류 계약과 legacy/public 회귀를 고정한다. + +- **추적 review ID:** `REV-006`, `REV-007`, `REV-008`. +- **시작 조건:** `P3-T6` 완료 또는 앞선 Goal의 근거 있는 `해당 없음` 판정. +- **완료 증거:** 권한 매트릭스, 정확한 domain/client 오류 status/key/KO·EN·JA, no-side-effect와 legacy 회귀 기록. +- **범위 밖:** Phase 1 공통 handler 재설계, Phase 4 series 기능. + +**Files:** + +- Confirm: `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` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **RED:** 테마·목록·상세·생성·수정 endpoint 각각의 JWT role × DB role, stale ADMIN claim과 허용·거부 Origin을 parameterized test로 고정한다. +- [x] **RED:** target/content/theme/series/date와 세 multipart part 누락의 exact status, exception type, message key와 KO/EN/JA envelope를 실제 endpoint에서 고정한다. +- [x] **RED 확인:** 실제 endpoint matrix와 legacy characterization을 실행해 누락된 인가·i18n·failure-order assertion이 의도대로 실패하는지 확인한다. +- [x] **GREEN:** 확정된 domain/client/server 오류만 최소 매핑하고 ownership 실패 시 DB insert/update/delete, S3, event 0건을 보장한다. +- [x] **GREEN 확인:** 같은 endpoint/error/ownership test를 다시 실행해 status/header/envelope, KO/EN/JA와 no-side-effect가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** legacy characterization에 validation·파일·가격·공개/예약·번역/알림·failure order를 보강하고 signed URL edge case와 함께 실행한다. +- [x] 실제 test 파일 목록과 targeted 명령을 대조해 존재하지 않는 `AiCharacterAdminAudioContentServiceTest`, `AiCharacterAdminAudioSignedUrlTest` 참조와 과거 test 수는 삭제하지 않고 정정 기록을 누적한다. +- [x] `AiCharacterAdminAudioContentThemeControllerTest`, `AiCharacterAdminAudioContentQueryTest`, `AiCharacterAdminAudioContentCreateTest`, `AiCharacterAdminAudioContentUpdateTest`, `AiCharacterAdminAudioContentOwnershipTest`의 파일 존재와 각 소유 계약 통과를 확인한다. +- [x] creator/admin/public content 회귀, 신규 DTO 의존 방향과 focused test·`ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): malformed `characterId`/`contentId` 6건이 미매핑 404 EN envelope을 기대한 새 ownership focused test에서 GET 400, write 415를 반환해 18건 중 6건이 실패했다. + - 검증 기록(GREEN/REFACTOR): 모든 resource path를 `[0-9]+`로 제한한 뒤 malformed path 404, 실제 5개 endpoint의 non-ADMIN/stale claim 403, 테마 CORS allow/deny, unknown target 생성 S3 0회를 고정했다. common authorization/error test의 KO·EN·JA matrix와 기존 content/legacy characterization을 재사용했다. focused는 `BUILD SUCCESSFUL in 1m 5s`, content+authorization+error 회귀는 `BUILD SUCCESSFUL in 2m 8s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 29s`였다. `AiCharacterAdminAudioContentServiceTest`, `AiCharacterAdminAudioSignedUrlTest`는 현재 존재하지 않는 과거 계획 참조이며 이 기록으로 정정한다. +- Verify: `./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` + +#### Phase 3 Gate + +**Goal 실행 `P3-GATE`:** Phase 3 콘텐츠 관리와 signed URL의 PRD 추적성, pipeline 안전성과 legacy 회귀를 최종 판정한다. + +- [x] **`P3-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 현재 상태표와 Progress를 갱신한다. + +- **시작 조건:** `P3-R1`, `P3-T3`~`P3-T7` 완료 또는 근거 있는 `해당 없음` 판정. +- **완료 증거:** 아래 명령 성공, review 후보 0건, 확정 finding 처리 완료와 Progress 기록. +- **범위 밖:** Gate 실패와 무관한 Phase 4 기능 구현. + +- [x] `REV-004`~`REV-008`의 response parity matrix, multipart exception, series metadata, DTO 경계와 test 증거가 각 소유 Goal의 Progress에 연결됐다. +- [x] 동일 `seriesIds`의 row metadata 보존, legacy `releaseDate`·rental 파생값, 세 필수 part와 실제 endpoint 권한·i18n matrix에 미결정 항목이 없다. +- [x] 완료 이력의 누락 test 파일·test 수·characterization 범위는 원문을 삭제하지 않고 최신 정정 기록으로 재현 가능하게 남겼다. + - 검증 기록: 무엇: `P3-GATE` Phase 3 최종 판정. 왜: `P3-R1`, `P3-T3`~`P3-T7`의 확정 finding 처리와 Gate 명령 성공을 확인하기 위해. 어떻게: 아래 세 Gate 명령을 fresh 실행했다. 결과: content focused 명령은 `BUILD SUCCESSFUL in 2m 15s`, authorization/error 명령은 `BUILD SUCCESSFUL in 1m 29s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 1s`였다. `git diff --check`는 `P3-T7` 완료 전 실행에서 출력 없음이었다. `REV-004`~`REV-008`의 Phase 3 소유 항목은 처리 완료로 판정했다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*' +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +**Expected:** 모든 명령 exit code 0, Phase 3 review의 후보·확정 미처리 항목 0건, Phase 4가 소비할 owner query 계약 확정. + +#### Phase 3 후속 리뷰 보완 + +- [x] **Task 3.9: 콘텐츠 생성 pipeline·multipart 오류 증거 보강** + +**Goal 실행 `P3-R2`:** `REV-010`의 생성 field adapter, 선검증, multipart 오류와 S3/processing/event 실패 순서를 실제 endpoint에서 고정한다. + +- **추적 review ID:** `REV-010`. +- **시작 조건:** `P2-R2-GATE` 완료와 기존 `P3-GATE` 완료 이력 존재. +- **완료 증거:** 생성 actual endpoint/legacy characterization test, 실패 지점별 관찰 결과, `ktlintCheck`와 Progress 기록. +- **범위 밖:** legacy upload/processing 정책 변경, 추정에 의한 S3 보상 추가, audio file 교체. +- **TDD 예외 사유:** 현재 production 실패가 아니라 `P3-T5` 완료 기록 대비 직접 검증 증거 누락이 확정된 test 보강 Task다. +- **대체 검증 방법:** legacy와 actual endpoint를 characterization하고 불일치가 재현될 때만 RED/GREEN으로 최소 수정한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **CONTRACT TEST:** `coverImage`, `audioFile`, `request` 누락의 실제 endpoint KO/EN/JA status/key/envelope와 facade·DB·S3·event 0건을 고정한다. +- [x] **CONTRACT TEST:** 생성 request 전체 field의 legacy adapter 결과와 target/theme/series 선검증을 확인한다. +- [x] **FAILURE CHARACTERIZATION:** cover upload, audio upload와 event 실패 지점별 DB/S3/event 결과를 legacy parity와 대조하고 비트랜잭션 S3 결과를 명시한다. +- [x] **GREEN:** 실제 계약 위반만 최소 수정하고, 현재 동작이 계약을 만족하면 production code를 변경하지 않는다. +- [x] **REFACTOR:** create/legacy/error focused test와 `ktlintCheck` 결과를 Progress와 리뷰 수정 후 기록에 누적한다. + - 검증 기록: 무엇: `REV-010`의 생성 multipart·theme·cover 실패 증거를 실제 endpoint test로 보강했다. 왜: 기존 완료 기록이 part별 KO/EN/JA message, invalid theme 선검증, cover upload 실패 후 DB/event 상태를 직접 고정하지 않았기 때문이다. 어떻게: `AiCharacterAdminAudioContentCreateTest`에 세 필수 part KO/EN/JA envelope, invalid theme 선검증, cover upload 실패 rollback/event 0회 단언을 추가했다. 결과: production code 변경 없이 create 단독 명령은 `BUILD SUCCESSFUL in 1m 4s`, content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 41s`였다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.LegacyCreatorAdminAudioContentCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +- [x] **Task 3.10: 콘텐츠 수정 차집합·cover·ownership 증거 보강** + +**Goal 실행 `P3-R3`:** `REV-011`의 `seriesIds` 교집합/차집합, cover 변경과 실제 endpoint ownership·오류 no-side-effect를 고정한다. + +- **추적 review ID:** `REV-011`. +- **시작 조건:** `P3-R2` 완료. +- **완료 증거:** update/ownership actual endpoint test, content/authorization/error 회귀, `ktlintCheck`와 Progress 기록. +- **범위 밖:** audio file 교체, hard delete, Phase 4 series API 구현. +- **TDD 예외 사유:** 현재 production 실패가 아니라 `P3-T6`~`P3-T7` 완료 기록 대비 직접 검증 증거 누락이 확정된 test 보강 Task다. +- **대체 검증 방법:** 교집합+추가+제거와 cover/ownership 계약을 non-vacuous test로 작성하고 실패가 재현될 때만 최소 수정한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt` +- Modify: `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/AiCharacterAdminAudioContentOwnershipTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` + +- [x] **CONTRACT TEST:** 기존 교집합, 신규 추가, 기존 제거를 한 요청에 포함해 교집합 row ID·`orders`·`createdAt` 보존과 차집합만 insert/delete됨을 확인한다. +- [x] **CONTRACT TEST:** cover 미지정 유지, 성공 교체와 업로드 실패, 날짜 변경, soft delete, cross-owner/invalid series의 DB/S3/event 결과를 고정한다. +- [x] **CONTRACT TEST:** 실제 목록·상세·생성·수정의 ownership/domain 오류를 KO/EN/JA envelope과 DB insert/update/delete·S3·event count로 확인한다. +- [x] **GREEN:** 실제 계약 위반만 최소 수정하고, 현재 동작이 계약을 만족하면 production code를 변경하지 않는다. +- [x] **REFACTOR:** content package와 공통 authorization/error, legacy characterization, `ktlintCheck` 결과를 Progress와 리뷰 수정 후 기록에 누적한다. + - 검증 기록: 무엇: `REV-011`의 수정 차집합·cover·ownership 증거를 보강했다. 왜: 기존 완료 기록보다 실제 endpoint의 교집합 보존, cover 유지/교체/실패, ownership/domain no-side-effect 증거가 좁았기 때문이다. 어떻게: `AiCharacterAdminAudioContentUpdateTest`와 `AiCharacterAdminAudioContentOwnershipTest`에 관련 회귀를 추가하고 content/common 회귀로 재확인했다. 결과: production code 변경 없이 content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 41s`였다. + +```bash +./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 +``` + +#### Phase 3 후속 리뷰 Gate + +**Goal 실행 `P3-R2-GATE`:** `REV-010`~`REV-011`의 직접 증거를 재검토하고 Phase 3 후속 리뷰를 종결한다. + +- [x] **`P3-R2-GATE` 완료:** `P3-R2`, `P3-R3` 완료 후 fresh 검증과 리뷰 문서 수정 후 기록을 남긴다. +- **시작 조건:** `P3-R2`, `P3-R3` 완료. +- **완료 증거:** 두 review ID 수정 완료, content/common 회귀와 `ktlintCheck` 성공, `phase3-audio-content-review.md` 최신 결론과 Progress 동기화. +- **범위 밖:** 기존 `P3-GATE` 이력 수정, Phase 4 기능 구현. + - 검증 기록: 무엇: `P3-R2-GATE` 후속 리뷰 종결. 왜: `REV-010`~`REV-011`의 직접 증거가 추가됐고 Phase 4 전 Phase 3 후속 보완 종료 여부를 판정하기 위해. 어떻게: content/common 회귀와 `ktlintCheck`를 fresh 실행하고 `phase3-audio-content-review.md`에 3차 후속 검증 기록을 누적했다. 결과: content/common 회귀는 `BUILD SUCCESSFUL in 2m 41s`였고, `ktlintCheck` 결과는 아래 Progress 검증 기록에 남긴다. + +#### Phase 3 4차 리뷰 보완 + +- [x] **Task 3.11: 생성 후반 실패·ownership no-side-effect 증거 보강** + +**Goal 실행 `P3-R4`:** `REV-013`~`REV-014`에서 남은 audio upload/event 실패와 실제 endpoint ownership/domain 오류의 부작용 경계를 고정한다. + +- **추적 review ID:** `REV-013`, `REV-014`. +- **시작 조건:** `P2-R3-GATE`와 기존 `P3-R2-GATE` 완료. +- **완료 증거:** create/update/ownership actual endpoint test, legacy characterization, content/common 회귀, `ktlintCheck`와 Progress 기록. +- **범위 밖:** S3 보상 정책 신설, audio file 수정 지원, Phase 4 기능 구현. +- **TDD 예외 사유:** 현재 production 실패가 아니라 `Task 3.9`~`Task 3.10` 완료 기록 대비 직접 검증 증거 누락이 확정된 test 보강 Task다. +- **대체 검증 방법:** cover 이후 audio upload와 event 실패, ownership/domain 거부를 실제 endpoint에서 먼저 characterization하고 계약 불일치가 재현될 때만 최소 수정한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt` + +- [x] **FAILURE CHARACTERIZATION:** 두 번째 S3 audio upload 실패와 event publish 실패에서 DB rollback, S3 호출·잔존 결과와 event 결과를 각각 고정한다. +- [x] **CONTRACT TEST:** 목록·상세·생성·수정의 target/ownership/domain 거부를 KO/EN/JA exact envelope로 확인한다. +- [x] **CONTRACT TEST:** 각 거부 뒤 AudioContent·SeriesContent·S3·event의 insert/update/delete count가 변하지 않음을 직접 단언한다. +- [x] **GREEN:** 실제 계약 위반만 최소 수정하고, legacy 비트랜잭션 S3 경계와 일치하면 production code를 변경하지 않는다. +- [x] **REFACTOR:** content/common/legacy 회귀와 `ktlintCheck` 결과를 Progress와 리뷰 수정 후 기록에 누적한다. + - 검증 기록: 무엇: `REV-013`~`REV-014`의 content 생성 후반 실패와 ownership/domain no-side-effect 증거를 보강했다. 왜: 기존 완료 기록이 cover upload 실패와 일부 ownership 경로에 치우쳐 있었기 때문이다. 어떻게: `AiCharacterAdminAudioContentCreateTest`에 audio upload 실패와 event publish 실패를 추가하고, `AiCharacterAdminAudioContentOwnershipTest`에 목록·상세·생성·수정 unknown target KO/EN/JA 및 AudioContent·SeriesContent·S3·event 무변경 단언을 추가했다. 결과: create+ownership focused 명령은 `BUILD SUCCESSFUL in 1m 8s`, content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 20s`였다. + +```bash +./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 +``` + +#### Phase 3 4차 리뷰 Gate + +**Goal 실행 `P3-R3-GATE`:** `REV-013`~`REV-014`의 직접 증거를 재검토하고 Phase 3 4차 리뷰를 종결한다. + +- [x] **`P3-R3-GATE` 완료:** `P3-R4` 완료 후 fresh 검증과 리뷰 문서 수정 후 기록을 남긴다. +- **시작 조건:** `P3-R4` 완료. +- **완료 증거:** 두 review ID 처리 완료, 위 두 명령 성공, `phase3-audio-content-review.md` 최신 결론과 Progress 동기화. +- **범위 밖:** 기존 Phase 3 완료 이력 수정, Phase 4 기능 구현. + - 검증 기록: 무엇: Phase 3 4차 리뷰의 `REV-013`~`REV-014` 처리를 종결했다. 왜: 사용자 지시에 따라 Phase 3 후속 보완까지만 완료하고 Phase 4로 넘어가지 않기 위해. 어떻게: content/common 회귀와 최종 `ktlintCheck`를 fresh 실행하고 `phase3-audio-content-review.md`를 처리 완료로 갱신했다. 결과: content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 20s`, 최종 `ktlintCheck`는 `BUILD SUCCESSFUL in 17s`였다. + +#### Phase 3 5차 리뷰 보완 + +- [x] **Task 3.12: 생성 필수 multipart part의 exact binding 계약 복구** + +**Goal 실행 `P3-R5`:** `REV-016`의 생성 필수 파일 part를 MVC non-null binding으로 고정하고 세 part 누락의 exact exception·KO/EN/JA 계약을 복구한다. + +- **추적 review ID:** `REV-016`. +- **시작 조건:** `P2-R4-GATE` 완료와 PRD API Expectations 179~180의 missing-part 계약. +- **완료 증거:** 세 part별 RED/GREEN, exact `MissingServletRequestPartException`, facade/DB/S3/event 0회, content/common 회귀와 Progress 기록. +- **범위 밖:** legacy `AudioContentService.createAudioContent` signature 변경, upload/processing pipeline 정책 변경, 오디오 파일 교체. + +**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` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Confirm: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **RED:** `coverImage`, `audioFile`, `request` 각각을 누락한 KO/EN/JA actual endpoint test에서 `MvcResult.resolvedException`이 정확히 `MissingServletRequestPartException`이고 message가 `common.error.invalid_request`인지 단언한다. +- [x] **RED 확인:** create focused test를 실행해 nullable `coverImage`·`audioFile`이 legacy `SodaException`까지 전달되어 exact exception/message assertion이 실패하는지 확인한다. +- [x] **GREEN:** 생성 controller와 facade의 `coverImage`, `audioFile`을 non-null `MultipartFile`로 바꾸고 legacy service에는 검증된 non-null 값을 그대로 전달한다. +- [x] **GREEN 확인:** 같은 focused test를 재실행해 세 part 누락 9건의 exact exception·KO/EN/JA 400 envelope과 facade/DB/S3/event 0회를 확인한다. +- [x] **REFACTOR:** 중복된 missing-part request/assertion만 parameterized helper로 정리하고 create/error/legacy 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./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 +``` + +- [x] **Task 3.13: ownership/domain KO·EN·JA no-side-effect matrix 완결** + +**Goal 실행 `P3-R6`:** `REV-017`의 cross-owner와 domain validation 경로를 actual endpoint KO/EN/JA 및 DB/S3/event 무변경 증거로 완결한다. + +- **추적 review ID:** `REV-017`. +- **시작 조건:** `P3-R5` 완료와 기존 `P3-R4` unknown target matrix. +- **완료 증거:** cross-owner detail/update, create/update other-owner series, invalid date의 exact envelope·side-effect assertions, content/common 회귀와 Progress 기록. +- **범위 밖:** 새로운 ownership 정책, 오류 key/status 변경, Phase 4 series API 구현. +- **TDD 예외 사유:** 현재 production 위반보다 `REV-014` 완료 기록 대비 대표 ownership/domain 직접 증거 누락이 확정된 test 보강 Task다. +- **대체 검증 방법:** 기존 실제 endpoint test를 KO/EN/JA parameterized matrix로 확장하고 요청 전후 entity field·연결 row와 S3/event interaction을 비교한다. 실패가 재현될 때만 validation 순서를 최소 수정한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Modify: `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/AiCharacterAdminErrorContractTest.kt` + +- [x] **OWNERSHIP TEST:** 다른 캐릭터 소유 콘텐츠의 detail/update를 KO/EN/JA actual endpoint matrix로 만들고 content field·S3·event 무변경을 단언한다. +- [x] **DOMAIN TEST:** create/update의 다른 owner `seriesIds`와 invalid `releaseDateUtc`를 KO/EN/JA matrix로 만들고 AudioContent·SeriesContent insert/update/delete, S3, event 0회를 단언한다. +- [x] **NON-VACUOUS 확인:** owner 또는 validation guard를 제거하면 각 matrix가 status/message 또는 side-effect assertion으로 실패하는지 확인한다. +- [x] **GREEN:** 현재 계약 위반이 재현될 때만 target/ownership/domain 선검증 순서를 최소 수정하고, 이미 충족하면 test-only로 종료한다. +- [x] **REFACTOR:** unknown target과 cross-owner/domain fixture의 공통 assertion만 정리하고 content/common 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. + +```bash +./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 +``` + +#### Phase 3 5차 리뷰 Gate + +**Goal 실행 `P3-R4-GATE`:** `REV-016`~`REV-017`의 exact multipart와 ownership/domain 증거를 재검토하고 Phase 3 5차 리뷰를 종결한다. + +- [x] **`P3-R4-GATE` 완료:** `P3-R5`, `P3-R6` 완료 후 위 content/common 회귀와 lint를 fresh 실행하고 리뷰 문서·Progress를 갱신한다. +- **시작 조건:** `P3-R5`, `P3-R6` 완료. +- **완료 증거:** `REV-016`, `REV-017` 수정 완료, 세 필수 part와 ownership/domain matrix 직접 증거, focused/영향 범위 회귀와 lint 성공. +- **범위 밖:** 기존 Phase 3 완료 이력 수정, Phase 4 기능 구현. + +#### Phase 3 6차 리뷰 보완 + +- [x] **Task 3.14: 빈 multipart 파일 계약 고정** + +**Goal 실행 `P3-R7`:** `REV-019`의 생성·수정 empty-file 경계를 v2 facade에서 고정해 0-byte upload와 수정 +`audioFile` 계약 우회를 차단한다. + +- **추적 review ID:** `REV-019`. +- **시작 조건:** `P2-R5-GATE`와 기존 `P3-R4-GATE` 완료. +- **완료 증거:** 생성 empty cover/audio 거부, 수정 empty cover 유지, empty/non-empty audio 거부 actual endpoint + RED/GREEN과 DB/S3/event assertion, content/common 회귀 및 Progress 기록. +- **범위 밖:** legacy `AudioContentService`·`CreatorAdminContentService` 공용 계약 변경, 파일 content-type/확장자 정책 추가, + 오디오 파일 교체, Phase 4 기능 구현. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Modify: `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: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt` + +- [x] **RED:** 생성의 빈 `coverImage`와 빈 `audioFile` actual endpoint를 KO/EN/JA로 요청해 400 + `common.error.invalid_request`, AudioContent/SeriesContent·S3·event 0회를 기대하고 현재 200/업로드 경로로 실패함을 + 확인한다. +- [x] **RED:** 수정의 빈 `coverImage`가 생략과 동일하게 기존 cover path를 유지하고 S3를 호출하지 않는 기대, 빈 + `audioFile` part가 non-empty와 동일하게 400으로 거부되는 기대가 현재 실패함을 확인한다. +- [x] **GREEN:** create 시작 시 `coverImage.isEmpty || audioFile.isEmpty`를 `invalidRequest()`로 거부한다. +- [x] **GREEN:** update는 `audioFile != null`이면 크기와 관계없이 `invalidRequest()`로 거부하고, + `coverImage?.takeUnless { it.isEmpty }`만 legacy update service에 전달한다. +- [x] **GREEN 확인:** 같은 focused test를 재실행해 create empty-file의 부작용 0회, update empty cover의 DB/S3 유지와 + empty/non-empty audio 거부가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** empty-file fixture만 공통화하고 legacy service를 수정하지 않은 채 content/common 회귀와 + `ktlintCheck` 결과를 Progress에 기록한다. + - 검증 기록(RED): 무엇: 생성 empty `coverImage`/`audioFile` KO/EN/JA와 수정 empty cover/audio actual endpoint 계약을 추가했다. 왜: 빈 multipart 파일이 null/non-empty 검사 사이를 통과하는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest`를 실행했다. 결과: 26건 중 신규 8건이 line 119, 204, 236에서 실패해 RED를 확인했다. + - 검증 기록(GREEN): 무엇: v2 facade empty-file 경계와 실제 service publisher no-interaction 증거. 왜: legacy 공용 service 변경 없이 신규 관리자 API 계약만 고정하고 event 부작용 assertion이 detached mock을 보지 않게 하기 위해. 어떻게: 같은 focused 명령을 재실행했다. 결과: reviewer gate 보완 후 최종 empty create/update focused는 `BUILD SUCCESSFUL in 43s`, non-empty audio update 보완 focused는 `BUILD SUCCESSFUL in 44s`였다. + +```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 --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./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 +``` + +- [x] **Task 3.15: ownership/domain event 0회 실제 publisher 증거 복구** + +**Goal 실행 `P3-R8`:** `REV-020`의 ownership/domain no-side-effect test가 실제 `AudioContentService`와 +`CreatorAdminContentService`의 publisher를 관찰하도록 연결해 NON-VACUOUS 완료 증거를 복구한다. + +- **추적 review ID:** `REV-020`. +- **시작 조건:** `P3-R7` 완료. +- **완료 증거:** 실제 두 service proxy target의 publisher 교체·복원, mock identity 확인, ownership/domain matrix의 + event 0회와 content/common 회귀 및 Progress 기록. +- **범위 밖:** production event 발행 순서·payload 변경, application context event infrastructure 변경, 신규 test 전용 + production seam 추가, Phase 4 기능 구현. +- **TDD 예외 사유:** 현재 production의 잘못된 event 발행이 아니라 detached mock으로 인한 완료 증거 공백이 확정된 + test-only Task다. +- **대체 검증 방법:** Phase 2와 기존 content create event failure test의 `AopTestUtils`·`ReflectionTestUtils` 방식을 + 재사용해 실제 proxy target field와 mock identity를 확인한다. + +**Files:** + +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Modify: `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/AiCharacterAdminAudioContentCreateTest.kt` + +- [x] **EVIDENCE RED:** 현재 `@MockBean ApplicationEventPublisher`와 실제 두 service proxy target의 publisher가 같은 + instance인지 단언해 detached 상태에서 실패함을 확인한다. +- [x] **EVIDENCE GREEN:** 각 test에서 `AudioContentService`, `CreatorAdminContentService` proxy target의 기존 publisher를 + 보관하고 같은 mock으로 교체하며 `finally`/teardown에서 원래 publisher를 복원한다. +- [x] **NON-VACUOUS 확인:** 교체 직후 실제 두 target field가 mock과 같은 instance인지 단언하고, cross-owner + detail/update·other-owner series·invalid date 및 unknown target matrix가 실제 publisher no-interaction을 통과하는지 + 확인한다. +- [x] **회귀 확인:** 기존 create event failure helper와 충돌하지 않고 정상 content 생성·수정의 event 회귀가 유지되는지 + content/common 명령으로 확인한다. +- [x] **REFACTOR:** Phase 2 및 create failure test의 기존 helper 패턴 범위에서만 중복을 정리하고 production seam이나 + 공용 test abstraction은 추가하지 않는다. + - 검증 기록: 무엇: `REV-020`의 실제 publisher no-interaction 증거를 복구했다. 왜: detached `@MockBean ApplicationEventPublisher`만 검증하면 실제 service field 호출 여부를 증명할 수 없기 때문이다. 어떻게: `AudioContentService`, `CreatorAdminContentService` proxy target의 `applicationEventPublisher`를 테스트 mock으로 교체·복원하고 field identity를 단언한 뒤 `./gradlew test --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 39s`였다. + +```bash +./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 +``` + +#### Phase 3 6차 리뷰 Gate + +**Goal 실행 `P3-R5-GATE`:** `REV-019`~`REV-020`의 empty-file 계약과 actual publisher 증거를 재검토하고 Phase 3 +6차 리뷰를 종결한다. + +- [x] **`P3-R5-GATE` 완료:** `P3-R7`, `P3-R8` 완료 후 위 content/common 회귀와 lint를 fresh 실행하고 + 리뷰 문서·Progress를 갱신한다. +- **시작 조건:** `P3-R7`, `P3-R8` 완료. +- **완료 증거:** `REV-019`, `REV-020` 처리 완료, 생성·수정 empty-file actual endpoint 계약, 실제 두 service publisher + 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 계약 정합화 + +기존 Phase 2·3 완료 이력은 보존한다. 2026-07-28 확정된 레거시 필드명·전체 payload 이관 정책에 따라 문서 계약을 먼저 +고정하고, 현재 구현된 캐릭터·오디오 콘텐츠 v2 DTO와 endpoint를 별도 후속 Goal에서 정합화한다. + +- [x] **Task 3.16: 전체 API OpenAPI 계약 고정** + +**Goal 실행 `P23-CONTRACT-1`:** 23개 endpoint의 request/response를 레거시 DTO 전체 필드와 직접 대조해 OpenAPI 3.1 +JSON과 설명 문서로 고정한다. + +- **시작 조건:** 사용자 확정 정책과 시리즈 미연결 콘텐츠 검색 endpoint 분리 결정. +- **완료 증거:** JSON 문법·OpenAPI lint/validate·TypeScript client 생성 및 `tsc --noEmit` 성공, 23개 operation과 누락 `$ref` 0건, + `./gradlew tasks --all` 성공 및 검증 기록. +- **범위 밖:** production DTO/controller/test 수정, legacy/public endpoint 변경. +- **TDD 예외 사유:** 실행 코드를 변경하지 않는 계약 문서 작성 Task다. +- **대체 검증 방법:** 레거시 Kotlin DTO의 생성자 필드와 OpenAPI schema를 대조하고 두 validator와 client generator로 + 기계 검증한다. + +**Files:** + +- Modify: `docs/20260724_AI캐릭터_관리자_API/prd.md` +- Modify: `docs/20260724_AI캐릭터_관리자_API/plan-task.md` +- Create: `docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json` +- Create: `docs/20260724_AI캐릭터_관리자_API/api-contract.md` + +- [x] 캐릭터, 테마·오디오 콘텐츠, 시리즈·연결 콘텐츠 검색, 커뮤니티, FanTalk의 레거시 DTO 전체 필드를 schema로 + 옮긴다. +- [x] path로 이동한 ID만 body에서 제거하고 나머지 query/body/response 필드명은 레거시와 동일하게 유지한다. +- [x] 레거시 mutation의 `data: null`, 오디오 생성의 `data.contentId`, FanTalk 답변 축약 응답 예외를 operation별로 + 고정한다. +- [x] FanTalk 관리자 목록과 시리즈 미연결 콘텐츠 검색을 별도 operation으로 포함해 총 23개 endpoint를 검증한다. +- [x] OpenAPI lint/validate, TypeScript Fetch client 생성과 `tsc --noEmit`을 실행하고 결과를 기록한다. + +- [x] **Task 3.17: Phase 2 캐릭터 runtime 계약 정합화** + +**Goal 실행 `P23-CONTRACT-2`:** 현재 구현된 캐릭터 4개 endpoint를 +`api-contract.openapi.json`의 레거시 필드명·전체 request/response·mutation 응답에 맞춘다. + +- **시작 조건:** `P23-CONTRACT-1` 완료. +- **완료 증거:** 4개 actual endpoint의 exact JSON field/required/nullable/multipart/`data: null` RED/GREEN, + Phase 2 focused·legacy 회귀와 Progress 기록. +- **범위 안:** `isActive=false`와 다른 optional JSON field의 동시 입력 허용 및 나머지 JSON field 미반영이라는 레거시 + request 의미 복구. +- **범위 밖:** 외부 API·ownership·soft delete persistence 결과 변경, 신규 character business behavior. + +**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/AiCharacterAdminCharacterDto.kt` +- 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` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt` +- 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` + +- [x] **RED:** 목록 `content`, 상세 전체 nested 필드, 생성 전체 request와 필수 `image`, 수정 optional 필드, + `isActive=false`와 다른 optional JSON field의 동시 입력·미반영, 생성·수정 `data: null` exact JSON 테스트를 작성해 + 현재 v2 축약/변환 DTO와의 불일치를 확인한다. +- [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.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +- 검증 기록(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·성공 응답에 맞춘다. + +- **시작 조건:** `P23-CONTRACT-2` 완료. +- **완료 증거:** 5개 actual endpoint의 exact query/multipart/JSON schema RED/GREEN, Phase 3 focused·legacy 회귀와 + Progress 기록. +- **범위 밖:** upload/processing pipeline, signed URL 정책, series 연결 behavior 변경. + +**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/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt` +- 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` + +- [x] **RED:** 테마 `id/theme/image`, 목록 전체 item, 상세 전체 nested DTO, 생성 `contentFile`과 + `CreateAudioContentRequest`, 수정 `UpdateCreatorAdminContentRequest` 및 각 성공 `data` 형태를 exact JSON으로 고정한다. +- [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.*' \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +``` + +#### Phase 2·3 계약 정합화 Gate + +**Goal 실행 `P23-CONTRACT-GATE`:** 문서 계약과 구현된 9개 endpoint의 runtime 응답이 일치하고 Phase 4가 같은 계약을 +소비할 수 있는지 판정한다. + +- [x] **`P23-CONTRACT-GATE` 완료:** `P23-CONTRACT-1`~`P23-CONTRACT-3` 완료 후 character/content focused·legacy + 회귀, OpenAPI validate/client 생성과 `ktlintCheck`를 fresh 실행한다. +- **범위 밖:** Gate에서 직접 production code 수정, Phase 4 이후 기능 구현. + +```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.AiCharacterAdminAuthorizationTest \ + --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest +./gradlew ktlintCheck +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 +``` + +- 검증 기록: 무엇: `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 + +#### 목표 +선택한 AI 캐릭터 소유 시리즈 CRUD, soft delete, 콘텐츠 연결/해제/검색/순서 관리를 owner-safe v2 경로로 제공한다. + +#### 범위와 비범위 +- 포함: 시리즈 목록/상세/생성/수정/soft delete, 콘텐츠 연결/해제, 시리즈 콘텐츠 조회/검색, owner-scoped 순서 변경, 기존 creator series behavior parity 특성화. +- 제외: 기존 `CreatorAdminContentSeriesController.updateSeriesOrders(ids)` 계약 변경. + +#### 선행 Phase 및 의존성 +- Phase 1 target resolver. +- Phase 3 content owner query를 재사용할 수 있다. +- 기존 creator series 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 변경 behavior를 통과하는 특성화 테스트로 먼저 고정해야 한다. + +#### API endpoint와 request/response contract +- 정식 전체 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}` request body 없음 +- `PUT /series/orders` request: `UpdateOrdersRequest(ids: List)` + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `Series`와 `SeriesContent` owner-scoped query/update adapter 추가. +- Service: 신규 series application service에서 기존 CRUD 핵심을 테스트 후 재사용하되, 모든 write 전에 series/content owner를 검증한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- 순서 변경은 동일 owner의 모든 series ID를 한 transaction에서 검증 후 갱신한다. +- 콘텐츠 연결/해제는 series와 content owner를 모두 검증한 뒤 수행한다. +- 동시에 순서 변경 요청이 들어오면 마지막 transaction 결과가 반영되는 기존 단순 정책을 유지하되 cross-owner 갱신은 절대 허용하지 않는다. + +#### 보안 및 개인정보 위험 +- ID-only order update로 다른 creator series를 변경하지 못해야 한다. +- 연결 가능한 content 검색은 target owner 범위로 제한한다. + +#### acceptance criteria +- 기존 creator series의 CRUD, soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 변경 behavior parity가 특성화 테스트로 고정된다. +- 모든 시리즈/콘텐츠 ID는 target creatorMember 소유일 때만 변경된다. +- soft delete는 `isActive=false`이며 활성 조회에서 제외된다. +- 기존 owner-less order update 취약 경로가 신규 v2 API에는 없다. + +#### targeted test +- Characterization: `LegacyCreatorAdminSeriesCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminSeriesServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*'` + +#### 전체 회귀 테스트 영향 +- 기존 creator-admin series endpoint 계약은 유지한다. +- 기존 series query/order 테스트가 있으면 함께 실행한다. + +#### rollback 전략 +- 신규 series v2 admin route/facade를 제거한다. +- 기존 data model 변경이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin series slice` + +- [x] **Task 4.1: 기존 series parity 특성화 baseline 고정** + +**Goal 실행 `P4-T1`:** 기존 creator series의 CRUD·연결·검색·순서 동작을 신규 v2 구현의 비교 기준으로 고정한다. + +- **시작 조건:** Phase 2·3 runtime 계약 정합화의 `P23-CONTRACT-GATE` 완료와 사용자 진행 지시. +- **완료 증거:** production 변경 전 특성화 테스트 통과, 관찰된 오류·side-effect 정책과 Progress 기록. +- **범위 밖:** 신규 v2 series production code 구현. + +**Files:** + +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt` + +- [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에 기록한다. + +- **관찰된 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 계약으로 제공한다. + +- **시작 조건:** `P4-T1` 완료와 Phase 4 오류 계약의 계획 반영. +- **완료 증거:** 목록·상세 전체 필드, inactive·cross-owner·pagination RED/GREEN과 Progress 기록. +- **범위 밖:** 시리즈 mutation, 콘텐츠 연결, 순서 변경. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt` +- 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` + +- [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으로 검증한다. +- [x] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + +- 검증 기록(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로 제공한다. + +- **시작 조건:** `P4-T1`, `P4-T2` 완료. +- **완료 증거:** CRUD RED/GREEN, ownership·inactive·no-side-effect와 legacy 회귀 기록. +- **범위 밖:** 콘텐츠 연결·해제, 순서 변경, hard delete. + +**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/AiCharacterAdminSeriesDto.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` + +- [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에 기록한다. + +- 검증 기록(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의 시리즈와 콘텐츠만 검색·연결·해제할 수 있도록 한다. + +- **시작 조건:** `P4-T2`, `P4-T3`와 Phase 3 owner query 계약 완료. +- **완료 증거:** 연결 목록·미연결 검색의 분리된 응답, pagination·전체 ID 사전 검증·원자적 연결/해제 RED/GREEN과 + Progress 기록. +- **범위 밖:** 콘텐츠 자체 수정, 시리즈 순서 변경. + +**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/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` + +- [x] `GET .../contents`와 `GET .../contents/search?search_word=...`의 별도 응답, 연결·해제와 cross-owner ID 실패 + test를 작성한다. +- [x] 모든 series/content ID를 mutation 전에 검증하는 최소 구현을 통과시킨다. +- [x] `contentIdList` 필드와 mutation `data: null`, 일부 연결 성공이 남지 않는 원자성과 pagination 경계를 검증한다. +- [x] focused/Phase 3 owner query 회귀와 `ktlintCheck` 결과를 Progress에 기록한다. + +- 검증 기록(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에서 순서를 변경한다. + +- **시작 조건:** `P4-T3` 완료. +- **완료 증거:** 정상·중복/누락·cross-owner·동시 요청 RED/GREEN과 owner-less 경로 비사용 증거. +- **범위 밖:** 기존 `CreatorAdminContentSeriesController.updateSeriesOrders(ids)` 변경. + +**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/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` + +- [x] 정상 순서와 cross-owner ID-only 취약 경로를 재현하는 실패 test를 작성한다. +- [x] 동일 owner 전체 검증 후 한 transaction에서 갱신하는 최소 구현을 통과시킨다. +- [x] 검증 실패 시 update 0건과 동시 요청의 기존 last-transaction 정책을 확인한다. +- [x] focused/legacy order test와 `ktlintCheck` 결과를 Progress에 기록한다. + +- 검증 기록(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 회귀를 고정한다. + +- **시작 조건:** `P4-T2`~`P4-T5` 완료. +- **완료 증거:** endpoint 권한 매트릭스, domain 오류 status/key/KO·EN·JA, legacy 회귀와 Progress 기록. +- **범위 밖:** Phase 5 community 기능. + +**Files:** + +- 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` + +- [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·회귀 품질을 최종 판정한다. + +- [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 + +#### 목표 +선택한 AI 캐릭터 소유 커뮤니티 게시글 등록, 수정, 고정/해제, soft delete, 관리자 조회와 댓글 CRUD를 제공한다. + +#### 범위와 비범위 +- 포함: 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 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: 변경 없음. +- Repository: creatorMember owner-scoped community post query adapter 추가 가능. +- Service: 신규 community application service에서 target owner 검증 후 기존 핵심 로직을 선택 재사용한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- 고정 게시글 수 검증과 고정 처리는 같은 transaction에서 수행한다. +- soft delete는 같은 transaction에서 `isActive=false`, `isFixed=false`, `fixedAt=null`을 함께 적용한다. +- 동시 고정 요청은 기존 최대 3개 정책이 깨지지 않도록 repository count와 update 순서를 테스트한다. + +#### 보안 및 개인정보 위험 +- 다른 character/HUMAN creator 게시글 수정, 고정, soft delete를 차단한다. +- 유료 게시글의 접근 정책과 파일 경로 노출 정책을 기존 동작과 맞춘다. + +#### acceptance criteria +- target creatorMember 소유 게시글만 조회/변경된다. +- 최대 고정 수 3개 정책이 유지된다. +- soft delete된 게시글은 fixed 상태와 fixedAt이 제거된다. +- invalid target/ownership 실패 시 DB/S3/event side effect가 없다. + +#### targeted test +- Characterization: `LegacyCommunityPostCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminCommunityPostServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'` + +#### 전체 회귀 테스트 영향 +- 기존 v2 community 조회와 legacy community write 테스트가 통과해야 한다. + +#### rollback 전략 +- 신규 community v2 admin route/facade를 제거한다. +- 신규 DDL이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin community slice` + +- [x] **Task 5.1: 기존 community behavior 특성화 baseline 고정** + +**Goal 실행 `P5-T1`:** 기존 community의 media·유료·고정·soft delete·알림 동작을 신규 v2 비교 기준으로 고정한다. + +- **시작 조건:** `P4-GATE` 완료. +- **완료 증거:** production 변경 전 특성화 테스트 통과, 오류·side-effect·동시성 관찰 결과와 Progress 기록. +- **범위 밖:** 신규 v2 community production code 구현. + +**Files:** + +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt` + +- [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에 기록한다. + +- **관찰된 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으로 제공한다. + +- **시작 조건:** `P5-T1` 완료와 Phase 5 오류 계약의 계획 반영. +- **완료 증거:** owner/inactive/pagination/DTO RED/GREEN과 public 조회 무비판적 복제 없음 증거. +- **범위 밖:** 게시글 생성·수정·고정·삭제. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` +- 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` + +- [x] 미구현 목록, owner 격리, pagination 경계와 관리자 DTO 실패 test를 작성한다. +- [x] 최소 owner-scoped query와 `page/size` 보정으로 focused test를 통과시킨다. +- [x] 유료/media private 정보와 public viewer 상태를 부적절하게 노출하지 않는지 검증한다. +- [x] focused test와 `ktlintCheck` 결과를 Progress에 기록한다. + +- 검증 기록: 무엇: 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로 생성한다. + +- **시작 조건:** `P5-T1`, `P5-T2` 완료. +- **완료 증거:** 정상/media/paid validation RED/GREEN, writer/owner와 S3/event 결과, Progress 기록. +- **범위 밖:** 게시글 수정·고정·soft delete, 구매 기능. + +**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` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` + +- [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에 기록한다. + +- [x] **Task 5.4: 게시글 수정·고정·soft delete 구현** + +**Goal 실행 `P5-T4`:** owner 게시글만 수정·고정/해제하고 soft delete 시 고정 상태와 시간을 함께 제거한다. + +- **시작 조건:** `P5-T1`, `P5-T2` 완료. +- **완료 증거:** update/fixed/soft delete/cross-owner RED/GREEN과 transaction 결과 기록. +- **범위 밖:** hard delete, 구매·좋아요·댓글 관리. + +**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` + +- [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에 기록한다. + +- 검증 기록(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개 정책이 동시 요청과 실패에서도 깨지지 않도록 고정한다. + +- **시작 조건:** `P5-T4` 완료. +- **완료 증거:** 세 번째/네 번째 고정, 동시 요청, cross-owner와 DB/S3/event side effect RED/GREEN 기록. +- **범위 밖:** 새로운 lock/dependency 도입, 기존 고정 정책 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt` +- 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` + +- [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에 기록한다. + +- **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 회귀를 고정한다. + +- **시작 조건:** `P5-T2`~`P5-T5` 완료. +- **완료 증거:** endpoint 권한 매트릭스, 오류 status/key/KO·EN·JA, legacy/public 회귀와 Progress 기록. +- **범위 밖:** Phase 6 FanTalk 기능. + +**Files:** + +- 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` + +- [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·회귀 품질을 최종 판정한다. + +- [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 + +#### 목표 +선택한 AI 캐릭터의 FanTalk 목록을 관리자 전용 경계에서 조회하고 자신의 활성 root FanTalk에 creator reply를 작성하며 +팬 작성 root를 soft delete하는 v2 관리자 API를 제공한다. + +#### 범위와 비범위 +- 포함: 관리자용 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 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: 변경 없음. +- Repository: 관리자 목록용 owner-scoped root/reply 조회와 root/active/creator owner 검증 adapter를 추가한다. +- Service: 관리자 목록은 공개 v2 DTO 형태로 조립하되 viewer/block 필터를 적용하지 않는다. reply는 target 검증 후 기존 + 저장/언어 감지 로직을 필요한 만큼 재사용한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- root FanTalk 조회와 reply 저장은 같은 transaction에서 수행한다. +- 중복 답변 허용 여부는 기존 domain 정책을 따른다. 기존 정책이 없다면 이번 API는 별도 중복 차단을 추가하지 않는다. + +#### 보안 및 개인정보 위험 +- 다른 character FanTalk, HUMAN creator FanTalk, inactive FanTalk, nested parent에는 답변하지 않는다. +- AI character Member 로그인/impersonation 없이 writer/creator만 해석된 creatorMember로 저장한다. + +#### acceptance criteria +- target AI character는 자신의 활성 root FanTalk에만 답변할 수 있다. +- 관리자는 공개 v2와 동일한 필드 형태로 target AI character의 root FanTalk와 creator reply를 조회할 수 있다. +- cross-character, nested parent, inactive/missing FanTalk는 4xx이며 reply 저장과 이벤트 발행이 없다. +- 저장된 답변의 writer/creator는 해석된 creatorMember와 일관된다. + +#### targeted test +- Characterization: `LegacyFanTalkReplyCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminFanTalkQueryTest`, `AiCharacterAdminFanTalkReplyServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'` + +#### 전체 회귀 테스트 영향 +- 기존 FanTalk 조회/작성 관련 테스트가 통과해야 한다. + +#### rollback 전략 +- 신규 FanTalk 목록·reply v2 admin route/facade를 제거한다. +- 신규 DDL이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin fan talk slice` + +- [x] **Task 6.1: 기존 FanTalk reply 의미 특성화 baseline 고정** + +**Goal 실행 `P6-T1`:** 기존 FanTalk의 root 판별, 언어 감지, 응답 의미와 writer/creator 저장 결과를 비교 기준으로 고정한다. + +- **시작 조건:** `P5-GATE` 완료. +- **완료 증거:** production 변경 전 특성화 테스트 통과, 중복 답변 정책과 Progress 기록. +- **범위 밖:** 신규 v2 reply production code 구현. + +**Files:** + +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt` + +- [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에 기록한다. + +- **관찰된 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 응답 필드 형태로 조회한다. + +- **시작 조건:** `P6-T1` 완료와 Phase 6 오류 계약의 계획 반영. +- **완료 증거:** owner-scoped 목록·nested reply·pagination exact JSON RED/GREEN과 Progress 기록. +- **범위 밖:** 공개 v2 endpoint 변경, viewer/block 필터 재사용, FanTalk 원글 작성. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt` +- 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` + +- [x] **RED:** `fanTalkCount/fanTalks/page/size/hasNext`와 root/reply 전체 필드, target owner, pagination 실패 + test를 작성한다. +- [x] **GREEN:** 공개 v2 DTO 필드 형태를 유지하면서 관리자 target 정책으로 조회하는 최소 구현을 통과시킨다. +- [x] **REFACTOR:** 공개 v2 endpoint 계약 불변, viewer/block 필터 비적용과 ADMIN 인가를 회귀하고 기록한다. + +- [x] **Task 6.3: FanTalk root reply 저장 구현** + +**Goal 실행 `P6-T3`:** 선택한 AI 캐릭터의 활성 root FanTalk에 creator reply를 저장하고 전용 응답을 반환한다. + +- **시작 조건:** `P6-T1`, `P6-T2` 완료와 Phase 6 오류 계약의 계획 반영. +- **완료 증거:** 정상 저장·언어 감지·DTO·writer/creator RED/GREEN과 Progress 기록. +- **범위 밖:** FanTalk 원글, nested reply, 일반 사용자 대리 작성. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyDto.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyFacade.kt` +- 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` + +- [x] 미구현 정상 root reply, 언어 감지와 응답 DTO 실패 test를 작성한다. +- [x] root 조회와 reply 저장을 같은 transaction에서 수행하는 최소 구현을 통과시킨다. +- [x] writer/creator가 resolver의 creatorMember와 일치하고 principal impersonation이 없음을 검증한다. +- [x] focused/legacy characterization test와 `ktlintCheck` 결과를 Progress에 기록한다. + +- [x] **Task 6.4: FanTalk target·root·ownership 거부 구현** + +**Goal 실행 `P6-T4`:** cross-character, nested, inactive, missing FanTalk를 저장·이벤트 없이 거부한다. + +- **시작 조건:** `P6-T1`~`P6-T3` 완료. +- **완료 증거:** 네 거부 분기의 정확한 오류 계약, DB/event 0건과 Progress 기록. +- **범위 밖:** 새로운 중복 답변 차단 정책. + +**Files:** + +- 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` + +- [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에 기록했다. + +- **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 회귀를 고정한다. + +- **시작 조건:** `P6-T2`~`P6-T4` 완료. +- **완료 증거:** 권한 매트릭스, request binding/domain 오류, legacy 회귀와 Progress 기록. +- **범위 밖:** Phase 7 외 전체 기능 수정. + +**Files:** + +- 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` + +- [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·저장·회귀 품질을 최종 판정한다. + +- [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 + +#### 목표 +신규 AI 캐릭터 관리자 API 전체가 source spec을 충족하고 기존 legacy/public 계약을 회귀시키지 않았음을 검증한다. + +#### 범위와 비범위 +- 포함: 전체 targeted test, 기존 회귀 테스트, ktlint, dependency/DDL/API contract 점검, 문서 검증 기록 누적. +- 제외: 신규 기능 추가, unrelated refactor. + +#### 선행 Phase 및 의존성 +- Phase 1~6 완료. + +#### API endpoint와 request/response contract +- `api-contract.openapi.json`의 37개 operation이 모두 구현되어야 한다. +- 모든 신규 endpoint가 JWT ADMIN + 현재 DB ADMIN 이중 인가와 공통 오류 envelope/i18n을 공유해야 한다. +- legacy/public endpoint URI와 성공·오류 status/body/message diff가 없어야 한다. + +#### entity, repository, service 변경 +- 신규 변경 없음. Phase 1~6 변경의 누락 import, unused code, package 의존 방향만 정리한다. +- 신규 v2 application/domain이 기존 controller 또는 v2 API response DTO를 역참조하지 않는지 점검한다. + +#### DB migration +- 없음. 새 DDL/migration 파일이 없는지 확인한다. + +#### transaction과 concurrency 고려사항 +- 각 write slice의 transaction 시작부 target/ownership 검증이 유지되는지 점검한다. +- 동시성 관련 targeted test가 실패 없이 통과해야 한다. + +#### 보안 및 개인정보 위험 +- JWT 또는 현재 DB role이 비ADMIN인 접근, stale ADMIN claim, AI login/token/impersonation, private path 노출, cross-owner write가 + 없는지 전체 점검한다. + +#### acceptance criteria +- Phase별 targeted test가 모두 통과한다. +- 모든 신규 endpoint에서 stale ADMIN claim은 403이고, 등록된 모든 API 오류 분기는 정확한 비2xx status + + `ApiResponse.error` + KO/EN/JA message를 반환한다. Spring CORS 정책 거부 403 body는 envelope 계약의 예외다. +- legacy/public 401/403/domain 오류의 status/body/message가 특성화 baseline과 동일하다. +- 전체 회귀 필요성 판정 결과 실행 대상이면 `./gradlew test`가 통과하고, 생략 대상이면 근거와 대체 targeted/영향 범위 회귀 + 결과가 기록된다. +- Kotlin 파일 변경이 있으면 `./gradlew ktlintCheck`가 통과한다. +- 신규 dependency와 신규 DDL이 없다. +- source spec acceptance criteria 25개를 각 Phase 결과와 대조해 누락이 없다. + +#### targeted test +- Run: + ```bash + ./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + ./gradlew ktlintCheck + ``` + +- Conditional full regression: 공통 경계·여러 Phase 영향 또는 targeted 결과로 영향 범위를 판단할 수 없을 때만 + `./gradlew test`를 추가 실행한다. + +#### 전체 회귀 테스트 영향 +- legacy `/admin/*`, `/creator-admin/*`, public `/api/v2/*` 관련 테스트 전체가 회귀 범위다. +- 기존 성공·오류 request/response 계약 변경이 없음을 controller/DTO diff와 legacy 오류 특성화 테스트로 확인한다. + +#### rollback 전략 +- 신규 `/api/v2/admin/ai-characters` controller bean 비활성화 또는 신규 package 제거로 기능 표면을 되돌린다. +- DB schema 변경이 없으므로 rollback은 code revert 중심이다. + +#### 권장 commit 경계 +- `test: verify ai character admin api integration` + +- [x] **Task 7.1: 전체 targeted 및 필요 시 전체 회귀 test 실행** + - **Goal 실행 `P7-T1`:** Phase 1~6 targeted test를 실행하고 위험 근거에 따라 전체 회귀 필요성을 판정해 결과를 확정한다. + - **시작 조건:** `P2-GATE`~`P6-GATE` 완료와 Phase 1 완료 증거 확인. + - **완료 증거:** targeted 결과와 전체 회귀 실행 또는 생략 판정·근거를 Progress와 하단 검증 기록에 누적. + - **범위 밖:** 실패와 무관한 기능 추가·리팩터링. + - TDD 예외 사유: 구현 완료 후 검증 전용 Task라 신규 실패 테스트를 작성하지 않는다. + - 대체 검증 방법: Phase 1~6 targeted test, `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`, 기존 admin + security test를 먼저 실행한다. 공통 경계·여러 Phase 영향 또는 targeted 실패로 영향 범위를 판단할 수 없을 때만 전체 회귀를 실행한다. + - REFACTOR: 실패가 있으면 관련 Phase Task로 되돌려 최소 수정 후 다시 실행한다. + - Verify: 위 targeted command를 실행하고 전체 회귀 필요성을 판정한다. 실행 시 `./gradlew test` 결과를, 생략 시 근거와 대체 + 회귀 범위를 이 문서 하단 검증 기록에 남긴다. + - [x] Phase 1~6 targeted test를 실행하고 각 결과를 기록한다. + - [x] 공통 경계·여러 Phase 변경과 targeted 결과를 근거로 전체 회귀 필요성을 판정한다. + - [x] 필요하면 `./gradlew test`의 exit code·실패 수를 기록하고, 불필요하면 생략 근거와 대체 회귀 범위를 기록한다. + - [x] 실패가 있으면 소유 Phase에 별도 회귀 수정 Goal을 추가하고 `P7-T1`을 완료 처리하지 않는다. + +- [x] **Task 7.2: API contract와 변경 범위 점검** + - **Goal 실행 `P7-T2`:** API·architecture·dependency·DDL·diff와 문서 추적성을 read-only로 최종 점검한다. + - **시작 조건:** `P7-T1` 완료. + - **완료 증거:** 아래 점검 체크박스, `ktlintCheck`, source spec acceptance criteria 추적 결과와 Progress 기록. + - **범위 밖:** 신규 기능 추가, 확정되지 않은 계약 보정. + - TDD 예외 사유: diff/architecture 검증 전용 Task라 신규 실패 테스트를 작성하지 않는다. + - 대체 검증 방법: legacy/public 성공·오류 status/body/message 특성화 baseline 통과, controller/DTO schema 변경 없음, 신규 + dependency 없음, 신규 DDL 없음, 신규 v2 application/domain에서 기존 controller와 v2 response DTO 역참조 없음. + - REFACTOR: 불필요한 import, 역방향 의존, 관련 없는 변경을 제거하고 diff를 다시 확인한다. + - Verify: `git diff --name-only`, `./gradlew ktlintCheck` + - [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의 최종 완료 여부를 판정한다. + +- [x] **`P7-GATE` 완료:** 시작 조건과 완료 증거를 모두 충족하고 문서 상태를 `구현 완료`로 갱신한다. + +- **시작 조건:** `P7-T1`, `P7-T2` 완료. +- **완료 증거:** 미완료 Goal·미처리 review finding·보류 없는 차단 사항 0건, 아래 완료 조건과 최종 Progress 기록. +- **범위 밖:** Gate에서 직접 production code 수정, test 삭제·skip·완화. + +- [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를 완료로 판정했다. + +--- + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P2-R1` | `P2-H1`, `P2-H2` | 아니요 | 리뷰 후보를 판정하고 계약 미확정이면 Decision Log에 기록 | +| 2 | `P2-T3` → `P2-T4` → `P2-T5` → `P2-T6` | `P2-R1` | 아니요 | 실패 소유 Goal에서 수정·검증을 끝낸 뒤 다음 Goal 수행 | +| 3 | `P2-GATE` | Phase 2 활성 Goal 전체 | 아니요 | 실패 소유 Task의 회귀 수정 Goal 추가 | +| 4 | `P3-R1` | `P2-GATE`, `P3-H1`, `P3-H2` | 아니요 | 리뷰 후보를 판정하고 계약 미확정이면 Decision Log에 기록 | +| 5 | `P3-T3` → `P3-T4` → `P3-T5` → `P3-T6` → `P3-T7` | `P3-R1` | 아니요 | 실패 소유 Goal에서 수정·검증을 끝낸 뒤 다음 Goal 수행 | +| 6 | `P3-GATE` | Phase 3 활성 Goal 전체 | 아니요 | 실패 소유 Task의 회귀 수정 Goal 추가 | +| 7 | `P2-R2` → `P2-R2-GATE` | 2차 Phase 2 review | 아니요 | `REV-009` 소유 Task에서 증거 보강 | +| 8 | `P3-R2` → `P3-R3` → `P3-R2-GATE` | `P2-R2-GATE`, 2차 Phase 3 review | 아니요 | `REV-010`~`REV-011` 소유 Task에서 증거 보강 | +| 9 | `P2-R3` → `P2-R3-GATE` | 4차 Phase 2 review | 아니요 | `REV-012` 소유 Task에서 증거 보강 | +| 10 | `P3-R4` → `P3-R3-GATE` | `P2-R3-GATE`, 4차 Phase 3 review | 아니요 | `REV-013`~`REV-014` 소유 Task에서 증거 보강 | +| 11 | `P2-R4` → `P2-R4-GATE` | 5차 Phase 2 review | 아니요 | `REV-015` actual transaction 증거 보강 | +| 12 | `P3-R5` → `P3-R6` → `P3-R4-GATE` | `P2-R4-GATE`, 5차 Phase 3 review | 아니요 | `REV-016`~`REV-017` 소유 Task에서 수정·증거 보강 | +| 13 | `P2-R5` → `P2-R5-GATE` | 6차 Phase 2 review | 아니요 | `REV-018` 문서 계약 동기화 | +| 14 | `P3-R7` → `P3-R8` → `P3-R5-GATE` | `P2-R5-GATE`, 6차 Phase 3 review | 아니요 | `REV-019`~`REV-020` 소유 Task에서 수정·증거 보강 | +| 15 | `P23-CONTRACT-1` → `P23-CONTRACT-2` → `P23-CONTRACT-3` → `P23-CONTRACT-GATE` | `P3-R5-GATE`, 사용자 계약 확정 | 아니요 | 문서 또는 runtime 불일치 소유 Goal에서 수정 | +| 16 | `P4-T1`~`P4-T6` → `P4-GATE` | `P23-CONTRACT-GATE` | 아니요 | 실패 소유 Task로 되돌림 | +| 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 상태를 통합 재판정 | + +## 변경 금지·중단 규칙 + +- PRD와 Endpoint Contract Summary를 근거 없이 변경하거나 제공되지 않은 DTO·enum·오류 status/key를 추정하지 않는다. +- 기존 완료 체크박스, 검증 기록과 Progress를 삭제·되돌리거나 덮어쓰지 않는다. +- review 후보는 재현·판정 전 production 수정으로 전환하지 않고, 확정 항목만 관련 Goal 또는 신규 회귀 수정 Goal로 처리한다. +- test를 삭제·skip·완화하거나 관련 없는 refactor·dependency·DDL을 추가해 Gate를 통과시키지 않는다. +- JWT, token, signed URL 전체, private path와 파일 본문을 문서·fixture·log에 기록하지 않는다. +- 일부 체크박스, 일부 test 또는 코드 작성만 끝난 상태에서는 Goal을 `complete`로 갱신하지 않는다. + +## Goal Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 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 + +- 상태: 대기 +- 무엇을: Phase 2~7의 Goal ID, 시작 조건, 완료 증거, 범위 밖, 체크박스와 Gate 구조를 준비했다. +- 왜: 기존 Phase 2·3 체크만으로 심층 리뷰와 최종 완료를 판정할 수 없었기 때문이다. +- 어떻게: PRD, 기존 계획과 `docs/sample/sample-plan-task.md`, `docs/sample/sample-review.md`를 대조했다. +- 남은 항목: `P2-R1` read-only 리뷰 실행. +- 다음 행동: 사용자가 goal 실행을 요청하면 `P2-R1`만 `create_goal`에 등록한다. + +### `P2-R1` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: Phase 2 character slice의 PRD Feature B, Endpoint Contract Summary, production/test 구현을 대조했다. +- 왜: 기존 `P2-H1`, `P2-H2` 완료 이력 이후 `P2-GATE` 전에 확정 finding을 소유 Goal에 연결해야 하기 때문이다. +- 어떻게: `docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md`를 작성하고 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'`를 실행했다. +- 결과: focused test는 `BUILD SUCCESSFUL in 51s`였고, `REV-001`~`REV-003`, `REV-007`, `REV-008`을 확정으로 유지해 `P2-T3`~`P2-T6`에 연결했다. +- 남은 항목: `P2-T3` 캐릭터 목록·검색·상세 보완부터 직렬 실행. + +### `P2-T3` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 캐릭터 목록 응답을 상세 DTO에서 목록 전용 DTO로 분리했다. +- 왜: 목록 API가 계약에 없는 상세 전용 `creatorProfileImageUrl`, `creatorIntroduce`, `updatedAtUtc`를 노출했기 때문이다. +- 어떻게: RED로 `AiCharacterAdminCharacterControllerTest` exact field 비노출 assertion을 추가했고, `AiCharacterAdminCharacterListItemResponse`와 `toListItemResponse`를 최소 구현했다. +- 결과: RED는 `AiCharacterAdminCharacterControllerTest` line 66 실패로 확인했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerTest`와 `./gradlew ktlintCheck`가 모두 `BUILD SUCCESSFUL`이었다. +- 남은 항목: `P2-T4` 캐릭터 생성 흐름 보완. + +### `P2-T4` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 생성 request의 서버 소유 field를 거부하고, 원작 존재를 외부 캐릭터 생성 전에 확인했다. 정상 생성의 원작 연결·AI creatorMember 표시 정보·언어 감지 event 및 중복·외부 API·S3 실패 결과를 v2/legacy test로 고정했다. +- 왜: `REV-002`, `REV-003`, `REV-007`에서 request 입력 의미와 외부 부작용 전 DB 검증 증거가 부족했기 때문이다. +- 어떻게: `DEC-P2-T4-001`로 canonical contract와 legacy failure boundary를 고정한 뒤 RED/GREEN test를 추가하고, focused/legacy 및 formatting 검증을 실행했다. +- 결과: specified focused/legacy test와 `ktlintCheck`가 모두 `BUILD SUCCESSFUL`이었다. 외부 API 삭제 endpoint나 신규 DB unique 제약은 기존 external contract·DDL 금지 범위 밖이라 추가하지 않았다. +- 남은 항목: `P2-T5` 캐릭터 수정·비활성화 흐름 보완. + +### `P2-T5` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: PUT의 response 전용 `externalCharacterId`와 `isActive=false` 혼합 요청을 거부하고, 원작을 외부 수정 전에 검증했다. 일반 수정의 image 유지·교체, AI creatorMember 표시 정보, 번역 event, soft delete의 Member·콘텐츠 보존과 flush 후 `updatedAtUtc`를 회귀로 고정했다. +- 왜: `REV-002`, `REV-003`, `REV-007`에서 update 입력 의미, S3/외부/DB 실패 경계와 response timestamp 증거가 부족했기 때문이다. +- 어떻게: `DEC-P2-T5-001`로 PUT contract와 non-compensated external update 경계를 확정한 뒤 RED/GREEN test를 추가하고 지정 focused/legacy 및 formatting 검증을 실행했다. +- 결과: 지정 focused/legacy test와 `ktlintCheck`가 모두 `BUILD SUCCESSFUL`이었다. 외부 update restore API와 기존 image hard delete는 external contract·legacy parity 범위 밖이라 추가하지 않았다. +- 남은 항목: `P2-T6` Phase 2 보안·오류·회귀 보완. + +### `P2-T6` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 문자열 `unmapped-path`를 character resource handler에서 제외해 prefix fallback의 404/i18n/CORS 계약으로 보냈고, 실제 목록·상세·생성·수정 endpoint의 ADMIN·binding/domain/multipart·CORS 증거를 보강했다. +- 왜: 문자열 path가 `Long` binding의 400으로 처리되어 `REV-001`을 위반했고, `REV-008`의 실제 endpoint matrix 증거가 부족했기 때문이다. +- 어떻게: 기존 404 KO/EN/JA·CORS 4건을 RED로 재현하고 GET/PUT path를 `[0-9]+`로 제한했다. 목록 binding, 상세 target, 생성/수정 multipart KO/EN/JA, 실제 네 endpoint non-ADMIN과 detail preflight를 parameterized/focused test로 확인했다. +- 결과: 지정 focused test는 `BUILD SUCCESSFUL in 1m 26s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 34s`였다. `AiCharacterAdminCharacterServiceTest`는 존재하지 않는 과거 계획 참조임을 정정 기록으로 보존했고, legacy/public contract와 DTO 의존 방향은 변경하지 않았다. +- 남은 항목: `P2-GATE`. + +### `P2-GATE` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: Phase 2 캐릭터 관리의 PRD 추적성, 정상·실패 흐름, legacy 회귀를 최종 판정했다. +- 왜: Phase 3 실행 전 `P2-R1`, `P2-T3`~`P2-T6`의 완료 증거와 Gate 명령 성공이 필요하기 때문이다. +- 어떻게: Gate에 명시된 세 명령과 `git diff --check`를 실행했다. +- 결과: character focused 명령은 병렬 실행 중 XML 결과 파일 write 충돌로 한 번 실패했으나 동일 명령 단독 재실행은 `BUILD SUCCESSFUL in 1m 11s`였다. authorization/error 명령은 `BUILD SUCCESSFUL in 1m 30s`, `ktlintCheck`는 `BUILD SUCCESSFUL`이었다. `REV-001`~`REV-003`, `REV-007`, `REV-008`의 Phase 2 소유 항목은 처리 완료로 판정했다. +- 남은 항목: `P3-R1` Phase 3 요구사항·계약·코드 리뷰. + +### `P3-T7` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 실제 Phase 3 테마·목록·상세·생성·수정 endpoint의 ADMIN/stale claim, CORS, ownership no-side-effect와 malformed resource path 오류 계약을 독립 focused test로 고정했다. +- 왜: 기존 공통 authorization/error test는 prefix 공통 계약을, 콘텐츠 focused test는 domain ownership을 보장했지만 실제 endpoint가 문자열 식별자를 404 fallback으로 보내는 증거가 없었다. +- 어떻게: `AiCharacterAdminAudioContentOwnershipTest`에서 malformed `characterId`/`contentId` RED를 먼저 확인하고, controller resource path를 `[0-9]+`로 제한했다. 기존 콘텐츠/authorization/error focused regression과 legacy characterization을 함께 실행했다. +- 결과: RED는 6건의 400/415 대 404 불일치로 확인했고, 최소 path 제약 적용 후 ownership focused 18건과 지정 content+authorization+error 회귀가 모두 `BUILD SUCCESSFUL`이었다. `ktlintCheck`도 통과했다. +- 남은 항목: `P3-GATE`. + +### `P3-GATE` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: Phase 3 콘텐츠 관리와 signed URL의 PRD 추적성, pipeline 안전성, legacy 회귀를 최종 판정했다. +- 왜: Phase 4 진행 전 `P3-R1`, `P3-T3`~`P3-T7`의 완료 증거와 Gate 명령 성공이 필요하기 때문이다. +- 어떻게: Gate에 명시된 content focused, authorization/error, `ktlintCheck` 세 명령을 fresh 실행했다. +- 결과: content focused 명령은 `BUILD SUCCESSFUL in 2m 15s`, authorization/error 명령은 `BUILD SUCCESSFUL in 1m 29s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 1s`였다. `REV-004`~`REV-008`의 Phase 3 소유 항목은 처리 완료로 판정했다. +- 남은 항목: `P4-T1` 시리즈 요구사항·계약·코드 리뷰. + +### Phase 2·3 2차 리뷰 완료 — 2026-07-27 + +- 상태: 리뷰 완료, 후속 수정 Goal 대기 +- 무엇을: Phase 2·3 production/test와 기존 review·Gate 완료 기록을 PRD와 다시 대조했다. +- 왜: 기존 focused test 성공뿐 아니라 각 완료 체크박스가 요구한 실제 endpoint·failure-order 증거가 존재하는지 독립 검증하기 위해. +- 어떻게: 두 review 문서에 2차 리뷰를 누적하고 character/content/common test를 `--rerun-tasks`로 한 번에 실행한 뒤 XML별 test 수와 lint를 확인했다. +- 결과: targeted 14개 XML class의 199건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 9m 23s`, `ktlintCheck --rerun-tasks`는 7개 task가 실행되어 `BUILD SUCCESSFUL in 27s`였다. 기능 실패는 재현되지 않았으나 완료 기록보다 직접 증거가 좁은 `REV-009`~`REV-011`을 확정했다. +- 남은 항목: `P2-R2` → `P2-R2-GATE` → `P3-R2` → `P3-R3` → `P3-R2-GATE`. + +### `P3-R2` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 실제 생성 endpoint의 multipart part별 KO/EN/JA 오류, invalid theme 선검증, cover upload 실패 rollback/event 0회 증거를 보강했다. +- 왜: `REV-010`에서 기존 생성 완료 증거가 계획의 직접 증거보다 좁다고 확정됐기 때문이다. +- 어떻게: `AiCharacterAdminAudioContentCreateTest`를 확장했고, 실패 원인은 production 계약 위반이 아니라 테스트 기대 message와 `NOT_SUPPORTED` fixture의 transaction 누락임을 확인해 테스트만 최소 정정했다. +- 결과: create 단독 명령은 `BUILD SUCCESSFUL in 1m 4s`, content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 41s`였다. +- 남은 항목: `P3-R3`. + +### `P3-R3` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 수정 차집합, cover 유지/교체/실패, ownership/domain no-side-effect 증거를 Phase 3 actual endpoint 범위에서 보강했다. +- 왜: `REV-011`에서 기존 수정·ownership 완료 증거가 계획의 직접 증거보다 좁다고 확정됐기 때문이다. +- 어떻게: 기존 Phase 3 content test 보강분을 content/common 회귀로 재검증했다. +- 결과: content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 41s`였다. +- 남은 항목: `P3-R2-GATE`. + +### `P3-R2-GATE` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: Phase 3 후속 리뷰의 `REV-010`~`REV-011` 처리를 종결했다. +- 왜: 사용자 지시에 따라 Phase 3 후속 보완까지만 진행하고 Phase 4 이후는 시작하지 않기 위해서다. +- 어떻게: `phase3-audio-content-review.md`에 3차 후속 검증 기록을 누적하고 content/common 회귀와 `ktlintCheck`를 실행했다. +- 결과: content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 41s`였다. `ktlintCheck` 결과는 검증 기록에 누적한다. +- 남은 항목: 없음. Phase 4는 진행하지 않는다. + +### 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`로 실행했다. +- 결과: 관련 14개 XML class의 228건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 9m 44s`, `ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 41s`였다. 적용된 테스트는 유효하지만 DB/event 실패, 생성 후반 S3/event 실패와 ownership no-side-effect matrix가 완료 기록보다 좁아 `REV-012`~`REV-014`를 확정했다. +- 남은 항목: `P2-R3` → `P2-R3-GATE` → `P3-R4` → `P3-R3-GATE`. + +### `P2-R3` / `P2-R3-GATE` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: character 생성·수정의 external/S3 실패 locale matrix와 event publish 실패 경계를 보강했다. +- 왜: `REV-012`에서 DB/event 실패 뒤 내부 rollback과 external/S3 잔존 결과 직접 증거가 부족하다고 확정됐기 때문이다. +- 어떻게: `AiCharacterAdminCharacterControllerMutationTest`에 KO/EN/JA 대표 실패와 facade 직접 event failure characterization을 추가했다. +- 결과: mutation focused 명령은 `BUILD SUCCESSFUL in 1m 1s`였다. +- 남은 항목: `P3-R4`. + +### `P3-R4` / `P3-R3-GATE` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: content 생성의 audio upload/event 실패와 실제 네 경로 unknown target no-side-effect matrix를 보강했다. +- 왜: `REV-013`~`REV-014`에서 생성 후반 실패 순서와 ownership/domain 부작용 없음 증거가 부족하다고 확정됐기 때문이다. +- 어떻게: `AiCharacterAdminAudioContentCreateTest`, `AiCharacterAdminAudioContentOwnershipTest`를 확장하고 content/common 회귀와 `ktlintCheck`를 실행했다. +- 결과: create+ownership focused 명령은 `BUILD SUCCESSFUL in 1m 8s`, content/common 회귀 명령은 `BUILD SUCCESSFUL in 2m 20s`, `ktlintCheck`는 import 정리 후 `BUILD SUCCESSFUL in 17s`였다. +- 남은 항목: 없음. 사용자 지시에 따라 Phase 4는 진행하지 않는다. + +### Phase 2·3 5차 재리뷰 완료 — 2026-07-27 + +- 상태: 리뷰 완료, 후속 수정 Goal 대기 +- 무엇을: `P2-R3`, `P3-R4` 반영분의 transaction/rollback, multipart binding, ownership/domain 완료 증거를 PRD·production·test method 단위로 다시 대조했다. +- 왜: 통과하는 테스트가 기존 finding의 exact exception, actual transaction과 전체 side-effect assertion을 실제로 보장하는지 확인하기 위해서다. +- 어떻게: `phase2-character-review.md`, `phase3-audio-content-review.md`에 5차 리뷰를 누적하고 변경된 핵심 test 세 클래스를 `--rerun-tasks`로 실행한 뒤 XML 수치, lint와 diff check를 확인했다. +- 결과: 세 XML 합계 79건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 7m 50s`, `ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 28s`, `git diff --check`는 출력이 없었다. 테스트는 통과했지만 `REV-015`~`REV-017`의 계약·완료 증거 누락을 확정했다. +- 남은 항목: `P2-R4` → `P2-R4-GATE` → `P3-R5` → `P3-R6` → `P3-R4-GATE`. + +### `P3-R5` 완료 — 2026-07-27 + +- 상태: 완료 +- 무엇을: 생성 controller와 facade의 `coverImage`·`audioFile`을 non-null `MultipartFile`로 고정하고, 세 필수 part 누락 KO/EN/JA actual endpoint 9건의 exact MVC binding 계약을 보강했다. +- 왜: nullable 파일 part가 facade와 legacy `AudioContentService`까지 전달되어 `MissingServletRequestPartException` 및 `common.error.invalid_request` 계약을 우회했기 때문이다. +- 어떻게: RED에서 `coverImage`·`audioFile` 누락 6건이 legacy content 전용 message로 실패함을 확인한 뒤, non-null binding으로 변경했다. 공통 helper는 각 요청의 400 `ApiResponse.error`, exact exception, DB count 0, S3 `putObject` 0회, event no-interaction을 단언한다. +- 결과: create/error focused 회귀는 `BUILD SUCCESSFUL in 1m 1s`, content/common 회귀는 `BUILD SUCCESSFUL in 2m 46s`, `ktlintCheck`는 `BUILD SUCCESSFUL`이었다. 전체 `./gradlew test`는 controller binding의 직접 영향 범위를 두 targeted 명령이 포함하고 release/Gate 범위가 아니므로 실행하지 않았다. +- 남은 항목: `P3-R6` 및 `P3-R4-GATE`. + +### `P3-R6` 완료 — 2026-07-27 + +- 상태: 완료. `P3-R4-GATE`는 `P2-R4-GATE` 뒤 실행 대기다. +- 무엇을: `AiCharacterAdminAudioContentControllerTest`의 cross-owner detail/update, create/update 다른 owner `seriesIds`, invalid `releaseDateUtc` 실제 endpoint를 각각 KO/EN/JA matrix로 확장했다. +- 왜: 기존 단일 locale 또는 부분 assertion으로는 ownership/domain 검증이 S3 업로드, DB 변경, event publish보다 앞선다는 완료 증거가 부족했다. +- 어떻게: 테스트 우선으로 exact 400 `ApiResponse.error` envelope, 요청 전후 `AudioContent`·`SeriesContent` count와 field/연결 row, S3 `putObject` 0회, `ApplicationEventPublisher` no-interaction을 단언했다. target과 other-owner ID를 분리한 fixture라 owner/domain guard를 제거하면 success/status 또는 state assertion이 실패한다. +- 결과: focused characterization은 `BUILD SUCCESSFUL in 34s`로 기존 production 계약 충족을 확인해 test-only로 종료했다. content/common 회귀는 `BUILD SUCCESSFUL in 1m 13s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 12s`였다. 전체 `./gradlew test`는 변경이 Phase 3 content endpoint 테스트에 한정되고 focused·content/common 명령이 직접 범위를 포함하므로 실행하지 않았다. +- 남은 항목: `P2-R4-GATE` 후 `P3-R4-GATE`. Phase 4는 진행하지 않는다. + +### `P2-R4-GATE` / `P3-R4-GATE` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: Phase 2·3 5차 리뷰 보완의 최종 Gate를 종결했다. +- 왜: `REV-015` actual transaction evidence, `REV-016` exact multipart binding, `REV-017` ownership/domain matrix가 모두 보강됐는지 fresh 검증으로 판정하기 위해서다. +- 어떻게: character mutation/error와 content create/controller/ownership focused 명령 및 `git diff --check`를 실행하고 review 문서의 상태를 처리 완료로 갱신했다. +- 결과: focused 명령은 `BUILD SUCCESSFUL in 52s`, `git diff --check`는 출력이 없었다. `P2-R4-GATE`, `P3-R4-GATE` 모두 완료했고 Phase 4는 사용자 진행 지시 전까지 시작하지 않는다. +- 남은 항목: 없음. 다음 Goal은 `P4-T1`이지만 사용자 진행 지시가 필요하다. + +### Phase 2·3 6차 리뷰 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 5차 보완 결과와 Endpoint Contract Summary, multipart empty-file 경계, Phase 3 event no-interaction의 실제 + 관찰 대상을 다시 대조했다. +- 왜: 통과하는 focused test와 완료된 review ID 외에 client contract 위반, 제한 조건의 파일 손상 가능성 또는 + detached mock으로 가려진 증거 공백이 남았는지 확인하기 위해서다. +- 어떻게: character/content production과 관련 test를 정적 대조하고 Phase 2·3 5차 Gate focused 5개 class를 + `--rerun-tasks`로 재실행했다. 두 review 문서에 `REV-018`~`REV-020`을 누적하고 각 finding을 독립 Task/Gate에 연결했다. +- 결과: focused XML 합계 216건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 4m 16s`, + `ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 17s`, staged/unstaged `git diff --check`는 출력이 없었다. + 테스트 통과와 별개로 문서 계약 1건, production empty-file 경계 1건, test evidence 1건을 확정했다. +- 보완 결과: `P2-R5`, `P2-R5-GATE`, `P3-R7`, `P3-R8`, `P3-R5-GATE`를 완료했다. +- 최종 검증: content/common 회귀는 `BUILD SUCCESSFUL in 1m 30s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 17s`, `git diff --check`는 출력이 없었다. +- 남은 항목: 없음. Phase 4는 사용자 진행 지시 전까지 시작하지 않는다. + +### Phase 2·3 6차 보완 재점검 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: `P2-R5`, `P2-R5-GATE`, `P3-R7`, `P3-R8`, `P3-R5-GATE`의 실제 코드·테스트·완료 기록을 다시 + 대조하고 하단 종합 finding 상태를 점검했다. +- 왜: 6차 보완의 empty-file·actual publisher·문서 계약 수정이 실제로 유지되는지와 완료된 finding이 미처리 상태로 + 남아 있지 않은지 확인하기 위해서다. +- 어떻게: Phase 2 character mutation과 Phase 3 create/update/controller/ownership 5개 class를 `--rerun-tasks`로 + 실행하고, `REV-001`~`REV-020`의 소유 Goal·Gate·Progress 기록을 하단 종합 표와 대조했다. +- 결과: 5개 XML 합계 130건은 failure/error/skipped 0으로 `BUILD SUCCESSFUL in 4m 19s`였다. 6차 보완 범위의 추가 + production 결함은 재현되지 않았다. 별도 문서 문제로 종결된 `REV-001`~`REV-009`가 종합 표에서 `확정`으로 남은 상태 + 불일치를 확인해 `처리 완료`로 동기화했다. +- 남은 항목: 없음. 다음 Goal은 `P4-T1`이지만 사용자 진행 지시 전까지 시작하지 않는다. + +### `P23-CONTRACT-1` 완료 — 2026-07-28 + +- 상태: 완료 +- 무엇을: 캐릭터, 테마·오디오 콘텐츠, 시리즈, 커뮤니티, FanTalk의 신규 관리자 API 23개 request/response를 + OpenAPI 3.1 JSON과 설명 문서로 고정했다. +- 왜: 신규 path와 관리자 target만 적용하고 클라이언트 JSON 계약은 레거시 필드명·타입·optional/nullable·기본값과 성공 + `data` 형태를 그대로 이관해야 하기 때문이다. +- 어떻게: 레거시 Kotlin DTO·controller·service와 schema를 대조하고 path ID만 body에서 제거했다. FanTalk 답변 축약 응답, + 관리자 FanTalk 목록, 분리된 시리즈 미연결 콘텐츠 검색만 확정 예외로 반영했다. 독립 리뷰에서 확인한 + `Accept-Language` fallback, 캐릭터 비활성화 혼합 입력, 시리즈 상세 문자열 `state`, Phase 4 선행 Gate 문제를 교정했다. +- 결과: JSON parse, 내부 `$ref` 누락 0건, 고유 operation 23개와 상태 9/14를 확인했다. Redocly lint와 OpenAPI Generator + validate가 통과했고 `typescript-fetch` 생성 후 TypeScript 5.9.3 `tsc --noEmit`도 성공했다. + `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 898ms`였으며 독립 최종 리뷰는 Critical 0, Important 0이었다. +- 남은 항목: 현재 구현된 캐릭터 4개 endpoint의 runtime DTO를 맞추는 `P23-CONTRACT-2`. production code는 변경하지 않았다. + +### `P23-CONTRACT-2`~`P7-GATE` 실행 계획 보완 — 2026-07-28 + +- 상태: 구현 시작 준비 완료 +- 무엇을: `P23-CONTRACT-2`·`P23-CONTRACT-3`의 실제 facade와 전용 query/theme/ownership/legacy test 범위를 보강하고, + Task별 focused 명령을 추가했다. Phase 4~6은 각 OpenAPI tag를 정식 schema로, Phase 7은 OpenAPI 23개 operation을 + 최종 구현 기준으로 명시했다. +- 왜: 확정 계약이 있어도 실제 request parsing·response 조립을 담당하는 facade와 전용 테스트가 계획에서 누락되면 + 구현 중 범위가 다시 흔들릴 수 있기 때문이다. +- 어떻게: OpenAPI operation 23개와 Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2 분류를 Phase/Goal과 + 대조하고 기존 source/test 파일 존재, Goal ID 중복, 선행 Gate와 PRD Open Questions를 확인했다. +- 결과: 과거 축약 예시는 비규범 이력으로 분리했고 현재 Endpoint Contract Summary는 OpenAPI와 같은 9/14 상태 및 예외만 + 제공한다. Task 3.17부터 `P7-GATE`까지 시작 조건·파일·RED/GREEN/REFACTOR·검증 명령이 연결됐으며 + `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 922ms`였다. +- 제외: 생성 TypeScript의 `tsc` 실행은 클라이언트 build/CI 책임으로 두고 server runtime `P23-CONTRACT-GATE`에는 추가하지 + 않았다. `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` | +| 2026-07-27 | `DEC-REVIEW-002` | 확정 | 기존 Phase 2·3 Task/Gate 완료 이력은 보존하고, 2차 리뷰에서 확인한 검증 증거 누락은 `REV-009`~`REV-011`과 새 후속 Task/Gate로 처리한다. | fresh 199건은 모두 통과했지만 기존 완료 체크리스트의 실제 endpoint·failure-order 범위와 test method가 일치하지 않았다. | `P2-R2`~`P3-R2-GATE`, 두 2차 review | +| 2026-07-27 | `DEC-REVIEW-003` | 확정 | 3차 수정에서 유효하게 보강된 범위와 기존 완료 이력은 보존하고, 아직 직접 고정되지 않은 실패·ownership 경계만 `REV-012`~`REV-014`와 새 Task/Gate로 추적한다. | fresh 228건은 모두 통과했지만 `Task 2.8`~`Task 3.10`의 완료 체크리스트와 실제 failure injection·side-effect assertion 범위가 다시 일치하지 않았다. | `P2-R3`~`P3-R3-GATE`, 두 4차 review | +| 2026-07-27 | `DEC-REVIEW-004` | 확정 | 4차 보완의 유효한 테스트와 완료 이력은 보존하고, exact multipart 계약과 actual transaction·ownership/domain 직접 증거 누락은 `REV-015`~`REV-017` 및 새 Task/Gate로 추적한다. | fresh 79건은 통과했지만 nullable file binding, direct facade event test와 unknown target에 한정된 matrix가 기존 완료 조건보다 좁았다. | `P2-R4`~`P3-R4-GATE`, 두 5차 review | +| 2026-07-28 | `DEC-REVIEW-005` | 확정 | 5차 보완과 완료 이력은 보존하고, 추가로 확인한 문서 계약·empty-file 경계·detached publisher 증거 문제를 `REV-018`~`REV-020`과 새 후속 Task/Gate로 추적한다. | fresh focused 216건과 lint는 통과했지만 Endpoint Contract Summary, `MultipartFile.isEmpty` 처리와 실제 service publisher field를 코드·test 단위로 대조해 세 문제가 재현됐다. | `P2-R5`~`P3-R5-GATE`, 두 6차 review | +| 2026-07-28 | `DEC-P3-R7-001` | 확정 | 생성의 빈 `coverImage`·`audioFile`은 400 `common.error.invalid_request`로 거부한다. 수정의 빈 `coverImage`는 생략으로 정규화하고, 수정 `audioFile`은 미지원이므로 part가 존재하면 크기와 관계없이 400으로 거부한다. | non-null binding은 part 누락만 차단하며 빈 파일은 0-byte upload와 cover 교체를 유발할 수 있다. optional cover의 빈 part는 일반 multipart client의 생략 표현으로 안전하게 처리할 수 있지만 미지원 audio part는 존재 자체가 계약 위반이다. | `P3-R7`, `REV-019`, Endpoint Contract Summary | +| 2026-07-28 | `DEC-REVIEW-006` | 확정 | Phase 2·3 6차 보완의 코드와 완료 이력은 유지하고 새 production Goal은 추가하지 않는다. 종결 Gate가 있는 `REV-001`~`REV-009`의 종합 표 상태만 `처리 완료`로 동기화한다. | fresh focused 130건이 모두 통과했고 6차 보완 범위의 추가 production 결함은 재현되지 않았지만, 하단 종합 표 상태가 각 Gate·Progress의 처리 완료 판정과 모순됐다. | `P2-GATE`, `P2-R2-GATE`, `P3-GATE`, 하단 발견된 문제 표 | +| 2026-07-28 | `DEC-API-CONTRACT-001` | 확정 | 신규 관리자 endpoint는 레거시 request/response의 필드명·타입·optional/nullable·기본값·성공 `data` 형태를 유지하고 path로 이동한 ID만 body에서 제거한다. FanTalk 답변은 축약 응답을 유지한다. | 신규 endpoint의 목적이 로그인 불가능한 AI 캐릭터를 관리자 경계로 대리 관리하는 것이며, 클라이언트 계약까지 임의로 재설계하는 범위가 아니기 때문이다. | PRD, `api-contract.openapi.json`, `P23-CONTRACT-1`~`P23-CONTRACT-GATE` | +| 2026-07-28 | `DEC-API-CONTRACT-002` | 확정 | FanTalk 목록은 공개 v2 field 형태를 유지하는 관리자 전용 endpoint로 추가하고, 시리즈 연결 목록과 미연결 검색은 응답 형태가 달라 별도 endpoint로 분리한다. | 공개 v2 직접 호출은 `creatorId`, viewer/block filter와 CORS 경계가 관리자 요구와 다르고, 시리즈 두 legacy API의 응답은 wrapper와 direct array로 서로 다르다. | Phase 4, Phase 6, `api-contract.openapi.json` | +| 2026-07-28 | `DEC-API-CONTRACT-003` | 확정 | `DEC-P2-T5-001`의 캐릭터 `isActive=false` 단독 입력 제한을 최신 JSON 계약에서 폐기한다. `ChatCharacterUpdateRequest`처럼 다른 optional field와 동시 입력을 허용하고, `isActive=false`이면 레거시 service와 같이 비활성화만 반영한다. | 사용자 확정 원칙은 path ID만 제거하고 레거시 request를 그대로 이관하는 것이다. 레거시 controller는 혼합 request를 받으며 service는 비활성화 분기에서 나머지 JSON field를 적용하지 않는다. | `api-contract.openapi.json`, `P23-CONTRACT-2` | +| 2026-07-28 | `DEC-API-CONTRACT-004` | 확정 | 생성된 TypeScript의 `tsc` 실행은 클라이언트 build/CI 책임으로 두고 server runtime `P23-CONTRACT-GATE` 완료 조건에는 추가하지 않는다. | 서버 Gate는 실제 HTTP runtime과 OpenAPI validate/client 생성 가능성을 판정하며, 언어별 client compile은 소비 클라이언트의 toolchain에서 검증해야 한다. 계약 작성 Task의 일회성 TypeScript 생성·컴파일 검증은 이미 완료됐다. | `P23-CONTRACT-GATE`, `api-contract.md` | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| `REV-001` | Blocker | 처리 완료 | 문자열 미매핑 경로가 `/{characterId}`에 매핑되어 Phase 1의 404 계약 대신 type mismatch 400을 반환한다. | `P2-R1`, `P2-T6` | numeric path mapping과 KO/EN/JA·CORS 회귀를 고정했다. | +| `REV-002` | High | 처리 완료 | 캐릭터 생성·수정 DTO의 `systemPrompt`, `externalCharacterId`, 생성 `isActive`와 soft-delete 혼합 의미가 Endpoint Contract Summary와 다르다. | `P2-R1`, `P2-T4`, `P2-T5` | canonical request를 확정하고 문서·actual endpoint 계약을 동기화했다. | +| `REV-003` | High | 처리 완료 | 외부 API·S3·DB 실패 사이에 보상 경계가 없고 동시 중복 이름 생성의 원자성 증거가 없다. | `P2-R1`, `P2-T4`, `P2-T5` | failure-order·선검증·비보상 경계와 신규 DDL 없는 동시성 정책을 고정했다. | +| `REV-004` | High | 처리 완료 | 동일한 `seriesIds` 수정도 기존 연결을 삭제·재생성해 row ID·`orders`·`createdAt`을 소실한다. | `P3-R1`, `P3-T6` | 교집합 row를 보존하고 차집합만 변경하도록 수정·검증했다. | +| `REV-005` | High | 처리 완료 | 콘텐츠 상세의 `releaseDate`, `isOnlyRental`, `purchaseOption`이 legacy 상세 파생 규칙과 다르다. | `P3-R1`, `P3-T4` | legacy 조합 matrix와 v2 `releaseDateUtc` 분리 mapping을 고정했다. | +| `REV-006` | High | 처리 완료 | 콘텐츠 생성의 필수 `coverImage`·`audioFile`이 nullable binding이라 정확한 missing-part 오류 계약을 우회한다. | `P3-R1`, `P3-T5`, `P3-T7` | non-null binding과 세 part별 exact exception·KO/EN/JA endpoint 회귀를 추가했다. | +| `REV-007` | Medium | 처리 완료 | 목록 초과 field, pre-flush timestamp, `characterType`, legacy/public 중첩 DTO, `tags`·생성 `isActive`에서 추가 계약/parity 차이가 있다. | `P2-T3`~`P2-T6`, `P3-T3`~`P3-T5`, `P3-T7` | 소유 Goal별 exact schema·field 의미·DTO 경계와 legacy parity를 분리 검증했다. | +| `REV-008` | High | 처리 완료 | endpoint별 인가/i18n, failure-order characterization과 계획에 명시된 test 파일·검증 수의 완료 증거가 부족하다. | `P2-R1`, `P2-T6`, `P3-R1`, `P3-T5`, `P3-T7` | 실제 endpoint matrix와 characterization을 보강하고 정정 기록을 누적했다. | +| `REV-009` | Medium | 처리 완료 | Phase 2 실제 네 endpoint의 stale claim·허용/거부 CORS·오류와 mutation 실패 경계 직접 증거가 Gate 기록보다 좁다. | `P2-R2`, `P2-R2-GATE` | actual endpoint matrix와 실패 후 DB/S3/external/event 결과를 보강했다. | +| `REV-010` | Medium | 처리 완료 | Phase 3 생성의 invalid theme, 실제 multipart KO/EN/JA와 S3/processing/event 실패 순서 직접 증거가 완료 기록보다 좁다. | `P3-R2`, `P3-R2-GATE` | 생성 actual endpoint와 legacy failure characterization을 보강했다. | +| `REV-011` | Medium | 처리 완료 | Phase 3 수정의 교집합+추가+제거 metadata, cover 성공/실패와 ownership no-side-effect 직접 증거가 완료 기록보다 좁다. | `P3-R3`, `P3-R2-GATE` | update/ownership actual endpoint 회귀를 보강했다. | +| `REV-012` | Medium | 처리 완료 | Phase 2의 DB save/flush·event publish 실패 뒤 내부 rollback과 external/S3 잔존 결과를 직접 검증하지 않았다. | `P2-R3`, `P2-R3-GATE` | 실제 transaction failure injection과 내부·외부 결과 단언을 보강했다. | +| `REV-013` | Medium | 처리 완료 | Phase 3 생성은 첫 cover upload 실패만 검증하고 두 번째 audio upload와 event 실패 결과를 직접 고정하지 않았다. | `P3-R4`, `P3-R3-GATE` | 생성 후반 failure order와 비트랜잭션 S3 결과를 특성화했다. | +| `REV-014` | Medium | 처리 완료 | Phase 3 실제 네 경로의 ownership/domain KO/EN/JA와 DB/S3/event no-side-effect matrix가 부분적이다. | `P3-R4`, `P3-R3-GATE` | actual endpoint 오류와 요청 전후 부작용 count를 보강했다. | +| `REV-015` | Medium | 처리 완료 | Phase 2 event 실패 테스트가 actual endpoint/Spring transaction을 통과하지 않고 DB save/flush·내부 rollback을 직접 단언하지 않는다. | `P2-R4`, `P2-R4-GATE` | actual POST/PUT persistence·event 실패와 transaction 종료 뒤 내부·외부 결과를 고정했다. | +| `REV-016` | High | 처리 완료 | Phase 3 생성 필수 `coverImage`·`audioFile`이 nullable이라 exact `MissingServletRequestPartException` 계약을 우회했다. | `P3-R5`, `P3-R4-GATE` | non-null binding과 세 part별 exact exception·KO/EN/JA·no-side-effect를 복구했다. | +| `REV-017` | Medium | 처리 완료 | Phase 3 4차 보강이 unknown target에 한정돼 cross-owner/domain KO/EN/JA와 DB/S3/event matrix가 완료 기록보다 좁다. | `P3-R6`, `P3-R4-GATE` | 대표 cross-owner/series/date actual endpoint의 전체 no-side-effect matrix를 보강했다. | +| `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 파일의 존재를 확인했다. +- 실행 계획 명령 유효성(2026-07-28): `./gradlew tasks --all` 실행 결과 `test`, `ktlintCheck`, `tasks`가 존재했고 + `BUILD SUCCESSFUL in 922ms`였다. `git diff --check`, `git diff --cached --check`는 출력이 없었다. +- `P23-CONTRACT-1` OpenAPI 검증(2026-07-28): `jq empty`, 23개 고유 operation과 + `implemented-contract-alignment-required` 9개/`planned` 14개 assertion, 내부 `$ref` 해석을 실행해 모두 성공했다. + `npx --yes @redocly/cli lint --skip-rule info-license ...`는 `valid`, OpenAPI Generator `validate`는 + `No validation issues detected`였다. +- `P23-CONTRACT-1` 클라이언트 검증(2026-07-28): OpenAPI Generator 7.24.0 `typescript-fetch` 생성을 완료하고, + 생성된 23개 Raw operation(Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2)을 확인했다. + `tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM .../index.ts`는 TypeScript 5.9.3에서 성공했다. +- `P23-CONTRACT-1` 문서·리뷰 Gate(2026-07-28): `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 898ms`였고, + 독립 레거시 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 영향 범위 회귀를 실행한다. +- Phase 2·3 5차 Gate focused 검증(2026-07-28): `./gradlew test --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 52s`였다. +- Phase 2·3 5차 Gate diff 검증(2026-07-28): `git diff --check`는 출력이 없었다. +- `P3-R5` RED(2026-07-27): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest` 실행 결과 14건 중 `coverImage`·`audioFile` 누락 KO/EN/JA 6건이 실패했다. 예를 들어 audio 누락 KO는 기대 `잘못된 요청입니다.` 대신 legacy `콘텐츠를 선택해 주세요.`를 반환해 nullable binding이 MVC 경계를 우회함을 확인했다. +- `P3-R5` focused 검증(2026-07-27): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` 실행 결과 `BUILD SUCCESSFUL in 1m 1s`였다. 세 필수 part 누락 KO/EN/JA는 400 generic envelope, exact `MissingServletRequestPartException`, DB/S3/event 0회를 실제 endpoint에서 확인했다. +- `P3-R5` content/common 회귀(2026-07-27): `./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 2m 46s`였다. +- `P3-R5` lint(2026-07-27): `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`이었다. +- `P3-R5` 최종 fresh 검증(2026-07-27): `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`는 모든 Gradle task를 재실행해 `BUILD SUCCESSFUL in 3m 33s`였고, `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL in 25s`였다. +- Phase 2·3 5차 리뷰 fresh focused 검증(2026-07-27): `./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.AiCharacterAdminAudioContentOwnershipTest` 실행 결과 `BUILD SUCCESSFUL in 7m 50s`였다. 세 XML 합계 79건의 failure/error/skipped는 모두 0이었다. +- Phase 2·3 5차 리뷰 lint/diff 검증(2026-07-27): `./gradlew ktlintCheck --rerun-tasks`는 7개 task가 실행되어 `BUILD SUCCESSFUL in 28s`였고, `git diff --check`는 출력이 없었다. +- Phase 2·3 5차 리뷰 전체 회귀 생략(2026-07-27): production code를 수정하지 않은 review/계획 문서 Task 등록이며 변경된 세 핵심 test를 fresh 실행해 finding을 판정했으므로 전체 `./gradlew test`는 실행하지 않았다. 실제 수정 Goal Gate에서 character/content/common 영향 범위 회귀를 각각 실행한다. +- Phase 2·3 5차 리뷰 문서 명령 유효성(2026-07-27): `./gradlew tasks --all` 실행 결과 `test`, `ktlintCheck`, `tasks`가 존재했고 `BUILD SUCCESSFUL in 1s`였다. +- Phase 2·3 4차 보완 focused 검증(2026-07-27): `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest` 실행 결과 `BUILD SUCCESSFUL in 1m 1s`였다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentOwnershipTest` 실행 결과 `BUILD SUCCESSFUL in 1m 8s`였다. +- Phase 2·3 4차 보완 content/common 회귀(2026-07-27): `./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 2m 20s`였다. +- Phase 2·3 4차 보완 lint(2026-07-27): `./gradlew ktlintCheck`는 신규 test import ordering/unused import 3건 실패 후 import만 정리해 재실행했고, `BUILD SUCCESSFUL in 17s`였다. +- Phase 2·3 4차 리뷰 fresh targeted 검증(2026-07-27): `./gradlew test --rerun-tasks --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.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` 실행 결과 `BUILD SUCCESSFUL in 9m 44s`였다. 관련 XML 14개 합계 228건의 failure/error/skipped는 모두 0이었다. +- Phase 2·3 4차 리뷰 lint(2026-07-27): `./gradlew ktlintCheck --rerun-tasks` 실행 결과 7개 task가 실행됐고 `BUILD SUCCESSFUL in 41s`였다. +- Phase 2·3 4차 리뷰 전체 회귀 생략(2026-07-27): production code를 변경하지 않은 리뷰·문서 Task 등록이며 character/content actual endpoint와 공통 authorization/error를 포함한 fresh 228건으로 직접 영향 범위를 확인했으므로 전체 `./gradlew test`는 실행하지 않았다. +- Phase 2·3 2차 리뷰 fresh targeted 검증(2026-07-27): `./gradlew test --rerun-tasks --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.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` 실행 결과 `BUILD SUCCESSFUL in 9m 23s`였다. 생성된 관련 XML 14개 합계 199건의 failure/error/skipped는 모두 0이었다. +- Phase 2·3 2차 리뷰 lint(2026-07-27): `./gradlew ktlintCheck --rerun-tasks` 실행 결과 7개 task가 실행됐고 `BUILD SUCCESSFUL in 27s`였다. +- Phase 2·3 2차 리뷰 전체 회귀 생략(2026-07-27): production code를 수정하지 않은 read-only review와 문서 후속 Task 등록이며, character/content actual endpoint와 공통 authorization/error를 포함한 fresh 199건으로 직접 영향 범위를 확인했으므로 전체 `./gradlew test`는 실행하지 않았다. +- Phase 3 후속 보완 content/common 회귀(2026-07-27): `./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 2m 41s`였다. +- Phase 3 후속 보완 lint(2026-07-27): `./gradlew ktlintCheck`는 import ordering 1건 실패 후 `AiCharacterAdminAudioContentUpdateTest.kt` import 순서만 정리해 재실행했고, `BUILD SUCCESSFUL in 16s`였다. +- Phase 2·3 코드 리뷰 Task 보완(2026-07-27): 확정 finding 8개를 `REV-001`~`REV-008`로 등록하고 기존 미실행 + `P2-T3`~`P2-T6`, `P3-T3`~`P3-T7`에 소유권, 정확한 RED/RED 확인/GREEN/GREEN 확인/REFACTOR, 파일 경로, + focused 명령과 Gate 완료 증거를 보강했다. 중복 Goal은 만들지 않고 각 Phase 안에서 직렬 실행하도록 의존성 표를 갱신했다. +- Task TDD 샘플 동기화(2026-07-27): `docs/sample/sample-plan-task.md`에 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` + 작성 규칙과 read-only Task의 `TDD 예외 사유`·`대체 검증 방법` 형식을 추가하고 기존 샘플 Task 체크박스를 같은 형식으로 통일했다. +- 문서 보완 검증(2026-07-27): `REV-001`~`REV-008` 추적 횟수, Phase 2·3 Task/Gate·직렬 Goal 순서, 중복 Goal ID와 + 미교체 placeholder를 `rg`로 확인했고 `git diff --check`, `git diff --cached --check`가 통과했다. + `./gradlew tasks --all`은 exit code 0, `BUILD SUCCESSFUL in 660ms`를 확인했다. 문서 전용 변경이며 사용자가 명시적으로 + 제외했으므로 전체 `./gradlew test`는 실행하지 않았다. +- 리뷰 문서 경로 보완(2026-07-27): 여러 리뷰를 범위별 파일로 누적할 수 있도록 공통 저장 경로를 + `docs/[날짜]_구현할내용한글/reviews/`로 정하고, Phase 2·3 리뷰 산출물과 샘플·가이드 경로를 동기화했다. +- 전체 회귀 실행 정책 보완(2026-07-27): focused/영향 범위 회귀를 우선하고 공통 경계·여러 Phase 영향 또는 targeted 결과만으로 + 영향 범위를 판단할 수 없는 경우에만 전체 회귀를 실행하도록 공통 가이드·샘플·`P7-T1`을 동기화했다. 생략 시 근거와 대체 + 검증 명령을 기록하도록 했다. +- Goal 실행형 계획 보완(2026-07-27): `docs/sample/sample-plan-task.md`와 PRD Feature B~F를 대조해 Phase 2·3에는 기존 완료 + 이력을 보존한 review/세부 보완/Gate Goal을 추가하고, Phase 4~6은 독립 검토 가능한 기능 단위 Task와 Gate로 분할했다. + Phase 7에는 targeted·조건부 전체 회귀, 계약·변경 범위와 최종 판정 Goal을 분리했다. +- 샘플 참조 가이드 보완(2026-07-27): `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`에 + `docs/sample/{sample-prd.md,sample-plan-task.md,sample-review.md}`의 용도, Goal 완료·Gate·리뷰 누적 규칙을 반영했다. +- 문서 검증(2026-07-27): Goal/Task/Gate heading과 ID, placeholder·구 샘플 경로, 중복 Goal ID를 `rg`로 확인했고 중복·미교체 + placeholder·구 샘플 경로 참조가 없음을 확인했다. `git diff --check`가 통과했다. +- 명령 유효성 검증(2026-07-27): sandbox 실행은 Gradle wrapper lock 권한으로 실패해 승인 범위에서 + `./gradlew tasks --all`을 재실행했고 `BUILD SUCCESSFUL`을 확인했다. +- 계획 작성 단계: 코드 변경 없음. +- 문서 규칙 확인: `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/테스트스타일.md`, `docs/sample/sample-prd.md`를 확인하고 `docs/20260724_AI캐릭터_관리자_API/{prd.md,plan-task.md}` 형식으로 작성했다. +- 금지어 확인: 계획 문서 금지어 검색 명령 실행 결과 없음. +- Phase 항목 확인: `rg -n "^### Phase|#### 목표|#### 범위와 비범위|#### 선행 Phase|#### API endpoint|#### entity, repository, service 변경|#### DB migration|#### transaction과 concurrency|#### 보안 및 개인정보|#### acceptance criteria|#### targeted test|#### 전체 회귀 테스트 영향|#### rollback 전략|#### 권장 commit 경계|Final Integration" "docs/20260724_AI캐릭터_관리자_API/plan-task.md"`로 Phase 1~7 전체에 필수 항목이 있음을 확인했다. +- 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- Markdown diagnostics: `.md` 확장자용 LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가. +- Endpoint Contract 보강: frontend HTTP 호출 기준으로 query parameter, request body, multipart form fields, response `data` JSON 예시를 추가했다. +- Endpoint Contract 보강 검증: 계획 문서 금지어 검색 결과 없음. `.md` 확장자용 LSP 서버가 없어 diagnostics는 실행 불가. +- 문서 동기화 보강: source spec 기준 `characterId` 예외, 시리즈 콘텐츠 검색, endpoint별 ADMIN 권한 테스트, parity RED 범위, 의존 방향 검증, PRD 가드레일, pagination 경계값, RED/GREEN/REFACTOR task 규칙을 반영했다. +- 문서 동기화 검증: `Read`로 `prd.md`와 `plan-task.md`의 반영 라인을 확인했다. `git status --short` 결과 `docs/20260724_AI캐릭터_관리자_API/`는 현재 untracked 디렉터리로 표시된다. `./gradlew tasks --all`은 이번 세션에서 120초, 300초 제한 모두 초과해 종료 결과를 확인하지 못했다. +- 문서 동기화 보강(2차): 기존 legacy/creator-admin 구현을 먼저 통과하는 특성화 baseline과 신규 v2 RED를 분리했고, character/content/series/community/FanTalk 재사용·parity 경계에 반영했다. 시리즈 CRUD·연결·해제·조회·순서 behavior parity와 signed URL 만료 계산식·edge case 특성화를 PRD와 Task에 명시했다. +- 문서 동기화 재검증(4번 제외): source spec acceptance criteria 25개 추적 검사 25/25, actor/scope/non-goal/architecture 가드레일 11/11을 확인했고 `git diff --check` 결과 문제가 없었다. plan 상단의 실행 skill 지침은 사용자 요청에 따라 변경·판정 범위에서 제외했다. +- 기존 기록은 보존한다. 각 Task의 실행 명령과 결과 요약은 해당 Task 아래에 누적하고, phase/전체 회귀·전체 빌드·포맷·문서 범위 확인만 이 섹션에 누적한다. +- Phase 1 RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` 실행 결과 `AiCharacterAdminTargetResolver`와 `ChatCharacterRepository.findByIdWithCreatorMember` 미구현으로 `compileTestKotlin` 실패를 확인했다. +- Phase 1 GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 ADMIN 권한 RED/GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest`에서 sample route 권한 실패를 확인한 뒤 `/api/v2/admin/ai-characters/**` ADMIN rule을 적용해 `BUILD SUCCESSFUL`을 확인했다. +- Phase 1 targeted 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 lint 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 LSP diagnostics: 현재 도구 목록에 LSP diagnostics tool이 노출되어 있지 않아 실행하지 못했고, 대신 Kotlin compile/test와 `ktlintCheck`로 대체 검증했다. +- Phase 1 reviewer gate: 1차 리뷰에서 invalid target 4xx 미충족, production SecurityConfig 미검증, cross-owner fixture 부족을 지적받아 수정했고, 재리뷰 결과 남은 blocking finding 없음으로 승인받았다. +- Phase 1 추가 리뷰 반영: mock 기반 resolver 테스트만으로 실제 repository/ownership 동작을 검증하지 못한다는 지적에 따라 `AiCharacterAdminTargetResolverIntegrationTest`를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 no-side-effect 근거 보정: 현재 검증은 resolver 자체가 DB row를 변경하지 않는다는 통합 테스트와 resolver가 S3/외부 API/event 의존성을 갖지 않는다는 구조에 한정한다. Phase 2~6 write vertical slice의 S3, 외부 API, 이벤트 no-side-effect는 각 slice 테스트에서 별도로 검증한다. +- Phase 1 후속 정책 반영 전 한계: 기존 ADMIN smoke는 JWT authority만 검증했고 현재 DB role/stale claim 및 신규 prefix 오류 + envelope/i18n을 검증하지 않았다. 2026-07-24 후속 확정 정책은 Task 1.4~1.5에서 RED/GREEN으로 보완한다. +- Phase 1 후속 정책 문서 갱신: JWT ADMIN + 현재 DB ADMIN 이중 인가, stale claim 403, 신규 prefix의 비2xx + `ApiResponse.error`/KO·EN·JA 계약, legacy 오류 응답 불변 조건을 PRD와 계획에 반영하고 미완료 Task 1.4~1.5를 추가했다. +- Phase 1 후속 정책 문서 자체 검토: 금지어/미확정 문구 검색 결과 없음, 요구사항 추적 검색으로 stale claim·오류 envelope·i18n· + legacy fallback·Task 1.4~1.5 반영을 확인했고 `git diff --check` 결과 문제가 없었다. +- Phase 1 후속 정책 명령 유효성: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- Task 1.4 RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest` + 실행 결과 6개 중 stale ADMIN claim과 비`MemberAdapter` principal이 403 기대 대비 200으로 통과해 2개 실패함을 확인했다. +- Task 1.4 GREEN: 신규 prefix의 단일 access 식에서 JWT ADMIN, `MemberAdapter` principal, 현재 DB ADMIN을 AND로 검증한 뒤 + 동일 테스트 실행 결과 `BUILD SUCCESSFUL`. +- Task 1.4 reviewer gate: 별도 read-only 리뷰에서 스펙 준수와 코드 품질 모두 승인됐고 Critical/Important/Minor finding이 + 없음을 확인했다. +- Task 1.5 최초 RED: legacy 오류 baseline 2개는 통과했고 신규 401/403/400/500 계약은 15개 중 13개가 status/content type/ + message 불일치로 실패함을 확인했다. target resolver의 기존 `ResponseStatusException`을 신규 API 예외로 교체하는 테스트도 + 새 예외 미구현 상태의 `compileTestKotlin` 실패로 RED를 확인했다. +- Task 1.5 확장 RED: handler 선택 전 오류와 filter 내부 장애까지 포함해 오류 계약 39개를 실행한 결과 신규 prefix의 404/405/ + 415 및 예상하지 못한 JWT filter 오류 500에 해당하는 12개만 실패했고 legacy baseline은 통과했다. 미등록 message key fallback은 + 44개 중 해당 KO/EN/JA 3개 실패로 별도 RED를 확인했다. +- Task 1.5 GREEN: URI prefix 기반 exception resolver, 낮은 우선순위 404 fallback mapping, prefix 전용 security handler, + known 인증 실패 401/그 외 filter 예외 500 분리, 미등록 message key의 localized unknown fallback을 구현했다. + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` 실행 결과 44개 invocation + 모두 통과해 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.5 기존 slice 회귀: production `SecurityConfig`를 import하는 기존 WebMvc test 5개와 신규 authorization/error contract + test를 함께 실행한 결과 `BUILD SUCCESSFUL`을 확인했다. 신규 writer/security handler/exception resolver는 `SecurityConfig`의 + 명시적 bean으로 등록해 slice와 실제 application 구성을 동일하게 유지했다. +- Task 1.5 reviewer gate: 최초 read-only 리뷰의 405/415 handler-less 경로, 비인증 filter 예외의 401 오분류, 기존 WebMvc slice + 빈 누락, 미매핑 404 지적을 모두 보완했다. 재리뷰 결과 Critical/Important/Minor finding 없이 승인됐다. +- Phase 1 후속 정책 최종 targeted/legacy 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 `BUILD SUCCESSFUL`(28초)을 확인했다. +- Phase 1 후속 정책 전체 회귀: 최신 작업 트리에서 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`(4분 22초)을 확인했다. +- Phase 1 후속 정책 lint: 최신 작업 트리에서 `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`(17초)을 확인했다. +- Phase 1 후속 정책 diff 무결성: `git diff --check` 통과, conflict marker 없음, build 설정·dependency·DDL 변경 없음을 확인했다. +- Task 1.5 최종 리뷰 보완 RED/GREEN: JWT parse 사이 만료 `JwtException` 401과 인증 저장소 장애 500을 추가했을 때 50개 중 + 해당 6개 실패를 확인한 뒤 known credential failure만 401로 분류해 50개 모두 통과했다. 필수 request header 누락 400은 + 53개 중 해당 KO/EN/JA 3개 실패를 확인한 뒤 `ServletRequestBindingException`을 400으로 분류해 모두 통과했다. +- Task 1.5 CORS RED/GREEN: 허용된 관리자 Origin의 404/405/415와 실제 `authorization,content-type` header를 요청하는 미매핑 + prefix preflight를 추가했을 때 57개 중 해당 4개 실패를 확인했다. fallback mapping에 기존 전역 설정과 동일한 CORS 설정을 + 적용한 뒤 57개 모두 통과했다. +- Phase 1 no-side-effect 검증 강화: `AiCharacterAdminTargetResolverIntegrationTest`에서 Hibernate statistics를 초기화한 뒤 + invalid target resolver 호출과 flush 후 entity insert/update/delete가 각각 0건임을 직접 검증했다. resolver 단위·통합 및 오류 + 계약 테스트를 함께 실행한 결과 `BUILD SUCCESSFUL`(27초)을 확인했다. +- Phase 1 최종 reviewer gate: 인증 예외 분류, request binding, fallback CORS, DB no-write, 문서/rollback을 독립 read-only로 + 재검토한 결과 Critical/Important/Minor finding이 없음을 확인했다. +- Phase 1 최신 targeted/legacy 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 `BUILD SUCCESSFUL`(28초)을 확인했다. +- Phase 1 최신 전체 회귀: 최종 코드 작업 트리에서 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`(4분 17초)을 확인했다. +- Phase 1 최신 lint: import 순서 1건을 수정한 뒤 `./gradlew ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`(10초)을 확인했다. +- Phase 1 최신 diff 무결성: `git diff --check`와 `git diff --cached --check`가 모두 통과했고 conflict marker가 없으며 build + 설정, dependency, DDL 변경이 없음을 확인했다. +- Phase 1 코드 리뷰 차단 이슈 보완: 리뷰에서 지적된 staged/untracked 누락을 재확인한 결과 최신 작업 트리는 Phase 1 신규 테스트 + 2개(`AiCharacterAdminAccessDeniedErrorContractTest`, `AiCharacterAdminLoginJwtIntegrationTest`)와 `AccessDeniedException` 403 수정이 + 모두 변경 세트에 포함되어 있음을 확인했다. `TokenProvider` subject parsing 보정과 관련 테스트, rollback 범위를 Task 1.4/1.5와 + Phase 1 rollback 전략에 반영했다. +- Phase 1 코드 리뷰 차단 이슈 재검증: `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'` 단독 실행 결과 `BUILD SUCCESSFUL`을 확인했다. 이전 QA의 Gradle + `TestOutputStore` EOF는 동시/강제 실행 환경에서 발생한 결과 저장소 문제로 보며, 현재 단독 fresh rerun에서는 재현되지 않았다. +- Phase 1 코드 리뷰 차단 이슈 최종 검증: 문서 보정 후 `git status --short --untracked-files=all`에서 untracked 파일이 없고 + `git diff --name-only` 결과가 비어 있음을 확인했다. `git diff --cached --check`, `git diff --check`, + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'`, + `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest' --tests 'kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest'`, + `./gradlew ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.6 CORS 계약 재확인: 현재 `WebConfig` 기준 전용 Origin + `http://localhost:8888`, `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net` 세 개를 + PRD/plan에 명시했다. preflight 테스트는 세 Origin 모두 허용하고 기존 범용 관리자/creator Origin 네 개를 + 거부하는지 고정했다. +- Task 1.6 JWT RED/GREEN: `TokenProviderTest` 9개 중 추가한 누락·빈 값·빈 분할 항목·비문자열 `auth` claim + 7개 invocation이 실패하는 RED를 확인했다. claim을 authority로 변환하기 전 검증해 + `common.error.bad_credentials`로 변환한 뒤 9개 모두 통과했다. +- Task 1.6 테스트 fixture 격리: 전용 controller 6개를 각 테스트의 nested `@TestComponent`로 이동하고 + `@Import`로만 등록했다. 초기 nested 이동 후 명시 등록이 누락된 WebMvc 요청 37개가 404로 실패한 것을 + 확인한 뒤 보정했고, 독립 재리뷰의 component scan 지적을 `@TestComponent`로 해소했다. +- Task 1.6 최종 targeted/레거시 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` 실행 결과 103개 모두 통과했다. 기존 + `AdminAgentReadControllerSecurityTest`, `AdminContentControllerSecurityTest` 보안 회귀도 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.6 최종 전체 회귀/lint: 최종 소스 상태에서 `./gradlew test`는 `BUILD SUCCESSFUL`(4분 55초), + `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(19초)를 확인했다. +- Task 1.6 최종 reviewer/diff gate: 독립 read-only 재리뷰 결과 CORS, malformed `auth`, fixture scan 격리에 + 잔여 finding이 없음을 확인했다. `git diff --check`, `git diff --cached --check`가 통과했고 conflict marker와 + untracked 파일이 없음을 확인했다. +- Task 1.6 2차 리뷰 CORS RED/GREEN: 실제 mapped endpoint, 공유 `/admin/member/login`, `/member/logout`의 캐릭터 관리자 + Origin 요청/preflight를 추가했을 때 `AiCharacterAdminLoginJwtIntegrationTest` 6개 중 3개 실패를 확인했다. 두 공유 인증 + exact path에만 기존 전역 Origin과 캐릭터 관리자 Origin 합집합을 적용한 뒤, 미등록 Origin 거부까지 포함한 7개가 모두 + 통과했다. 신규 prefix의 기존 범용 관리자 Origin 거부와 실제 로그인·로그아웃도 함께 검증했다. +- Task 1.6 2차 리뷰 HTTP 오류 RED/GREEN: 406, 405 `Allow`, 415 `Accept`/`Accept-Patch`, + `MissingPathVariableException` 500 계약을 추가해 신규 13개 실패를 확인했다. `AiCharacterAdminExceptionHandler`에 Spring 기본 + HTTP 의미를 보존하는 최소 분기와 header 처리를 추가한 뒤 `AiCharacterAdminErrorContractTest` 80개가 모두 통과했다. +- Task 1.6 2차 리뷰 최종 targeted/레거시 회귀: `TokenProviderTest`와 `aicharacter.*` 114개, 기존 + `AdminAgentReadControllerSecurityTest`와 `AdminContentControllerSecurityTest` 11개를 함께 실행해 총 125개 모두 통과했고 + `BUILD SUCCESSFUL`(42초)을 확인했다. +- Task 1.6 2차 리뷰 전체 회귀/lint: 최종 소스 상태에서 `./gradlew test`는 `BUILD SUCCESSFUL`(5분 4초), + `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(20초)을 확인했다. +- Task 1.6 2차 리뷰 최종 gate: 공유 인증 exact path가 전역 fallback보다 먼저 적용되고 신규 prefix/fallback은 전용 Origin을 + 유지하는지 독립 read-only로 재검토했다. `git diff --check`, `git diff --cached --check`가 통과했고 conflict marker와 untracked + 파일이 없음을 확인했다. +- Task 1.6 후속 리뷰 mapped write preflight 보완: 기존 신규 prefix write preflight 검증이 fallback + `/api/v2/admin/ai-characters/unmapped-path`만 타는 한계를 확인했다. 테스트 controller에 실제 mapped + `/api/v2/admin/ai-characters/error-contract/write-preflight`의 `POST`/`PUT`/`PATCH`/`DELETE` 매핑을 추가하고, + 해당 경로 preflight에서 `Access-Control-Allow-Origin`과 `Access-Control-Allow-Methods`를 함께 검증하도록 보완했다. +- Task 1.6 후속 리뷰 fallback write preflight 보완: actual mapping과 fallback이 서로 다른 CORS 설정을 사용하므로, + mapped endpoint 검증과 별도로 fallback `/api/v2/admin/ai-characters/unmapped-path`에서도 `POST`/`PUT`/`PATCH`/`DELETE` + preflight의 `Access-Control-Allow-Origin`과 `Access-Control-Allow-Methods`를 검증하도록 보완했다. +- Task 1.6 후속 리뷰 추가 계약 보완: wrong-role `USER` + `AI_CHARACTER` target은 resolver가 400으로 거부하고 + DB insert/update/delete 없이 기존 role/memberKind를 유지하는지 고정했다. 캐릭터 관리자 Origin이 공유 인증 외 + legacy/public 경로로 확산되지 않는지 확인했고, 406 Not Acceptable 응답도 허용 Origin에서는 localized `ApiResponse`와 + `Access-Control-Allow-Origin`을 함께 반환하는지 검증했다. +- Task 1.6 후속 리뷰 계약 테스트 현황: mapped write preflight 보완 후 `AiCharacterAdminErrorContractTest` 85개, + `AiCharacterAdminLoginJwtIntegrationTest` 8개, `AiCharacterAdminTargetResolverIntegrationTest` 4개 기준으로 + 후속 리뷰 항목을 회귀했다. +- Task 1.6 후속 리뷰 최종 targeted/레거시 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests + kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 targeted+legacy 132개가 모두 통과했고 + `BUILD SUCCESSFUL`(40초)을 확인했다. +- Task 1.6 후속 리뷰 전체 회귀/lint: mapped write preflight 보완 후 소스 상태에서 `./gradlew test`는 전체 1,259개 기준 + `BUILD SUCCESSFUL`(4분 53초), `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(37초)을 확인했다. +- Task 1.6 후속 리뷰 fallback 보완 후 계약 테스트 현황: fallback write preflight 보완 후 `AiCharacterAdminErrorContractTest` 89개, + `AiCharacterAdminLoginJwtIntegrationTest` 8개, `AiCharacterAdminTargetResolverIntegrationTest` 4개 기준으로 후속 리뷰 항목을 회귀했다. +- Task 1.6 후속 리뷰 fallback 보완 후 최종 targeted/레거시 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests + kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 targeted+legacy 136개가 모두 통과했고 + `BUILD SUCCESSFUL`(1분 38초)을 확인했다. +- Task 1.6 후속 리뷰 fallback 보완 후 전체 회귀/lint: fallback write preflight 보완 후 최종 소스 상태에서 `./gradlew test`는 + 전체 1,263개 기준 `BUILD SUCCESSFUL`(5분 58초), `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(35초)을 확인했다. +- Task 1.6 추가 코드 리뷰 보완: 실제 logout 후 동일 JWT로 신규 prefix 보호 경로를 호출하면 localized 401을 반환하는지 + `AiCharacterAdminLoginJwtIntegrationTest`에 통합 테스트로 고정했다. 공유 `/admin/member/login`, `/member/logout` CORS preflight는 + `WebConfig`의 기존 전역 Origin과 캐릭터 관리자 Origin 합집합 전체를 허용하는 parameterized test로 확장했다. Phase 1 targeted + Run 명령에는 `TokenProviderTest`를 포함하도록 보정했다. +- Task 1.6 추가 코드 리뷰 보완 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest` + 실행 결과 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.6 추가 코드 리뷰 보완 최종 회귀/lint: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` 실행 결과 `BUILD SUCCESSFUL`(44초), `./gradlew ktlintCheck` 실행 결과 + `BUILD SUCCESSFUL`(25초)을 확인했다. +- Phase 1 최신 canonical fresh targeted/legacy 회귀: `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 145/145, failure/error 0, + `BUILD SUCCESSFUL`(3분 12초)을 확인했다. +- Phase 1 최신 전체 fresh 회귀: `./gradlew test --rerun-tasks` 실행 결과 1,272/1,272, failure/error 0, + `BUILD SUCCESSFUL`(6분 49초)을 확인했다. +- Phase 1 최신 lint: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL`(17초)을 확인했다. +- Phase 1 최신 명령 유효성: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이며 `test`, `ktlintCheck` task가 존재함을 + 확인했다. +- Phase 1 최종 보강 후 canonical fresh targeted/legacy 회귀: `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 9개 XML class, 154/154, + failure/error/skipped 0, `BUILD SUCCESSFUL`(5분)을 확인했다. +- Phase 1 최종 보강 후 전체 fresh 회귀: `./gradlew test --rerun-tasks` 실행 결과 243개 XML class, 1,281/1,281, + 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 new file mode 100644 index 00000000..480cfb5d --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/prd.md @@ -0,0 +1,422 @@ +# PRD: AI 캐릭터 관리자 API + +## 1. Overview +운영자가 AI 캐릭터용 Member로 직접 로그인하지 않고, `ADMIN` 권한으로 선택한 AI 캐릭터의 크리에이터 채널 자산과 팬 상호작용을 +대리 관리하는 신규 v2 관리자 API를 제공한다. + +--- + +## 2. Problem +- AI 캐릭터용 `Member(memberKind = AI_CHARACTER)`는 직접 로그인할 수 없어야 하지만, 운영자는 캐릭터의 콘텐츠, 시리즈, + 커뮤니티, 각 자산의 댓글과 FanTalk를 관리해야 한다. +- 기존 기능은 `creatorMember.id` 기반으로 흩어져 있으며, 관리자 frontend가 레거시 endpoint를 조합하면 권한, 소유권, soft delete 의미가 일관되지 않을 수 있다. +- 기존 creator/admin service 일부에는 소유권 검증이 약한 경로가 있어, 단순 위임만으로는 다른 캐릭터나 HUMAN creator 자원을 변경할 위험이 있다. +- 캐릭터 등록에 필요한 원작 검색과 시리즈 등록에 필요한 장르 목록은 범용 관리자 API에만 있어 캐릭터 관리자 배포 Origin에서 + 호출할 수 없다. +- 기존 legacy/public API 계약은 유지해야 하므로 신규 관리자 표면은 별도 v2 경계로 제공되어야 한다. + +--- + +## 3. Goals +- 신규 prefix `/api/v2/admin/ai-characters/**`는 JWT `auth` claim의 `ROLE_ADMIN`과 JWT subject로 조회한 현재 DB + `Member.role == ADMIN`을 모두 만족하는 요청만 허용한다. +- 모든 신규 target endpoint는 외부 대상 식별자로 `characterId`를 받고, 서버가 `ChatCharacter.creatorMember`를 내부 행위자로 + 해석한다. 단, 캐릭터 목록/검색·생성, 원작 검색과 시리즈 장르 목록은 선택된 target이 필요하지 않아 `characterId`를 받지 + 않는다. +- target 해석 시 `ChatCharacter` 존재, `creatorMember` 존재, `creatorMember.role == CREATOR`, `creatorMember.memberKind == AI_CHARACTER`를 모두 검증한다. +- 검증 실패 시 4xx로 거부하고 DB, S3, 외부 캐릭터 API, 이벤트 발행 등 후속 부작용을 만들지 않는다. +- 캐릭터, 오디오 콘텐츠와 댓글, 시리즈, 커뮤니티 게시글과 댓글, FanTalk 목록·답변 작성·수정·팬 원글 삭제, 오디오 + signed URL을 신규 관리자 API에서 관리한다. +- 기존 legacy/public endpoint의 URI, 성공·오류 HTTP status, response body, message/i18n을 포함한 외부 계약은 변경하지 않는다. + 단, 캐릭터 관리자 frontend가 기존 관리자 인증을 재사용할 수 있도록 `/admin/member/login`, `/member/logout`의 CORS 허용 + Origin만 path-specific으로 확장한다. +- 내부 구현은 신규 v2 controller/facade/application 경계를 두고, 기존 entity/repository/S3/CloudFront/event 컴포넌트는 테스트로 고정한 뒤 선택적으로 재사용한다. + +--- + +## 4. Non-Goals +- 이번 PRD는 관리자 API backend 요구사항과 구현 계획만 포함하며, 관리자 UI/frontend 구현은 포함하지 않는다. +- AI 캐릭터용 Member의 access token, refresh token, 임시 세션, impersonation 로그인은 만들지 않는다. +- `creatorMemberId`를 관리자 frontend의 필수 입력으로 노출하지 않는다. +- HUMAN creator를 이 API로 대리 관리하지 않는다. +- 위 두 공유 인증 경로의 CORS 허용 Origin 확장 외 기존 legacy/public endpoint 변경, 폐기, deprecation, schema 변경은 포함하지 + 않는다. +- 기존 external character API business contract 변경은 포함하지 않으며, 변경이 필요하면 재확인한다. +- 기존 soft delete 의미 변경은 포함하지 않으며, 변경이 필요하면 재확인한다. +- 물리 삭제와 연관 데이터 cascade 삭제는 포함하지 않는다. +- 신규 DB schema/DDL 또는 `ChatCharacter`-`Member` 관계 모델 변경은 포함하지 않는다. +- 라이브, DM, 후원, 정산, 알림 설정, 랭킹 관리, 콘텐츠 구매·좋아요와 커뮤니티 구매·좋아요 관리는 포함하지 않는다. +- 사용하지 않는 레거시 캐릭터 직접 댓글 `/api/chat/character/{characterId}/comments`의 v2 전환·조회·삭제는 포함하지 않는다. +- FanTalk 원글 작성, creator reply 전용 삭제 endpoint·hard delete, 일반 사용자 대리 작성, nested reply 작성과 FanTalk + 원글 삭제 시 reply cascade 변경은 포함하지 않는다. +- `AudioContentCloudFront` 복사/이동, signed URL 신규 dependency 추가는 포함하지 않는다. + +--- + +## 5. Target Users +- 운영자: AI 캐릭터를 대신해 캐릭터 프로필, 콘텐츠, 시리즈, 커뮤니티 게시글, 각 자산의 댓글과 FanTalk를 관리하는 관리자 +- 관리자 frontend: 신규 v2 AI 캐릭터 관리자 API만으로 In-Scope 작업을 수행해야 하는 클라이언트 +- 서버 개발자: 기존 creator 기능을 회귀시키지 않으면서 AI 캐릭터 대리 관리 경계를 유지해야 하는 개발자 + +--- + +## 6. User Stories +- 운영자는 AI 캐릭터 목록을 검색하고 상세 정보를 확인한 뒤 생성, 수정, 비활성화하고 싶다. +- 운영자는 캐릭터 등록 시 soft delete되지 않은 원작을 검색해 `originalWorkId`를 선택하고 싶다. +- 운영자는 선택한 AI 캐릭터 소유 오디오 콘텐츠를 조회, 생성, 수정, soft delete하고 관리자 화면에서 재생 가능한 signed URL을 받고 싶다. +- 운영자는 선택한 AI 캐릭터의 오디오 콘텐츠에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을 + soft delete하고 싶다. +- 운영자는 선택한 AI 캐릭터 소유 시리즈를 조회, 생성, 수정, soft delete하고 콘텐츠 연결/해제/순서를 관리하고 싶다. +- 운영자는 시리즈 등록 시 활성 장르 목록에서 `genreId`를 선택하고 싶다. +- 운영자는 선택한 AI 캐릭터 소유 커뮤니티 게시글을 작성, 수정, 고정/해제, soft delete하고 싶다. +- 운영자는 선택한 AI 캐릭터의 커뮤니티 게시글에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을 + soft delete하고 싶다. +- 운영자는 선택한 AI 캐릭터의 FanTalk 목록과 기존 creator reply를 확인한 뒤 활성 root FanTalk에 답변하고, 기존 답변의 + 내용·활성 상태를 수정하거나 팬이 작성한 원글을 soft delete하고 싶다. +- 서버는 다른 AI 캐릭터나 HUMAN creator의 resource ID가 전달되면 변경 없이 4xx로 거부해야 한다. + +--- + +## 7. Core Features + +### Feature A. 공통 인증, 인가, target 해석 + +#### Requirements +- 모든 신규 prefix endpoint는 JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 독립적으로 모두 검증한다. +- JWT가 없거나 잘못됐거나 만료·폐기된 경우는 401, JWT role과 현재 DB role 중 하나라도 ADMIN이 아닌 경우는 403으로 + 처리한다. +- JWT에는 `ROLE_ADMIN`이 남아 있지만 현재 DB role이 강등된 stale claim도 403으로 거부한다. +- 선택한 캐릭터 자원을 다루는 domain write/read는 `characterId`로 `ChatCharacter`를 조회한 뒤 연결된 `creatorMember`를 + 사용한다. 원작 검색과 시리즈 장르 목록은 target 없는 reference endpoint이므로 `characterId`를 받거나 target을 해석하지 + 않는다. +- `creatorMember`는 도메인 소유권/작성자 판단에만 사용하고 Spring Security principal로 교체하지 않는다. +- `creatorMember` 누락, role 불일치, memberKind 불일치 요청은 4xx로 거부한다. +- 요청 중 누락 Member 생성, role/memberKind 자동 보정 같은 lazy repair는 하지 않는다. + +#### Edge Cases +- stale claim을 포함한 인증·인가 실패는 target resolver와 domain use-case 실행 전에 종료되어야 한다. +- 유효하지 않은 `characterId` 요청은 DB write, S3 upload/delete, 외부 캐릭터 API 호출, 이벤트 발행 없이 실패해야 한다. +- 다른 AI 캐릭터 또는 HUMAN creator 소유 resource ID는 조회/수정/삭제/연결/답변 모두 거부해야 한다. + +### Feature B. AI 캐릭터 관리 + +#### Requirements +- 목록 조회, 검색, 상세 조회, 생성, 수정, 삭제 의미의 비활성화(`isActive=false`)를 제공한다. +- 캐릭터 등록 화면에서 사용할 soft delete되지 않은 원작 검색을 `characterId` 없는 관리자 전용 endpoint로 제공한다. +- 원작 검색은 레거시 `AdminOriginalWorkController.search`처럼 필수 `searchTerm`으로 제목·콘텐츠 타입·카테고리를 부분 + 검색하고 soft delete된 원작을 제외하며, 페이징 없는 `OriginalWorkResponse` 목록을 반환한다. +- 레거시 플랫폼 관리자와 중복 이름 검증, 외부 캐릭터 API 연동, 대표 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, AI 캐릭터용 `creatorMember` 생성 및 표시 정보 동기화 동작 parity를 유지한다. +- 삭제는 soft delete이며 row, 연결 Member, 콘텐츠를 물리 삭제하지 않는다. + +#### Edge Cases +- 중복 이름, 외부 캐릭터 API 실패, 이미지 저장 실패는 기존 관리자 동작을 특성화 테스트로 고정한 뒤 유지한다. +- 비활성화 실패 시 일부 관계만 변경된 상태로 남기지 않는다. +- 원작 검색은 캐릭터 target을 해석하지 않지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 계약을 동일하게 적용한다. + +### Feature C. 오디오 콘텐츠 관리 및 signed URL + +#### Requirements +- 캐릭터 소유 콘텐츠 목록/검색/상세 조회, 생성, 수정, 기존 삭제 동작에 따른 soft delete를 제공한다. +- 콘텐츠 생성 화면에서 사용할 활성 콘텐츠 테마(카테고리) 목록 조회를 제공한다. 기존 크리에이터 관리자 콘텐츠 등록 + 화면의 `GetAudioContentThemeResponse`와 같은 `id`, `theme`, `image` 필드명을 유지한다. +- 기존 크리에이터 콘텐츠 관리의 검증, 파일 처리, content upload/processing pipeline, 가격, 공개/예약, 번역/알림 등 business behavior parity를 유지한다. +- 관리자 화면 재생용 signed URL을 콘텐츠 목록/상세 응답에 제공한다. +- signed URL은 공통 `AudioContentCloudFront`를 재사용하고 기존 크리에이터 관리자와 같은 만료 정책을 따른다. +- 기존 signed URL 구현을 재사용하기 전에 creator admin 만료 계산식과 만료 계산·path 처리에서 실제로 관찰되는 edge case를 통과하는 특성화 테스트로 고정한다. +- 응답에 private object path나 서명 키 정보를 노출하지 않는다. +- 신규 관리자 오디오 생성 request는 `timezone`을 받지 않고 nullable `releaseDate`를 클라이언트가 변환한 ISO-8601 + UTC(`Z`)로 받는다. 로컬 시각과 timezone을 함께 받는 레거시 생성 형식은 병행 지원하지 않는다. +- 오디오 상세의 nullable `releaseDate`는 기존 필드명과 null/노출 조건을 유지하고, 값이 있으면 ISO-8601 UTC(`Z`)로 + 반환한다. 상세 조회는 `timezone` query를 받지 않는다. +- target 소유의 활성 오디오 콘텐츠에 대해 댓글 목록, 답글 목록, 댓글·답글 작성, 캐릭터가 작성한 댓글·답글 수정과 모든 + 댓글·답글 soft delete를 제공한다. +- 댓글·답글 작성자는 관리자 principal이 아니라 target `creatorMember`이며, optional `parentId`가 없으면 원댓글, 있으면 + 답글로 저장한다. +- 댓글·답글 내용 수정은 작성자가 target `creatorMember`인 활성 row에만 허용한다. +- 댓글·답글 삭제는 작성자와 관계없이 target 소유 콘텐츠에 연결된 row의 `isActive=false`만 반영하고 자식 답글을 cascade + 변경하거나 물리 삭제하지 않는다. +- 댓글·답글 조회는 `timezone` 없이 `page`, `size`를 받고 레거시 응답 필드를 유지하되, 각 `date`를 ISO-8601 + UTC(`Z`)로 반환한다. + +#### Edge Cases +- 콘텐츠 소유자가 target `creatorMember`와 다르면 조회/수정/삭제 모두 거부한다. +- 댓글 또는 답글이 target 소유 콘텐츠에 연결되지 않았거나, 답글 작성의 `parentId`가 같은 콘텐츠의 활성 원댓글이 아니면 + mutation 전에 400으로 거부한다. +- 댓글 수정은 팬이 작성한 row를 target 캐릭터 명의로 변경하지 않으며, 이미 비활성인 row도 수정하지 않는다. +- 이미 비활성인 댓글·답글의 삭제는 성공 no-op으로 처리하고 추가 상태 변경이나 이벤트를 만들지 않는다. +- 커뮤니티 오디오의 기존 30분 signed URL 정책은 이 콘텐츠 재생 정책과 임의 통합하지 않는다. + +### Feature D. 시리즈 관리 + +#### Requirements +- 목록/상세 조회, 생성, 수정, `isActive=false` soft delete를 제공한다. +- 시리즈 상세 응답 `data`는 목록 wrapper가 아니라 목록 `items`의 단일 항목과 동일한 schema를 사용한다. 필드는 + `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `isActive`, + `writer`, `studio`이며 기존 상세 전용 `genre`, `keywords`는 반환하지 않는다. +- 콘텐츠 연결/해제, 시리즈 콘텐츠 조회/검색, 순서 관리를 제공한다. +- 기존 creator series 관리의 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 관리 behavior를 먼저 통과하는 특성화 테스트로 고정하고 신규 v2 경로에서 parity를 유지한다. +- 시리즈와 연결 콘텐츠는 모두 동일한 `creatorMember` 소유여야 한다. +- 기존 `updateSeriesOrders(ids)`처럼 소유권 없는 ID-only 갱신은 신규 v2 경로에서 허용하지 않는다. +- 시리즈 콘텐츠 조회는 관리자 연결 작업을 위해 검색어 기반 필터를 제공한다. +- 시리즈 등록 화면에서 사용할 활성 장르 목록을 `characterId` 없는 관리자 전용 endpoint로 제공한다. +- 장르 목록은 레거시 범용 관리자 API처럼 활성 장르 전체를 `orders` 오름차순으로 반환하며 각 항목에 `id`, `genre`, + `isAdult`를 포함한다. + +#### Edge Cases +- 순서 변경 요청의 모든 series/content ID는 target character 소유 검증을 통과해야 한다. +- inactive series는 일반 활성 조회에서 제외한다. +- 장르 목록은 캐릭터 target을 해석하지 않지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 계약을 동일하게 적용한다. + +### Feature E. 커뮤니티 게시글 관리 + +#### Requirements +- 등록, 수정, 공지 고정/해제(`isFixed`), 수정 요청의 `isActive=false` soft delete를 제공한다. +- soft delete 시 현재 동작처럼 `isFixed=false`, `fixedAt=null`을 적용한다. +- 기존 최대 고정 게시글 수 3개, 이미지/오디오/유료 게시글 검증, 알림/최근 소식 side effect를 유지한다. +- 관리자 UI에 필요한 조회는 기존 v2 커뮤니티 조회 로직을 무비판적으로 복제하지 않고 신규 관리자 facade/endpoint에서 안전하게 재사용하거나 최소 query adapter를 둔다. +- 관리자 커뮤니티 목록은 `timezone` query를 받지 않고 `page`, `size`만 받는다. +- 목록 응답 `data`는 `totalCount`, `page`, `size`, `hasNext`, `items`를 포함해 관리자 UI가 전체 개수와 다음 페이지 + 추가 로딩 필요 여부를 판단할 수 있어야 한다. +- target 소유의 활성 커뮤니티 게시글에 대해 댓글 목록, 답글 목록, 댓글·답글 작성, 캐릭터가 작성한 댓글·답글 수정과 모든 + 댓글·답글 soft delete를 제공한다. +- 댓글·답글 작성자는 관리자 principal이 아니라 target `creatorMember`이며, optional `parentId`가 없으면 원댓글, 있으면 + 답글로 저장한다. +- 댓글·답글 내용 수정은 작성자가 target `creatorMember`인 활성 row에만 허용한다. +- 댓글·답글 삭제는 작성자와 관계없이 target 소유 게시글에 연결된 row의 `isActive=false`만 반영하고 자식 답글을 cascade + 변경하거나 물리 삭제하지 않는다. +- 댓글·답글 조회는 `timezone` 없이 `page`, `size`를 받고 레거시 응답 필드를 유지하되, 각 `date`를 ISO-8601 + UTC(`Z`)로 반환한다. + +#### Edge Cases +- 고정 게시글이 이미 3개인 상태에서 추가 고정은 기존 정책대로 실패한다. +- soft delete된 고정 게시글은 고정 상태와 시간이 반드시 제거되어야 한다. +- 댓글 또는 답글이 target 소유 게시글에 연결되지 않았거나, 답글 작성의 `parentId`가 같은 게시글의 활성 원댓글이 아니면 + mutation 전에 400으로 거부한다. +- 댓글 수정은 팬이 작성한 row를 target 캐릭터 명의로 변경하지 않으며, 이미 비활성인 row도 수정하지 않는다. +- 이미 비활성인 댓글·답글의 삭제는 성공 no-op으로 처리하고 추가 상태 변경이나 이벤트를 만들지 않는다. + +### Feature F. FanTalk 관리 + +#### Requirements +- 선택한 AI 캐릭터의 FanTalk 목록과 각 root 글의 creator reply 목록을 관리자 전용 endpoint로 조회한다. +- 목록 응답 필드와 page 정책은 공개 v2 `CreatorChannelFanTalkTabResponse`를 유지하되, 공개 v2 endpoint를 직접 재사용하지 + 않고 `characterId` target 해석과 관리자 ownership 정책을 적용한다. +- 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성한다. +- 요청은 `characterId`와 대상 root `fanTalkId`를 포함한다. +- 대상 FanTalk가 존재하고 활성 상태이며, 대상 creator가 해석된 `creatorMember`와 일치하는 root 글인지 검증한다. +- 언어 감지와 기존 응답 DTO 의미 등 검증 가능한 business behavior를 유지하고, 저장된 답변의 writer/creator는 해석된 `creatorMember`와 일관되어야 한다. +- 선택한 AI 캐릭터가 작성한 기존 direct reply의 내용과 활성 상태를 수정하는 관리자 전용 endpoint를 제공한다. +- 답변 수정 request는 레거시 `PutWriteCheersRequest`에서 path로 이동한 `cheersId`를 제외한 optional/nullable `content`, + `isActive`를 그대로 받는다. 두 필드는 함께 입력할 수 있고 모두 생략하거나 `null`이면 성공 no-op이다. +- 답변 수정 대상은 target `creatorMember`가 writer이자 creator인 direct reply여야 하며, `fanTalkId`로 지정한 target + 채널의 활성 root에 직접 연결되어야 한다. 비활성 reply는 조회 대상에 포함해 `isActive=true`로 재활성화할 수 있다. +- 답변 수정은 레거시와 같이 non-null field만 반영하며 저장된 `languageCode`를 변경하거나 언어 감지·이벤트를 발생시키지 + 않는다. +- 답변 수정 성공 `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태를 유지한다. 응답의 `fanTalkId`는 수정한 + reply row ID이고 `creatorReplies`는 빈 배열이다. +- 팬이 작성한 root FanTalk를 `isActive=false`로 soft delete하는 관리자 전용 endpoint를 제공한다. +- FanTalk 삭제는 root row만 비활성화하고 연결된 creator reply row는 변경하지 않는다. 비활성 root가 목록에서 제외되므로 + 연결된 reply도 함께 노출되지 않는다. + +#### Edge Cases +- 다른 캐릭터의 FanTalk, reply에 대한 nested reply, 비활성 FanTalk, 미존재 FanTalk에는 답변하지 않는다. +- 실패 시 reply 저장과 이벤트 발행이 없어야 한다. +- 답변 수정 시 root·reply가 다른 target에 속하거나, reply가 지정한 root의 direct child가 아니거나, root가 + 비활성·미존재이거나, target AI가 작성하지 않은 팬 root/reply이면 400으로 거부하고 변경하지 않는다. +- 답변 수정 대상 reply 자체의 비활성 상태는 거부 조건이 아니며, `isActive=true` 재활성화를 허용한다. +- FanTalk 삭제 대상은 target `creatorMember` 채널에 연결된 `parent=null`의 fan 작성 root여야 한다. 같은 target의 이미 + 비활성인 fan root는 성공 no-op이고, creator가 작성한 root, reply, 다른 creator의 root, 미존재 root는 변경 없이 + 400으로 거부한다. +- FanTalk 원글 삭제는 reply 삭제·수정이나 별도 이벤트를 발생시키지 않는다. + +--- + +## 8. API Expectations +- 신규 endpoint prefix는 기존 공개 `/api/v2/creator-channels/*`와 legacy `/admin/*`, `/creator-admin/*`를 변경하지 않기 위해 `/api/v2/admin/ai-characters`를 기본안으로 한다. +- 성공 응답은 `ApiResponse.ok(...)`, API application/controller/security filter 오류는 오류 의미에 맞는 HTTP status와 + `ApiResponse.error(...)`를 사용한다. +- 이 API 오류 응답은 `success=false`와 현지화된 `message`를 포함하며 2xx로 normalize하지 않는다. +- `Accept-Language: ko|en|ja`에 따라 KO/EN/JA 메시지를 반환하고, 없거나 지원하지 않는 언어는 KO로 fallback한다. +- security filter 단계의 오류도 MVC interceptor에 의존하지 않고 `Accept-Language`를 직접 해석해 동일한 응답 계약을 따른다. +- 신규 prefix는 캐릭터 관리자 frontend Origin `http://localhost:8888`, + `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 허용한다. +- 기존 범용 관리자 frontend와 creator frontend Origin을 캐릭터 관리자 Origin 대신 허용하지 않는다. +- 공유 인증 경로 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 위 캐릭터 관리자 Origin의 합집합만 허용한다. + 이 path-specific 확장은 다른 legacy/public 경로의 CORS 허용 범위를 변경하지 않는다. +- 위 관리자 Origin의 신규 prefix 오류와 preflight는 404 fallback 및 실제 mapped endpoint의 405/406/415 경로를 포함해 기존 + 전역 CORS 응답 계약을 유지하며, 두 공유 인증 경로에서도 허용·거부 Origin을 검증한다. +- 허용되지 않은 Origin, method 또는 header를 Spring CORS 계층에서 정책 거부하는 경우는 handler 진입 전 403으로 종료되는 + 브라우저 보안 경계다. 이 403의 body, content type, 현지화 및 `ApiResponse.error` envelope는 신규 API 오류 계약의 예외로 + 두고 외부 계약으로 고정하지 않는다. +- 표준 HTTP method가 MVC까지 도달했지만 해당 mapping이 없으면 기존 Spring MVC의 405와 `Allow` header를 유지한다. +- `StrictHttpFirewall`이 신규 prefix에서 비표준 HTTP method 또는 위험 URL을 `RequestRejectedException`으로 거부하면, + 캐릭터 관리자 허용 Origin에는 CORS header를 포함한 400 `common.error.invalid_request`와 현지화된 `ApiResponse.error`를 + 반환한다. 허용되지 않은 Origin은 기존 Spring CORS 정책과 같이 body 계약 없는 403으로 종료한다. +- `SecurityConfig`는 기존 `AiCharacterAdminSecurityErrorHandler`를 global `RequestRejectedHandler`로 등록하되 신규 prefix만 위 + 400/CORS 계약으로 처리하고, legacy/public은 `DefaultRequestRejectedHandler`에 위임해 기존 `RequestRejectedException` 동작을 + 유지한다. Spring 5.3의 비표준 method enum 한계 때문에 CORS 검사 request만 `GET` wrapper를 사용하며 실제 firewall method + 허용 범위는 확장하지 않고 `setUnsafeAllowAnyHttpMethod(true)`도 사용하지 않는다. +- Phase 1 공통 오류는 인증 정보 없음·잘못됨·만료·폐기 401 `common.error.bad_credentials`, JWT 또는 현재 DB role의 + ADMIN 불충족 403 `common.error.access_denied`, request/target 미존재·불변식 위반 400, 신규 prefix 미매핑 경로 404, 지원하지 + 않는 HTTP method 405, 응답 media type 406, 요청 media type 415를 `common.error.invalid_request`로 고정한다. 405는 표준 + `Allow` header를, 415는 표준 `Accept` header를 유지한다. controller mapping의 필수 path variable 선언이 누락된 + `MissingPathVariableException`과 예상하지 못한 controller/JWT filter 오류는 500 `common.error.unknown`으로 고정한다. +- malformed JSON의 `HttpMessageNotReadableException`, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part + 누락의 `MissingServletRequestPartException`은 각각 400 `common.error.invalid_request`와 KO/EN/JA `ApiResponse.error`를 반환한다. +- 이후 phase의 domain/client/server 오류는 각 task에서 정확한 HTTP status와 KO/EN/JA message key를 먼저 정의하고 같은 + envelope를 적용한다. +- 신규 prefix 전용 오류 처리는 legacy/public endpoint의 기존 성공·오류 응답에 적용하지 않는다. +- request/response의 기계 검증 가능한 단일 기준은 같은 디렉터리의 `api-contract.openapi.json`이다. 설명과 레거시 근거는 + `api-contract.md`에 기록한다. +- 신규 endpoint는 레거시 API의 request/response 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태를 그대로 + 이관한다. `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`처럼 신규 path로 이동한 ID만 + request body에서 중복 제거한다. +- 캐릭터 등록용 원작 검색은 `GET /api/v2/admin/ai-characters/original-works/search?searchTerm=...`, 시리즈 장르 목록은 + `GET /api/v2/admin/ai-characters/series-genres`로 제공하며 두 endpoint 모두 `characterId`를 받지 않는다. +- 오디오 콘텐츠 댓글은 + `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments`, 커뮤니티 댓글은 + `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` 하위에서 각각 댓글 목록, 답글 목록, 작성, + 수정, 삭제 5개 operation으로 제공한다. 답글 목록은 `/{commentId}/replies`, 수정·삭제는 `/{commentId}` 하위 path를 + 사용한다. +- 오디오 댓글 작성 request는 `comment`, optional `parentId`, `isSecret`, `languageCode`, 커뮤니티 댓글 작성 request는 + `comment`, optional `parentId`, `isSecret`을 받는다. 수정 request는 두 domain 모두 `comment`만 받으며, 삭제는 request + body를 받지 않는다. +- 두 댓글 domain의 목록과 답글 목록은 `GetAudioContentCommentListResponse` 또는 + `GetCommunityPostCommentListResponse`에 해당하는 `totalCount`, `items` 형태를 유지한다. 두 목록은 `timezone` + query를 받지 않고 각 댓글 `date`를 ISO-8601 UTC(`Z`)로 반환한다. +- FanTalk 팬 원글 삭제는 + `DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}`로 제공한다. +- FanTalk 답변 수정은 + `PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`로 제공한다. +- 위 14개 신규 operation을 기존 23개에 추가해 전체 관리자 계약은 37개 operation으로 관리한다. +- `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}`의 성공 `data`는 + `GET /api/v2/admin/ai-characters/{characterId}/series`의 `items` 단일 항목과 동일한 schema를 참조한다. +- 캐릭터 수정은 레거시 `ChatCharacterUpdateRequest`처럼 `isActive=false`와 다른 optional field의 동시 입력을 허용하며, + 이 경우 레거시 service 의미대로 비활성화만 반영한다. +- 목록 endpoint의 query와 page 동작은 각 레거시 API를 따른다. FanTalk 관리자 목록만 공개 v2 탭의 `page` 기본값 0, + `size` 기본값 20, 최소 20, 최대 50 보정을 따른다. +- 오디오 콘텐츠 생성 multipart의 `request`는 `timezone`을 포함하지 않는다. nullable `releaseDate`는 클라이언트가 + UTC로 변환한 ISO-8601 `date-time`(`Z`)이며 기존 로컬 시각+timezone 형식은 신규 endpoint에서 받지 않는다. +- 오디오 콘텐츠 상세, 오디오 댓글·답글 목록, 커뮤니티 댓글·답글 목록은 `timezone` query를 받지 않는다. 상세 + `releaseDate`와 댓글 `date`는 기존 필드명 및 nullable/노출 조건을 유지한 ISO-8601 UTC(`Z`)다. +- 2026-07-29 사용자 확정에 따라 커뮤니티 관리자 목록은 위 레거시 이관 원칙의 예외로 둔다. 사용하지 않는 + `timezone` query를 제거하고 `data`를 `totalCount`, `page`, `size`, `hasNext`, `items` pagination wrapper로 반환한다. +- multipart 생성/수정은 기존 admin/creator-admin 관례대로 파일 part와 `request` JSON string part를 사용한다. +- 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 응답 형태가 다르므로 각각 + `GET .../series/{seriesId}/contents`와 `GET .../series/{seriesId}/contents/search?search_word=...`로 분리한다. +- 레거시 mutation이 `ApiResponse.ok(null)`을 반환하면 신규 endpoint도 `data: null`을 반환한다. 오디오 콘텐츠 생성은 + 레거시 `CreateAudioContentResponse(contentId)`를 유지한다. FanTalk 답변 작성만 신규 계획의 축약 응답 + `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. FanTalk 답변 수정은 레거시 + `CreatorChannelFanTalkResponse` 필드 형태를 유지한다. +- 신규 댓글 작성·수정·삭제와 FanTalk 원글 삭제의 성공 응답은 모두 `ApiResponse.ok(null)`을 사용한다. +- request/response 구현 DTO는 신규 v2 AI character admin API 전용으로 둘 수 있지만, JSON 외부 계약은 + `api-contract.openapi.json`의 레거시 필드명과 형태를 유지한다. + +--- + +## 9. Technical Constraints +- Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper를 유지한다. +- 신규 dependency를 추가하지 않는다. +- 신규 DB schema/DDL을 만들지 않는다. +- 기존 v2 API 조립 계층과 domain/application 의존 방향을 따른다. +- controller 내부 호출, 서버 내부 legacy HTTP 호출, 기존 controller 역참조는 하지 않는다. +- 신규 v2 application/domain 계층은 기존 controller와 v2 API response DTO를 역참조하지 않는다. +- 기존 business method를 재사용하기 전 특성화/회귀 테스트를 작성한다. +- 특성화/회귀 테스트는 신규 v2 use-case의 미구현 RED 테스트와 분리하고, 기존 legacy/creator-admin 구현을 대상으로 먼저 통과해야 한다. +- 단순 복사-붙여넣기 대신 필요한 최소 추출 또는 v2 use-case 재개발을 선택한다. + +--- + +## 10. Metrics +- 신규 endpoint별 또는 controller slice별 JWT role × 현재 DB role 인가 매트릭스와 stale ADMIN claim 403 테스트 존재 여부 +- 신규 prefix의 각 API 오류 분기에 정확한 HTTP status, `ApiResponse.error`, KO/EN/JA와 405 `Allow`/415 `Accept` header 테스트 + 존재 여부 +- 신규 prefix 실제 mapped endpoint 및 공유 인증 경로의 허용·거부 Origin/preflight 테스트 존재 여부 +- 신규 prefix의 표준 method 미매핑 405 `Allow` 유지와 `RequestRejectedException` 400/i18n/`ApiResponse.error`/허용 Origin CORS + header, 미허용 Origin body 계약 없는 403 테스트 존재 여부 +- legacy/public firewall 동작 불변 및 `setUnsafeAllowAnyHttpMethod(true)` 미사용 확인 여부 +- core controller security/error 계약의 production `@SpringBootTest` full-context 실행과 Redis token fixture cleanup 확인 여부 +- target resolver 조회 직후 `creatorMember` 초기화와 fetch join 제거 시 실패하는 non-vacuous 회귀 테스트 존재 여부 +- `HttpMessageNotReadableException`, `MethodArgumentNotValidException`, `MissingServletRequestPartException`의 exact exception + type과 KO/EN/JA 400 envelope 직접 검증 여부 +- target/ownership 실패 시 no-side-effect 테스트 존재 여부 +- character/original-work/content/content-comment/series/series-genre/community/community-comment/FanTalk slice별 targeted test + 통과 여부 +- 오디오 생성 request와 오디오 상세·댓글·답글 및 커뮤니티 댓글·답글 조회에서 `timezone`이 제거되고, + `releaseDate`/`date`가 ISO-8601 UTC(`Z`)로 검증되는지 여부 +- 댓글 작성·수정의 target 작성자 제한, 소유 자산 댓글 삭제, parent 소유권·root 검증과 soft-delete no-cascade 테스트 존재 여부 +- FanTalk 팬 root 삭제, 비활성 팬 root no-op, creator reply row 보존·비노출 테스트 존재 여부 +- FanTalk 답변 수정의 optional/nullable `content`·`isActive`, 빈 객체 no-op, 비활성 reply 재활성화, + target/root/direct-reply ownership과 레거시 성공 응답 테스트 존재 여부 +- signed URL TTL 계산식·edge case parity 및 private path 비노출 테스트 통과 여부 +- 기존 legacy/public endpoint의 성공·오류 status/body/message 회귀 테스트 통과 여부 + +--- + +## 11. Acceptance Criteria +- JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 모두 만족하는 요청만 유효한 AI character 대상으로 신규 endpoint를 + 호출할 수 있다. +- 비로그인 또는 잘못된 JWT 요청은 401이고, JWT 비ADMIN + DB ADMIN과 JWT ADMIN + DB 비ADMIN stale claim은 모두 403이다. +- 신규 prefix의 API application/controller/security filter 오류는 정확한 비2xx status, `ApiResponse.error`, + `Accept-Language`에 따른 KO/EN/JA message를 반환한다. Spring CORS 계층의 정책 거부 403 body는 이 envelope 계약의 예외다. +- 지원하지 않는 HTTP method는 405와 `Allow` header, 응답 media type은 406, 요청 media type은 415와 `Accept` header를 + 반환하고, `MissingPathVariableException`은 500 `common.error.unknown`을 반환한다. +- 표준 HTTP method가 MVC에 도달한 뒤 mapping이 없을 때는 기존 405와 `Allow` header를 유지한다. 신규 prefix의 비표준 HTTP + method 또는 위험 URL이 `StrictHttpFirewall`에서 `RequestRejectedException`으로 거부되면 허용된 캐릭터 관리자 Origin에는 + CORS header와 현지화된 400 `common.error.invalid_request` `ApiResponse.error`를, 미허용 Origin에는 body 계약 없는 403을 + 반환한다. legacy/public firewall 동작은 변하지 않고 `setUnsafeAllowAnyHttpMethod(true)`는 사용하지 않는다. +- 신규 prefix는 캐릭터 관리자 Origin만 허용하고, `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 관리자 + Origin의 합집합을 허용한다. 실제 mapped endpoint와 공유 인증 경로의 CORS 허용·거부가 테스트로 고정된다. +- core controller security/error 계약은 production `@SpringBootTest` full context에서 검증하고 Redis token fixture를 테스트 후 + 정리해 다음 테스트에 남기지 않는다. +- target resolver의 repository 조회 결과는 반환 직후 `creatorMember`가 초기화되어 있어야 하며, fetch join 제거 시 실패하는 + 회귀 테스트로 고정한다. +- malformed JSON, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part 누락은 각각 정확한 MVC exception + type과 현지화된 400 `ApiResponse.error` 계약을 만족한다. +- character 미존재, creatorMember 미존재, role 불일치, memberKind 불일치 요청은 4xx이며 아무 side effect도 남기지 않는다. +- 다른 character 소유 resource ID를 사용한 조회/수정/삭제/연결/답변은 4xx로 거부된다. +- 캐릭터 생성/수정/비활성화는 레거시 관리자 behavior parity를 유지한다. +- 캐릭터 등록용 원작 검색은 soft delete된 원작을 제외하고 제목·콘텐츠 타입·카테고리 부분 검색 결과를 반환한다. +- 콘텐츠 생성/수정/soft delete와 signed URL 응답은 기존 creator/admin behavior parity를 유지한다. +- 신규 관리자 오디오 생성은 `timezone` 없이 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받고, 상세 조회도 + `timezone` 없이 기존 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 반환한다. +- 오디오 콘텐츠 댓글은 target 소유 활성 콘텐츠 범위에서 조회되고, target 명의 작성·작성자 한정 수정·소유자 moderation + soft delete가 적용된다. 댓글·답글 목록은 `timezone` 없이 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다. +- 시리즈 CRUD, 콘텐츠 연결/해제/순서 변경은 target character 소유 범위를 벗어나지 않는다. +- 시리즈 상세 `data`는 목록 `items` 한 건과 동일한 필드·타입을 반환하고 상세 전용 `genre`, `keywords`를 반환하지 않는다. +- 시리즈 장르 목록은 활성 장르의 `id`, `genre`, `isAdult`를 `orders` 순으로 반환한다. +- 커뮤니티 생성/수정/고정/해제/soft delete는 target creatorMember 소유 게시글에만 적용된다. +- 커뮤니티 목록은 `timezone` 없이 조회되고 active owner 게시글의 전체 개수, 요청 page/size, 다음 페이지 여부와 + 기존 목록 item 필드를 반환한다. +- 커뮤니티 댓글은 target 소유 활성 게시글 범위에서 조회되고, target 명의 작성·작성자 한정 수정·소유자 moderation soft + delete가 적용된다. 댓글·답글 목록은 `timezone` 없이 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다. +- FanTalk 관리자 목록은 target AI character의 root 글과 creator reply를 공개 v2 응답 필드명으로 반환하며, 공개 v2의 + viewer/block 필터나 creator 관리자 CORS 경계에 의존하지 않는다. +- FanTalk 답변은 target AI character 자신의 활성 root FanTalk에만 저장된다. +- FanTalk 답변 수정은 target AI character가 작성하고 해당 target의 활성 root에 직접 연결된 reply에만 적용된다. + optional/nullable `content`와 `isActive`의 레거시 상태 전이 및 빈 객체 no-op을 유지하며, 비활성 reply를 재활성화할 수 + 있고 성공 `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태다. +- FanTalk 원글 삭제는 target 채널의 팬 작성 root만 비활성화하고 이미 비활성이면 성공 no-op이며 연결 creator reply row를 변경하지 않는다. +- 레거시 캐릭터 직접 댓글 API는 신규 v2 endpoint나 구현 계획에 포함되지 않는다. +- 기존 legacy/public endpoint 테스트가 통과하고, 두 공유 인증 경로의 CORS 허용 Origin 확장 외 성공·오류 + status/body/message를 포함한 request/response contract가 변경되지 않는다. +- 신규 dependency, 신규 DDL, 관련 없는 리팩터링이 없다. + +--- + +## 12. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 범위 | +|---|---|---|---|---|---| +| 2026-07-29 | `DEC-COMMENT-001` | 확정 | 오디오 콘텐츠·커뮤니티 댓글은 관리자 principal이 아닌 target AI 캐릭터 명의로 작성하고, target 작성 댓글만 내용을 수정하며, target 소유 자산의 댓글은 작성자와 관계없이 soft delete한다. | 사용자 승인과 기존 콘텐츠·게시글 소유자의 댓글 비활성화 동작 | Feature C, Feature E, API Expectations | +| 2026-07-29 | `DEC-CHAR-COMMENT-001` | 제외 | 사용하지 않는 레거시 캐릭터 직접 댓글 API는 v2로 전환하거나 관리자 삭제 기능을 추가하지 않는다. | 사용자 확인 결과 v2 전환 후 미사용 | Non-Goals, Acceptance Criteria | +| 2026-07-29 | `DEC-FANTALK-DELETE-001` | 확정 | 팬 작성 FanTalk root 삭제는 원글만 soft delete하고 연결 creator reply row는 변경하지 않는다. | 사용자 승인과 기존 `CreatorCheers.isActive` 동작 유지 | Feature F, API Expectations | +| 2026-07-29 | `DEC-FANTALK-REPLY-UPDATE-001` | 확정 | FanTalk 답변 수정은 레거시 `PUT /explorer/profile/cheers`의 optional/nullable `content`, `isActive`, 빈 객체 no-op, 비활성 reply 재활성화와 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. 신규 path의 `characterId`, root `fanTalkId`, `replyId`로 target AI 소유 direct reply를 한정한다. | 사용자 요청과 “기존 계약과 동일” 확정, 레거시 `ExplorerService.modifyCheers` 동작 | Feature F, API Expectations | +| 2026-07-29 | `DEC-REGISTRATION-REFERENCE-001` | 확정 | 캐릭터 등록용 원작 검색과 시리즈 등록용 장르 목록을 target 없는 신규 v2 관리자 endpoint로 제공한다. | 레거시 API는 캐릭터 관리자 배포 Origin에서 호출할 수 없고 신규 frontend는 v2 경계를 사용해야 함 | Feature B, Feature D, API Expectations | +| 2026-07-29 | `DEC-SERIES-DETAIL-001` | 확정 | 시리즈 상세 `data`를 목록 `items`의 단일 항목과 동일한 schema로 변경하고 기존 상세 전용 `genre`, `keywords`를 제거한다. | 사용자 확정과 관리자 목록·상세 DTO 일관성 | Feature D, API Expectations | +| 2026-07-29 | `DEC-UTC-DATE-001` | 확정 | 신규 관리자 오디오 생성의 `timezone` body와 오디오 상세·오디오 댓글/답글·커뮤니티 댓글/답글 조회의 `timezone` query를 제거한다. 생성 `releaseDate`는 클라이언트가 UTC로 변환해 보내고, 상세 `releaseDate`와 댓글 `date`는 기존 필드명을 유지한 ISO-8601 UTC(`Z`)로 반환한다. | 클라이언트별 timezone 표시 변환을 제거하고 단일 절대 시각 계약을 유지한다는 사용자 승인 | Feature C, Feature E, API Expectations | + +--- + +## 13. Open Questions +- 없음. 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 new file mode 100644 index 00000000..8b873530 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase2-character-review.md @@ -0,0 +1,1013 @@ +# Phase 2 캐릭터 관리 리뷰 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 2 / AI 캐릭터 관리 vertical slice | +| 기준 commit 또는 working tree | `8197f90b` + staged Phase 2/3 changes | +| 리뷰 일자 | 2026-07-27 | +| 리뷰어 | Sisyphus | +| 기준 문서 | `docs/20260724_AI캐릭터_관리자_API/prd.md`, `docs/20260724_AI캐릭터_관리자_API/plan-task.md` | +| 리뷰 상태 | 후속 수정 및 Gate 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- PRD Feature B와 Endpoint Contract Summary의 캐릭터 목록/상세/생성/수정/비활성화 계약을 현재 Phase 2 코드·테스트에 추적한다. +- 기존 완료 이력(`P2-H1`, `P2-H2`) 이후 남은 확정 finding을 `P2-T3`~`P2-T6` 소유 Goal에 연결한다. + +### 포함 범위 + +- 코드: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/*`, `ChatCharacterRepository.kt` +- 테스트: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/*` +- 문서: PRD Feature B, API Expectations, plan Phase 2 + +### 제외 범위 + +- Phase 3 이후 기능, production code 수정, 신규 계약 변경 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 | +| Medium | 제한된 조건에서 발생하는 기능·복구 문제 | +| Low | 유지보수성, 문서 정합성 문제 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `prd.md` Feature B, API Expectations 184~189 +- 계획: `plan-task.md` `P2-R1`, `P2-T3`~`P2-T6`, `REV-001`~`REV-003`, `REV-007`, `REV-008` +- 코드: `AiCharacterAdminCharacterController.kt:20`, `AiCharacterAdminCharacterDto.kt:3`, `AiCharacterAdminCharacterDto.kt:11`, `AiCharacterAdminCharacterFacade.kt:38`, `AiCharacterAdminCharacterFacade.kt:63`, `AiCharacterAdminCharacterMapper.kt:20`, `ChatCharacterRepository.kt:59` +- 테스트: `AiCharacterAdminCharacterControllerTest.kt:43`, `AiCharacterAdminCharacterControllerMutationTest.kt:50`, `LegacyChatCharacterAdminCharacterizationTest.kt:39`, `LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt:42` + +### 실행 환경 + +```text +OS: macOS (darwin) +Java/Gradle: repository Gradle wrapper +환경 변수: test profile defaults, EmbeddedRedisInitializer, local stub external character API +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'` | 성공 | `BUILD SUCCESSFUL in 51s` | +| 코드 추적 | 성공 | 목록 응답이 상세 DTO를 재사용하고 있음 확인 | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-001` | Blocker | 확정 | 문자열 미매핑 경로가 detail binding에 잡힐 수 있음 | `P2-T6` | numeric path mapping 오류 계약 보완 | +| `REV-002` | High | 확정 | mutation request 계약이 Endpoint Contract Summary와 불일치 | `P2-T4`, `P2-T5` | canonical request 확정 후 RED/GREEN | +| `REV-003` | High | 확정 | 외부 API·S3·DB 실패 보상/원자성 증거 부족 | `P2-T4`, `P2-T5` | failure-order와 no-partial-state 보완 | +| `REV-007` | Medium | 확정 | 목록 응답이 상세 전용 field를 포함 | `P2-T3` | 목록 전용 DTO와 exact key 테스트 | +| `REV-008` | High | 확정 | 실제 endpoint별 인가·i18n·회귀 증거 부족 | `P2-T6` | endpoint matrix 보강 | + +## 6. 발견 사항 상세 + +### REV-001 — 문자열 미매핑 경로 404 계약 보강 필요 + +- **심각도:** Blocker +- **상태:** 확정 +- **관련 요구사항:** PRD API Expectations 174~180 +- **관련 계약:** 신규 prefix 미매핑 경로 404, binding 오류 400 분리 +- **소유 Task:** `P2-T6` + +**관찰 내용** + +`AiCharacterAdminCharacterController`는 `@GetMapping("/{characterId}")`에서 `Long` path variable을 직접 받는다. 문자열 segment가 이 mapping에 먼저 걸리면 404 fallback이 아니라 type mismatch 400으로 분류될 수 있으므로 `P2-T6`의 numeric path mapping RED/GREEN이 필요하다. + +**근거** + +- 코드: `AiCharacterAdminCharacterController.kt:29` `/{characterId}` mapping +- 코드: `AiCharacterAdminExceptionHandler.kt:67` `MethodArgumentTypeMismatchException`은 400으로 처리 +- 문서: `plan-task.md` `P2-T6` RED 조건 + +**권장 조치** + +numeric `characterId` path만 resource handler에 매핑되도록 최소 수정하고 KO/EN/JA·CORS 포함 404 회귀 테스트를 추가한다. + +### REV-002 — mutation request 계약 불일치 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Feature B, API Expectations 185~189 +- **관련 계약:** Endpoint Contract Summary의 생성/수정 multipart request +- **소유 Task:** `P2-T4`, `P2-T5` + +**관찰 내용** + +현재 생성 DTO는 `systemPrompt`를 필수로 요구하고 `externalCharacterId`, 생성 `isActive`를 받지 않는다. Endpoint Contract Summary의 축약 request와 Phase 2 후속 계획의 canonical request 확정 항목이 충돌하므로, 구현 수정 전에 기존 legacy/external API parity 기준으로 canonical request를 먼저 고정해야 한다. + +**근거** + +- 코드: `AiCharacterAdminCharacterDto.kt:27` 생성 DTO field set +- 코드: `AiCharacterAdminCharacterExternalApiClient.kt:22` 외부 API create body는 서버가 받은 field를 외부 생성 요청으로 전송 +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:50` 생성 정상 케이스는 `systemPrompt` 포함 최소 JSON만 검증 + +**권장 조치** + +`P2-T4`에서 문서 JSON 역직렬화·반영 테스트와 invalid `characterType` 테스트를 먼저 실패시킨 뒤, 확정 계약만 최소 구현한다. + +### REV-003 — mutation 실패 원자성 증거 부족 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Feature B Edge Cases +- **관련 계약:** 외부 API·S3·DB 실패 시 부분 저장 방지 +- **소유 Task:** `P2-T4`, `P2-T5` + +**관찰 내용** + +생성은 외부 API 호출 후 DB 생성, 선택 이미지 업로드, 원작 연결, 이벤트 발행 순서로 진행된다. 기존 테스트는 정상 생성, unreadable JSON, soft delete 보존만 검증하고 외부 API 실패, S3 실패, 존재하지 않는 원작, 동시 중복 이름에서 잔존 DB/S3/event 결과를 고정하지 않는다. + +**근거** + +- 코드: `AiCharacterAdminCharacterFacade.kt:63` 생성 transaction +- 코드: `AiCharacterAdminCharacterFacade.kt:71` 외부 API 결과로 DB 생성 +- 코드: `AiCharacterAdminCharacterFacade.kt:96` 이미지 업로드 후 save +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:50` 실패 지점별 원자성 테스트 부재 + +**권장 조치** + +`P2-T4`/`P2-T5`에서 실패 지점별 RED를 만들고, 외부 부작용 전 DB 참조 검증과 필요한 보상 경계만 추가한다. + +### REV-007 — 목록 응답 field set 계약 위반 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD API Expectations 189 +- **관련 계약:** Endpoint Contract Summary의 목록 item과 상세 response 분리 +- **소유 Task:** `P2-T3` + +**관찰 내용** + +목록 응답 `items`가 상세 응답 DTO `AiCharacterAdminCharacterResponse`를 그대로 사용한다. 그래서 목록 계약에 없는 `creatorProfileImageUrl`, `creatorIntroduce`, `updatedAtUtc`가 노출될 수 있다. + +**근거** + +- 코드: `AiCharacterAdminCharacterDto.kt:3` `AiCharacterAdminListResponse.items: List` +- 코드: `AiCharacterAdminCharacterDto.kt:18` 상세 전용 `creatorProfileImageUrl` +- 코드: `AiCharacterAdminCharacterDto.kt:19` 상세 전용 `creatorIntroduce` +- 코드: `AiCharacterAdminCharacterDto.kt:24` 상세 전용 `updatedAtUtc` +- 코드: `AiCharacterAdminCharacterFacade.kt:51` 목록에서 `characterMapper::toResponse` 사용 +- 테스트: `AiCharacterAdminCharacterControllerTest.kt:43` 목록 exact key 검증 부재 + +**권장 조치** + +목록 전용 item DTO를 추가하고 exact JSON key 테스트로 상세 전용 field 비노출을 고정한다. + +### REV-008 — endpoint matrix와 완료 증거 부족 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Metrics 207~221 +- **관련 계약:** endpoint별 ADMIN, 오류 i18n, legacy 회귀 증거 +- **소유 Task:** `P2-T6` + +**관찰 내용** + +현재 Phase 2 focused test는 통과하지만 목록 anonymous/admin smoke와 일부 binding만 검증한다. 목록·상세·생성·수정 각각의 JWT role × DB role, stale claim, domain/client/server 오류의 exact status/key/KO·EN·JA matrix는 아직 Phase 2 endpoint 기준으로 충분히 고정되어 있지 않다. + +**근거** + +- 테스트: `AiCharacterAdminCharacterControllerTest.kt:139` 목록 endpoint 인가 smoke만 존재 +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:89` unreadable create request만 400 검증 +- 문서: `plan-task.md` `P2-T6` RED/GREEN 조건 + +**권장 조치** + +`P2-T6`에서 실제 Phase 2 endpoint matrix와 legacy/public 회귀를 focused 명령으로 보강한다. + +## 7. 확정 항목의 plan·goal 전환 + +확정 항목은 기존 `plan-task.md`의 `P2-T3`~`P2-T6`에 이미 소유 Goal로 연결되어 있다. 신규 회귀 수정 Task는 만들지 않는다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD Feature B, Endpoint Contract Summary, Phase 2 production/test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-001`~`REV-003`, `REV-007`, `REV-008` 확정 | +| 확정 항목 plan 반영 | 충족 | 기존 `P2-T3`~`P2-T6`에 연결됨 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 | +| 검증 명령과 결과 기록 | 충족 | focused test `BUILD SUCCESSFUL in 51s` | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-T3`부터 직렬 실행한다. + +## 9. 수정 후 검증 기록 + +### 1차 수정 검증 — 2026-07-27 + +- 무엇을: `P2-T3`에서 `REV-007`의 목록 응답 field set을 수정했다. +- 왜: 목록 item이 상세 DTO를 재사용해 `creatorProfileImageUrl`, `creatorIntroduce`, `updatedAtUtc`를 노출했기 때문이다. +- 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerTest` — RED는 line 66 실패, GREEN은 `BUILD SUCCESSFUL`. + - `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'` — `BUILD SUCCESSFUL`. + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`. +- 남은 항목: `P2-T4`, `P2-T5`, `P2-T6`. + +### 2차 수정 검증 — 2026-07-27 + +- 무엇을: `P2-T4`에서 `REV-002`, `REV-003`, `REV-007` 중 생성 흐름을 보완했다. +- 왜: 생성 request의 서버 소유 field와 존재하지 않는 원작 검증이 외부 부작용 전에 고정되어야 했기 때문이다. +- 어떻게: + - `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest` — `BUILD SUCCESSFUL`. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL`. +- 남은 항목: `P2-T5`, `P2-T6`, `P2-GATE`. + +### 3차 수정 검증 — 2026-07-27 + +- 무엇을: `P2-T5`에서 `REV-002`, `REV-003`, `REV-007`의 캐릭터 수정·비활성화 흐름을 보완했다. +- 왜: `externalCharacterId` 입력, `isActive=false`와 일반 수정/image 혼합, 원작·외부/S3/DB 실패 경계와 flush 전 `updatedAtUtc`가 확정 계약과 달랐기 때문이다. +- 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.AiCharacterAdminCharacterControllerMutationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest` — `BUILD SUCCESSFUL in 1m 16s`. + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL in 17s`. +- 남은 항목: `P2-T6`, `P2-GATE`. + +### 4차 수정 검증 — 2026-07-27 + +- 무엇을: `P2-T6`에서 `REV-001`의 문자열 path 404 계약과 `REV-008`의 실제 Phase 2 endpoint 보안·오류·CORS 증거를 보완했다. +- 왜: `/api/v2/admin/ai-characters/unmapped-path`가 detail `Long` binding에 매칭되어 KO/EN/JA·허용 Origin CORS 4건 모두 404 대신 400을 반환했고, 목록·상세·생성·수정의 실제 경로 matrix가 부족했기 때문이다. +- 어떻게: + - RED: `./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`를 실행해 전체 146건 중 위 4건만 404 기대 대비 400으로 실패함을 확인했다. + - GREEN: `AiCharacterAdminCharacterController`의 GET/PUT `characterId` path를 `[0-9]+`로 제한하고, 실제 목록 binding·상세 target·생성/수정 multipart KO/EN/JA, 네 endpoint non-ADMIN, detail preflight 테스트를 추가했다. + - 회귀: 같은 focused 명령은 `BUILD SUCCESSFUL in 1m 26s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 34s`였다. +- 결과: `REV-001`, `REV-008`의 P2-T6 범위는 종료했다. 공통 Phase 1 handler, legacy/public contract, Phase 3 이후 코드는 변경하지 않았고, `P2-GATE`는 별도 Goal로 남겼다. + +## 10. 2차 리뷰 및 검증 — 2026-07-27 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: Phase 2·3 staged/unstaged 변경 전체 +- 리뷰 상태: 판정 완료, 후속 수정 goal 필요 +- 검토 범위: PRD Feature B, Endpoint Contract Summary, `P2-T3`~`P2-GATE`, character production/test와 공통 authorization/error test +- fresh 검증: + - `./gradlew test --rerun-tasks --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.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` — `BUILD SUCCESSFUL in 9m 23s`, 관련 XML 14개 합계 199건, failure/error/skipped 0. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 27s`, 7개 task 실행. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-009` | Medium | 확정 | Phase 2 실제 endpoint별 보안·오류·실패 경계 증거가 Gate 기록보다 좁음 | `Task 2.8` | `P2-R2` | + +### REV-009 — Phase 2 실제 endpoint별 완료 증거 보강 필요 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Metrics 207~221, Acceptance Criteria 225~254 +- **관련 계약:** 실제 endpoint별 JWT role × 현재 DB role, stale claim, CORS, KO/EN/JA 오류와 실패 후 상태 +- **소유 Task:** `Task 2.8`, `P2-R2` + +**관찰 내용** + +현재 작성된 character와 공통 authorization/error test 146건은 모두 통과한다. 다만 `P2-T6`과 `P2-GATE`는 목록·상세·생성·수정 각각의 stale ADMIN claim, 허용·거부 Origin, domain/client/server 오류와 외부/S3/DB 실패 경계를 모두 고정했다고 기록했으나 실제 소유 테스트는 네 endpoint의 non-ADMIN JWT, 상세 GET의 허용 Origin, 일부 binding/multipart KO/EN/JA와 S3 실패를 각각 분리해 검증한다. 실제 네 endpoint의 stale claim과 허용·거부 Origin 전체, 외부/API·S3·DB 실패의 exact envelope 및 side-effect 결과를 한 매트릭스로 재현할 직접 증거가 없다. + +**근거** + +- 테스트: `AiCharacterAdminCharacterControllerTest.kt:175`는 네 endpoint의 non-ADMIN JWT만 순회하며 stale ADMIN claim을 포함하지 않는다. +- 테스트: `AiCharacterAdminCharacterControllerTest.kt:195`는 상세 GET의 허용 Origin만 확인하고 거부 Origin과 POST/PUT preflight를 직접 확인하지 않는다. +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:340` 이후는 외부 API/S3 실패의 DB 결과를 확인하지만 실제 endpoint별 KO/EN/JA와 DB 실패 경계를 모두 고정하지 않는다. +- 문서: `plan-task.md`의 `P2-T6`·`P2-GATE`는 위 매트릭스와 외부/S3/DB 보상에 미결정 항목이 없다고 완료 처리했다. + +**영향** + +공통 security/error 계층 때문에 현재 production 결함은 재현되지 않았지만, 실제 character mapping이나 facade 호출 순서가 바뀌어도 공통 fixture test만 통과해 Phase 2 Gate가 놓칠 수 있다. + +**권장 조치** + +`P2-R2`에서 네 실제 endpoint의 JWT/DB role·stale claim과 허용/거부 CORS, 대표 binding/domain/client/server 실패의 exact envelope 및 DB/S3/external/event 결과를 non-vacuous test로 보강한다. 현재 동작이 계약을 이미 만족하면 production code를 수정하지 않고 test와 검증 기록만 추가한다. + +**판정 기록** + +- 2026-07-27 — fresh 199건과 lint는 모두 통과했으나 계획 체크리스트와 실제 test method를 대조해 검증 증거 누락을 확정했다. + +### plan·goal 전환 + +`plan-task.md` Phase 2에 `Task 2.8` / `P2-R2`와 별도 후속 Gate `P2-R2-GATE`를 추가한다. 기존 `P2-T3`~`P2-GATE` 완료 이력은 변경하지 않는다. + +### 2차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD·계획·production·test 재대조 | +| 후보 항목 판정 완료 | 충족 | `REV-009` 확정 | +| 확정 항목 plan 반영 | 충족 | `Task 2.8`, `P2-R2`, `P2-R2-GATE` | +| 검증 명령과 결과 기록 | 충족 | fresh targeted 199건, lint 성공 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R2` 실행 후 `P2-R2-GATE`에서 Phase 2 후속 리뷰를 종결한다. + +## 11. 3차 수정 검증 — 2026-07-27 + +- 무엇을: `P2-R2`에서 `REV-009`의 실제 character endpoint별 보안·오류·실패 경계 증거를 보강했다. +- 왜: 기존 `P2-T6`/`P2-GATE` 기록이 네 실제 endpoint의 stale ADMIN claim, 허용·거부 Origin preflight, mutation 실패의 KO/EN/JA envelope를 직접 매트릭스로 고정했다는 증거가 부족했기 때문이다. +- 어떻게: + - `AiCharacterAdminCharacterControllerTest`에 목록·상세·생성·수정 실제 경로의 stale ADMIN claim 403과 allow/deny Origin preflight를 추가했다. + - `AiCharacterAdminCharacterControllerMutationTest`의 external API 실패, 생성 S3 실패, 수정 S3 실패를 KO/EN/JA exact message와 DB/S3/external 잔존 상태 단언으로 확장했다. + - `./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 47s`. +- 결과: production code 변경 없이 `REV-009`의 직접 증거 보강이 완료됐다. `P2-R2-GATE`를 종료하고 Phase 3 후속 보완으로 진행한다. + +### 3차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-009` 처리 | 충족 | 실제 endpoint stale claim/CORS와 failure envelope·side-effect test 추가 | +| focused 회귀 | 충족 | character/common focused 명령 성공 | +| 범위 준수 | 충족 | Phase 3 production, Phase 4 이후 작업 없음 | + +**최종 결론:** Phase 2 후속 리뷰 종결 + +## 12. 4차 재리뷰 및 검증 — 2026-07-27 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: `P2-R2` 반영 후 staged/unstaged Phase 2 변경 +- 리뷰 상태: 판정 완료, 추가 수정 goal 필요 +- fresh 검증: + - `./gradlew test --rerun-tasks --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.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` — `BUILD SUCCESSFUL in 9m 44s`, 관련 XML 14개 합계 228건, failure/error/skipped 0. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 41s`, 7개 task 실행. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-012` | Medium | 처리 완료 | DB·event 실패 후 내부·외부 부작용 직접 증거가 여전히 없음 | `Task 2.9` | `P2-R3` | + +### REV-012 — character DB·event 실패 경계 증거 보강 필요 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Metrics 207~221, Acceptance Criteria 225~254 +- **관련 계약:** 생성·수정 실패 뒤 DB/creatorMember/originalWork/event rollback과 이미 발생한 external/S3 비보상 결과 +- **소유 Task:** `Task 2.9`, `P2-R3` + +**관찰 내용** + +`P2-R2` 반영으로 네 실제 endpoint의 stale ADMIN claim과 허용·거부 Origin, external API·생성 S3·수정 S3 실패의 KO/EN/JA message는 직접 고정됐다. 그러나 완료 처리한 `Task 2.8`의 failure checklist에는 DB 실패와 creatorMember/originalWork/event 결과까지 포함되어 있다. 현재 mutation test는 DB save/flush 또는 event publish 실패를 주입하지 않으며, 실패 test의 `@DisplayName`에 event 미발행을 적었지만 publisher 호출을 직접 단언하지 않는다. + +**근거** + +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:346`~`408`은 external API와 첫 S3 실패를 확인하지만 DB/event 실패를 재현하지 않는다. +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:143`, `676`의 publisher mock은 정상 생성·수정 event 확인에만 사용된다. +- 코드: `AiCharacterAdminCharacterFacade.create`와 `update`는 external/S3/DB 처리 뒤 event를 발행하므로 DB 또는 event 실패 시 transaction rollback과 이미 발생한 외부 결과를 별도로 관찰해야 한다. +- 문서: `plan-task.md`의 `Task 2.8`은 DB 실패와 DB/creatorMember/originalWork/S3/external/event 결과를 모두 직접 단언했다고 완료 처리했다. + +**영향** + +현재 테스트는 모두 통과하지만 persistence 또는 event 지점에서 실패했을 때 내부 row가 롤백되고 external/S3 결과만 남는 기존 경계가 바뀌어도 `P2-R2-GATE`가 이를 감지하지 못한다. + +**권장 조치** + +`P2-R3`에서 실제 transaction을 거치는 생성·수정의 DB flush/save와 event publish 실패를 재현하고 ChatCharacter·creatorMember·originalWork·event 및 external/S3 결과를 직접 단언한다. 기존 비보상 경계와 일치하면 production code를 변경하지 않는다. + +**처리 결과** + +`AiCharacterAdminCharacterControllerMutationTest`에 external/S3 실패 KO/EN/JA matrix와 생성·수정 event publish 실패 특성화를 추가했다. production code 변경 없이 mutation focused 명령은 `BUILD SUCCESSFUL in 1m 1s`, 최종 `ktlintCheck`는 `BUILD SUCCESSFUL in 17s`였다. + +### plan·goal 전환 + +`plan-task.md` Phase 2에 `Task 2.9` / `P2-R3`와 `P2-R3-GATE`를 추가한다. 기존 `Task 2.8`과 `P2-R2-GATE` 완료 이력은 변경하지 않는다. + +### 4차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 적용된 stale claim/CORS/i18n 보강 | 충족 | 실제 endpoint 및 external/S3 failure test 추가 | +| DB/event 실패 경계 직접 증거 | 충족 | event failure injection과 external/S3/DB/event 단언 추가 | +| 확정 항목 plan 반영 | 충족 | `Task 2.9`, `P2-R3`, `P2-R3-GATE` | +| fresh 검증 | 충족 | targeted 228건, lint 성공 | + +**최종 결론:** Phase 2 4차 리뷰 종결 + +**남은 항목:** 없음. Phase 3 4차 리뷰 보완으로 진행한다. + +## 13. 5차 재리뷰 및 판정 — 2026-07-27 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: `P2-R3`와 `P3-R4` 반영 후 staged/unstaged Phase 2·3 변경 +- 리뷰 상태: 판정 완료, 추가 수정 goal 필요 +- 검토 범위: `REV-012` 처리 결과, character mutation의 transaction/DB/event failure evidence +- fresh 검증: + - `./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.AiCharacterAdminAudioContentOwnershipTest` — `BUILD SUCCESSFUL in 7m 50s`, 세 XML 합계 79건, failure/error/skipped 0. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 28s`, 7개 task 실행. + - `git diff --check` — 출력 없음. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-015` | Medium | 처리 완료 | character DB·event 실패 경계가 실제 transaction과 내부 rollback을 직접 검증하지 않음 | `Task 2.10` | `P2-R4-GATE` | + +### REV-015 — character DB·event 실패의 실제 transaction 증거 보강 필요 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Metrics 207~221, Acceptance Criteria 225~254 +- **관련 계약:** 생성·수정의 persistence/event 실패 뒤 ChatCharacter·creatorMember·originalWork rollback과 external/S3 비보상 결과 +- **소유 Task:** `Task 2.10`, `P2-R4` + +**관찰 내용** + +`P2-R3`에서 추가한 생성·수정 event 실패 테스트는 `AiCharacterAdminCharacterFacade`를 테스트 안에서 직접 생성하고 예외를 잡는다. 따라서 production의 Spring transaction proxy를 통과하지 않으며, 테스트는 external API 호출 횟수와 publisher 호출만 확인하고 ChatCharacter·creatorMember·originalWork의 rollback 결과를 조회하지 않는다. 계획이 완료 조건으로 둔 DB save/flush 실패 주입도 없다. + +**근거** + +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:415`의 생성 event 실패는 `createFacade(publisher).create(...)`를 직접 호출하고 DB row 부재를 단언하지 않는다. +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:696`의 수정 event 실패도 직접 facade를 호출하며 수정 전 DB 상태 유지 여부를 단언하지 않는다. +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:836`의 `createFacade`는 Spring bean이 아닌 일반 객체를 반환한다. +- 문서: `plan-task.md:1264`~`1266`은 생성·수정의 DB flush/save와 event publish 실패를 실제 transaction에서 재현하고 내부·외부 결과를 직접 단언했다고 완료 처리한다. +- 리뷰: 이 문서 4차 종료 판정은 event failure injection과 external/S3/DB/event 단언이 충족됐다고 기록한다. + +**재현 또는 검증 절차** + +1. 위 두 event 실패 test와 `createFacade` helper를 확인한다. +2. 각 test가 actual multipart endpoint를 호출하는지, exception 후 DB를 재조회하는지 확인한다. +3. 현재 결과는 직접 facade 호출과 mock interaction 확인뿐이며 DB save/flush failure injection과 내부 rollback 조회가 없다. +4. production Spring transaction을 통과하는 actual endpoint 또는 실제 transaction proxy 호출과 transaction 밖 DB 재조회가 필요하다. + +**영향** + +현재 focused test는 통과하지만 facade의 `@Transactional` 경계가 제거되거나 event/flush 실패 뒤 일부 내부 row가 남는 회귀가 발생해도 `P2-R3-GATE`가 이를 검출하지 못한다. + +**권장 조치** + +`P2-R4`에서 test transaction을 비활성화한 actual POST/PUT endpoint로 event 실패를 주입하고, transaction 종료 뒤 ChatCharacter·creatorMember·originalWork와 event 결과를 재조회한다. update의 명시적 `chatCharacterRepository.flush()` 실패도 실제 transaction에서 주입해 외부 호출·S3·DB 잔존 결과를 고정한다. 기존 비보상 계약과 일치하면 production code는 변경하지 않는다. + +**판정 기록** + +- 2026-07-27 — fresh 79건과 lint는 통과했지만 테스트 호출 경계와 assertion을 대조해 `REV-012`의 완료 증거가 충족되지 않았음을 확정했다. + +### plan·goal 전환 + +`plan-task.md` Phase 2에 `Task 2.10` / `P2-R4`와 `P2-R4-GATE`를 추가한다. 기존 `Task 2.9`와 `P2-R3-GATE` 완료 이력은 변경하지 않는다. + +### 5차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | production transaction 경계와 새 event failure test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-015` 확정 | +| 확정 항목 plan 반영 | 충족 | `Task 2.10`, `P2-R4`, `P2-R4-GATE` | +| fresh 검증 | 충족 | 관련 79건과 lint 성공, diff check 이상 없음 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R4` 실행 후 `P2-R4-GATE`에서 Phase 2 후속 리뷰를 다시 판정한다. + +### P2-R4 실행 기록 + +- **REV-015 상태:** 처리 완료. +- `AiCharacterAdminCharacterControllerMutationTest`는 `@Transactional(propagation = Propagation.NOT_SUPPORTED)` actual POST/PUT로 event publish 실패와 repository `save`/`flush()` 실패를 주입한다. +- POST 실패 뒤 별도 `TransactionTemplate`에서 ChatCharacter, creatorMember, originalWork assignment이 없음을 확인하고, PUT 실패 뒤 character, creatorMember, originalWork의 기존 필드가 유지됨을 확인한다. 모든 대표 KO/EN/JA 응답은 500 `common.error.unknown` message이며 external 호출은 1회, image 없는 S3 호출은 없다. +- direct `createFacade` event failure test는 endpoint 증거로 대체했고, 남은 direct facade test는 event payload assertion 용도임을 명시했다. +- 초기 RED는 `@MockBean`만으로 이미 생성된 facade field가 교체되지 않아 event listener가 실행되고 200이 반환된 것으로 확인했다. 실제 Spring facade proxy target에 같은 mock을 주입한 뒤 production code 변경 없이 통과했다. +- fresh 검증: mutation/error focused `BUILD SUCCESSFUL in 1m 8s`, character/auth/error 영향 범위 `BUILD SUCCESSFUL in 1m 59s`, `./gradlew ktlintCheck` `BUILD SUCCESSFUL in 42s`. + +### P2-R4-GATE 종료 판정 + +- 무엇을: `REV-015` actual transaction·rollback 증거 보강을 최종 판정했다. +- 왜: `P2-R4` 구현 후 Phase 2 5차 리뷰를 종결하기 위해서다. +- 어떻게: character mutation/error와 content create/controller/ownership focused 명령 및 `git diff --check`를 fresh 실행했다. +- 결과: focused 명령은 `BUILD SUCCESSFUL in 52s`, `git diff --check`는 출력이 없었다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-015` 처리 | 충족 | actual POST/PUT event/save/flush 실패와 transaction 종료 뒤 DB 상태 재조회 추가 | +| focused 회귀 | 충족 | 5차 Gate focused 명령 성공 | +| 범위 준수 | 충족 | Phase 3 이후 production·Phase 4 변경 없음 | + +**최종 결론:** Phase 2 5차 리뷰 종결 + +**남은 항목:** 없음. Phase 3 5차 Gate로 진행한다. + +## 14. 6차 재리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: `P2-R4-GATE`, `P3-R4-GATE` 반영 후 staged/unstaged Phase 2·3 변경 +- 리뷰 상태: 판정 완료, 추가 수정 goal 필요 +- 검토 범위: Endpoint Contract Summary의 캐릭터 생성 예시와 `DEC-P2-T4-001`, production DTO/facade 및 actual endpoint test +- fresh 검증: + - Phase 2·3 5차 Gate focused 5개 test class를 `--rerun-tasks`로 실행해 XML 합계 216건, failure/error/skipped 0과 `BUILD SUCCESSFUL in 4m 16s`를 확인했다. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 17s`, 7개 task 실행. + - staged/unstaged `git diff --check` — 출력 없음. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-018` | High | 확정 | 캐릭터 생성 Endpoint Contract Summary가 확정 request 계약과 불일치 | `Task 2.11` | `P2-R5` | + +### REV-018 — 캐릭터 생성 Endpoint Contract Summary 불일치 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Feature B, Endpoint Contract Summary, `DEC-P2-T4-001` +- **관련 계약:** POST 생성은 필수 `systemPrompt`를 받고 `externalCharacterId`와 `isActive`는 client request로 받지 않는다. +- **소유 Task:** `Task 2.11`, `P2-R5` + +**관찰 내용** + +Endpoint Contract Summary의 POST 생성 예시는 필수 `systemPrompt`를 누락하고, 외부 API response 전용 +`externalCharacterId`와 서버가 `true`로 생성하는 `isActive`를 request에 포함한다. 같은 문서의 `DEC-P2-T4-001`과 +production DTO/facade는 반대 계약을 사용한다. + +**근거** + +- 문서: `plan-task.md:189`~`196`의 생성 JSON은 `systemPrompt`가 없고 `externalCharacterId`, `isActive`를 포함한다. +- 결정: `plan-task.md:2831`의 `DEC-P2-T4-001`은 `systemPrompt` 필수, `externalCharacterId` response 전용, + `isActive` 서버 소유를 확정한다. +- 코드: `AiCharacterAdminCharacterDto.kt:40`~`45`는 `systemPrompt`를 non-null로 받고 두 서버 소유 field를 명시적으로 + 수신해 facade에서 거부할 수 있게 한다. +- 코드: `AiCharacterAdminCharacterFacade.kt:64`~`68`은 두 서버 소유 field가 존재하면 400으로 거부한다. +- 테스트: `AiCharacterAdminCharacterControllerMutationTest.kt:103`~`141`은 `systemPrompt`를 포함한 정상 생성과 + response의 외부 ID·활성 상태를 확인하고, 같은 파일 `:239` 이후는 서버 소유 field 입력을 거부한다. + +**영향** + +Endpoint Contract Summary를 source of truth로 사용하는 client는 제공 예시 그대로 요청하면 400을 받는다. production 동작은 +확정 결정과 일치하지만 공개 request 예시가 반대라 Phase 2 API 계약 완료 판정이 문서 수준에서 성립하지 않는다. + +**권장 조치** + +`P2-R5`에서 생성 예시에 `systemPrompt`를 추가하고 `externalCharacterId`, `isActive`를 제거한다. production 변경 없이 기존 +정상 생성·서버 소유 field 거부 actual endpoint test와 문서 문자열 검사를 실행해 예시와 `DEC-P2-T4-001`을 동기화한다. + +### plan·goal 전환 + +`plan-task.md` Phase 2에 `Task 2.11` / `P2-R5`와 `P2-R5-GATE`를 추가한다. 기존 `Task 2.10`과 +`P2-R4-GATE` 완료 이력은 변경하지 않는다. + +### 6차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Endpoint Contract Summary·Decision Log·production·actual endpoint test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-018` 확정 | +| 확정 항목 plan 반영 | 충족 | `Task 2.11`, `P2-R5`, `P2-R5-GATE` | +| fresh 검증 | 충족 | 관련 216건과 lint 성공, staged/unstaged diff check 이상 없음 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R5` 실행 후 `P2-R5-GATE`에서 Phase 2 후속 리뷰를 다시 판정한다. + +### P2-R5-GATE 종료 판정 — 2026-07-28 + +- 무엇을: `REV-018`의 캐릭터 생성 Endpoint Contract Summary 예시를 최종 판정했다. +- 왜: 공개 예시와 `DEC-P2-T4-001`/production request 계약의 불일치를 해소했는지 확인하기 위해서다. +- 어떻게: `plan-task.md` 생성 예시를 확인하고 `AiCharacterAdminCharacterControllerMutationTest`를 실행했다. +- 결과: 생성 예시는 필수 `systemPrompt`를 포함하고 서버 소유 `externalCharacterId`, `isActive`를 제외했다. focused test는 `BUILD SUCCESSFUL in 30s`였다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-018` 처리 | 충족 | Endpoint Contract Summary 생성 예시와 production DTO/facade 계약 일치 | +| actual endpoint 회귀 | 충족 | 정상 생성·서버 소유 field 거부 focused test 성공 | +| 범위 준수 | 충족 | production code 변경 없음, Phase 3 이후 기능 변경 없음 | + +**최종 결론:** 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 new file mode 100644 index 00000000..6c783cb4 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/reviews/phase3-audio-content-review.md @@ -0,0 +1,1347 @@ +# Phase 3 오디오 콘텐츠 관리 리뷰 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 3 / 오디오 콘텐츠 관리와 signed URL vertical slice | +| 기준 commit 또는 working tree | `feat/ai-character-admin-api` working tree | +| 리뷰 일자 | 2026-07-27 | +| 리뷰어 | Sisyphus | +| 기준 문서 | `docs/20260724_AI캐릭터_관리자_API/prd.md`, `docs/20260724_AI캐릭터_관리자_API/plan-task.md` | +| 리뷰 상태 | 후속 수정 및 Gate 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- PRD Feature C와 Endpoint Contract Summary의 테마, 목록, 상세, 생성, 수정, soft delete, signed URL 계약을 현재 Phase 3 코드와 테스트에 추적한다. +- 기존 완료 이력(`P3-H1`, `P3-H2`) 이후 남은 확정 finding을 `P3-T3`~`P3-T7` 소유 Goal에 연결한다. + +### 포함 범위 + +- 코드: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/*` +- 테스트: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/*` +- 문서: PRD Feature C, API Expectations, plan Phase 3 + +### 제외 범위 + +- Phase 4 이후 시리즈/커뮤니티/FanTalk 기능, production code 수정, 신규 계약 변경 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 | +| Medium | 제한된 조건에서 발생하는 기능·복구 문제 | +| Low | 유지보수성, 문서 정합성 문제 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `prd.md` Feature C, API Expectations 184~189, Metrics 207~221 +- 계획: `plan-task.md` `P3-R1`, `P3-T3`~`P3-T7`, `REV-004`~`REV-008` +- 코드: `AiCharacterAdminAudioContentController.kt`, `AiCharacterAdminAudioContentFacade.kt`, `AiCharacterAdminAudioContentDto.kt`, `AiCharacterAdminAudioContentMapper.kt`, `AiCharacterAdminAudioContentRepository.kt` +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt`, `LegacyCreatorAdminAudioContentCharacterizationTest.kt`, `AudioContentCloudFrontCharacterizationTest.kt` + +### 실행 환경 + +```text +OS: macOS (darwin) +Java/Gradle: repository Gradle wrapper +환경 변수: test profile defaults, EmbeddedRedisInitializer, mocked AudioContentCloudFront/AmazonS3Client +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'` | 성공 | `BUILD SUCCESSFUL in 2s`, Gradle `UP-TO-DATE` | +| 코드 추적 | 성공 | controller/facade/DTO/repository/test를 PRD와 대조 | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-004` | Medium | 확정 | 콘텐츠 수정의 `seriesIds` 교체가 기존 연결 metadata를 보존하지 않음 | `P3-T6` | 차집합 기반 연결 갱신 | +| `REV-005` | High | 확정 | 상세 응답이 legacy/public DTO 타입에 직접 의존하고 일부 legacy parity 필드 검증이 분리되지 않음 | `P3-T4` | v2 전용 중첩 DTO와 상세 parity 보강 | +| `REV-006` | High | 확정 | 생성 multipart·field·failure-order 계약 증거가 분리된 소유 테스트로 부족함 | `P3-T5`, `P3-T7` | 생성 pipeline과 오류 matrix 보강 | +| `REV-007` | Medium | 확정 | 테마·조회 DTO 경계와 exact field set 증거가 단일 대형 controller test에 섞여 있음 | `P3-T3`, `P3-T4` | 전용 focused test로 계약 분리 | +| `REV-008` | High | 확정 | 실제 endpoint별 ADMIN·CORS·i18n·legacy 회귀 matrix가 부족함 | `P3-T7` | endpoint/error/ownership matrix 보강 | + +## 6. 발견 사항 상세 + +### REV-004 — `seriesIds` 교체가 기존 연결 metadata를 삭제함 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C, Feature D 선행 owner query 계약 +- **관련 계약:** 콘텐츠 수정의 `seriesIds` 연결 유지와 owner 검증 +- **소유 Task:** `P3-T6` + +**관찰 내용** + +`AiCharacterAdminAudioContentRepository.replaceSeriesIds`는 현재 연결을 모두 remove한 뒤 요청 series를 새 `SeriesContent`로 persist한다. 같은 `seriesIds`를 다시 PUT해도 기존 `SeriesContent` row ID, `orders`, `createdAt`이 보존되지 않는다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentRepository.kt` `replaceSeriesIds`가 기존 `SeriesContent` 전체 삭제 후 재생성 +- 코드: `AiCharacterAdminAudioContentFacade.kt` update가 `request.seriesIds` 존재 시 항상 `replaceSeriesIds` 호출 +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt`는 교체 결과 ID 목록만 검증하고 교집합 metadata 보존을 검증하지 않음 + +**권장 조치** + +`P3-T6`에서 동일 ID, 추가 ID, 제거 ID 케이스를 RED로 만들고, 교집합은 그대로 두고 차집합만 insert/delete한다. + +### REV-005 — 상세 응답 DTO 경계와 legacy parity 검증 부족 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD API Expectations 188~189 +- **관련 계약:** 신규 v2 전용 DTO, legacy 상세 field 전체 포함, viewer 상태 기본값 +- **소유 Task:** `P3-T4` + +**관찰 내용** + +`AiCharacterAdminAudioContentResponse`가 `AudioContentCreator`, `ContentBuyer`, `OtherContentResponse`, `GetAudioContentCommentListItem`, `OrderType`, `TranslatedContent` 같은 legacy/public DTO 타입을 직접 노출한다. 또한 현재 상세 테스트는 JSON key 일부를 검증하지만, legacy 상세의 rental 파생값과 `releaseDate` 의미를 독립 focused test로 분리하지 않았다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentDto.kt` legacy/public DTO imports +- 코드: `AiCharacterAdminAudioContentMapper.kt` viewer 상태는 기본값으로 채우나 타입은 legacy DTO에 의존 +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt` 상세 happy path 안에 여러 상세 field assertion이 혼재 + +**권장 조치** + +`P3-T4`에서 exact JSON schema와 legacy compatibility field RED를 추가한 뒤, v2 전용 중첩 DTO로 최소 매핑한다. + +### REV-006 — 생성 pipeline과 오류 순서 증거 부족 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C, API Expectations 179~185 +- **관련 계약:** multipart 필수 part, `description/releaseDateUtc` 변환, target/theme/series 실패 no-side-effect +- **소유 Task:** `P3-T5`, `P3-T7` + +**관찰 내용** + +생성 정상·일부 실패 테스트는 존재하지만 `coverImage`, `audioFile`, `request` 각 part 누락의 exact exception/envelope, 전체 field 변환, theme 오류, S3/processing/event 실패 순서가 독립 소유 테스트로 충분히 분리되어 있지 않다. controller는 `coverImage`와 `audioFile`을 nullable `MultipartFile?`로 받아 필수 part 계약을 별도 RED로 확인해야 한다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentController.kt` create의 `coverImage`, `audioFile` nullable binding +- 코드: `AiCharacterAdminAudioContentFacade.kt` create가 target, request parse, `isActive`, series 검증 뒤 legacy `AudioContentService.createAudioContent` 호출 +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt` 생성 정상, invalid series, inactive, invalid price는 있으나 part별 exception/i18n matrix는 없음 + +**권장 조치** + +`P3-T5`에서 필수 part와 field 변환 RED를 만들고, `P3-T7`에서 actual endpoint 오류/i18n matrix로 묶어 회귀한다. + +### REV-007 — 테마·조회 DTO focused 증거 분리 필요 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C Requirements 98, API Expectations 186~189 +- **관련 계약:** 테마 `themeId/themeName/imageUrl`, 목록/상세 v2 DTO 경계 +- **소유 Task:** `P3-T3`, `P3-T4` + +**관찰 내용** + +테마 endpoint와 조회 계약은 현재 단일 `AiCharacterAdminAudioContentControllerTest` 안에서 일부 검증된다. 하지만 계획은 `AiCharacterAdminAudioContentThemeControllerTest`, `AiCharacterAdminAudioContentQueryTest` 같은 소유 focused test로 계약을 분리해 Gate 증거로 삼도록 요구한다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentController.kt` `GET /audio-content-themes` 존재 +- 코드: `AiCharacterAdminAudioContentDto.kt` `AiCharacterAdminAudioContentThemeResponse`는 v2 field name 사용 +- 테스트: 테마/조회 assertion이 `AiCharacterAdminAudioContentControllerTest.kt`에 집중됨 +- 문서: `plan-task.md` `P3-T3`, `P3-T4`의 전용 테스트 파일 요구 + +**권장 조치** + +이미 만족한 production 동작은 재구현하지 말고, `P3-T3`/`P3-T4`에서 focused test와 Progress 증거만 최소 보강한다. + +### REV-008 — endpoint 보안·오류·회귀 matrix 부족 + +- **심각도:** High +- **상태:** 확정 +- **관련 요구사항:** PRD Metrics 207~221 +- **관련 계약:** endpoint별 ADMIN, 오류 i18n, ownership no-side-effect, legacy 회귀 +- **소유 Task:** `P3-T7` + +**관찰 내용** + +현재 content focused test는 anonymous/admin smoke와 일부 domain failure를 포함하지만, 테마·목록·상세·생성·수정 각각의 JWT role × DB role, stale ADMIN claim, 허용·거부 Origin, target/content/theme/series/date와 multipart 누락의 exact status/key/KO·EN·JA matrix는 아직 Phase 3 endpoint 기준으로 충분히 고정되어 있지 않다. + +**근거** + +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt` 목록 anonymous/admin smoke만 존재 +- 테스트: 공통 `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`에 Phase 3 실제 endpoint별 matrix 보강 필요 +- 문서: `plan-task.md` `P3-T7` RED/GREEN 조건 + +**권장 조치** + +`P3-T7`에서 실제 Phase 3 endpoint matrix와 legacy characterization 회귀를 focused 명령으로 보강한다. + +## 7. 확정 항목의 plan·goal 전환 + +확정 항목은 기존 `plan-task.md`의 `P3-T3`~`P3-T7`에 이미 소유 Goal로 연결되어 있다. 신규 회귀 수정 Task는 만들지 않는다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD Feature C, Endpoint Contract Summary, Phase 3 production/test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-004`~`REV-008` 확정 | +| 확정 항목 plan 반영 | 충족 | 기존 `P3-T3`~`P3-T7`에 연결됨 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 | +| 검증 명령과 결과 기록 | 충족 | content focused test `BUILD SUCCESSFUL in 2s` | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P3-T3`부터 직렬 실행한다. + +## 9. 수정 후 검증 기록 + +기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다. + +### 1차 수정 검증 — 2026-07-27 + +- 무엇을: `P3-T3`에서 `REV-007`, `REV-008` 중 활성 콘텐츠 테마 endpoint의 focused 증거를 분리했다. +- 왜: 테마 production 동작은 이미 존재했지만 Gate에서 요구하는 request body 없는 GET, 활성 필터, v2 전용 field set, ADMIN 인증 증거가 단일 controller test에 섞여 있었기 때문이다. +- 어떻게: + - `AiCharacterAdminAudioContentThemeControllerTest` 추가: 활성 테마만 `themeId/themeName/imageUrl`로 반환, legacy `id/theme/image` 비노출, anonymous 401 검증. + - `AiCharacterAdminAudioContentControllerTest`의 중복 테마 케이스 제거. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentThemeControllerTest` — `BUILD SUCCESSFUL in 35s`. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentThemeControllerTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest` — `BUILD SUCCESSFUL in 48s`. + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL in 11s`. +- 남은 항목: `P3-T4`, `P3-T5`, `P3-T6`, `P3-T7`, `P3-GATE`. + +### 2차 수정 검증 — 2026-07-27 + +- 무엇을: `P3-T4`에서 `REV-005`, `REV-007` 중 콘텐츠 상세 조회 DTO와 signed URL/query 증거를 보완했다. +- 왜: 상세 응답이 legacy/public 중첩 DTO 타입에 직접 의존했고, 과거 공개일을 `releaseDateUtc`뿐 아니라 legacy `releaseDate`에도 노출했기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminAudioContentQueryTest` 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest` — 2건 실패. `releaseDate` 존재와 legacy nested DTO package 누출을 확인했다. + - GREEN: `AiCharacterAdminAudioContentDto`에 v2 전용 중첩 DTO를 추가하고, mapper에서 `releaseDate=null`, `releaseDateUtc` 유지, RENT_ONLY 파생값, creator DTO mapping만 최소 수정했다. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest` — `BUILD SUCCESSFUL in 29s`. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentQueryTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AudioContentCloudFrontCharacterizationTest --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest` — `BUILD SUCCESSFUL in 41s`. + - `./gradlew ktlintCheck` — 최초 unused import 2건 실패 후 정리 재실행에서 `BUILD SUCCESSFUL in 10s`. +- 남은 항목: `P3-T5`, `P3-T6`, `P3-T7`, `P3-GATE`. + +### 3차 수정 검증 — 2026-07-27 + +- 무엇을: `P3-T5`에서 `REV-006`, `REV-008` 중 콘텐츠 생성 multipart와 legacy 기본 계약 증거를 분리했다. +- 왜: 생성 필수 part, `tags` 누락, `isActive=true` 요청의 실제 legacy processing 결과가 독립 focused test로 고정되어야 했기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminAudioContentCreateTest` 추가 후 focused 실행에서 정상 생성 케이스가 200 기대 대비 500으로 실패했다. 원인은 production 계약이 아니라 test fixture의 `AmazonS3Client.getUrl` 미설정이었다. + - GREEN: S3 `getUrl` mock을 추가해 기존 `S3Uploader` 동작을 test fixture에서 충족했다. production code는 변경하지 않았다. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest` — `BUILD SUCCESSFUL`. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.LegacyCreatorAdminAudioContentCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` — `BUILD SUCCESSFUL`. + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL in 10s`. +- 남은 항목: `P3-T6`, `P3-T7`, `P3-GATE`. + +### 4차 수정 검증 — 2026-07-27 + +- 무엇을: `P3-T6`에서 `REV-004`의 콘텐츠 수정 `seriesIds` 재연결 metadata 손실을 보완했다. +- 왜: 같은 `seriesIds`를 다시 PUT해도 기존 `SeriesContent` row ID, `orders`, `createdAt`은 보존되어야 하기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminAudioContentUpdateTest` 추가 후 focused 실행에서 정상 응답 기대가 500으로 실패했다. 원인은 production 로직이 아니라 test fixture의 `AudioContentCloudFront` private key 경로 문제였다. + - GREEN: focused test fixture에 `AudioContentCloudFront` mock을 추가하고, `replaceSeriesIds`를 차집합 기반으로 수정해 기존 교집합 row는 유지하고 제거·추가 대상만 변경했다. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentUpdateTest` — `BUILD SUCCESSFUL in 46s`. + - `./gradlew test --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.LegacyCreatorAdminAudioContentCharacterizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` — `BUILD SUCCESSFUL in 1m 21s`. +- 남은 항목: `P3-T7`, `P3-GATE`. + +### 5차 수정 검증 — 2026-07-27 + +- 무엇을: `P3-T7`에서 실제 Phase 3 endpoint의 ownership·ADMIN·오류/i18n·CORS와 legacy 회귀 증거를 완료했다. +- 왜: 공통 prefix contract만으로는 콘텐츠 resource path의 문자열 식별자가 404 fallback이 아닌 400/415로 처리되는 회귀를 검출할 수 없었기 때문이다. +- 어떻게: + - RED: `AiCharacterAdminAudioContentOwnershipTest`의 목록·상세·생성·수정 malformed `characterId`/`contentId` 6건이 404 EN envelope을 기대하도록 작성했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentOwnershipTest` 실행에서 18건 중 6건이 400 또는 415를 반환해 `BUILD FAILED in 51s`였다. + - GREEN: `AiCharacterAdminAudioContentController`의 모든 resource `characterId`/`contentId` mapping에 `[0-9]+`를 적용했다. 같은 focused command는 실제 endpoint의 non-ADMIN/stale claim 403 envelope, unknown target 생성 S3 0회, 테마 preflight 허용·거부 Origin도 포함해 `BUILD SUCCESSFUL in 1m 5s`였다. + - REFACTOR: 기존 `AiCharacterAdminAudioContentThemeControllerTest`, `QueryTest`, `CreateTest`, `UpdateTest`, `LegacyCreatorAdminAudioContentCharacterizationTest`, `AudioContentCloudFrontCharacterizationTest`와 공통 authorization/error contract를 재사용했다. `./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 2m 8s`, `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 29s`였다. +- 정정 기록: 계획의 `AiCharacterAdminAudioContentServiceTest`, `AiCharacterAdminAudioSignedUrlTest`는 현재 존재하지 않는 과거 참조다. 기존 기록을 삭제하지 않고 실제 focused file 목록과 위 regression 명령으로 대체 증거를 누적했다. +- 남은 항목: `P3-GATE`. + +## 10. 2차 리뷰 및 검증 — 2026-07-27 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: Phase 2·3 staged/unstaged 변경 전체 +- 리뷰 상태: 판정 완료, 후속 수정 goal 필요 +- 검토 범위: PRD Feature C, Endpoint Contract Summary, `P3-T3`~`P3-GATE`, content production/test와 공통 authorization/error test +- fresh 검증: + - `./gradlew test --rerun-tasks --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.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` — `BUILD SUCCESSFUL in 9m 23s`, 관련 XML 14개 합계 199건, failure/error/skipped 0. + - Phase 3 content package는 8개 XML class, 53건, failure/error/skipped 0. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 27s`, 7개 task 실행. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-010` | Medium | 처리 완료 | 생성 pipeline·multipart 오류·실패 순서의 완료 증거가 계획보다 좁음 | `Task 3.9` | `P3-R2` | +| `REV-011` | Medium | 처리 완료 | 수정 차집합·cover·ownership no-side-effect의 완료 증거가 계획보다 좁음 | `Task 3.10` | `P3-R3` | + +### REV-010 — 생성 pipeline과 실제 multipart 오류 증거 보강 필요 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C, Metrics 207~221 +- **관련 계약:** 생성 전체 field 변환, target/theme/series 선검증, S3/processing/event 실패 순서, 세 필수 part의 실제 endpoint KO/EN/JA envelope +- **소유 Task:** `Task 3.9`, `P3-R2` + +**관찰 내용** + +`P3-T5`는 생성 전체 field와 target/theme/series 오류, S3/processing/event 실패 순서, 세 필수 part의 exact KO/EN/JA를 완료 처리했다. 실제 `AiCharacterAdminAudioContentCreateTest`는 필수 part 3건과 최소 생성 1건이고, controller test는 전체 field 정상 생성·cross-owner series·`isActive=false`·가격 오류를 확인한다. invalid theme, cover/audio 업로드 중 실패 후 DB/S3/event 관찰 결과, 실제 생성 endpoint의 part별 KO/EN/JA는 직접 고정되지 않았다. + +**근거** + +- 테스트: `AiCharacterAdminAudioContentCreateTest.kt`는 4건이며 missing-part 응답에서 `success=false`만 확인한다. +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt:59`~`257`은 정상 전체 field, cross-owner series, inactive, 가격 오류를 확인하지만 invalid theme와 S3/event 실패를 포함하지 않는다. +- 코드: `AudioContentService.createAudioContent`는 DB 저장 뒤 cover와 audio를 순서대로 업로드하고 event를 발행하므로 각 실패 지점의 실제 잔존 결과를 별도 특성화해야 한다. +- 문서: `plan-task.md` `P3-T5`의 RED/REFACTOR와 `P3-GATE`가 해당 증거를 완료로 기록했다. + +**권장 조치** + +`P3-R2`에서 실제 생성 endpoint의 세 missing part KO/EN/JA, invalid theme/series/target 선검증, 전체 field adapter와 cover/audio/event 실패 순서를 test로 고정한다. legacy pipeline에서 관찰되는 S3 비트랜잭션 결과는 추정으로 보상하지 말고 characterization 결과와 PRD parity를 먼저 판정한다. + +**처리 결과** + +`AiCharacterAdminAudioContentCreateTest`에 `coverImage`, `audioFile`, `request` 누락 KO/EN/JA envelope, invalid theme 선검증, cover upload 실패 rollback/event 0회 단언을 추가했다. 생산 코드는 변경하지 않았다. + +### REV-011 — 수정 차집합과 ownership 실패 증거 보강 필요 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C Edge Cases, Metrics 218~221 +- **관련 계약:** `seriesIds` 교집합 metadata 보존과 추가·제거 차집합, cover 유지/교체, cross-owner/invalid series no-side-effect, 실제 endpoint 오류 matrix +- **소유 Task:** `Task 3.10`, `P3-R3` + +**관찰 내용** + +`P3-T6`는 동일·추가·제거 ID를 각각 검증했다고 완료 처리했지만 전용 update test는 동일 집합 1건이다. 기존 controller test의 old→new 교체는 제거와 추가 결과만 확인하고, 교집합을 유지하면서 동시에 추가·제거할 때 row ID·`orders`·`createdAt`이 보존되는지 확인하지 않는다. 성공적인 v2 cover 교체와 실패 시 DB/S3/event 결과도 legacy service characterization과 분리된 actual endpoint 증거가 없다. `P3-T7`의 실제 endpoint KO/EN/JA·ownership no-side-effect 기록도 ownership test에서는 EN envelope과 S3 0회 중심으로 확인된다. + +**근거** + +- 테스트: `AiCharacterAdminAudioContentUpdateTest.kt:50`은 동일 `seriesIds` 1건만 포함한다. +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt:112`는 old→new 결과 ID만 확인하고 교집합 metadata를 확인하지 않는다. +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt:559` 이후는 cross-owner DB title, invalid series S3 0회, invalid date S3 0회를 각각 확인하지만 성공/실패 cover와 DB/S3/event 전체 경계를 한 actual endpoint test로 고정하지 않는다. +- 테스트: `AiCharacterAdminAudioContentOwnershipTest.kt`는 actual endpoint stale claim은 확인하지만 domain 오류는 EN 중심이며 DB/event count를 직접 확인하지 않는다. + +**권장 조치** + +`P3-R3`에서 기존 교집합+신규 추가+기존 제거를 한 요청에 포함한 metadata test, cover 유지/성공 교체/실패 결과, cross-owner·invalid series/date·soft delete의 DB/S3/event 및 KO/EN/JA contract를 보강한다. 현재 production이 이미 충족하면 test-only로 종료한다. + +**처리 결과** + +`AiCharacterAdminAudioContentUpdateTest`와 `AiCharacterAdminAudioContentOwnershipTest`의 Phase 3 후속 보강분을 content/common 회귀로 재검증했다. production code 변경 없이 처리 완료로 판정했다. + +**판정 기록** + +- 2026-07-27 — fresh content 53건과 lint는 모두 통과했으나 `P3-T5`~`P3-T7` 체크리스트와 실제 test method를 대조해 `REV-010`, `REV-011`을 확정했다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.9` / `P3-R2`, `Task 3.10` / `P3-R3`와 별도 후속 Gate `P3-R2-GATE`를 추가한다. 기존 `P3-T3`~`P3-GATE` 완료 이력은 변경하지 않는다. + +### 2차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD·계획·production·test 재대조 | +| 후보 항목 판정 완료 | 충족 | `REV-010`, `REV-011` 확정 | +| 확정 항목 plan 반영 | 충족 | `Task 3.9`, `Task 3.10`, 후속 Gate | +| 검증 명령과 결과 기록 | 충족 | fresh content 53건 포함 targeted 199건, lint 성공 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R2-GATE` 후 `P3-R2` → `P3-R3` → `P3-R2-GATE` 순서로 실행한다. + +## 11. 3차 후속 검증 — 2026-07-27 + +- 무엇을: `P3-R2`, `P3-R3`, `P3-R2-GATE` 후속 보완을 완료했다. +- 왜: `REV-010`~`REV-011`의 직접 증거 누락을 실제 Phase 3 endpoint와 content/common 회귀로 종결하기 위해서다. +- 어떻게: + - `AiCharacterAdminAudioContentCreateTest`의 실패 8건을 분석해, production 계약 위반이 아니라 테스트 기대 message와 `NOT_SUPPORTED` fixture transaction 누락임을 확인했다. + - 테스트 기대값을 실제 i18n 계약에 맞추고 `TransactionTemplate`으로 fixture만 보정했다. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentCreateTest` — `BUILD SUCCESSFUL in 1m 4s`. + - `./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 2m 41s`. + - `./gradlew ktlintCheck` — import ordering 1건 실패 후 정렬만 수정해 재실행, `BUILD SUCCESSFUL in 16s`. + - `git diff --check` — 출력 없음. +- 최종 결론: `REV-010`, `REV-011` 처리 완료. Phase 3 후속 리뷰 보완 종료. +- 남은 항목: 없음. 사용자 지시에 따라 Phase 4는 진행하지 않는다. + +## 12. 4차 재리뷰 및 검증 — 2026-07-27 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: `P3-R2`~`P3-R3` 반영 후 staged/unstaged Phase 3 변경 +- 리뷰 상태: 판정 완료, 추가 수정 goal 필요 +- fresh 검증: + - `./gradlew test --rerun-tasks --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.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` — `BUILD SUCCESSFUL in 9m 44s`, 관련 XML 14개 합계 228건, failure/error/skipped 0. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 41s`, 7개 task 실행. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-013` | Medium | 처리 완료 | 생성의 audio upload·event 실패 순서 직접 증거가 없음 | `Task 3.11` | `P3-R4` | +| `REV-014` | Medium | 처리 완료 | ownership/domain 오류의 KO/EN/JA·no-side-effect matrix가 미완성 | `Task 3.11` | `P3-R4` | + +### REV-013 — 생성 후반 S3·event 실패 순서 증거 보강 필요 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Feature C, Metrics 207~221 +- **관련 계약:** cover upload 뒤 audio upload와 event publish 실패의 DB/S3/event 결과 및 legacy parity +- **소유 Task:** `Task 3.11`, `P3-R4` + +**관찰 내용** + +`P3-R2` 반영으로 세 필수 part의 KO/EN/JA, invalid theme 선검증과 첫 cover upload 실패 rollback은 직접 고정됐다. 그러나 완료 처리한 `Task 3.9`는 cover upload, audio upload, event 실패 지점별 결과를 요구한다. 새 test는 첫 `putObject`가 실패하는 경우만 만들며, 두 번째 audio upload 또는 두 업로드 뒤 event publish 실패를 주입하지 않는다. + +**근거** + +- 코드: `AudioContentService.kt:333`, `362`, `381`은 cover upload → audio upload → event 순서다. +- 테스트: `AiCharacterAdminAudioContentCreateTest.kt:179`~`208`은 첫 `putObject`에 예외를 주입하는 cover 실패 1건만 포함한다. +- 테스트: `LegacyCreatorAdminAudioContentCharacterizationTest.kt:108`은 정상 생성의 S3 두 번 호출만 확인하고 각 실패 결과는 특성화하지 않는다. +- 문서: `plan-task.md` `Task 3.9`의 `FAILURE CHARACTERIZATION`은 세 실패 지점과 비트랜잭션 S3 결과를 모두 완료 처리했다. + +**영향** + +audio upload 또는 event 실패에서 DB는 롤백되지만 이미 업로드된 S3 객체가 남는 legacy 경계가 변경되어도 현재 회귀가 감지하지 못한다. + +**권장 조치** + +두 번째 S3 호출 실패와 event publish 실패를 실제 endpoint에서 재현하고 DB/SeriesContent rollback, S3 호출·잔존 결과와 event 결과를 각각 고정한다. 추정으로 보상 로직을 추가하지 않고 legacy parity를 먼저 판정한다. + +**처리 결과** + +`AiCharacterAdminAudioContentCreateTest`에 audio upload 실패와 event publish 실패를 추가했다. 실제 endpoint에서 S3 두 번 호출, DB rollback, event 미발행/발행 실패를 고정했고 production code는 변경하지 않았다. + +### REV-014 — ownership/domain no-side-effect matrix 보강 필요 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Metrics 218~221, Acceptance Criteria 225~254 +- **관련 계약:** 목록·상세·생성·수정의 target/ownership/domain KO/EN/JA envelope과 DB/S3/event 무변경 +- **소유 Task:** `Task 3.11`, `P3-R4` + +**관찰 내용** + +`P3-R3` 반영으로 series 교집합 metadata와 차집합, cover 유지·교체·첫 upload 실패가 고정됐고 존재하지 않는 character 생성은 KO/EN/JA로 확장됐다. 그러나 `Task 3.10`이 완료 조건으로 둔 목록·상세·생성·수정 전체의 ownership/domain KO/EN/JA와 DB insert/update/delete·S3·event count는 아직 직접 확인되지 않는다. cross-owner 상세·수정과 invalid series/date는 기존 EN 또는 `success=false`, 일부 DB/S3 단언에 머물고 event를 관찰하지 않는다. + +**근거** + +- 테스트: `AiCharacterAdminAudioContentOwnershipTest.kt:89`의 KO/EN/JA domain matrix는 존재하지 않는 character 생성 한 경로뿐이다. +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt:530` 이후 cross-owner 상세·수정, invalid series/date는 언어 matrix와 DB/S3/event 전체 count를 함께 단언하지 않는다. +- 테스트: `AiCharacterAdminAudioContentUpdateTest.kt:184`의 cover 실패는 DB title/cover만 확인하고 event 결과를 관찰하지 않는다. +- 문서: `plan-task.md` `Task 3.10`은 네 실제 endpoint의 ownership/domain 오류와 모든 부작용 count를 완료 처리했다. + +**영향** + +target/ownership 검증 순서가 늦어져 DB/S3/event 부작용이 먼저 발생하거나 특정 locale의 오류 계약이 달라져도 현재 부분 회귀만으로는 Gate가 놓칠 수 있다. + +**권장 조치** + +`P3-R4`에서 목록·상세·생성·수정의 대표 target/ownership/domain 거부를 KO/EN/JA actual endpoint test로 만들고, 각 요청 전후 AudioContent·SeriesContent와 S3/event count가 변하지 않음을 직접 단언한다. + +**처리 결과** + +`AiCharacterAdminAudioContentOwnershipTest`에 목록·상세·생성·수정 unknown target KO/EN/JA matrix와 AudioContent·SeriesContent·S3·event no-side-effect 단언을 추가했다. create+ownership focused 명령은 `BUILD SUCCESSFUL in 1m 8s`, content/common 회귀는 `BUILD SUCCESSFUL in 2m 20s`, 최종 `ktlintCheck`는 `BUILD SUCCESSFUL in 17s`였다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.11` / `P3-R4`와 `P3-R3-GATE`를 추가한다. 기존 `Task 3.9`~`Task 3.10`과 `P3-R2-GATE` 완료 이력은 변경하지 않는다. + +### 4차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 적용된 multipart/theme/series/cover 보강 | 충족 | 새 actual endpoint regression 추가 | +| 생성 후반 실패 순서 직접 증거 | 충족 | audio upload·event failure injection 추가 | +| ownership/domain 전체 matrix | 충족 | 실제 네 경로 KO/EN/JA 및 부작용 count 추가 | +| 확정 항목 plan 반영 | 충족 | `Task 3.11`, `P3-R4`, `P3-R3-GATE` | +| fresh 검증 | 충족 | targeted 228건, lint 성공 | + +**최종 결론:** Phase 3 4차 리뷰 종결 + +**남은 항목:** 없음. 사용자 지시에 따라 Phase 4는 진행하지 않는다. + +## 13. 5차 재리뷰 및 판정 — 2026-07-27 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: `P2-R3`와 `P3-R4` 반영 후 staged/unstaged Phase 2·3 변경 +- 리뷰 상태: 판정 완료, 추가 수정 goal 필요 +- 검토 범위: 생성 multipart 필수 part 계약, `REV-014` ownership/domain 처리 결과 +- fresh 검증: + - `./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.AiCharacterAdminAudioContentOwnershipTest` — `BUILD SUCCESSFUL in 7m 50s`, 세 XML 합계 79건, failure/error/skipped 0. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 28s`, 7개 task 실행. + - `git diff --check` — 출력 없음. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-016` | High | 처리 완료 | 생성 필수 파일 part가 nullable이라 exact missing-part 계약을 우회했음 | `Task 3.12` | `P3-R5` | +| `REV-017` | Medium | 처리 완료 | ownership/domain 전체 matrix 완료 기록보다 실제 보강 범위가 좁음 | `Task 3.13` | `P3-R6` | + +### REV-016 — 생성 필수 multipart part의 exact binding 계약 미충족 + +- **심각도:** High +- **상태:** 처리 완료 +- **관련 요구사항:** PRD API Expectations 179~180, Metrics 216~218, Acceptance Criteria 243~248 +- **관련 계약:** `coverImage`, `audioFile`, `request` 누락의 `MissingServletRequestPartException`, 400 `common.error.invalid_request`, KO/EN/JA `ApiResponse.error` +- **소유 Task:** `Task 3.12`, `P3-R5` + +**관찰 내용** + +`P3-T5`는 필수 파일 part를 non-null binding으로 변경한다고 완료 처리했지만 controller의 생성 `coverImage`와 `audioFile`은 여전히 `MultipartFile?`이다. 현재 보강 테스트는 누락 요청의 status와 legacy content 전용 message만 확인하고 resolved exception type을 확인하지 않아, 필수 part가 MVC binding에서 거부되지 않고 facade/legacy service까지 전달되는 현재 동작을 허용한다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentController.kt:47`~`48`은 생성 `coverImage`, `audioFile`을 nullable로 선언한다. +- 코드: `AudioContentService.kt:217`~`237`은 null 파일을 `SodaException`의 `content.error.cover_image_required` 또는 `content.error.content_required`로 변환한다. +- 테스트: `AiCharacterAdminAudioContentCreateTest.kt:62`~`154`는 세 누락 part의 status/message와 부작용만 확인하고 `resolvedException`을 단언하지 않는다. +- 문서: `prd.md:179`~`180`과 `plan-task.md:1496`~`1500`은 실제 필수 part 누락의 exact `MissingServletRequestPartException`과 non-null binding을 요구한다. + +**재현 또는 검증 절차** + +1. actual 생성 endpoint에 `coverImage` 또는 `audioFile`을 제외한 multipart 요청을 보낸다. +2. `MvcResult.resolvedException`과 응답 message를 확인한다. +3. 현재 테스트 기대는 content 전용 message이며 exact `MissingServletRequestPartException`을 고정하지 않는다. +4. 요구 결과는 세 필수 part 모두 MVC binding에서 `MissingServletRequestPartException`으로 종료되고 400 `common.error.invalid_request` KO/EN/JA envelope을 반환하는 것이다. + +**영향** + +확정 API 오류 계약과 실제 생성 endpoint의 binding/error key가 다르며, facade 또는 legacy service가 호출되기 전 종료돼야 한다는 trust-boundary 보장이 약해진다. + +**권장 조치** + +`P3-R5`에서 생성 controller와 facade의 두 파일 인자를 non-null로 고정하고, `coverImage`·`audioFile`·`request` 각각의 KO/EN/JA actual endpoint test에서 exact exception type, generic invalid-request envelope과 facade/DB/S3/event 0회를 확인한다. + +**판정 기록** + +- 2026-07-27 — PRD·기존 Task 완료 조건과 현재 nullable signature/test assertion을 대조해 contract 위반을 확정했다. +- 2026-07-27 — `P3-R5`에서 controller/facade 생성 파일 인자를 non-null로 고정했다. actual endpoint KO/EN/JA 9건은 exact `MissingServletRequestPartException`, 400 `common.error.invalid_request` envelope, DB count 0, S3 `putObject` 0회, event no-interaction을 확인했다. create/error focused와 content/common 회귀, `ktlintCheck`가 성공했다. + +### REV-017 — ownership/domain no-side-effect matrix 보강 범위 부족 + +- **심각도:** Medium +- **상태:** 처리 완료 +- **관련 요구사항:** PRD Edge Cases 78~81, Metrics 218~221, Acceptance Criteria 245~248 +- **관련 계약:** target 미존재와 cross-owner/domain 오류의 KO/EN/JA envelope 및 DB/S3/event 무변경 +- **소유 Task:** `Task 3.13`, `P3-R6` + +**관찰 내용** + +`P3-R4`에서 새로 추가한 matrix는 목록·상세·생성·수정의 unknown `characterId`만 KO/EN/JA와 count 기반 no-side-effect로 확장했다. 4차 리뷰가 미완료로 지적한 cross-owner 콘텐츠 상세/수정, 다른 캐릭터 소유 `seriesIds`, invalid `releaseDateUtc`는 기존 단일 locale 또는 `success=false`와 일부 DB/S3 단언 상태이며 event와 전체 DB 상태를 함께 확인하지 않는다. + +**근거** + +- 테스트: `AiCharacterAdminAudioContentOwnershipTest.kt:95`~`188`의 신규 네 matrix는 모두 unknown `characterId=999999`만 사용한다. +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt:529`~`708`의 cross-owner 콘텐츠, 다른 owner series, invalid date는 KO/EN/JA matrix가 아니고 event no-interaction을 단언하지 않는다. +- 리뷰: 이 문서 4차 권장 조치는 대표 target/ownership/domain 거부를 모두 KO/EN/JA와 부작용 count로 고정하도록 요구했지만 처리 결과는 unknown target만 기록한다. +- 문서: `plan-task.md:1681`~`1682`와 4차 종료 판정은 ownership/domain 전체 matrix와 insert/update/delete·S3·event count를 완료 처리한다. + +**재현 또는 검증 절차** + +1. 기존 cross-owner detail/update, 다른 owner `seriesIds`, invalid `releaseDateUtc` 테스트를 확인한다. +2. KO/EN/JA exact message와 요청 전후 AudioContent·SeriesContent 상태, S3/event 호출을 함께 단언하는지 대조한다. +3. 현재는 각 경로가 단일 locale·부분 assertion에 머물고, 신규 matrix는 unknown target만 다룬다. +4. 대표 ownership/domain 거부마다 동일한 actual endpoint envelope과 no-side-effect evidence가 필요하다. + +**영향** + +ownership 또는 domain validation 순서가 뒤로 이동해 DB update/delete나 S3/event가 먼저 발생하거나 특정 locale의 오류 key가 바뀌어도 현재 Gate가 놓칠 수 있다. + +**권장 조치** + +`P3-R6`에서 cross-owner detail/update, create/update의 다른 owner `seriesIds`, invalid `releaseDateUtc`를 KO/EN/JA actual endpoint matrix로 보강한다. 각 요청 전후 entity field/연결 상태와 S3/event 호출 0회를 확인하고, 현재 production이 충족하면 test-only로 종료한다. + +**판정 기록** + +- 2026-07-27 — fresh 테스트 성공과 별개로 4차 완료 조건과 실제 test method 범위를 대조해 증거 누락을 확정했다. +- 2026-07-27 — `P3-R6`에서 cross-owner detail/update, create/update 다른 owner `seriesIds`, invalid `releaseDateUtc`를 KO/EN/JA actual endpoint 15건으로 보강했다. exact 400 `ApiResponse.error`, 전후 `AudioContent`·`SeriesContent` count와 field/연결 row, S3 `putObject` 0회와 event no-interaction을 확인했다. focused characterization, content/common 회귀, `ktlintCheck`가 모두 성공해 production 변경 없이 처리 완료로 판정했다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.12` / `P3-R5`, `Task 3.13` / `P3-R6`와 `P3-R4-GATE`를 추가한다. 기존 `Task 3.11`과 `P3-R3-GATE` 완료 이력은 변경하지 않는다. + +### 5차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | PRD multipart·ownership 계약과 production/test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-016`, `REV-017` 처리 완료 | +| 확정 항목 plan 반영 | 충족 | `Task 3.12`, `Task 3.13`, `P3-R4-GATE` | +| fresh 검증 | 충족 | 관련 79건과 lint 성공, diff check 이상 없음 | + +**최종 결론:** `REV-016`, `REV-017` 처리 완료, Phase 3 Gate 완료 + +**남은 항목:** 없음. Phase 4는 사용자 진행 지시 전까지 시작하지 않는다. + +### P3-R4-GATE 종료 판정 + +- 무엇을: `REV-016` exact multipart와 `REV-017` ownership/domain matrix 보강을 최종 판정했다. +- 왜: Phase 3 5차 리뷰를 종결하고 Phase 4를 대기 상태로 넘기기 위해서다. +- 어떻게: character mutation/error와 content create/controller/ownership focused 명령 및 `git diff --check`를 fresh 실행했다. +- 결과: focused 명령은 `BUILD SUCCESSFUL in 52s`, `git diff --check`는 출력이 없었다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-016` 처리 | 충족 | non-null multipart binding과 exact `MissingServletRequestPartException` KO/EN/JA test 추가 | +| `REV-017` 처리 | 충족 | cross-owner/domain actual endpoint KO/EN/JA no-side-effect matrix 추가 | +| focused 회귀 | 충족 | 5차 Gate focused 명령 성공 | +| 범위 준수 | 충족 | Phase 4 기능 구현 없음 | + +**최종 결론:** Phase 3 5차 리뷰 종결 + +**남은 항목:** 없음. 다음은 사용자 지시 후 `P4-T1`이다. + +## 14. 6차 재리뷰 및 판정 — 2026-07-28 + +### 리뷰 정보와 실행 결과 + +- 기준 working tree: `P2-R4-GATE`, `P3-R4-GATE` 반영 후 staged/unstaged Phase 2·3 변경 +- 리뷰 상태: 판정 완료, 추가 수정 goal 필요 +- 검토 범위: 생성·수정 multipart empty-file 경계와 `REV-017`의 event no-interaction 실제 관찰 대상 +- fresh 검증: + - Phase 2·3 5차 Gate focused 5개 test class를 `--rerun-tasks`로 실행해 XML 합계 216건, failure/error/skipped 0과 `BUILD SUCCESSFUL in 4m 16s`를 확인했다. + - `./gradlew ktlintCheck --rerun-tasks` — `BUILD SUCCESSFUL in 17s`, 7개 task 실행. + - staged/unstaged `git diff --check` — 출력 없음. + +### 추가 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-019` | Medium | 확정 | 빈 multipart 파일이 생성·수정의 파일 계약을 우회함 | `Task 3.14` | `P3-R7` | +| `REV-020` | Low | 확정 | ownership/domain event 0회 assertion이 실제 service publisher를 관찰하지 않음 | `Task 3.15` | `P3-R8` | + +### REV-019 — 빈 multipart 파일의 생성·수정 계약 우회 + +- **심각도:** Medium +- **상태:** 확정 +- **관련 요구사항:** PRD Feature C, Endpoint Contract Summary의 생성 필수 파일·수정 `audioFile` 미지원 계약 +- **관련 계약:** 생성의 `coverImage`·`audioFile`은 비어 있지 않은 필수 파일이며, 수정의 빈 `coverImage`는 생략으로 + 처리하고 `audioFile` part는 크기와 관계없이 거부한다. +- **소유 Task:** `Task 3.14`, `P3-R7` + +**관찰 내용** + +`REV-016`은 part 누락을 non-null MVC binding으로 막았지만 `MultipartFile.isEmpty`는 검증하지 않는다. 따라서 생성의 빈 +cover/audio part는 legacy null 검사 뒤 0-byte S3 업로드를 시도할 수 있다. 수정은 빈 `coverImage`도 non-null인 경우 legacy +service가 업로드하고, 빈 `audioFile`은 `audioFile?.isEmpty == false` 조건을 우회해 미지원 요청이 성공할 수 있다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentController.kt:47`~`48`은 생성 파일의 존재만 non-null type으로 강제한다. +- 코드: `AudioContentService.kt:218`~`237`은 두 생성 파일을 null로만 검사하고 `:333`, `:362`에서 S3 upload에 전달한다. +- 코드: `AiCharacterAdminAudioContentFacade.kt:140`은 non-empty `audioFile`만 거부하고 `:148`에서 `coverImage`를 그대로 + legacy update service에 전달한다. +- 코드: `CreatorAdminContentService.kt:118`은 `coverImage != null`이면 크기와 관계없이 upload와 cover path 교체를 수행한다. +- 테스트: `AiCharacterAdminAudioContentUpdateTest.kt:145` 이후는 cover 생략과 non-empty 교체만, + `AiCharacterAdminAudioContentControllerTest.kt:691` 이후는 non-empty `audioFile` 거부만 확인한다. + +**영향** + +빈 파일 part를 생성하는 multipart client에서 생성 콘텐츠가 0-byte S3 객체를 참조하거나 수정 요청이 기존 cover를 빈 객체로 +교체할 수 있다. 수정 `audioFile` 미지원 계약도 part 존재 여부가 아니라 파일 크기에 따라 달라진다. + +**권장 조치** + +`P3-R7`에서 v2 facade 경계만 수정한다. 생성의 빈 cover/audio는 400 `common.error.invalid_request`로 거부하고, 수정의 빈 +cover는 `null`로 정규화해 기존 경로와 S3 상태를 유지한다. 수정 `audioFile`은 part가 존재하면 빈 파일도 400으로 거부한다. +legacy service의 공용 동작은 변경하지 않는다. + +### REV-020 — event no-interaction의 실제 publisher 미관찰 + +- **심각도:** Low +- **상태:** 확정 +- **관련 요구사항:** PRD Metrics 218~221, `P3-R6` NON-VACUOUS 완료 조건 +- **관련 계약:** ownership/domain validation 실패는 실제 생성·수정 service의 event publisher를 호출하지 않는다. +- **소유 Task:** `Task 3.15`, `P3-R8` + +**관찰 내용** + +ownership/domain 테스트는 `@MockBean ApplicationEventPublisher`에 `verifyNoInteractions`를 수행하지만, Phase 2에서 같은 +`@MockBean`이 이미 생성된 service/facade field를 대체하지 못한다는 사실을 확인했다. Phase 3 event 실패 테스트는 +`AudioContentService` proxy target의 publisher를 명시적으로 교체하지만 `P3-R6` 테스트에는 같은 연결이 없다. + +**근거** + +- 문서: `plan-task.md:1319`는 `@MockBean`이 이미 생성된 facade field를 대체하지 못해 실제 listener가 실행됐음을 기록한다. +- 테스트: `AiCharacterAdminAudioContentControllerTest.kt:66`, `AiCharacterAdminAudioContentOwnershipTest.kt:53`은 + `ApplicationEventPublisher`를 `@MockBean`으로 선언한다. +- 테스트: 두 파일의 `verifyNoInteractions`는 각각 `AiCharacterAdminAudioContentControllerTest.kt:944`, + `AiCharacterAdminAudioContentOwnershipTest.kt:194`에서 위 mock만 관찰한다. +- 대조: `AiCharacterAdminAudioContentCreateTest.kt:247`, `:355` 이후 event 실패 테스트는 실제 + `AudioContentService` proxy target의 publisher를 교체한다. + +**영향** + +현재 production이 validation 실패에서 event를 발행한다는 증거는 없다. 다만 `REV-017` 처리 결과의 event 0회 assertion은 실제 +생성·수정 service publisher를 측정하지 않아 `P3-R6`의 NON-VACUOUS 완료 증거로 사용할 수 없다. + +**권장 조치** + +`P3-R8`에서 Phase 2와 기존 create failure test의 proxy-target 교체 방식을 재사용해 +`AudioContentService`와 `CreatorAdminContentService`의 실제 publisher field를 같은 mock으로 교체하고 테스트 뒤 복원한다. +production code는 변경하지 않는다. + +### plan·goal 전환 + +`plan-task.md` Phase 3에 `Task 3.14` / `P3-R7`, `Task 3.15` / `P3-R8`과 `P3-R5-GATE`를 추가한다. 기존 +`Task 3.12`~`Task 3.13`과 `P3-R4-GATE` 완료 이력은 변경하지 않는다. + +### 6차 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | multipart boundary·production service·actual endpoint test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-019`, `REV-020` 확정 | +| 확정 항목 plan 반영 | 충족 | `Task 3.14`, `Task 3.15`, `P3-R5-GATE` | +| fresh 검증 | 충족 | 관련 216건과 lint 성공, staged/unstaged diff check 이상 없음 | + +**최종 결론:** 수정 goal 필요 + +**남은 항목:** `P2-R5-GATE` 후 `P3-R7` → `P3-R8` → `P3-R5-GATE` 순서로 실행한다. Phase 4는 진행하지 않는다. + +### P3-R5-GATE 종료 판정 — 2026-07-28 + +- 무엇을: `REV-019` empty-file 계약과 `REV-020` actual publisher 증거를 최종 판정했다. +- 왜: Phase 3 6차 리뷰를 종결하고 Phase 4를 대기 상태로 유지하기 위해서다. +- 어떻게: empty-file RED/GREEN focused, publisher focused, content/common 회귀, `ktlintCheck`, `git diff --check`를 실행했다. +- 결과: empty-file RED는 신규 8건 실패로 재현됐고, reviewer gate 보완 뒤 empty create/update focused는 `BUILD SUCCESSFUL in 43s`, non-empty audio update 보완 focused는 `BUILD SUCCESSFUL in 44s`였다. publisher focused는 `BUILD SUCCESSFUL in 39s`, 최종 content/common 회귀는 `BUILD SUCCESSFUL in 2m 21s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 44s`, `git diff --check`는 출력이 없었다. + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| `REV-019` 처리 | 충족 | 생성 empty cover/audio 400, 수정 empty cover 유지, audio part 존재 거부 actual endpoint test 추가 | +| `REV-020` 처리 | 충족 | `AudioContentService`, `CreatorAdminContentService` proxy target publisher identity/no-interaction 단언 | +| focused/영향 회귀 | 충족 | content/common 회귀와 lint 성공 | +| 범위 준수 | 충족 | legacy service 공용 계약과 Phase 4 기능 변경 없음 | + +**최종 결론:** 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:** 없음. diff --git a/docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md b/docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md new file mode 100644 index 00000000..ba47daad --- /dev/null +++ b/docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md @@ -0,0 +1,410 @@ +# 무료 콘텐츠 포인트 결제 불가 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 리뷰 후속 작업 완료 | +| 작성일 | 2026-07-31 | +| 요구사항 기준 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` | +| API 기준 | PRD `8. API 계약` | +| 현재 Phase | Phase 1 회귀 수정 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +무료 오디오 콘텐츠가 소비자 조회에서 포인트 결제 가능으로 노출되거나 POINT 전용 목록에 포함되지 않게 한다. + +## 구현 방식 + +- 응답 DTO의 공개 필드와 내부 조회 record는 변경하지 않는다. +- 각 소비자 응답 조립 지점에서 `isPointAvailable && price > 0`을 적용한다. +- 추천과 전체 탭 POINT repository 조건에 `price > 0`을 추가한다. +- 관리자 mapper와 저장 로직은 수정하지 않는다. +- 단일 식 적용을 위한 새 공통 abstraction이나 dependency는 만들지 않는다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1. 소비자 응답 정규화 | 완료 | `3/3` | 없음 | 없음 | +| 2. POINT 조회 조건 보정 | 완료 | `1/1` | 없음 | 없음 | + +- 동시에 하나의 미완료 goal만 운용한다. +- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다. +- goal에는 token budget을 설정하지 않는다. + +## 범위 + +### 포함 + +- legacy 콘텐츠 상세 `isAvailableUsePoint` 보정 +- v2 콘텐츠 overview·전체 탭·추천 탭 응답 보정 +- v2 홈 추천 첫 오디오 응답 보정 +- v2 크리에이터 채널 홈·오디오·라이브의 공통 오디오 응답 보정 +- 추천 `pointAudios`와 전체 탭 `type=POINT`의 유료 조건 보강 +- 전체 탭 POINT count·pagination 회귀 검증 +- AI 캐릭터 관리자 조회 원본값 유지 회귀 검증 + +### 제외 + +- DB 저장값과 기존 데이터 변경 +- 콘텐츠 생성·수정 validation 변경 +- 관리자 콘텐츠 mapper·service 변경 +- legacy 콘텐츠 상세 이외의 legacy 조회 API 변경 +- 결제·주문·포인트 차감 로직 변경 +- API schema와 dependency 변경 + +## 기술적 제약 + +- 기술 스택: Kotlin, Java 17, Spring Boot 2.7.14, JUnit 5, QueryDSL/JPA. +- 기존 package와 mapper/repository 책임을 유지한다. +- 공개 API 필드명 `isAvailableUsePoint`, `isPointAvailable`을 유지한다. +- `price == 0`은 무료, 실질 포인트 가능 여부는 `configured && price > 0`으로 고정한다. +- POINT 조회의 목록과 count는 같은 repository 조건을 사용한다. +- 모든 구현 Task는 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` 순서로 실행한다. +- focused test부터 실행하고 최종 Gate에서 영향 범위 회귀와 전체 `test`를 실행한다. + +## Phase 1: 소비자 응답 정규화 + +**Phase 결과:** 무료·저장값 true인 콘텐츠가 대상 소비자 응답에서는 false로 보이지만 관리자 조회에서는 true를 유지한다. + +**선행조건:** PRD `POINT-001~004`, `POINT-007~008` 확정. + +**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 검증 기록 누적. + +**후속 리뷰 완료 조건:** `P1-R1` 완료, `P1-GATE` 재검증과 수정 후 검증 기록 누적. + +### Task 1.1 소비자 응답의 실질 포인트 가능 여부 적용 + +**Goal 실행 `P1-T1`:** legacy 상세와 모든 대상 v2 응답 조립 경계에서 가격을 반영한 포인트 가능 여부를 반환한다. + +- **시작 조건:** PRD `DEC-001`, `DEC-003` 확인. +- **완료 증거:** RED/GREEN 체크박스, 대상 mapper 테스트, 관리자 원본값 회귀 테스트와 Progress 기록. +- **범위 밖:** POINT 전용 repository 필터와 콘텐츠 저장값 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` + +**Interfaces:** + +- Consumes: 각 mapper가 이미 받는 `price: Int`와 저장된 Boolean 필드. +- Produces: schema를 바꾸지 않고 `configured && price > 0`으로 보정된 소비자 응답 Boolean. +- Preserves: AI 캐릭터 관리자 목록·상세의 원본 Boolean. + +- [x] **RED:** 각 응답 경계에 무료·저장값 true fixture를 추가하고 소비자 응답은 false, 유료·저장값 true 응답은 true로 기대한다. 관리자 상세에는 무료·저장값 true가 true로 유지되는 회귀 assertion을 추가한다. +- [x] **RED 확인:** 아래 focused test를 실행해 소비자 응답이 현재 true를 전달하여 발생하는 assertion 실패를 확인하고, 관리자 회귀 assertion은 기존 동작으로 통과하는지 구분해 기록한다. + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest' +``` + +- [x] **GREEN:** 소비자 응답 조립 지점의 Boolean 대입을 `storedValue && price > 0`으로 바꾼다. `AiCharacterAdminAudioContentMapper`와 관리자 service는 수정하지 않는다. +- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 무료 true → false, 유료 true → true, 관리자 무료 true → true가 모두 통과하는지 확인한다. +- [x] **REFACTOR:** 동일한 한 줄 식을 유지하고 새 helper나 구조 변경을 추가하지 않는다. 변경 파일에 `./gradlew ktlintCheck`를 실행하고 결과를 Progress에 기록한다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 대상 소비자 응답 계약과 관리자 제외 계약을 최종 판정한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 아래 명령 성공, 공개 필드명 유지 확인과 Progress 기록. +- **범위 밖:** POINT repository 필터 구현과 관련 없는 응답 리팩터링. + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.home.*' +./gradlew ktlintCheck +``` + +**Expected:** 대상 소비자 응답에서 무료 콘텐츠가 포인트 가능으로 노출되지 않고 기존 JSON 필드 집합이 유지된다. + +### Task 1.2 소비자·관리자 포인트 가능 계약의 회귀 증거 보강 + +**Goal 실행 `P1-R1`:** 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다. + +- **시작 조건:** `REV-P1-001`, `REV-P1-002` 확정과 기존 `P1-GATE` 완료. +- **완료 증거:** 대상 소비자 경계의 `price > 0, storedIsPointAvailable == false` assertion, 관리자 목록·상세의 + `price == 0, storedIsPointAvailable == true` assertion, focused test와 `P1-GATE` 재검증 기록. +- **범위 밖:** production 코드 변경, 관리자 응답 보정, 공개 API schema 변경. +- **TDD 예외 사유:** production 구현은 정적 검토상 계약을 이미 충족하며, 누락된 것은 완료 근거인 회귀 assertion이다. + 의도적인 production 결함을 만들어 RED를 재현하지 않는다. +- **대체 검증 방법:** 기존 fixture가 누락한 진리표 조건을 추가하고 focused test와 `P1-GATE`를 통과시킨 뒤 mapper 식과 + 관리자 원본 전달 식을 다시 대조한다. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt` +- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` + +- [x] 대상 소비자 응답 경계마다 `price > 0, storedIsPointAvailable == false`가 false로 유지되는 assertion을 추가한다. +- [x] AI 캐릭터 관리자 목록·상세 fixture를 `price == 0, storedIsPointAvailable == true`로 구성하고 저장값 true가 그대로 + 반환되는지 확인한다. +- [x] Task 1.1 focused test와 `P1-GATE`를 재실행하고 결과를 Progress에 누적한다. +- [x] `REV-P1-001`, `REV-P1-002`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다. + +### Task 1.3 legacy 상세의 유료 포인트 가능 positive 회귀 증거 보강 + +**Goal 실행 `P1-R2`:** legacy 상세에서 `price > 0, storedIsPointAvailable == true`가 true를 반환하는 계약을 자동 회귀 테스트로 증명한다. + +- **시작 조건:** `REV-P1-003` 확정과 `P1-R1` 완료. +- **완료 증거:** legacy 상세의 무료·저장값 true → false, 유료·저장값 false → false, + 유료·저장값 true → true assertion, focused test와 `P1-GATE` 재검증 기록. +- **범위 밖:** production 코드, v2 응답 test, 관리자 mapper/service 변경. +- **TDD 예외 사유:** production 구현은 정적 검토상 positive 계약을 이미 충족하며, 누락된 것은 회귀 assertion이다. + 의도적인 production 결함을 만들어 RED를 재현하지 않는다. +- **대체 검증 방법:** 기존 legacy 상세 test의 진리표를 완성하고 focused test와 `P1-GATE`를 통과시킨다. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` +- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` + +- [x] legacy 상세 test에 `price > 0, storedIsPointAvailable == true` 응답이 true인 assertion을 추가한다. +- [x] 하나의 test가 세 계약 조건을 드러내도록 `DisplayName`과 test 함수명을 맞춘다. +- [x] `AudioContentServiceTest`, `P1-GATE`, `ktlintCheck`를 실행하고 결과를 Progress에 누적한다. +- [x] `REV-P1-003`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다. + +## Phase 2: POINT 조회 조건 보정 + +**Phase 결과:** 추천과 전체 탭 POINT 목록, count와 pagination 후보에서 무료 콘텐츠가 제외된다. + +**선행조건:** `P1-GATE` 완료와 PRD `POINT-005~006` 확정. + +**Phase 완료 조건:** `P2-T1`과 `P2-GATE` 완료, 검증 기록 누적. + +### Task 2.1 추천·전체 탭 POINT 조회에 유료 조건 적용 + +**Goal 실행 `P2-T1`:** 두 POINT 조회가 `isPointAvailable == true && price > 0` 조건을 공통으로 사용한다. + +- **시작 조건:** `P1-GATE` 완료, PRD `DEC-002` 확인. +- **완료 증거:** repository RED/GREEN, endpoint E2E와 Progress 기록. +- **범위 밖:** FREE/AUDIO/추천 점수·랜덤 정렬·공개/성인/차단 조건 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` + +**Interfaces:** + +- Consumes: 기존 `audioContent.isPointAvailable`과 `audioContent.price` QueryDSL 필드. +- Produces: 추천 `pointAudios`, 전체 탭 POINT 목록과 count에 공통 적용되는 `isPointAvailable.isTrue.and(price.gt(0))` 조건. +- Preserves: FREE 목록은 `price.eq(0)`, AUDIO 목록은 `price.gt(0)`인 기존 조건. + +- [x] **RED:** 두 repository fixture에 `price = 0, isPointAvailable = true`와 `price > 0, isPointAvailable = true`를 함께 두고 POINT 결과에는 유료 항목만 포함되도록 기대한다. 전체 탭 E2E는 `totalCount`, `audios`, `hasNext`가 같은 후보 집합을 반영하도록 기대한다. +- [x] **RED 확인:** 아래 focused test를 실행해 무료 true 콘텐츠가 POINT 결과에 포함되어 발생하는 목록 또는 count assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' \ + --tests 'kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest' +``` + +- [x] **GREEN:** `findPointAudios` 조건과 `optionalAudioPointCondition`에 `price.gt(0)`을 결합한다. 전체 탭 count와 목록은 기존 `audioCondition`을 계속 공유한다. +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 POINT 결과에서 무료가 제외되고 유료 true는 유지되며 `totalCount`와 `hasNext`가 일치하는지 확인한다. +- [x] **REFACTOR:** FREE/AUDIO 조건, 랜덤/가격/인기 정렬과 공통 visibility 조건이 바뀌지 않았는지 직접 영향 회귀와 `ktlintCheck`로 확인한다. + +### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** 소비자 응답과 POINT 조회의 전체 요구사항을 최종 판정한다. + +- **시작 조건:** `P2-T1` 완료. +- **완료 증거:** focused·전체 회귀·lint 성공, 문서 Progress와 최종 검증 기록. +- **범위 밖:** 실패를 숨기기 위한 test 삭제·skip·완화와 관련 없는 코드 수정. + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' \ + --tests 'kr.co.vividnext.sodalive.v2.content.all.*' \ + --tests 'kr.co.vividnext.sodalive.v2.api.content.*' +./gradlew test +./gradlew ktlintCheck +``` + +**Expected:** 모든 테스트와 lint가 성공하고 무료 콘텐츠가 어떤 대상 소비자 응답이나 POINT 전용 결과에서도 포인트 결제 가능으로 취급되지 않는다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 없음 | 아니요 | 실패 응답 경계와 fixture를 다시 대조 | +| 2 | `P1-GATE` | `P1-T1` | 아니요 | 실패 소유 mapper의 회귀 수정 goal 추가 | +| 3 | `P2-T1` | `P1-GATE` | 아니요 | 목록·count 조건 공유 여부 확인 | +| 4 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 repository 또는 mapper로 되돌림 | +| 5 | `P1-R1` | Phase별 review 판정 완료 | 아니요 | 누락된 계약 fixture와 assertion 범위를 다시 대조 | +| 6 | `P1-R2` | `P1-R1` | 아니요 | legacy 상세의 세 계약 조건을 다시 대조 | + +```text +P1-T1 → P1-GATE → P2-T1 → P2-GATE +``` + +리뷰 후속 실행 순서: + +```text +P1-R1 → P1-R2 +``` + +## 변경 금지 항목 + +- 관리자 조회와 DB 저장값을 보정하지 않는다. +- 공개 DTO 필드를 추가·삭제·이름 변경하지 않는다. +- POINT 조건 보강을 FREE/AUDIO/추천 점수·정렬 변경으로 확장하지 않는다. +- 새 dependency, schema migration과 공통 abstraction을 추가하지 않는다. +- 기존 완료 문서의 체크박스와 검증 기록을 삭제하거나 덮어쓰지 않는다. +- test를 삭제·skip·완화해 Gate를 통과시키지 않는다. + +## Progress + +실제 구현 시 기존 기록을 삭제하거나 덮어쓰지 않고 Goal 실행 결과를 차수별로 누적한다. + +### 문서 작성 검증 — 2026-07-31 + +- 상태: 완료 +- 무엇을: 확정 요구사항을 새 PRD와 goal 실행형 계획에 기록하고 기존 추천·전체 탭 문서의 충돌 계약을 정정했다. +- 왜: 구현 전에 단일 기준 문서, 범위, 제외 조건과 완료 증거를 확정하기 위해서다. +- 어떻게: + - `rg` placeholder·요구사항/Goal 추적 검색 — placeholder 없음, `POINT-001~008`과 `P1-T1`·`P2-T1` 연결 확인. + - 계획에 기록된 production 파일 존재 확인 — 누락 없음. + - `git diff --check` — 출력 없음. + - `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0. +- 구현 test: 문서만 변경했으므로 실행하지 않았다. +- 남은 항목: `P1-T1`부터 구현 실행. +- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`의 RED부터 시작한다. + +### 구현 검증 — 2026-07-31 + +- 상태: 완료 +- 무엇을: 무료 콘텐츠 포인트 결제 불가 정책을 대상 소비자 응답과 POINT 전용 조회 조건에 적용했다. +- 왜: `price == 0` 콘텐츠가 포인트 결제 가능 상태와 동시에 노출되거나 POINT 목록·개수·페이징 후보에 포함되지 않도록 하기 위해서다. +- 어떻게: + - P1 RED focused test — 소비자 응답 경계에서 무료·저장값 true가 기존 true로 전달되어 assertion 실패 확인. + - P1 GREEN focused test — `BUILD SUCCESSFUL`, 무료 true → false, 유료 true → true, 관리자 상세 원본 true 유지 확인. + - P1-GATE — 원 wildcard 명령은 600초 제한 초과로 분할 실행했고, content 범위 `BUILD SUCCESSFUL`(9m31s), creator/home 범위 `BUILD SUCCESSFUL`(4m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(1m02s). + - P2 RED focused test — recommendation/main-all repository와 E2E에서 무료·저장값 true가 POINT 후보에 포함되어 4개 assertion 실패 확인. + - P2 GREEN focused test — `BUILD SUCCESSFUL`, 추천 `pointAudios`와 전체 탭 POINT 목록·`totalCount`·`hasNext`가 유료 포인트 후보만 반영함을 확인. + - P2-GATE 영향 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*'` `BUILD SUCCESSFUL`(1m48s). + - 최종 Gate — `./gradlew test` `BUILD SUCCESSFUL`(13m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(40s). +- 결정: 관리자 mapper/service, DB 저장값, 공개 DTO 필드명과 구조는 변경하지 않았다. +- 남은 항목: 없음. + +### Phase별 리뷰 — 2026-07-31 + +- 상태: Phase 1 수정 goal 필요, Phase 2 확정 발견 사항 없음. +- 무엇을: PRD, 구현 계획, staged production/test diff와 관련 호출 경계를 Phase별로 대조했다. +- 왜: 완료 체크와 실제 계약 증거가 일치하는지 판정하기 위해서다. +- 어떻게: + - `git diff --cached --check` — 출력 없음. + - `rg`로 대상 응답 mapper, POINT repository 조건과 관련 test assertion을 대조했다. + - `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0. + - 사용자 지시에 따라 compile과 test는 다시 실행하지 않았으며 기존 구현 검증 기록을 근거로만 확인했다. +- 후속: `REV-P1-001`, `REV-P1-002`를 `P1-R1`로 전환했다. +- 리뷰 문서: + - `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md` + - `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md` + +### 리뷰 후속 검증 — 2026-07-31 + +- 상태: 완료 +- 무엇을: `REV-P1-001`, `REV-P1-002`의 누락된 회귀 증거를 `P1-R1`로 보강했다. +- 왜: 소비자 응답의 저장값 false 조건과 AI 캐릭터 관리자 무료 원본값 유지 조건을 자동 테스트로 고정하기 위해서다. +- 어떻게: + - P1-R1 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'` `BUILD SUCCESSFUL`(3m11s). + - P1-GATE — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(12m15s). + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`(47s). +- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다. +- 남은 항목: 없음. + +### 2차 Phase 1 리뷰 — 2026-07-31 + +- 상태: `REV-P1-001`, `REV-P1-002` 수정 확인, 추가 수정 goal 필요. +- 무엇을: `P1-R1` test diff, Phase 1 production mapper, 관리자 제외 경계와 완료 기록을 다시 대조했다. +- 왜: 기존 확정 발견 사항이 실제로 수정됐는지, 계약 진리표에 다른 누락은 없는지 판정하기 위해서다. +- 어떻게: + - staged diff와 `rg`로 6개 소비자 조립 경계의 유료·저장값 false assertion을 확인했다. + - 관리자 목록·상세의 `price == 0, stored == true` fixture와 true assertion을 확인했다. + - `git diff --check`, `git diff --cached --check` — 출력 없음. + - `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0. + - 사용자 지시에 따라 compile과 test는 다시 실행하지 않았다. +- 후속: legacy 상세의 유료·저장값 true positive assertion 누락을 `REV-P1-003`, `P1-R2`로 전환했다. + +### 리뷰 후속 검증 2차 — 2026-07-31 + +- 상태: 완료 +- 무엇을: `REV-P1-003`의 누락된 legacy 상세 positive 회귀 증거를 `P1-R2`로 보강했다. +- 왜: legacy 상세에서 무료·저장값 true, 유료·저장값 false, 유료·저장값 true의 포인트 사용 가능 계약을 하나의 test로 고정하기 위해서다. +- 어떻게: + - P1-R2 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest'` `BUILD SUCCESSFUL`(14s). + - P1-GATE 단위 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(2m 26s). + - `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`(14s). +- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다. +- 전체 테스트: 사용자 지시에 따라 실행하지 않았다. +- 남은 항목: 없음. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-07-31 | `PLAN-DEC-001` | 확정 | 응답 조립 지점에서 `configured && price > 0`을 직접 적용하고 새 abstraction을 만들지 않는다 | 기존 mapper 책임과 최소 변경 원칙 | `P1-T1` | +| 2026-07-31 | `PLAN-DEC-002` | 확정 | POINT repository 조건에 `price > 0`을 결합하고 전체 탭 목록과 count의 기존 조건 공유 구조를 유지한다 | 목록·count 일관성 | `P2-T1` | +| 2026-07-31 | `PLAN-DEC-003` | 확정 | 여러 소비자 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다 | 영향 범위 회귀 증거 필요 | `P2-GATE` | + +## 발견된 문제 + +초기 구현 시 발견된 문제 없음. + +### 2026-07-31 Phase별 리뷰 + +- `REV-P1-001` — 소비자 응답 테스트에 `price > 0, storedIsPointAvailable == false` 계약 증거가 없다. +- `REV-P1-002` — 관리자 회귀 테스트가 무료 fixture로 저장값 유지 계약을 검증하지 않는다. +- `REV-P1-003` — legacy 상세 test가 유료·저장값 true의 positive 계약을 검증하지 않는다. +- 후속 Goal: `P1-R1` +- 처리: 2026-07-31 `P1-R1` 완료. +- 추가 후속 Goal: `P1-R2` +- 처리: 2026-07-31 `P1-R2` 완료. +- Phase 2: 확정 발견 사항 없음. + +## 최종 보고 형식 + +```markdown +구현 결과: 무료 콘텐츠 포인트 결제 불가 정책을 소비자 응답과 POINT 조회에 적용 + +- 변경: 대상 응답 mapper와 추천·전체 탭 POINT 조회 조건 +- 결정: 관리자·DB 원본 유지, 공개 schema 유지 +- 검증: focused test, 영향 범위 회귀, 전체 test, ktlintCheck 결과 +- 남은 항목: 없음 또는 실패·외부 조건 +- 문서: PRD와 이 plan-task.md의 Progress·검증 기록 +``` diff --git a/docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md b/docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md new file mode 100644 index 00000000..b53abe73 --- /dev/null +++ b/docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md @@ -0,0 +1,171 @@ +# PRD: 무료 콘텐츠 포인트 결제 불가 + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 기준 확정 | +| 작성일 | 2026-07-31 | +| 최종 수정일 | 2026-07-31 | +| 대상 제품 | 소비자용 오디오 콘텐츠 조회 API | +| 작성자·결정권자 | 사용자 | +| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 | +| 관련 구현 계획 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` | +| 관련 review | `reviews/phase-1-review.md`, `reviews/phase-2-review.md` | + +## 1. Overview + +무료 오디오 콘텐츠와 포인트 결제 가능 상태가 소비자 화면에서 동시에 노출되지 않도록 조회 계약을 보정한다. +저장된 포인트 결제 가능 설정은 유지하되, 소비자용 응답과 포인트 전용 목록에서는 가격을 함께 반영한 실질 상태를 사용한다. + +## 2. Problem Statement + +- 현재 일부 조회 응답은 `price == 0`이면서 저장된 `isPointAvailable == true`인 콘텐츠를 그대로 포인트 결제 가능으로 노출한다. +- 포인트 추천과 전체 탭 POINT 조회는 저장된 `isPointAvailable`만 필터링해 무료 콘텐츠가 포함될 수 있다. +- 무료와 포인트 결제 가능 상태가 함께 노출되면 클라이언트의 가격 표시와 결제 진입 판단이 서로 모순될 수 있다. + +문제를 해결했다는 판단은 소비자용 모든 대상 응답에서 무료 콘텐츠의 포인트 결제 가능 여부가 `false`이고, +POINT 전용 목록과 개수에서 무료 콘텐츠가 제외되는 것으로 한다. + +## 3. Goals + +- 무료 콘텐츠의 소비자용 포인트 결제 가능 여부를 항상 `false`로 응답한다. +- 유료이면서 저장된 포인트 결제 가능 설정이 `true`인 콘텐츠는 기존처럼 `true`로 응답한다. +- 포인트 전용 목록, 전체 개수와 페이징 판단에서 무료 콘텐츠를 제외한다. +- 기존 공개 API 필드명, 응답 구조와 DB 저장값을 변경하지 않는다. + +## 4. Non-Goals + +- 콘텐츠 생성·수정 시 `isPointAvailable` 저장값을 강제로 변경하지 않는다. +- 기존 데이터의 일괄 수정이나 DB migration을 수행하지 않는다. +- `/api/v2/admin/ai-characters/**/audio-contents` 관리자 목록·상세의 저장값 표현을 변경하지 않는다. +- `/audio-content/{id}` 상세를 제외한 legacy 목록·추천·랭킹 API는 변경하지 않는다. +- 콘텐츠 구매·대여·소장·포인트 차감 로직은 변경하지 않는다. +- 공개 DTO의 필드 추가·삭제·이름 변경을 수행하지 않는다. + +## 5. Target Users and Permissions + +| 사용자 | 목표 | 주요 작업 | 적용 범위 | +|---|---|---|---| +| 소비자 | 무료 콘텐츠를 포인트 결제 대상으로 오인하지 않는다 | 콘텐츠 상세·목록·추천 조회 | 대상 소비자용 API | +| 관리자 | 저장된 콘텐츠 설정을 그대로 확인한다 | AI 캐릭터 콘텐츠 목록·상세 조회 | 변경 제외 | + +기존 endpoint별 인증·성인 노출·차단 관계·공개 상태 정책은 변경하지 않는다. + +## 6. 핵심 정책 + +### 6.1 가격과 포인트 결제 가능 여부 + +- 무료 콘텐츠는 `price == 0`으로 정의한다. +- 소비자에게 노출하는 실질 포인트 결제 가능 여부는 다음 식으로 정의한다. + +```text +effectivePointAvailable = storedIsPointAvailable && price > 0 +``` + +- `price == 0`이고 저장값이 `true`이면 소비자 응답은 `false`다. +- `price > 0`이고 저장값이 `true`이면 소비자 응답은 `true`다. +- 저장값이 `false`이면 가격과 관계없이 소비자 응답은 `false`다. +- 이 정책은 응답 조립 시 적용하며 엔티티의 저장값은 변경하지 않는다. + +### 6.2 포인트 전용 조회 + +- 포인트 전용 콘텐츠는 `isPointAvailable == true && price > 0`인 공개 오디오로 정의한다. +- `GET /api/v2/audio/recommendations`의 `pointAudios`는 이 조건을 사용한다. +- `GET /api/v2/audio/contents?type=POINT`의 목록과 `totalCount`는 동일한 조건을 사용한다. +- `hasNext`는 보정된 목록 조건으로 조회한 `size + 1` 결과를 기준으로 기존 방식대로 계산한다. +- 무료 콘텐츠는 `isPointAvailable == true`로 저장되어 있어도 POINT 목록, 개수와 페이징 후보에서 제외한다. + +## 7. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 | +|---|---|---|---|---| +| `POINT-001` | 확정 | 무료 기준은 `price == 0`이다 | 무료 fixture가 가격 0으로 판정된다 | `P1-T1`, `P2-T1` | +| `POINT-002` | 확정 | 소비자용 실질 포인트 가능 여부는 `storedIsPointAvailable && price > 0`이다 | 무료·저장값 true 응답이 false이고 유료·저장값 true 응답이 true다 | `P1-T1` | +| `POINT-003` | 확정 | legacy 콘텐츠 상세의 `isAvailableUsePoint`에 실질 상태를 적용한다 | `GET /audio-content/{id}` 응답 회귀 테스트가 통과한다 | `P1-T1` | +| `POINT-004` | 확정 | 대상 v2 소비자 응답의 `isPointAvailable`에 실질 상태를 적용한다 | 각 응답 변환 테스트가 무료 true 저장값을 false로 보정한다 | `P1-T1` | +| `POINT-005` | 확정 | 추천 `pointAudios`에서 무료 콘텐츠를 제외한다 | 추천 repository·E2E 테스트에서 가격 0 항목이 없다 | `P2-T1` | +| `POINT-006` | 확정 | 전체 탭 POINT 목록·`totalCount`·`hasNext`가 같은 유료 포인트 조건을 사용한다 | repository·E2E 테스트의 목록과 페이징 메타데이터가 일치한다 | `P2-T1` | +| `POINT-007` | 확정 | AI 캐릭터 관리자 콘텐츠 조회는 저장값을 그대로 반환한다 | 무료·저장값 true인 관리자 상세가 true를 유지한다 | `P1-T1` | +| `POINT-008` | 확정 | 공개 API 스키마와 DB 저장값을 유지한다 | DTO 필드 집합과 관리자 저장값 회귀 테스트가 통과한다 | `P1-GATE`, `P2-GATE` | + +## 8. API 계약 + +### 8.1 응답 보정 대상 + +| Method | Path | 응답 경계 | 보정 필드 | +|---|---|---|---| +| GET | `/audio-content/{id}` | `GetAudioContentDetailResponse` | `isAvailableUsePoint` | +| GET | `/api/v2/contents` | `ContentOverviewItemResponse` | `isPointAvailable` | +| GET | `/api/v2/audio/contents` | `MainContentAudioResponse` | `isPointAvailable` | +| GET | `/api/v2/audio/recommendations` | `AudioCardResponse` | `isPointAvailable` | +| GET | `/api/v2/home/recommendations` | `HomeFirstAudioContentItem` | `isPointAvailable` | +| GET | `/api/v2/creator-channels/{creatorId}/home` | `CreatorChannelAudioContentResponse` | `isPointAvailable` | +| GET | `/api/v2/creator-channels/{creatorId}/audio` | `CreatorChannelAudioContentResponse` | `isPointAvailable` | +| GET | `/api/v2/creator-channels/{creatorId}/live` | `CreatorChannelAudioContentResponse` | `isPointAvailable` | + +- `GET /api/v2/creator-channels/{creatorId}/series`는 콘텐츠 가격과 포인트 가능 필드를 반환하지 않아 코드 변경 대상이 아니다. +- 현재 v2 소비자용 API에 동일 필드를 반환하는 새 경로가 발견되면 같은 식을 적용하고 계획 범위를 먼저 갱신한다. + +### 8.2 포인트 전용 조회 조건 보정 대상 + +| Method | Path/section | 변경 전 | 변경 후 | +|---|---|---|---| +| GET | `/api/v2/audio/recommendations`의 `pointAudios` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` | +| GET | `/api/v2/audio/contents?type=POINT` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` | + +### 8.3 변경 제외 관리자 계약 + +| Method | Path | 정책 | +|---|---|---| +| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | 저장된 `isPointAvailable`을 그대로 반환 | +| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 저장된 값을 `isAvailableUsePoint`에 그대로 반환 | + +## 9. 기술적 제약 + +- Kotlin, Java 17, Spring Boot 2.7.14와 현재 QueryDSL/JPA 구조를 유지한다. +- 새 dependency, DB schema, API endpoint와 DTO를 추가하지 않는다. +- 응답 변환 경계에서는 `configured && price > 0` 식을 직접 사용해 현재 파일 책임 안에서 최소 변경한다. +- POINT 조회 조건은 기존 repository 조건 함수에 `price > 0`을 결합해 목록과 count가 같은 조건을 공유하게 한다. +- 관리자 mapper와 관리자 조회 service는 변경하지 않는다. +- 관련 없는 콘텐츠 가격·결제·추천 점수·정렬·성인·차단 정책은 변경하지 않는다. + +## 10. 테스트와 품질 요구사항 + +- TDD 순서로 무료·저장값 true fixture의 실패 테스트를 먼저 작성하고 실패 원인이 기존 원본 전달임을 확인한다. +- 소비자 응답 경계별로 무료 true → false와 유료 true → true를 검증한다. +- 추천 POINT와 전체 탭 POINT에 무료 true fixture를 추가해 목록 제외를 검증한다. +- 전체 탭은 POINT `totalCount`와 `hasNext`가 목록 조건과 일치하는지 검증한다. +- 관리자 상세는 무료 true 저장값을 그대로 true로 응답하는 회귀 테스트를 유지한다. +- focused test 후 직접 영향받는 v2 콘텐츠·홈·크리에이터 채널 회귀와 `ktlintCheck`를 실행한다. +- 여러 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다. + +## 11. 성공 기준 + +- [ ] `price == 0`, 저장값 `true`인 콘텐츠가 모든 대상 소비자 응답에서 `false`다. (`POINT-002~004`) +- [ ] `price > 0`, 저장값 `true`인 콘텐츠가 대상 소비자 응답에서 `true`다. (`POINT-002`) +- [ ] 저장값 `false`인 콘텐츠는 가격과 관계없이 `false`다. (`POINT-002`) +- [ ] 무료·저장값 true 콘텐츠가 추천 `pointAudios`에서 제외된다. (`POINT-005`) +- [ ] 무료·저장값 true 콘텐츠가 전체 탭 POINT 목록·`totalCount`·`hasNext` 후보에서 제외된다. (`POINT-006`) +- [ ] 관리자 목록·상세와 DB 저장값은 변경되지 않는다. (`POINT-007~008`) +- [ ] 공개 응답 필드명과 구조가 변경되지 않는다. (`POINT-008`) + +## 12. Open Questions + +없음. + +## 13. 요구사항 추적표 + +| 요구사항 | 계획 Phase | Goal | 자동 검증 | +|---|---:|---|---| +| `POINT-001~004`, `POINT-007~008` | 1 | `P1-T1`, `P1-GATE` | legacy 상세·v2 응답 mapper·관리자 회귀 테스트 | +| `POINT-005~006`, `POINT-008` | 2 | `P2-T1`, `P2-GATE` | 추천/전체 탭 repository·E2E 테스트 | + +## 14. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal | +|---|---|---|---|---|---| +| 2026-07-31 | `DEC-001` | 확정 | 무료 기준을 `price == 0`으로 고정하고 소비자용 포인트 가능 여부를 `storedIsPointAvailable && price > 0`으로 계산한다 | 사용자 인터뷰 | `POINT-001~004`, `P1-T1` | +| 2026-07-31 | `DEC-002` | 확정 | 추천과 전체 탭 POINT 조회에서 무료 콘텐츠를 제외한다 | 사용자 선택 A | `POINT-005~006`, `P2-T1` | +| 2026-07-31 | `DEC-003` | 확정 | AI 캐릭터 관리자 조회와 DB 저장값은 변경하지 않는다 | 사용자 선택 A | `POINT-007~008`, `P1-T1` | +| 2026-07-31 | `DEC-004` | 확정 | 새 통합 문서를 기준으로 만들고 충돌하는 기존 추천·전체 탭 문서에는 정정 기록을 누적한다 | 사용자 승인 | 관련 문서 전체 | diff --git a/docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md b/docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md new file mode 100644 index 00000000..1230764b --- /dev/null +++ b/docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md @@ -0,0 +1,229 @@ +# Phase 1 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` | +| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- `POINT-001~004`, `POINT-007~008` 구현과 완료 증거가 일치하는지 확인한다. +- 대상 소비자 응답과 변경 제외 관리자 응답이 각각 확정 계약을 유지하는지 확인한다. + +### 포함 범위 + +- 코드: Phase 1에서 변경한 consumer response mapper와 `AudioContentService` +- 테스트: Phase 1의 unit/facade test와 AI 캐릭터 관리자 controller test +- 문서: PRD Phase 1 요구사항, `P1-T1`, `P1-GATE`, Progress + +### 제외 범위 + +- Phase 2 POINT 전용 repository 조건 +- legacy 상세 외 legacy 목록·추천·랭킹 API +- compile과 test 재실행 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항 또는 공개 API 계약 위반 | +| Medium | 제한된 조건의 기능 회귀 또는 핵심 계약의 자동 검증 누락 | +| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `POINT-001~004`, `POINT-007~008` +- 계획: `P1-T1`, `P1-GATE` +- 코드: + - `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt:970` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt:49` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt:63` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt:72` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt:35` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt:270` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt:69` +- 테스트: + - `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt:286` + - `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt:495` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `git diff --cached --check` | 성공 | 출력 없음 | +| staged diff와 `rg` 기반 호출·assertion 대조 | 성공 | 6개 소비자 조립 경계는 모두 `stored && price > 0` 적용 | +| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 | +| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P1-001` | Medium | 수정 완료 | 소비자 테스트가 저장값 false 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` | +| `REV-P1-002` | Low | 수정 완료 | 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` | +| `REV-P1-003` | Medium | 수정 완료 | legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음 | `Task 1.3` | `P1-R2` | + +## 6. 발견 사항 상세 + +### REV-P1-001 — 소비자 테스트가 저장값 false 조건을 증명하지 않음 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** `POINT-002`, `POINT-004` +- **소유 Task:** `Task 1.2`, `P1-R1` + +**관찰 내용** + +변경된 소비자 테스트는 주로 `price == 0, stored == true → false`와 +`price > 0, stored == true → true`만 검증한다. 따라서 구현이 실수로 `price > 0`만 반환해도 해당 두 조건은 통과한다. +PRD 성공 기준인 `stored == false → false`를 자동으로 구분할 수 없다. + +**근거** + +- 코드: 각 소비자 조립 경계는 현재 `stored && price > 0`으로 올바르게 구현되어 있다. +- 테스트: Phase 1 변경 test에는 각 변경 경계의 `price > 0, stored == false` assertion이 없다. +- 문서: PRD `POINT-002`와 성공 기준은 저장값 false가 가격과 무관하게 false일 것을 요구한다. + +**영향** + +현재 production 동작 결함은 확인되지 않았다. 다만 저장값 조건이 제거되는 회귀가 발생해도 Phase 1 test가 탐지하지 못한다. + +**권장 조치** + +각 변경 경계의 기존 test fixture에 `price > 0, stored == false` 사례를 최소 추가하고 `P1-GATE`를 재검증한다. + +**판정 기록** + +- 2026-07-31 — staged 구현식은 정상이나 확정 계약의 자동 검증 누락으로 판정해 `P1-R1`로 전환했다. +- 2026-07-31 — `P1-R1`에서 대상 소비자 경계에 `price > 0, stored == false` assertion을 추가하고 focused test, `P1-GATE`, `ktlintCheck` 성공을 확인했다. + +### REV-P1-002 — 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** `POINT-007`, `POINT-008` +- **소유 Task:** `Task 1.2`, `P1-R1` + +**관찰 내용** + +계획과 Progress는 관리자 상세의 “무료·저장값 true → true” 확인을 완료 증거로 기록했지만, +`AiCharacterAdminAudioContentControllerTest`의 공통 fixture는 `price = 100`이다. 상세 assertion은 저장값 true 전달만 검증하며 +무료 조건에서 소비자 보정이 관리자 경계로 번지지 않았는지는 증명하지 않는다. 관리자 목록의 동일 조건 assertion도 없다. + +**근거** + +- 코드: `AiCharacterAdminAudioContentMapper`는 현재 `content.isPointAvailable`을 그대로 전달한다. +- 테스트: 관리자 helper의 `price = 100`, 상세의 `isAvailableUsePoint == true` assertion. +- 문서: `P1-T1`과 구현 Progress는 무료 관리자 원본값 유지 확인을 완료 증거로 기록한다. + +**영향** + +현재 관리자 mapper의 기능 결함은 확인되지 않았다. 그러나 완료 기록과 실제 test fixture가 불일치하며 관리자 제외 계약의 +핵심 경계가 회귀 test로 고정되지 않았다. + +**권장 조치** + +관리자 목록·상세 fixture를 `price == 0, stored == true`로 구성해 목록 `isPointAvailable`과 상세 +`isAvailableUsePoint`가 true를 유지하는지 검증한다. + +**판정 기록** + +- 2026-07-31 — 구현은 정상이나 완료 증거 불일치가 확인되어 `P1-R1`로 전환했다. +- 2026-07-31 — `P1-R1`에서 관리자 목록·상세 fixture를 `price == 0, stored == true`로 보강하고 원본 true 유지 assertion 통과를 확인했다. + +### REV-P1-003 — legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음 + +- **심각도:** Medium +- **상태:** 수정 완료 +- **관련 요구사항:** `POINT-002`, `POINT-003` +- **소유 Task:** `Task 1.3`, `P1-R2` + +**관찰 내용** + +`AudioContentServiceTest` 상세 회귀 test는 무료·저장값 true와 유료·저장값 false가 false인 것만 검증한다. +`price > 0, stored == true → true` assertion이 없어 legacy 상세 구현이 항상 false로 회귀해도 해당 test가 통과한다. + +**근거** + +- 코드: `AudioContentService` 상세 응답은 현재 `audioContent.isPointAvailable && audioContent.price > 0`으로 올바르게 구현되어 있다. +- 테스트: `AudioContentServiceTest` 상세 포인트 assertion은 false 사례 2건만 포함한다. +- 문서: `P1-T1`은 각 응답 경계의 유료·저장값 true 유지를 완료 증거로 요구한다. + +**영향** + +현재 production 동작 결함은 확인되지 않았다. 다만 legacy 상세의 positive 계약이 회귀 test로 고정되지 않았다. + +**권장 조치** + +기존 상세 test에 유료·저장값 true 응답 assertion을 추가해 세 계약 조건을 완성한다. + +**판정 기록** + +- 2026-07-31 — staged production 구현은 정상이나 legacy 상세 positive 회귀 증거 누락으로 판정해 `P1-R2`로 전환했다. +- 2026-07-31 — `P1-R2`에서 legacy 상세 test에 유료·저장값 true assertion을 추가해 세 계약 조건을 고정하고 focused test, P1-GATE 단위 범위, `ktlintCheck` 성공을 확인했다. + +## 7. 확정 항목의 plan·goal 전환 + +- 신규 회귀 수정 Task: `plan-task.md`의 `Task 1.2` +- 후속 goal: `P1-R1` +- objective: 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다. +- 추가 회귀 수정 Task: `plan-task.md`의 `Task 1.3` +- 추가 후속 goal: `P1-R2` +- objective: legacy 상세의 유료·저장값 true positive 계약을 자동 회귀 테스트로 증명한다. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 1 production/test/document diff와 관련 mapper 확인 | +| 후보 항목 판정 완료 | 충족 | 3건 모두 판정 완료 | +| 확정 항목 plan 반영 | 충족 | `Task 1.2`, `P1-R1`, `Task 1.3`, `P1-R2` | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 | + +**최종 결론:** 수정 완료 + +**남은 항목:** 없음 + +## 9. 수정 후 검증 기록 + +기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다. + +### 1차 수정 검증 — 2026-07-31 + +- 무엇을: `REV-P1-001`, `REV-P1-002`의 수정 내용과 회귀 증거를 재검토했다. +- 왜: 소비자 저장값 false 계약과 관리자 무료 원본값 유지 계약의 자동 검증 누락을 해소했는지 확인하기 위해서다. +- 어떻게: + - staged diff와 `rg` — 6개 소비자 조립 경계의 유료·저장값 false assertion 확인. + - staged diff — 관리자 목록·상세의 무료·저장값 true fixture와 true assertion 확인. + - `git diff --check`, `git diff --cached --check` — 출력 없음. + - `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0. + - compile/test — 사용자 지시에 따라 재실행하지 않고 `plan-task.md`의 기존 성공 기록만 확인. +- 판정: `REV-P1-001`, `REV-P1-002` 수정 완료. +- 남은 항목: `REV-P1-003`, `P1-R2`. + +### 2차 수정 검증 — 2026-07-31 + +- 무엇을: `REV-P1-003`의 수정 내용과 회귀 증거를 재검토했다. +- 왜: legacy 상세의 유료·저장값 true positive 계약이 자동 검증으로 고정됐는지 확인하기 위해서다. +- 어떻게: + - `AudioContentServiceTest` — 무료·저장값 true → false, 유료·저장값 false → false, 유료·저장값 true → true assertion 확인. + - `AudioContentService` — 응답식 `audioContent.isPointAvailable && audioContent.price > 0`과 세 assertion 대조. + - `plan-task.md`의 기존 완료 기록 — focused test `BUILD SUCCESSFUL`(14s), P1-GATE 단위 범위 + `BUILD SUCCESSFUL`(2m 26s), `ktlintCheck` `BUILD SUCCESSFUL`(14s) 확인. + - `git diff --check`, `git diff --cached --check` — 출력 없음. + - `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0. + - compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음. +- 판정: `REV-P1-003` 수정 완료. +- 남은 항목: 없음. diff --git a/docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md b/docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md new file mode 100644 index 00000000..9c8e4e2e --- /dev/null +++ b/docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md @@ -0,0 +1,111 @@ +# Phase 2 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 2 / `P2-T1`, `P2-GATE` | +| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree | +| 리뷰 일자 | 2026-07-31 | +| 리뷰어 | Codex | +| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` | +| 리뷰 상태 | 판정 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- `POINT-005~006`, `POINT-008` 구현과 완료 증거가 일치하는지 확인한다. +- 추천과 전체 탭 POINT 조회가 무료 콘텐츠를 목록·count·pagination 후보에서 제외하는지 확인한다. + +### 포함 범위 + +- 코드: 추천·전체 탭 QueryDSL repository 변경 +- 테스트: 두 repository test와 추천·전체 탭 E2E test +- 문서: 관련 PRD, 기존 추천·전체 탭 후속 정정, `P2-T1`, `P2-GATE`, Progress + +### 제외 범위 + +- Phase 1 응답 mapper +- FREE/AUDIO/정렬 정책의 신규 변경 +- compile과 test 재실행 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항 또는 공개 API 계약 위반 | +| Medium | 제한된 조건의 목록·count·pagination 불일치 | +| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `POINT-005~006`, `POINT-008` +- 계획: `P2-T1`, `P2-GATE` +- 코드: + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt:135` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:35` + - `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:409` +- 테스트: + - `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt:87` + - `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt:43` + - `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt:42` + - `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt:97` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `git diff --cached --check` | 성공 | 출력 없음 | +| staged diff와 `rg` 기반 조건·호출 대조 | 성공 | 추천은 유료 POINT 조건, 전체 탭 list/count는 같은 `audioCondition` 공유 | +| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 | +| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 | + +## 5. 발견 사항 요약 + +확정 발견 사항 없음. + +## 6. 검토 결과 + +- 추천 `findPointAudios`는 `isPointAvailable.isTrue.and(price.gt(0))`을 조회 전에 적용해 limit 후보에서도 무료 콘텐츠를 제외한다. +- 전체 탭 `countAudios`와 `findAudios`는 동일한 `audioCondition`과 `optionalAudioPointCondition`을 사용한다. +- 전체 탭 E2E는 유료 1건과 무료·저장값 true 1건에서 `size=1`로 조회해 `totalCount=1`, 목록 1건, + `hasNext=false`를 함께 검증한다. +- repository test는 유료·저장값 false, 유료·저장값 true, 무료·저장값 true를 구분한다. +- 기존 추천·전체 탭 문서는 2026-07-31 후속 요구사항 정정을 누적해 현재 PRD와 일치한다. + +## 7. 확정 항목의 plan·goal 전환 + +전환 항목 없음. + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 2 production/test/document diff와 관련 호출 경계 확인 | +| 후보 항목 판정 완료 | 충족 | 후보 없음 | +| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 | + +**최종 결론:** 확정 발견 사항 없음 + +**남은 항목:** 없음 + +## 9. 후속 상태 확인 + +### 1차 재확인 — 2026-07-31 + +- 무엇을: Phase 1의 `P1-R2` 수정이 Phase 2 POINT 조회 계약에 영향을 주지 않았는지 재검토했다. +- 왜: 후속 test 변경 뒤에도 POINT 목록·count·pagination 조건과 기존 Phase 2 판정이 유효한지 확인하기 위해서다. +- 어떻게: + - production diff — `P1-R2`에 따른 Phase 2 repository 변경 없음 확인. + - repository와 E2E test 정적 대조 — 추천 유료 POINT 조건과 전체 탭 list/count 공통 조건 유지 확인. + - `git diff --check`, `git diff --cached --check` — 출력 없음. + - `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0. + - compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음. +- 판정: 기존 Phase 2 판정 유지. +- 남은 항목: 없음. diff --git a/docs/agent-guides/문서유지보수.md b/docs/agent-guides/문서유지보수.md index 674fe31c..04156143 100644 --- a/docs/agent-guides/문서유지보수.md +++ b/docs/agent-guides/문서유지보수.md @@ -4,20 +4,35 @@ - PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다. - 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다. - PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다. -- PRD 문서는 `sample-prd.md`에서 필요한 섹션만 발췌해 작성하고, 불필요한 빈 섹션을 기계적으로 복사하지 않는다. -- `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다. +- 리뷰 문서는 같은 작업 디렉터리의 `reviews/` 아래에 둔다. 기본 경로는 + `docs/[날짜]_구현할내용한글/reviews/[리뷰범위]-review.md`이며, 여러 리뷰는 범위별 파일로 누적한다. +- 요구사항 문서는 `docs/sample/sample-prd.md`, 구현 계획/TASK 문서는 `docs/sample/sample-plan-task.md`, 요구사항·구현·완료 상태 + 리뷰 문서는 `docs/sample/sample-review.md`를 기준 템플릿으로 참조한다. +- 샘플의 `<...>`, 예시 ID, frontend 전용 항목은 실제 작업의 확정 근거와 범위에 맞게 교체·발췌하고 불필요한 빈 섹션을 + 기계적으로 복사하지 않는다. +- 필요한 샘플이 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다. - 구현 계획/TASK 문서는 의미 단위 phase로 나누고 `### Phase 1: ...`, `### Phase 2: ...` 형식의 heading을 사용한다. - 각 phase 아래에는 단계별 task를 체크박스(`- [ ] **Task N.N: ...**`) 형태로 작성한다. +- goal 기능으로 실행할 계획은 문서 상단에 현재 상태·활성/다음 Goal을 기록하고, Task 또는 Phase Gate 하나만 단일 goal로 + 실행할 수 있도록 고유 Goal ID와 실행 순서를 둔다. +- 각 goal에는 objective, 시작 조건, 완료 증거, 범위 밖을 명시하며, Phase마다 모든 Task 완료 후 별도로 판정하는 Gate Goal을 둔다. - 각 task에는 구현 시 생성/수정/확인할 파일 경로를 명시한다. - 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다. - 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유`와 `대체 검증 방법`을 task에 명시한다. - 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다. -- 각 task의 검증 기준에는 단일 테스트 실행 명령과 필요한 경우 전체 회귀 명령을 포함한다. +- 각 task의 검증 기준에는 focused test와 직접 영향받는 회귀 명령을 우선 명시한다. 전체 회귀는 광범위한 공통 코드 변경, + 여러 domain/phase 변경, release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 + 수 없는 경우 또는 사용자 요청일 때만 포함한다. +- 계획에서 전체 회귀를 조건부로 두면 실행 조건, 생략 시 기록할 근거와 대체 focused/영향 범위 회귀 명령을 함께 명시한다. - 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다. - 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다. - 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다. - 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증 기록은 문서 하단에 한국어로 남긴다. - 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 누적한다. +- 완료된 Task의 후속 리뷰는 기존 체크박스를 되돌리지 않는다. `docs/sample/sample-review.md` 형식으로 후보를 판정하고 확정된 + 항목만 새 회귀 수정 Task/Goal과 Progress 기록으로 누적한다. +- 기존 리뷰 파일을 삭제하거나 덮어쓰지 않는다. 같은 범위의 후속 검증은 기존 파일에 차수별로 누적하고, 검토 범위가 다르면 + `reviews/` 아래에 새 파일을 만든다. - `build.gradle.kts` 변경 시 실행 명령 섹션을 함께 갱신한다. - 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. - `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. diff --git a/docs/agent-guides/실행명령어.md b/docs/agent-guides/실행명령어.md index 5dd5f636..40afc69d 100644 --- a/docs/agent-guides/실행명령어.md +++ b/docs/agent-guides/실행명령어.md @@ -3,8 +3,18 @@ ## 실행 기준 - 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다. - 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다. +- 검증은 focused test → 영향받는 package/feature 회귀 → 전체 회귀 순서로 범위를 넓힌다. +- 전체 회귀 테스트(`./gradlew test`, `./gradlew check`, 전체 `build`)는 실행 시간이 길므로 매 Task나 일반적인 작은 변경에서 + 관성적으로 실행하지 않는다. +- 공통 인증·보안·예외·설정·serialization처럼 영향 범위가 넓은 코드 변경, 여러 domain/phase에 걸친 변경, release/final Gate에서 + 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만 + 전체 회귀를 실행한다. +- 전체 회귀를 생략하면 실행하지 않은 사실, 생략 근거와 대신 실행한 focused/영향 범위 회귀 명령을 계획 문서에 기록한다. ## Build/Lint/Test + +아래는 사용 가능한 명령 목록이며 모든 변경에서 전부 실행하라는 의미가 아니다. + ```bash ./gradlew tasks --all ./gradlew bootRun diff --git a/docs/agent-guides/작업절차.md b/docs/agent-guides/작업절차.md index 41ca4fe9..f995d0ca 100644 --- a/docs/agent-guides/작업절차.md +++ b/docs/agent-guides/작업절차.md @@ -2,11 +2,20 @@ ## 작업 절차 체크리스트 - 변경 전: 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다. -- 변경 전: 사용자 프롬프트를 받으면 먼저 PRD 문서를 작성한다. +- 변경 전: 사용자 프롬프트를 받으면 먼저 PRD 문서를 작성한다. 새 요구사항 문서는 + `docs/sample/sample-prd.md`에서 필요한 섹션을 발췌하고 모든 placeholder와 예시 ID를 실제 값으로 교체한다. - 변경 전: PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰하고 PRD를 보강한다. -- 변경 전: PRD는 `sample-prd.md`에서 작업에 필요한 부분만 발췌해 작성한다. `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다. +- 변경 전: PRD는 `docs/sample/sample-prd.md`에서 작업에 필요한 부분만 발췌해 작성한다. 파일이 없거나 읽을 수 없으면 추측하지 + 말고 사용자에게 확인한다. - 변경 전: 문서는 `docs/[날짜]_구현할내용한글/prd.md`, `docs/[날짜]_구현할내용한글/plan-task.md` 형식으로 작성한다. -- 변경 전: 보강된 PRD를 바탕으로 구현 계획/TASK 문서를 작성한 뒤, 해당 문서를 기준으로 필요한 내용만 최소 구현한다. +- 변경 전: 보강된 PRD를 바탕으로 `docs/sample/sample-plan-task.md`를 참조해 goal 실행형 구현 계획/TASK 문서를 작성한 뒤, + 해당 문서를 기준으로 필요한 내용만 최소 구현한다. +- 변경 전: 구현 계획은 Phase를 결과·의존성 단위로, Task와 Phase Gate를 한 번에 하나씩 실행할 goal 단위로 작성한다. 각 Goal에는 + 고유 ID, 한 문장 objective, 시작 조건, 완료 증거, 범위 밖, 정확한 파일 경로, 실행 체크박스와 검증 명령을 포함한다. +- 변경 전: 요구사항 또는 구현 완료 상태를 재검토하는 문서는 `docs/sample/sample-review.md`를 참조한다. 리뷰 후보는 재현·판정하고, + 확정 항목만 기존 계획에 새 회귀 수정 Task와 goal로 추가한다. +- 변경 전: 리뷰 문서는 PRD·구현 계획과 같은 `docs/[날짜]_구현할내용한글/` 아래의 `reviews/` 폴더에 저장한다. 리뷰가 여러 + 개면 목적과 범위가 드러나는 개별 파일명으로 분리하고 기존 리뷰를 덮어쓰지 않는다. - 변경 전: 구현 계획/TASK 문서의 각 task에는 TDD 기준의 실패 테스트 작성, 실패 확인, 최소 구현, 통과 확인, 리팩터링/회귀 확인 단계를 포함한다. - 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다. - 변경 전: 신규 API나 하위 코드 작성 시 `docs/agent-guides/코드스타일.md`의 패키지/코드 배치 규칙을 확인한다. @@ -16,6 +25,12 @@ - 변경 중: Todo를 사용할 때는 사용자에게 보이는 Todo 내용을 한국어로 작성한다. 경로, 클래스명, 명령어, 코드 식별자는 원문을 유지한다. - 변경 중: 공개 API 스키마를 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 변경 중: 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다. -- 변경 후: 최소 단일 테스트 또는 `./gradlew test`를 실행하고, 필요 시 `./gradlew ktlintCheck`를 수행한다. +- 변경 중: 동시에 하나의 미완료 goal만 운용한다. Goal의 체크박스, focused test, 완료 증거와 Progress 기록이 모두 충족되기 + 전에는 goal이나 Phase Gate를 완료 처리하지 않는다. +- 변경 중: 완료된 Task와 기존 검증 기록은 되돌리거나 삭제하지 않는다. 후속 리뷰에서 발견한 회귀는 별도 Task/Goal로 누적한다. +- 변경 후: 변경 범위의 focused test를 우선 실행하고, 공유 경계와 직접 영향받는 package/feature 회귀까지만 단계적으로 확장한다. +- 변경 후: 전체 회귀 테스트는 공통 인증·보안·예외·설정 등 광범위한 변경, 여러 domain/phase 변경, release/final Gate에서 전체 + 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만 실행한다. +- 변경 후: 전체 회귀 테스트를 실행하지 않으면 생략 사실과 근거, 대신 실행한 focused/영향 범위 회귀 명령을 검증 기록에 남긴다. - 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다. - 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다. diff --git a/docs/prd/sample-prd.md b/docs/prd/sample-prd.md deleted file mode 100644 index 3136d742..00000000 --- a/docs/prd/sample-prd.md +++ /dev/null @@ -1,106 +0,0 @@ -# PRD: [제품명] - -## 1. Overview -이 제품이 무엇인지 한 줄 설명 - ---- - -## 2. Problem -어떤 문제를 해결하는가? - -- 현재 사용자의 불편 -- 기존 방식의 한계 -- 왜 지금 필요한가 - ---- - -## 3. Goals -성공 기준 - -예: -- 가입 전환율 20% -- 작업 시간 50% 감소 - ---- - -## 4. Non-Goals -이번에 하지 않을 것 - -매우 중요함. - -예: -- 모바일 앱 지원 안 함 -- 실시간 협업 제외 -- 다국어 제외 - ---- - -## 5. Target Users -누가 사용하는가? - -- 초보 개발자 -- PM -- 디자이너 - ---- - -## 6. User Stories -사용자 행동 시나리오 - -예: -- 사용자는 버튼 하나로 요약하고 싶다 -- 사용자는 로그인 없이 체험하고 싶다 - ---- - -## 7. Core Features - -### Feature A -설명 - -#### Requirements -- must -- should -- constraints - -#### Edge Cases -- 빈 입력 -- timeout -- 중복 요청 - ---- - -## 8. UX / UI Expectations - -- 반응속도 -- 클릭 수 -- 모바일 대응 -- 접근성 - ---- - -## 9. Technical Constraints - -- Next.js 사용 -- PostgreSQL 사용 -- API latency 2초 이하 - ---- - -## 10. Metrics - -무엇을 측정할 것인가? - -- retention -- DAU -- conversion - ---- - -## 11. Open Questions - -아직 결정 안 된 것 - -- OAuth 제공? -- pricing? -- offline mode? diff --git a/docs/sample/sample-plan-task.md b/docs/sample/sample-plan-task.md new file mode 100644 index 00000000..4a062dae --- /dev/null +++ b/docs/sample/sample-plan-task.md @@ -0,0 +1,317 @@ +# Goal 실행형 구현 계획 샘플 + +> 이 문서는 goal 기능으로 구현 계획을 실행하기 위한 템플릿이다. 실제 `plan-task.md`를 만들 때 `<...>` placeholder를 모두 구체적인 값으로 교체한다. Phase는 결과와 의존성을 묶고, `create_goal`에는 Task 또는 Phase Gate 하나만 등록한다. + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 계획 작성 중 / 구현 중 / 외부 조건 대기 / 구현 완료 | +| 작성일 | `YYYY-MM-DD` | +| 요구사항 기준 | `<대상 prd.md 경로>` | +| API 기준 | `<대상 api-contract.md 경로>` | +| 현재 Phase | `` | +| 현재 활성 Goal | `<없음 또는 Goal ID>` | + +## 목표 + +`<사용자가 얻는 최종 결과를 한 문장으로 작성한다. 구현 수단보다 완결된 사용자 흐름을 먼저 쓴다.>` + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 대기 / 진행 중 / 완료 / 제외 | `0/N` | `P1-T1` | `<없음 또는 조건>` | +| 2 | 대기 / 진행 중 / 완료 / 제외 | `0/N` | `P2-T1` | `<없음 또는 조건>` | + +- 동시에 하나의 미완료 goal만 운용한다. +- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다. 후속 수정은 회귀 수정 Task와 새 goal ID를 추가한다. +- 사용자가 명시적으로 요청하지 않으면 goal에 token budget을 설정하지 않는다. + +## 범위 + +### 포함 + +- `<구현할 사용자 흐름>` +- `<구현할 route/API/UI 범위>` +- `<필수 오류·반응형·접근성·test 범위>` + +### 제외 + +- `<이번 릴리스에서 만들지 않을 기능>` +- `<외부 시스템 또는 다음 Phase 소유 범위>` +- `<복원, hard delete처럼 명시적으로 금지한 기능>` + +## 기술적 제약 + +- 기술 스택: `<언어, framework, 주요 library와 version 기준>` +- 아키텍처: `<상태·API·UI·파일 책임 경계>` +- 데이터·보안: `<저장 위치, 인증, 민감정보 비기록 규칙>` +- 호환성: `<지원 browser, viewport, runtime>` +- 의존성: 실제 소비 Task에서 필요한 최소 dependency만 추가한다. +- 계약: 제공되지 않은 endpoint, DTO, enum, 오류 status/key와 validation 상한을 추정하지 않는다. +- backend 구현 전 UI 확인이 필요하면 제공 계약 기반 explicit mock mode를 사용하고 실제 404 자동 fallback·production mock을 금지하며 mock/server 완료 증거를 분리한다. +- 구현: 모든 구현 Task는 아래 `RED → GREEN → REFACTOR` 순서를 체크박스에 명시하고, 가장 작은 실패 test에서 시작해 최소 구현으로 통과시킨다. +- 검증: focused test에서 시작해 영향받는 package/feature 회귀로 확장한다. 전체 회귀는 공통 경계 변경, 여러 domain/phase 변경, + release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 + 명시 요청이 있을 때만 실행한다. +- 전체 회귀를 생략하면 생략 사실·근거와 대신 실행한 focused/영향 범위 회귀 명령을 Progress에 기록한다. + +## Task TDD 작성 규칙 + +구현 Task의 실행 체크박스는 아래 순서와 표제어를 그대로 사용한다. 각 단계에는 대상 test, 실행 명령과 관찰할 실패·성공 결과를 +구체적으로 적고, `RED` 확인 없이 production 구현을 시작하거나 `GREEN` 확인 없이 `REFACTOR`로 넘어가지 않는다. + +- [ ] **RED:** 가장 작은 실패 test를 작성한다. +- [ ] **RED 확인:** focused test를 실행해 요구 동작이 없어서 발생한 의도한 assertion 실패를 확인한다. +- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다. +- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다. +- [ ] **REFACTOR:** 새 동작을 바꾸지 않는 범위에서 이번 Task가 만든 중복만 정리하고, focused test·직접 영향 회귀·lint를 다시 실행해 실제 결과를 Progress에 기록한다. + +test 작성이 현실적으로 불가능한 read-only 리뷰·문서·외부 확인 Task는 TDD 단계를 형식적으로 만들지 않는다. 대신 Task 본문에 +`TDD 예외 사유`와 실행 명령·대조표·수동 확인을 포함한 `대체 검증 방법`을 명시한다. + +## Phase 1 + +**Phase 결과:** `<이 Phase가 끝나면 사용자가 완료할 수 있는 흐름>` + +**선행조건:** `<없음 또는 선행 Goal/Gate ID>` + +**Phase 완료 조건:** `P1-T1`~`P1-TN`과 `P1-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 1.1 `<독립적으로 검토 가능한 결과>` + +**Goal 실행 `P1-T1`:** `<이 Task가 만드는 결과를 한 문장으로 작성한다.>` + +- **시작 조건:** `<선행 Goal, 요구사항 ID, API Contract section>` +- **완료 증거:** `<체크박스 전체, focused test, 산출물, 문서 기록>` +- **범위 밖:** `<다음 Task 또는 다른 Phase가 소유하는 항목>` + +**Files:** + +- Create: `<정확한 파일 경로>` +- Modify: `<정확한 파일 경로>` +- Test: `<정확한 test 파일 경로>` + +**Interfaces:** + +- Consumes: `<선행 Task가 제공하는 type/function/component contract>` +- Produces: `<후속 Task가 사용할 정확한 type/function/component contract>` + +- [ ] **RED:** `<요구 동작>`을 재현하는 가장 작은 실패 test를 작성한다. +- [ ] **RED 확인:** ``을 실행해 `<요구 동작 미구현 때문에 발생할 assertion 실패>`를 확인한다. +- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다. +- [ ] **GREEN 확인:** ``을 다시 실행해 성공을 확인한다. +- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test·직접 영향 회귀·typecheck·lint 결과를 Progress에 기록한다. + +#### Task 1.2 `<두 번째 독립 결과>` + +**Goal 실행 `P1-T2`:** `<이 Task가 만드는 결과를 한 문장으로 작성한다.>` + +- **시작 조건:** `P1-T1` 완료와 `<필요한 계약>`. +- **완료 증거:** `<체크박스 전체, test와 문서 기록>` +- **범위 밖:** `<이 Task에서 다루지 않는 항목>` + +**Files:** + +- Create: `<정확한 파일 경로>` +- Modify: `<정확한 파일 경로>` +- Test: `<정확한 test 파일 경로>` + +**Interfaces:** + +- Consumes: `` +- Produces: `` + +- [ ] **RED:** `<두 번째 요구 동작>`의 실패 test를 작성한다. +- [ ] **RED 확인:** ``으로 의도한 assertion 실패를 확인한다. +- [ ] **GREEN:** 최소 구현으로 focused test를 통과시킨다. +- [ ] **GREEN 확인:** 오류·loading·empty·success와 접근성 상태를 포함한 focused test 성공을 확인한다. +- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관련 test·typecheck·lint 결과를 Progress에 기록한다. + +### 완료 조건 + +- [ ] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 모두 충족됐다. +- [ ] Phase 1의 확정 요구사항이 구현·명시적 제외·후속 결정 중 하나로 추적된다. +- [ ] 알려진 문서와 구현의 차이가 없다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** Phase 1의 사용자 흐름과 공통 품질 기준을 최종 판정한다. + +- **시작 조건:** Phase 1의 모든 활성 Task goal 완료. +- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록. +- **범위 밖:** Gate 통과를 위한 test 삭제·완화와 관련 없는 기능 수정. + +```bash + + + + + +``` + +**Expected:** `<0 exit code, test 수, 사용자가 완료할 흐름, 금지 요청 0회 등 관찰 가능한 결과>` + +수동 검증: + +- [ ] `` +- [ ] `` +- [ ] `<민감정보·network request 검증>` + +## Phase 2 + +**Phase 결과:** `` + +**선행조건:** `P1-GATE` 또는 `` 완료. + +**Phase 완료 조건:** `P2-T1`~`P2-TN`과 `P2-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 2.1 계약·상태 확인 + +**Goal 실행 `P2-T1`:** Phase 2 구현에 필요한 계약, 상태/action inventory와 component/file map을 확정한다. + +- **시작 조건:** `<선행 Goal>` 완료, 관련 PRD/API Contract 확인. +- **완료 증거:** 계약 제공 또는 제외 결정이 기준 문서에 일치하고 구현 map이 기록됨. +- **범위 밖:** 계약을 추정한 production adapter와 실제 기능 구현. +- **TDD 예외 사유:** production 동작을 변경하지 않는 read-only 계약·상태 확인 Task다. +- **대체 검증 방법:** PRD/API Contract/현재 구현의 field·상태·파일 대조표와 누락·결정 기록을 작성하고 문서 검증 명령 결과를 Progress에 남긴다. + +- [ ] 필요한 endpoint·DTO·오류·pagination 계약을 확인한다. +- [ ] loading·empty·error·success·read-only·viewport 상태와 action을 inventory한다. +- [ ] 계약이 없으면 담당 주체·영향·재개 조건과 제외/후속 결정을 문서화한다. +- [ ] Page·feature·shared component와 test file 책임을 확정한다. + +#### Task 2.2 `` + +**Goal 실행 `P2-T2`:** `<사용자가 직접 확인할 수 있는 흐름을 한 문장으로 작성한다.>` + +- **시작 조건:** `P2-T1` 완료. +- **완료 증거:** `` +- **범위 밖:** `<다음 Task 또는 후속 Phase>` + +**Files:** + +- Create: `<정확한 파일 경로>` +- Modify: `<정확한 파일 경로>` +- Test: `<정확한 test 파일 경로>` + +- [ ] **RED:** contract·serializer와 사용자 action의 가장 작은 실패 test를 작성한다. +- [ ] **RED 확인:** ``을 실행해 각 요구 동작이 없어 발생한 assertion 실패를 확인한다. +- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다. +- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 contract와 사용자 흐름의 성공을 확인한다. +- [ ] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관련 integration/E2E·공통 품질 명령의 실제 결과와 남은 항목을 Progress에 기록한다. + +### 완료 조건 + +- [ ] `P2-T1`, `P2-T2`의 체크박스와 완료 증거가 모두 충족됐다. +- [ ] 외부 의존은 제공 계약 구현 또는 명시적 제외/후속 결정으로 종결됐다. +- [ ] Phase 2의 사용자 흐름과 오류·반응형·접근성 상태가 검증됐다. + +### 검증 방법 + +#### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** Phase 2의 contract, 사용자 흐름과 회귀 방지를 최종 판정한다. + +- **시작 조건:** Phase 2의 모든 활성 Task goal 완료. +- **완료 증거:** 아래 명령과 Expected 통과, Progress에 실제 결과 누적. +- **범위 밖:** 실패와 무관한 다음 Phase 구현. + +```bash + + + +``` + +**Expected:** `<사용자 journey, 오류 처리, request payload와 금지 동작을 포함한 최종 결과>` + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 없음 | 아니요 | 근거 문서 보정 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | 독립 검증 계속 | +| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 | +| 4 | `P2-T1` | `P1-GATE` | 아니요 | 외부 계약 담당·재개 조건 기록 | +| 5 | `P2-T2` | `P2-T1` | `<예/아니요>` | 독립 범위만 계속 수행 | +| 6 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패 소유 Task로 되돌림 | + +```text +P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE +``` + +## 변경 금지 항목 + +- 확정된 요구사항·API Contract를 근거 없이 변경하지 않는다. +- 기존 완료 체크박스와 Progress·Decision Log·검증 기록을 삭제하거나 덮어쓰지 않는다. +- 제공되지 않은 endpoint·DTO·enum·오류 값과 validation 상한을 만들지 않는다. +- 요청 범위 밖의 리팩터링, dependency 추가와 공통 abstraction을 확장하지 않는다. +- test를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과시키지 않는다. +- JWT, password, signed URL, 파일 본문 같은 민감정보를 log·fixture·문서에 기록하지 않는다. + +## 의사결정 및 중단 규칙 + +- PRD와 API Contract가 충돌하면 프로젝트가 정한 우선순위에 따라 결정하고 Decision Log에 근거를 남긴다. +- 구현 범위가 바뀌면 `plan-task.md` 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. +- 안전한 최소 기본값이 문서에 있으면 그 값만 구현한다. 안전하게 구현할 수 없으면 추정하지 않는다. +- 계약 미제공 기능을 제외할 때는 PRD 결정 기록 → API Contract → 이 계획 순서로 갱신한다. +- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고, 문서화나 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다. +- 체크박스 일부, test 일부 또는 코드 작성만 끝난 상태에서는 goal을 `complete`로 갱신하지 않는다. +- 완료 증거와 Progress 기록까지 충족한 뒤에만 goal을 `complete`로 갱신한다. + +## Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다. + +### `` N차 실행 — YYYY-MM-DD + +- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단 +- 무엇을: `<이번 실행에서 완료한 체크박스와 산출물>` +- 왜: `` +- 어떻게: + - `<실행 명령>` — `<성공/실패, exit code, test 수와 핵심 결과>` + - `<수동 검증>` — `<성공/실패/불가 사유>` +- 남은 항목: `<체크박스, 외부 조건 또는 없음>` +- 다음 행동: `<같은 goal에서 이어서 할 가장 작은 단계>` + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| `YYYY-MM-DD` | `DEC-001` | 제안 / 확정 / 정정 | `<결정 내용>` | `<요구사항, 계약, 검증 근거>` | `` | + +- 기존 결정을 정정할 때 원문을 지우지 않고 새 `정정` 행을 추가한다. +- 외부 의존 제외, 새로운 dependency, 범위 변경, 안전한 기본값 선택은 반드시 기록한다. + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| `ISSUE-001` | Blocker / High / Medium / Low | 후보 / 확정 / 보류 / 해결 | `<관찰 사실>` | `` | `<수정 Task, 외부 담당 또는 없음>` | + +- 구현 중 발견한 범위 내 문제는 근거와 재현 방법을 기록하고 해당 Task에서 처리한다. +- 완료된 범위의 회귀는 기존 Task를 다시 열지 않고 별도 회귀 수정 Task와 goal을 만든다. +- 범위 밖 문제는 임의로 수정하지 않고 사용자에게 보고하거나 후속 Task로 결정한다. +- 상세 코드 리뷰 결과가 필요하면 `docs/sample/sample-review.md` 형식으로 + `docs/[날짜]_구현할내용한글/reviews/[리뷰범위]-review.md`에 별도 문서를 만든다. + +## 최종 보고 형식 + +```markdown +구현 결과: <완료한 Phase와 사용자 흐름> + +- 변경: <주요 파일과 동작> +- 결정: <중요한 Decision Log ID와 내용> +- 검증: + - `<실행 명령>` — <성공/실패와 핵심 수치> + - `<수동 검증>` — <성공/실패/불가 사유> +- 남은 항목: <외부 의존, 후속 범위 또는 없음> +- 문서: <갱신한 PRD/API Contract/plan/review 링크> +``` + +최종 보고는 성공을 추정하지 않는다. 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다. diff --git a/docs/sample/sample-prd.md b/docs/sample/sample-prd.md new file mode 100644 index 00000000..ec5e3471 --- /dev/null +++ b/docs/sample/sample-prd.md @@ -0,0 +1,271 @@ +# 제품 요구사항 문서(PRD) 샘플 + +> 이 문서는 요구사항을 API Contract와 goal 실행형 `plan-task.md`로 연결하기 위한 템플릿이다. 실제 `prd.md`를 만들 때 `<...>` placeholder와 예시 ID를 모두 제품의 구체적인 값으로 교체한다. + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 초안 / 검토 중 / 구현 기준 확정 / 구현 완료 | +| 작성일 | `YYYY-MM-DD` | +| 최종 수정일 | `YYYY-MM-DD` | +| 대상 제품 | `<제품 또는 기능 이름>` | +| 작성자·결정권자 | `<이름 또는 역할>` | +| 관련 API Contract | `<대상 api-contract.md 경로>` | +| 관련 구현 계획 | `<대상 plan-task.md 경로>` | +| 관련 review | `<없음 또는 같은 작업 디렉터리의 reviews/ 아래 문서 링크>` | + +### 요구사항 상태 + +| 상태 | 의미 | 구현 처리 | +|---|---|---| +| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 | +| 미결 | 제품·UX·운영 결정이 더 필요함 | 권고안과 결정 주체·기한을 기록하고 임의 구현 금지 | +| 외부 의존 | 프론트엔드 밖의 계약·권한·환경 제공이 필요함 | 담당 주체·영향·재개 조건을 기록하고 추정 구현 금지 | +| 권고 | 미결 항목에 대한 현재 추천안 | 확정되기 전 계약이나 수용 기준으로 사용하지 않음 | +| 제외 | 현재 릴리스에서 구현하지 않기로 결정 | 제외 이유와 후속 조건을 Decision Log에 기록 | + +### 문서 우선순위와 갱신 순서 + +1. 사용자·제품 결정은 이 PRD에 기록한다. +2. request/response/error 계약은 `api-contract.md`에 정규화한다. +3. 구현 범위·순서·완료 증거는 `plan-task.md`에 반영한다. +4. 요구사항이 바뀌면 Decision Log → 관련 요구사항·수용 기준 → API Contract → plan 순서로 갱신한다. +5. 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 기록을 누적한다. + +## 1. Overview + +`<누가 어떤 상황에서 어떤 가치를 얻는 제품인지 2~4문장으로 설명한다.>` + +## 2. Problem Statement + +현재 사용자는 다음 문제를 겪는다. + +- `<관찰 가능한 현재 문제>` +- `<기존 방식의 비용·위험·제약>` +- `<해결하지 않을 때의 사용자 또는 사업 영향>` + +문제를 해결했다는 판단은 `<측정하거나 직접 확인할 결과>`로 한다. + +## 3. Goals + +### 3.1 제품 목표 + +- `<사용자가 완료할 수 있어야 하는 핵심 결과>` +- `<안전성·운영 효율·데이터 품질 목표>` +- `<릴리스 후 측정 가능한 성공 목표>` + +### 3.2 UX 목표 + +- `<핵심 흐름의 명확성·속도 목표>` +- `<오류·loading·empty·success feedback 목표>` +- `<반응형·keyboard·접근성 목표>` + +## 4. Non-Goals + +- `<명시적으로 구현하지 않을 기능>` +- `<다음 릴리스 또는 외부 시스템 소유 범위>` +- `<복원, hard delete, 자동 갱신처럼 금지할 동작>` + +Non-Goal을 변경하려면 Decision Log와 `plan-task.md` 범위를 먼저 갱신한다. + +## 5. Target Users and Permissions + +### 5.1 사용자 + +| 사용자 | 목표 | 주요 작업 | 사용 환경 | +|---|---|---|---| +| `<역할>` | `<달성 목표>` | `<조회·생성·수정 등>` | `` | + +### 5.2 권한 + +- 인증 주체: `<사용자 또는 시스템>` +- 허용 역할: `` +- 거부 조건: `<401/403 또는 제품 정책>` +- 리소스 소유권: `` +- read-only 조건: `<비활성·권한 부족·외부 상태 등>` + +## 6. 핵심 사용자 흐름 + +1. `<시작 조건과 진입점>` +2. `<대상 탐색·선택>` +3. `<핵심 생성·조회·수정 동작>` +4. `<성공 feedback과 다음 화면>` +5. `<오류·권한·session 만료 복구>` + +각 흐름은 `plan-task.md`의 최소 하나의 Phase 결과와 E2E 완료 증거로 연결한다. + +## 7. 정보 구조와 라우팅 + +```text +/ +/ +//:resourceId + / +``` + +- 전역 화면과 선택된 리소스 문맥의 경계를 명시한다. +- URL path와 query에 보존할 식별자·검색·filter·page 상태를 명시한다. +- 직접 링크·새로고침이 가능한 화면과 collection modal/Sheet처럼 별도 route가 없는 화면을 구분한다. +- 존재하지 않음, 다른 소유자, 비활성 리소스의 처리는 서버 계약과 공통 오류 정책을 따른다. + +## 8. 기능 요구사항 + +요구사항 ID는 `-NNN` 형식을 사용한다. 하나의 행에는 독립적으로 판정 가능한 요구사항 하나만 작성한다. + +### 8.1 `<도메인 A>` + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `DOMAINA-001` | 확정 | `<사용자가 할 수 있어야 하는 동작>` | `<관찰 가능한 성공·오류 결과>` | `api-contract.md §<번호>`, `P1-T1` | +| `DOMAINA-002` | 확정 | `` | `<보내야/보내지 말아야 할 값과 test>` | `P1-T2` | +| `DOMAINA-003` | 외부 의존 | `<외부 제공이 필요한 계약>` | `<제공 전 network integration 0건>` | `EXT-001`, `P1-T1` | + +### 8.2 `<도메인 B>` + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `DOMAINB-001` | 확정 | `<목록·상세·mutation 흐름>` | `` | `api-contract.md §<번호>`, `P2-T2` | +| `DOMAINB-002` | 미결 | `<제품 결정이 필요한 항목>` | `<확정 전 최대값·동작 추정 금지>` | `OQ-001` | + +### 8.3 공통 파일·데이터 정책 + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `FILE-001` | 확정 | `<허용 확장자·MIME·크기>` | `<정확한 byte 경계 test>` | `api-contract.md §<번호>`, `` | +| `DATA-001` | 확정 | `<날짜·가격·enum·pagination 규칙>` | `` | `` | + +## 9. 반응형 기능 범위 + +| 기능 | Desktop | Tablet | Mobile | 비고 | +|---|---:|---:|---:|---| +| 조회 | 전체 | 전체 | 전체 | `<예외>` | +| 생성·수정 | 허용 | 허용 | 허용 / 조회 전용 | `<직접 route 차단 정책>` | +| 파일 upload | 허용 | 허용 | 허용 / 미지원 | `<이유>` | + +- 화면에서 action을 숨기는 것뿐 아니라 직접 route와 mutation capability도 같은 정책으로 차단한다. +- 최소 viewport, zoom, touch target과 virtual keyboard 조건을 수용 기준에 연결한다. + +## 10. UI/UX Expectations + +### 10.1 디자인과 component 원칙 + +- `<브랜드 token, theme 범위, typography>` +- Page는 route·query·permission·component 조합을 담당한다. +- 도메인 표시·입력·상호작용은 feature component가 담당한다. +- 두 개 이상 Phase에서 같은 의미·동작으로 재사용할 때만 shared component로 올린다. + +### 10.2 화면 상태 + +- 모든 비동기 화면에 loading·empty·error·success 상태를 정의한다. +- mutation에는 진행 중·성공·실패·재시도·중복 제출 정책을 정의한다. +- destructive action에는 대상·영향·복구 여부를 알리는 확인 절차를 둔다. + +### 10.3 접근성 + +- 모든 input은 visible label과 연결된 오류를 가진다. +- keyboard-only 흐름, focus 표시·복귀, skip link와 live region 기준을 명시한다. +- 색상만으로 상태를 전달하지 않고 목표 대비와 touch target을 명시한다. +- `<지원 viewport>`, 200% zoom과 axe critical·serious 0건을 수용 기준으로 사용한다. + +## 11. API 계약 + +### 11.1 공통 규칙 + +- base URL과 인증 header: `<값>` +- locale: `` +- 성공 envelope: `` +- 오류 envelope와 status: `` +- pagination: `` +- multipart JSON part: `<이름>` + +### 11.2 Endpoint 추적 + +| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal | +|---|---|---|---|---|---| +| `DOMAINA-001` | `` | `` | 제공됨 / 보정 필요 / 제공 대기 | `§<번호>` | `` | + +### 11.3 외부 제공 대기 계약 + +| ID | 우선순위 | 제공 필요 계약 | 담당 주체 | 구현 영향 | 재개 조건 | +|---|---:|---|---|---|---| +| `EXT-001` | P0 / P1 | `` | `<팀/역할>` | `<차단되는 흐름>` | `<문서와 fixture 제공>` | + +- P0 계약이 없으면 영향을 받는 network flow를 완료로 표시하지 않는다. +- 계약에 의존하지 않는 UI shell·상태 inventory·문서화는 독립적으로 진행할 수 있다. + +## 12. 보안과 데이터 취급 + +- 인증 정보 저장 위치와 lifecycle: `<정확한 정책>` +- log·분석·오류 리포트 금지 값: `` +- 업로드 파일명·MIME·본문 취급: `<정책>` +- 리소스 격리와 ownership 검증: `<정책>` +- 401·403·동시 실패 처리: `<정책>` +- 감사 로그: `<포함, 외부 의존 또는 후속 범위>` + +## 13. 성능과 품질 요구사항 + +- 목록 pagination과 전체 로드 예외: `<정책>` +- 검색 debounce와 기존 데이터 유지: `<정책>` +- image layout shift와 lazy loading: `<정책>` +- mutation 중복 제출·upload 취소/재시도: `<정책>` +- 지원 runtime·browser: `<정확한 범위>` +- test stack과 필수 Gate: `` +- backend 구현 전 UI 확인: `<불필요 또는 explicit mock mode, production 금지, no-auto-fallback, mock/server 완료 상태 분리>` + +## 14. 성공 기준 + +### 14.1 기능 수용 기준 + +- [ ] `<핵심 사용자 journey가 성공한다.>` (`DOMAINA-001`, `P1-GATE`) +- [ ] `<권한·오류·payload 불변식이 검증된다.>` (`DOMAINA-002`, ``) +- [ ] `<외부 의존의 구현 또는 제외 결정이 문서화된다.>` (`EXT-001`) + +### 14.2 UI/UX 수용 기준 + +- [ ] loading·empty·error·success 상태가 있다. +- [ ] keyboard-only로 핵심 흐름을 완료한다. +- [ ] `<최소 viewport>`와 200% zoom에서 핵심 control이 가려지지 않는다. +- [ ] axe critical·serious 위반이 0건이다. + +### 14.3 추적성 완료 기준 + +- [ ] 모든 `확정` 요구사항이 API Contract와 하나 이상의 Task/Goal 완료 증거로 연결된다. +- [ ] 모든 `미결` 항목에 결정 주체와 다음 행동이 있다. +- [ ] 모든 `외부 의존` 항목에 담당 주체·영향·재개 조건이 있다. +- [ ] `제외` 항목에 Decision Log와 후속 조건이 있다. + +## 15. Open Questions + +| ID | 상태 | 결정 필요 사항 | 현재 권고 | 결정 주체 | 결정 기한/시점 | 영향 Goal | +|---|---|---|---|---|---|---| +| `OQ-001` | 미결 | `<질문>` | `<권고안 또는 없음>` | `<역할>` | `<날짜 또는 UI 작성 후>` | `` | + +- Open Question과 외부 의존을 혼합하지 않는다. 제품이 결정할 수 없는 backend 계약은 `EXT-*`로 관리한다. +- 미결 값을 임의의 상수·enum·endpoint로 구현하지 않는다. + +## 16. 요구사항 추적표 + +| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 | +|---|---|---:|---|---|---| +| `DOMAINA-001~003` | `§<번호>` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | `` | `<흐름>` | +| `DOMAINB-001~002` | `§<번호 또는 제공 대기>` | 2 | `P2-T1`, `P2-T2`, `P2-GATE` | `` | `<흐름>` | + +## 17. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal | +|---|---|---|---|---|---| +| `YYYY-MM-DD` | `DEC-001` | 확정 / 정정 / 폐기 | `<결정 내용>` | `<인터뷰·계약·검증 근거>` | `` | + +- 기존 결정을 수정할 때 원문을 지우지 않고 `정정` 행을 추가한다. +- 범위 변경, Non-Goal 변경, 외부 의존 제외, 안전한 기본값과 주요 기술 선택을 기록한다. + +## 18. 변경 관리 + +요구사항 변경 시 다음을 확인한다. + +- [ ] Decision Log에 변경 이유와 날짜를 기록했다. +- [ ] 관련 요구사항 상태·본문·수용 기준을 갱신했다. +- [ ] API Contract의 request/response/error와 fixture를 갱신했다. +- [ ] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신했다. +- [ ] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않았다. diff --git a/docs/sample/sample-review.md b/docs/sample/sample-review.md new file mode 100644 index 00000000..6378b595 --- /dev/null +++ b/docs/sample/sample-review.md @@ -0,0 +1,187 @@ +# 코드 리뷰 보고서 샘플 + +> 이 문서는 완료된 Phase를 다시 검토할 때 사용하는 템플릿이다. 리뷰에서 발견한 후보를 먼저 검증하고, **확정**된 항목만 `plan-task.md`의 회귀 수정 Task와 goal로 전환한다. 기존 완료 체크박스와 검증 기록은 삭제하거나 되돌리지 않는다. +> 실제 리뷰 문서는 대상 PRD·`plan-task.md`와 같은 작업 디렉터리의 `reviews/` 아래에 +> `docs/[날짜]_구현할내용한글/reviews/[리뷰범위]-review.md` 형식으로 저장한다. + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase `<번호>` / Task `<번호 또는 범위>` | +| 기준 commit 또는 working tree | `` | +| 리뷰 일자 | `YYYY-MM-DD` | +| 리뷰어 | `<이름 또는 agent>` | +| 기준 문서 | `<대상 prd.md, api-contract.md, plan-task.md 경로>` | +| 리뷰 상태 | 진행 중 / 판정 완료 / 수정 검증 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- `<예: Phase 0~1 구현이 요구사항과 API Contract를 충족하는지 확인한다.>` +- `<예: 완료 체크박스와 실제 코드·test·검증 기록이 일치하는지 확인한다.>` + +### 포함 범위 + +- 코드: `<검토할 경로>` +- 테스트: `<검토할 unit/integration/E2E 경로>` +- 문서: `<검토할 요구사항·계약·Task 범위>` +- 수동 검증: `<브라우저, viewport, keyboard, 접근성 등>` + +### 제외 범위 + +- `<이번 리뷰에서 다루지 않는 Phase, 기능 또는 외부 계약>` + +## 3. 판정 기준 + +### 심각도 + +| 심각도 | 기준 | +|---|---| +| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 | +| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 | +| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 | + +### 상태 + +| 상태 | 의미 | 후속 처리 | +|---|---|---| +| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 | +| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 | +| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 | +| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 | +| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: `<예: AUTH-001~013>` +- API Contract: `<예: §3 인증>` +- 계획: `<예: P1-T2, P1-T3, P1-GATE>` +- 코드: `<파일 경로와 line>` +- 테스트: `<테스트 파일과 test name>` + +### 실행 환경 + +```text +OS: <값> +Node: <값> +npm: <값> +Browser/viewport: <값> +환경 변수: 민감정보를 제외한 이름과 사용 mode만 기록 +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `<실제 실행 명령>` | 성공 / 실패 / 불가 | `` | +| `<수동 검증 절차>` | 성공 / 실패 / 불가 | `<관찰 결과>` | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P1-001` | `<심각도>` | 후보 | `<한 문장 제목>` | `<예: P1-T3>` | 판정 전 | + +발견 사항이 없으면 “확정 발견 사항 없음”이라고 명시하고, 검토 범위와 실행 증거는 그대로 남긴다. + +## 6. 발견 사항 상세 + +### REV-P1-001 — `<한 문장 제목>` + +- **심각도:** `` +- **상태:** `<후보 | 확정 | 오탐 | 보류 | 수정 완료>` +- **관련 요구사항:** `<요구사항 ID 또는 없음>` +- **관련 계약:** `` +- **소유 Task:** `<기존 Goal ID 또는 신규 회귀 Task>` + +**관찰 내용** + +`<실제로 관찰한 동작을 추정 없이 작성한다.>` + +**근거** + +- 코드: `<파일 경로:line과 관련 동작>` +- 테스트: `<테스트 파일:test name 또는 누락 사실>` +- 문서: `<문서 경로와 요구사항/계약/계획 항목>` + +**재현 또는 검증 절차** + +1. `<사전 조건>` +2. `<실행 명령 또는 사용자 동작>` +3. `<실제 결과>` +4. `<요구되는 결과>` + +**영향** + +`<사용자, 데이터, 보안, 접근성, 회귀 범위를 구체적으로 작성한다.>` + +**권장 조치** + +`<최소 수정 방향과 추가해야 할 회귀 test를 작성한다. 구현 전 확정되지 않은 endpoint·DTO·오류 값은 추정하지 않는다.>` + +**판정 기록** + +- `YYYY-MM-DD` — `<확정/오탐/보류 판정과 근거>` +- `YYYY-MM-DD` — `<후속 정정이 있으면 기존 기록을 지우지 않고 추가>` + +## 7. 확정 항목의 plan·goal 전환 + +확정 발견 사항이 없으면 이 절에 “전환 항목 없음”을 기록한다. 확정 항목이 있으면 구현 전에 `plan-task.md`에 아래 내용을 반영한다. + +### 신규 회귀 수정 Task 초안 + +```markdown +### Task R<번호>.<번호> <수정할 결과> + +**Goal 실행 `P-R<번호>`:** <확정된 문제를 수정하고 회귀를 방지하는 한 문장 objective> + +- **시작 조건:** <관련 review ID, 기존 Task/Gate, 필요한 계약> +- **완료 증거:** <실패 재현 test → 수정 후 focused test → Phase Gate → 검증 기록> +- **범위 밖:** <이번 수정에서 건드리지 않을 기능> + +- [ ] `<재현 가능한 실패 test를 먼저 추가하고 의도한 assertion 실패를 확인한다.>` +- [ ] `<최소 수정으로 test를 통과시킨다.>` +- [ ] `<관련 focused test와 Phase Gate를 실행한다.>` +- [ ] `` +``` + +### create_goal objective 초안 + +```text +[P-R<번호>]의 확정 review 항목 을 수정하고 회귀를 방지한다. +plan-task.md에 추가된 회귀 수정 Task만 수행한다. +실패 재현, 최소 수정, focused test, Phase Gate와 검증 기록이 모두 끝나기 전에는 complete로 표시하지 않는다. +관련 없는 리팩터링과 계약 추정은 범위 밖이다. +``` + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 / 미충족 | `<근거>` | +| 후보 항목 판정 완료 | 충족 / 미충족 | `<근거>` | +| 확정 항목 plan 반영 | 충족 / 해당 없음 / 미충족 | `` | +| 보류 항목의 담당·재개 조건 기록 | 충족 / 해당 없음 / 미충족 | `<근거>` | +| 검증 명령과 결과 기록 | 충족 / 미충족 | `<근거>` | + +**최종 결론:** `<확정 발견 사항 없음 | 수정 goal 필요 | 외부 조건 대기 | 수정 검증 완료>` + +**남은 항목:** `<없음 또는 review ID와 다음 행동>` + +## 9. 수정 후 검증 기록 + +기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다. + +### N차 수정 검증 — YYYY-MM-DD + +- 무엇을: `<수정한 review ID와 결과>` +- 왜: `<요구사항·계약 위반 또는 회귀 위험>` +- 어떻게: + - `<실행 명령>` — `<성공/실패와 핵심 수치>` + - `<수동 검증>` — `<성공/실패/불가 사유>` +- 남은 항목: `<없음, 보류 또는 후속 review ID>` diff --git a/openapitools.json b/openapitools.json new file mode 100644 index 00000000..936223a5 --- /dev/null +++ b/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "7.24.0" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index 51cd9808..9a0a78cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.chat.character.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -53,6 +56,48 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): Page + @EntityGraph(attributePaths = ["creatorMember", "originalWork"]) + @Query( + value = """ + SELECT DISTINCT c FROM ChatCharacter c + JOIN c.creatorMember cm + LEFT JOIN c.tagMappings tm + LEFT JOIN tm.tag t + WHERE c.isActive = true + AND cm.role = :role + AND cm.memberKind = :memberKind + AND ( + :searchTerm = '' OR + LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR + (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) + ) + """, + countQuery = """ + SELECT COUNT(DISTINCT c) FROM ChatCharacter c + JOIN c.creatorMember cm + LEFT JOIN c.tagMappings tm + LEFT JOIN tm.tag t + WHERE c.isActive = true + AND cm.role = :role + AND cm.memberKind = :memberKind + AND ( + :searchTerm = '' OR + LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR + (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR + (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) + ) + """ + ) + fun searchAiCharacters( + @Param("searchTerm") searchTerm: String, + @Param("role") role: MemberRole, + @Param("memberKind") memberKind: MemberKind, + pageable: Pageable + ): Page + /** * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) */ @@ -142,6 +187,15 @@ interface ChatCharacterRepository : JpaRepository { ) fun findByIdInWithTagMappings(@Param("ids") ids: List): List + @Query( + """ + SELECT c FROM ChatCharacter c + LEFT JOIN FETCH c.creatorMember + WHERE c.id = :id + """ + ) + fun findByIdWithCreatorMember(@Param("id") id: Long): ChatCharacter? + fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter? fun existsByCreatorMemberId(creatorMemberId: Long): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt index a8ab7ee1..e5b9ff8d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt @@ -1,12 +1,22 @@ package kr.co.vividnext.sodalive.common import com.fasterxml.jackson.databind.ObjectMapper +import io.jsonwebtoken.JwtException +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.servlet.HandlerExceptionResolver import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() { +class ExceptionHandlerFilter( + private val objectMapper: ObjectMapper, + private val apiRequestMatcher: RequestMatcher, + private val apiAuthenticationEntryPoint: AuthenticationEntryPoint, + private val apiExceptionResolver: HandlerExceptionResolver +) : OncePerRequestFilter() { override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, @@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe try { filterChain.doFilter(request, response) } catch (e: Exception) { + if (apiRequestMatcher.matches(request)) { + if (isAuthenticationFailure(e)) { + val authenticationException = e as? BadCredentialsException + ?: BadCredentialsException("Authentication failed", e) + apiAuthenticationEntryPoint.commence(request, response, authenticationException) + } else { + val resolved = apiExceptionResolver.resolveException(request, response, null, e) + if (resolved == null) throw e + } + return + } + response.status = 401 response.contentType = "application/json" response.characterEncoding = "UTF-8" @@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe response.writer.write(json) } } + + private fun isAuthenticationFailure(exception: Exception): Boolean { + return exception is JwtException || + exception is BadCredentialsException || + (exception is SodaException && exception.messageKey == "common.error.bad_credentials") + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt index 96bf7e7e..86046fdc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt @@ -1,12 +1,9 @@ package kr.co.vividnext.sodalive.common -import org.springframework.http.HttpStatus - class SodaException( message: String? = null, val errorProperty: String? = null, - val messageKey: String? = null, - val httpStatus: HttpStatus? = null + val messageKey: String? = null ) : RuntimeException(message) class AdsChargeException( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt index 2ebdfae8..ecb706c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity import org.springframework.security.access.AccessDeniedException import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.InternalAuthenticationServiceException @@ -14,9 +13,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.multipart.MaxUploadSizeExceededException -import org.springframework.web.multipart.MultipartException import org.springframework.web.server.ResponseStatusException -import javax.servlet.http.HttpServletRequest @RestControllerAdvice class SodaExceptionHandler( @@ -41,25 +38,12 @@ class SodaExceptionHandler( ) } - @ExceptionHandler(MaxUploadSizeExceededException::class, MultipartException::class) - fun handleMultipartException(e: MultipartException, request: HttpServletRequest) = run { - val isAiCharacterAdminPath = isAiCharacterAdminPath(request.requestURI) - val messageKey = if (e is MaxUploadSizeExceededException) { - "common.error.max_upload_size" - } else if (isAiCharacterAdminPath) { - "common.error.invalid_request" - } else { - "common.error.unknown" - } - val logMessage = messageSource.getMessage(messageKey, logLang) + @ExceptionHandler(MaxUploadSizeExceededException::class) + fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run { + val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang) logger.error("API error: {}", logMessage, e) - val message = messageSource.getMessage(messageKey, langContext.lang) - val body = ApiResponse.error(message = message) - if (isAiCharacterAdminPath) { - ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body) - } else { - body - } + val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang) + ApiResponse.error(message = message) } @ExceptionHandler(AccessDeniedException::class) @@ -115,12 +99,4 @@ class SodaExceptionHandler( val message = messageSource.getMessage("common.error.unknown", langContext.lang) ApiResponse.error(message) } - - private fun isAiCharacterAdminPath(requestUri: String): Boolean { - return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/") - } - - companion object { - private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters" - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index e3c96b9d..65eaad1d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -2,16 +2,19 @@ package kr.co.vividnext.sodalive.configs import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint import kr.co.vividnext.sodalive.jwt.JwtFilter import kr.co.vividnext.sodalive.jwt.TokenProvider -import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler -import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint -import org.springframework.beans.factory.ObjectProvider +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity @@ -20,8 +23,16 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler +import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.web.HttpRequestHandler +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping @Configuration @EnableWebSecurity @@ -30,9 +41,7 @@ class SecurityConfig( private val objectMapper: ObjectMapper, private val tokenProvider: TokenProvider, private val accessDeniedHandler: JwtAccessDeniedHandler, - private val authenticationEntryPoint: JwtAuthenticationEntryPoint, - private val aiCharacterAdminAuthenticationEntryPoint: ObjectProvider, - private val aiCharacterAdminAccessDeniedHandler: ObjectProvider + private val authenticationEntryPoint: JwtAuthenticationEntryPoint ) { @Bean fun passwordEncoder(): PasswordEncoder { @@ -40,39 +49,94 @@ class SecurityConfig( } @Bean - fun webSecurityCustomizer(): WebSecurityCustomizer { + fun webSecurityCustomizer( + aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler + ): WebSecurityCustomizer { return WebSecurityCustomizer { web: WebSecurity -> web + .requestRejectedHandler(aiCharacterAdminSecurityErrorHandler) .ignoring() .antMatchers("/h2-console/**", "/favicon.ico", "/error") } } @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { + fun aiCharacterAdminErrorResponseWriter(messageSource: SodaMessageSource): AiCharacterAdminErrorResponseWriter { + return AiCharacterAdminErrorResponseWriter(objectMapper, messageSource) + } + + @Bean + fun aiCharacterAdminSecurityErrorHandler( + responseWriter: AiCharacterAdminErrorResponseWriter + ): AiCharacterAdminSecurityErrorHandler { + return AiCharacterAdminSecurityErrorHandler( + responseWriter, + AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH), + WebConfig.createAiCharacterAdminCorsConfiguration() + ) + } + + @Bean + fun aiCharacterAdminExceptionHandler( + responseWriter: AiCharacterAdminErrorResponseWriter + ): AiCharacterAdminExceptionHandler { + return AiCharacterAdminExceptionHandler( + responseWriter, + AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH) + ) + } + + @Bean + fun aiCharacterAdminFallbackHandlerMapping( + responseWriter: AiCharacterAdminErrorResponseWriter + ): SimpleUrlHandlerMapping { + val notFoundHandler = HttpRequestHandler { request, response -> + responseWriter.write( + request, + response, + HttpStatus.NOT_FOUND, + "common.error.invalid_request" + ) + } + return SimpleUrlHandlerMapping( + mapOf(AI_CHARACTER_ADMIN_PATH to notFoundHandler), + Ordered.LOWEST_PRECEDENCE - 2 + ).apply { + setCorsConfigurations( + mapOf(AI_CHARACTER_ADMIN_PATH to WebConfig.createAiCharacterAdminCorsConfiguration()) + ) + } + } + + @Bean + fun filterChain( + http: HttpSecurity, + aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler, + aiCharacterAdminExceptionHandler: AiCharacterAdminExceptionHandler + ): SecurityFilterChain { val jwtFilter = JwtFilter(tokenProvider) + val aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH) + val authenticationEntryPoints = linkedMapOf( + aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler + ) + val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply { + setDefaultEntryPoint(authenticationEntryPoint) + } + val accessDeniedHandlers = linkedMapOf( + aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler + ) + val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler( + accessDeniedHandlers, + accessDeniedHandler + ) return http .cors() .and() .csrf().disable() .exceptionHandling() - .authenticationEntryPoint { request, response, authException -> - val adminEntryPoint = aiCharacterAdminAuthenticationEntryPoint.getIfAvailable() - if (isAiCharacterAdminPath(request.requestURI) && adminEntryPoint != null) { - adminEntryPoint.commence(request, response, authException) - } else { - authenticationEntryPoint.commence(request, response, authException) - } - } - .accessDeniedHandler { request, response, accessDeniedException -> - val adminAccessDeniedHandler = aiCharacterAdminAccessDeniedHandler.getIfAvailable() - if (isAiCharacterAdminPath(request.requestURI) && adminAccessDeniedHandler != null) { - adminAccessDeniedHandler.handle(request, response, accessDeniedException) - } else { - accessDeniedHandler.handle(request, response, accessDeniedException) - } - } + .authenticationEntryPoint(delegatingAuthenticationEntryPoint) + .accessDeniedHandler(delegatingAccessDeniedHandler) .and() .headers() .frameOptions() @@ -82,7 +146,15 @@ class SecurityConfig( .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) - .addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java) + .addFilterBefore( + ExceptionHandlerFilter( + objectMapper, + aiCharacterAdminRequestMatcher, + aiCharacterAdminSecurityErrorHandler, + aiCharacterAdminExceptionHandler + ), + JwtFilter::class.java + ) .authorizeRequests() .antMatchers("/member/check/email").permitAll() .antMatchers("/member/check/nickname").permitAll() @@ -119,7 +191,6 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() - .antMatchers(HttpMethod.PUT, "/audio-content/upload-complete").hasAnyRole("ADMIN", "BOT") .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll() @@ -128,19 +199,20 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated() + .antMatchers(AI_CHARACTER_ADMIN_PATH) + .access( + "hasRole('ADMIN') and " + + "principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " + + "principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN" + ) // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() - .antMatchers(AI_CHARACTER_ADMIN_PATH_PREFIX, "$AI_CHARACTER_ADMIN_PATH_PREFIX/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .build() } - private fun isAiCharacterAdminPath(requestUri: String): Boolean { - return requestUri == AI_CHARACTER_ADMIN_PATH_PREFIX || requestUri.startsWith("$AI_CHARACTER_ADMIN_PATH_PREFIX/") - } - companion object { - private const val AI_CHARACTER_ADMIN_PATH_PREFIX = "/admin/ai-characters" + private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**" } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt index fe0b9c9e..aeb47fe2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs import kr.co.vividnext.sodalive.common.CountryInterceptor import kr.co.vividnext.sodalive.i18n.LangInterceptor import org.springframework.context.annotation.Configuration +import org.springframework.web.cors.CorsConfiguration import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @@ -18,15 +19,49 @@ class WebConfig( } override fun addCorsMappings(registry: CorsRegistry) { + listOf("/admin/member/login", "/member/logout").forEach { path -> + registry.addMapping(path) + .allowedOrigins(*AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS.toTypedArray()) + .allowedMethods("*") + .allowCredentials(true) + } + + registry.addMapping("/api/v2/admin/ai-characters/**") + .allowedOrigins(*AI_CHARACTER_ADMIN_ALLOWED_ORIGINS.toTypedArray()) + .allowedMethods("*") + .allowCredentials(true) + registry.addMapping("/**") - .allowedOrigins( - "http://localhost:8888", - "https://creator.sodalive.net", - "https://test-creator.sodalive.net", - "https://test-admin.sodalive.net", - "https://admin.sodalive.net" - ) + .allowedOrigins(*ALLOWED_ORIGINS.toTypedArray()) .allowedMethods("*") .allowCredentials(true) } + + companion object { + private val ALLOWED_ORIGINS = listOf( + "http://localhost:8888", + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", + "https://test-admin.sodalive.net", + "https://admin.sodalive.net" + ) + + private val AI_CHARACTER_ADMIN_ALLOWED_ORIGINS = listOf( + "http://localhost:8888", + "https://test-character-admin.sodalive.net", + "https://character-admin.sodalive.net" + ) + + private val AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS = + (ALLOWED_ORIGINS + AI_CHARACTER_ADMIN_ALLOWED_ORIGINS).distinct() + + internal fun createAiCharacterAdminCorsConfiguration(): CorsConfiguration { + return CorsConfiguration().apply { + applyPermitDefaultValues() + allowedOrigins = AI_CHARACTER_ADMIN_ALLOWED_ORIGINS + allowedMethods = listOf(CorsConfiguration.ALL) + allowCredentials = true + } + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 6f9d33b0..328ad868 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -36,6 +36,7 @@ import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.utils.generateFileName @@ -57,6 +58,8 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale +private const val MAX_PIN_CONTENT_COUNT = 9 + @Service @Transactional(readOnly = true) class AudioContentService( @@ -72,6 +75,7 @@ class AudioContentService( private val commentRepository: AudioContentCommentRepository, private val audioContentLikeRepository: AudioContentLikeRepository, private val pinContentRepository: PinContentRepository, + private val memberRepository: MemberRepository, private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, private val contentTranslationRepository: ContentTranslationRepository, @@ -203,8 +207,14 @@ class AudioContentService( val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!) ?: throw SodaException(messageKey = "content.error.invalid_content_retry") + val pinContent = pinContentRepository.findByContentIdAndMemberId( + contentId = audioContentId, + memberId = member.id!! + ) + audioContent.isActive = false audioContent.releaseDate = null + pinContent?.isActive = false } @Transactional @@ -220,9 +230,6 @@ class AudioContentService( // request 내용 파싱 val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) - // 미리듣기 시간 체크 - validatePreviewTime(request.previewStartTime, request.previewEndTime) - val releaseDate = if (request.releaseDate != null) { request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm") .atZone(ZoneId.of(request.timezone)) @@ -232,11 +239,26 @@ class AudioContentService( LocalDateTime.now() } + return createAudioContent(contentFile, coverImage, request, releaseDate, member) + } + + @Transactional + fun createAudioContent( + contentFile: MultipartFile?, + coverImage: MultipartFile?, + request: CreateAudioContentRequest, + releaseDate: LocalDateTime, + member: Member + ): CreateAudioContentResponse { + if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required") + // contentFile 체크 if (contentFile == null) { throw SodaException(messageKey = "content.error.content_required") } + validatePreviewTime(request.previewStartTime, request.previewEndTime) + // 테마 체크 val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) ?: throw SodaException(messageKey = "content.error.invalid_theme") @@ -783,7 +805,7 @@ class AudioContentService( val pinContentListCount = pinContentRepository.getPinContentList(memberId = member.id!!, active = true).size val isAvailablePin = if (member.id!! == audioContent.member!!.id!!) { - pinContentListCount < 3 + pinContentListCount < MAX_PIN_CONTENT_COUNT } else { false } @@ -945,7 +967,7 @@ class AudioContentService( previousContent = previousContent, nextContent = nextContent, buyerList = buyerList, - isAvailableUsePoint = audioContent.isPointAvailable, + isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0, translated = translated ) } @@ -1215,7 +1237,9 @@ class AudioContentService( @Transactional fun pinToTheTop(contentId: Long, member: Member) { - val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!) + val creator = memberRepository.findByIdForUpdate(member.id!!) + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") + val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = creator.id!!) ?: throw SodaException(messageKey = "content.error.invalid_content_retry") if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) { @@ -1224,21 +1248,24 @@ class AudioContentService( var pinContent = pinContentRepository.findByContentIdAndMemberId( contentId = contentId, - memberId = member.id!! + memberId = creator.id!! ) + val activePinContentList = pinContentRepository.getPinContentList(memberId = creator.id!!, active = true) if (pinContent != null) { + if (!pinContent.isActive && activePinContentList.size >= MAX_PIN_CONTENT_COUNT) { + activePinContentList[0].isActive = false + } pinContent.isActive = true } else { - val pinContentList = pinContentRepository.getPinContentList(memberId = member.id!!) - pinContent = if (pinContentList.size >= 3) { - pinContentList[0] + pinContent = if (activePinContentList.size >= MAX_PIN_CONTENT_COUNT) { + activePinContentList[0] } else { PinContent() } pinContent.isActive = true - pinContent.member = member + pinContent.member = creator pinContent.content = audioContent pinContentRepository.save(pinContent) } @@ -1246,9 +1273,11 @@ class AudioContentService( @Transactional fun unpinAtTheTop(contentId: Long, member: Member) { + val creator = memberRepository.findByIdForUpdate(member.id!!) + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") val pinContent = pinContentRepository.findByContentIdAndMemberId( contentId = contentId, - memberId = member.id!! + memberId = creator.id!! ) ?: throw SodaException(messageKey = "content.error.invalid_content_retry") pinContent.isActive = false diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/pin/PinContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/pin/PinContentRepository.kt index 18198210..be375d54 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/pin/PinContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/pin/PinContentRepository.kt @@ -15,6 +15,7 @@ interface PinContentQueryRepository { class PinContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : PinContentQueryRepository { override fun getPinContentList(memberId: Long, active: Boolean?): List { var where = pinContent.member.id.eq(memberId) + .and(pinContent.content.isActive.eq(true)) if (active != null) { where = where.and(pinContent.isActive.eq(active)) @@ -30,6 +31,7 @@ class PinContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : override fun findByContentIdAndMemberId(contentId: Long, memberId: Long, active: Boolean?): PinContent? { var where = pinContent.content.id.eq(contentId) .and(pinContent.member.id.eq(memberId)) + .and(pinContent.content.isActive.eq(true)) if (active != null) { where = where diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt index 7ee5536c..665042aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesRepository.kt @@ -7,8 +7,19 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri import kr.co.vividnext.sodalive.creator.admin.content.series.content.QSearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType -interface CreatorAdminContentSeriesRepository : JpaRepository, CreatorAdminContentSeriesQueryRepository +interface CreatorAdminContentSeriesRepository : JpaRepository, CreatorAdminContentSeriesQueryRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from Series s where s.member.id = :creatorId and s.id in :ids and s.isActive = true order by s.id asc") + fun findActiveByCreatorIdAndIdInForUpdate( + @Param("creatorId") creatorId: Long, + @Param("ids") ids: List + ): List +} interface CreatorAdminContentSeriesQueryRepository { fun findByIdAndCreatorId(id: Long, creatorId: Long): Series? diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt index f851665a..41a4d481 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt @@ -83,15 +83,21 @@ class TokenProvider( .parseClaimsJws(token) .body - val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) } - val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong()) + val memberId = claims.subject?.toLongOrNull() + ?: throw SodaException(messageKey = "common.error.bad_credentials") + val authorityNames = (claims[AUTHORITIES_KEY] as? String) + ?.split(",") + ?.takeIf { names -> names.all { it.isNotBlank() } } + ?: throw SodaException(messageKey = "common.error.bad_credentials") + val authorities = authorityNames.map { SimpleGrantedAuthority(it) } + val memberToken = tokenRepository.findByIdOrNull(id = memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (!memberToken.tokenSet.contains(token)) { throw SodaException(messageKey = "common.error.bad_credentials") } - val member = repository.findByIdOrNull(id = claims.subject.toLong()) + val member = repository.findByIdOrNull(id = memberId) ?: throw SodaException(messageKey = "common.error.bad_credentials") val principal = MemberAdapter(member) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 559b9445..71eb01ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -497,12 +497,14 @@ class MemberService( fun creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long) { if (creatorId == memberId) return - val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( + val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate( creatorId = creatorId, memberId = memberId ) if (creatorFollowing == null) { + if (!isActive) return + val creator = repository.findByIdOrNull(creatorId) ?: throw SodaException(messageKey = "member.validation.creator_not_found") val member = repository.findByIdOrNull(memberId) @@ -515,12 +517,15 @@ class MemberService( } else { creatorFollowing.isNotify = isNotify && isActive creatorFollowing.isActive = isActive + if (!isActive) { + homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId) + } } } @Transactional fun creatorUnFollow(creatorId: Long, memberId: Long) { - val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( + val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate( creatorId = creatorId, memberId = memberId ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt index d74b551d..3dc9ac73 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt @@ -4,12 +4,14 @@ import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository +import javax.persistence.LockModeType @Repository interface CreatorFollowingRepository : JpaRepository, CreatorFollowingQueryRepository interface CreatorFollowingQueryRepository { fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing? + fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing? } @Repository @@ -25,4 +27,15 @@ class CreatorFollowingQueryRepositoryImpl( ) .fetchFirst() } + + override fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing? { + return queryFactory + .selectFrom(creatorFollowing) + .where( + creatorFollowing.creator.id.eq(creatorId) + .and(creatorFollowing.member.id.eq(memberId)) + ) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .fetchFirst() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt deleted file mode 100644 index bb455bb4..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAccessDeniedHandler.kt +++ /dev/null @@ -1,27 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security - -import com.fasterxml.jackson.databind.ObjectMapper -import kr.co.vividnext.sodalive.common.ApiResponse -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.security.access.AccessDeniedException -import org.springframework.security.web.access.AccessDeniedHandler -import org.springframework.stereotype.Component -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -@Component -class AiCharacterAdminAccessDeniedHandler( - private val objectMapper: ObjectMapper -) : AccessDeniedHandler { - override fun handle( - request: HttpServletRequest, - response: HttpServletResponse, - accessDeniedException: AccessDeniedException - ) { - response.status = HttpStatus.FORBIDDEN.value() - response.contentType = MediaType.APPLICATION_JSON_VALUE - response.characterEncoding = Charsets.UTF_8.name() - response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("권한이 없습니다."))) - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt deleted file mode 100644 index 2e13f7d8..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/security/AiCharacterAdminAuthenticationEntryPoint.kt +++ /dev/null @@ -1,27 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security - -import com.fasterxml.jackson.databind.ObjectMapper -import kr.co.vividnext.sodalive.common.ApiResponse -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.AuthenticationEntryPoint -import org.springframework.stereotype.Component -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -@Component -class AiCharacterAdminAuthenticationEntryPoint( - private val objectMapper: ObjectMapper -) : AuthenticationEntryPoint { - override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: AuthenticationException - ) { - response.status = HttpStatus.UNAUTHORIZED.value() - response.contentType = MediaType.APPLICATION_JSON_VALUE - response.characterEncoding = Charsets.UTF_8.name() - response.writer.write(objectMapper.writeValueAsString(ApiResponse.error("로그인 정보를 확인해주세요."))) - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt deleted file mode 100644 index aeb3e4fa..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidator.kt +++ /dev/null @@ -1,395 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import kr.co.vividnext.sodalive.common.SodaException -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Component -import org.springframework.web.multipart.MultipartFile -import java.awt.Rectangle -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import javax.imageio.ImageIO - -@Component -class AdminImagePartValidator { - fun validate(image: MultipartFile, allowGif: Boolean): AdminValidatedImage { - if (image.isEmpty) { - throw invalidImage(image.name) - } - if (image.size > MAX_IMAGE_BYTES) { - throw invalidImage(image.name) - } - - val bytes = image.bytes - val signatureFormat = detectSignatureFormat(bytes) ?: throw invalidImage(image.name) - if (signatureFormat == "gif" && !allowGif) { - throw invalidImage(image.name) - } - if (!hasBoundedContainer(bytes = bytes, format = signatureFormat)) { - throw invalidImage(image.name) - } - val format = detectFormat(bytes) ?: throw invalidImage(image.name) - - val contentType = when (format) { - "jpg" -> "image/jpeg" - else -> "image/$format" - } - - return AdminValidatedImage( - bytes = bytes, - contentType = contentType, - extension = format - ) - } - - private fun detectSignatureFormat(bytes: ByteArray): String? { - return when { - bytes.size >= PNG_SIGNATURE.size && bytes.take(PNG_SIGNATURE.size) == PNG_SIGNATURE.toList() -> "png" - bytes.size >= 3 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte() -> "jpg" - bytes.size >= 6 && bytes.copyOfRange(0, 6).decodeToString() in setOf("GIF87a", "GIF89a") -> "gif" - else -> null - } - } - - private fun detectFormat(bytes: ByteArray): String? { - ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { stream -> - if (stream == null) return null - return try { - val readers = ImageIO.getImageReaders(stream) - if (!readers.hasNext()) return null - val reader = readers.next() - try { - reader.setInput(stream, false, true) - val format = normalizeFormat(reader.formatName) ?: return null - val frameCount = validatedFrameCount(reader = reader, format = format, bytes = bytes) ?: return null - - repeat(frameCount) { index -> - val readParam = reader.defaultReadParam - readParam.sourceRegion = Rectangle(0, 0, 1, 1) - reader.read(index, readParam) ?: return null - } - format - } finally { - reader.dispose() - } - } catch (e: java.io.IOException) { - null - } catch (e: IndexOutOfBoundsException) { - null - } catch (e: IllegalArgumentException) { - null - } catch (e: RuntimeException) { - null - } - } - } - - private fun hasBoundedContainer(bytes: ByteArray, format: String): Boolean { - return when (format) { - "png" -> hasBoundedPngChunks(bytes) - "gif" -> hasBoundedGifBlocks(bytes) - else -> true - } - } - - private fun hasBoundedPngChunks(bytes: ByteArray): Boolean { - var offset = PNG_SIGNATURE.size - var chunkCount = 0 - var ancillaryBytes = 0L - - while (offset + PNG_CHUNK_HEADER_BYTES + PNG_CHUNK_CRC_BYTES <= bytes.size) { - if (++chunkCount > MAX_PNG_CHUNKS) return false - val length = readBigEndianInt(bytes = bytes, offset = offset) - if (length < 0) return false - val typeOffset = offset + 4 - val dataOffset = typeOffset + 4 - val nextOffset = dataOffset.toLong() + length.toLong() + PNG_CHUNK_CRC_BYTES.toLong() - if (nextOffset > bytes.size) return false - - val isAncillary = (bytes[typeOffset].toInt() and 0x20) != 0 - if (isAncillary) { - ancillaryBytes += length.toLong() - if (length > MAX_IMAGE_METADATA_BYTES || ancillaryBytes > MAX_IMAGE_METADATA_BYTES) return false - } - if (bytes.copyOfRange(typeOffset, typeOffset + 4).decodeToString() == "IEND") return nextOffset == bytes.size.toLong() - offset = nextOffset.toInt() - } - - return false - } - - private fun hasBoundedGifBlocks(bytes: ByteArray): Boolean { - if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET + 3) return false - var offset = GIF_LOGICAL_SCREEN_END_OFFSET - val packed = bytes[offset].toInt() and 0xFF - offset += 3 - if ((packed and 0x80) != 0) { - offset += 3 * (1 shl ((packed and 0x07) + 1)) - if (offset > bytes.size) return false - } - - var frameCount = 0 - var extensionCount = 0 - var extensionBytes = 0L - var framePixels = 0L - while (offset < bytes.size) { - when (bytes[offset++].toInt() and 0xFF) { - 0x2C -> { - if (++frameCount > MAX_GIF_FRAMES || offset + GIF_IMAGE_DESCRIPTOR_BYTES > bytes.size) return false - val width = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 4) - val height = readLittleEndianUnsignedShort(bytes = bytes, offset = offset + 6) - if (!hasValidDimensions(width = width, height = height)) return false - framePixels += width.toLong() * height - if (framePixels > MAX_IMAGE_PIXELS) return false - - val imagePacked = bytes[offset + 8].toInt() and 0xFF - offset += GIF_IMAGE_DESCRIPTOR_BYTES - if ((imagePacked and 0x80) != 0) { - offset += 3 * (1 shl ((imagePacked and 0x07) + 1)) - if (offset > bytes.size) return false - } - if (offset >= bytes.size) return false - val minimumCodeSize = bytes[offset++].toInt() and 0xFF - val scanned = scanGifSubBlocks(bytes = bytes, offset = offset, maxBlocks = null, collectPayload = true) - ?: return false - val payload = scanned.payload ?: return false - if (!hasExactGifLzwPixelCount(payload, minimumCodeSize, width.toLong() * height)) return false - offset = scanned.nextOffset - } - 0x21 -> { - if (++extensionCount > MAX_GIF_EXTENSIONS || offset >= bytes.size) return false - val label = bytes[offset++].toInt() and 0xFF - val scanned = when (label) { - 0xF9 -> scanGifGraphicControlExtension(bytes, offset) - 0x01 -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 12) - 0xFF -> scanGifFixedHeaderExtension(bytes, offset, fixedHeaderSize = 11) - else -> scanGifSubBlocks( - bytes = bytes, - offset = offset, - maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS, - collectPayload = false - ) - } ?: return false - extensionBytes += scanned.payloadBytes - if (extensionBytes > MAX_IMAGE_METADATA_BYTES) return false - offset = scanned.nextOffset - } - 0x3B -> return offset == bytes.size && frameCount > 0 - else -> return false - } - } - - return false - } - - private fun scanGifSubBlocks( - bytes: ByteArray, - offset: Int, - maxBlocks: Int?, - collectPayload: Boolean - ): GifSubBlockScan? { - var currentOffset = offset - var payloadBytes = 0L - var blockCount = 0 - val payload = if (collectPayload) ByteArrayOutputStream() else null - while (currentOffset < bytes.size) { - val size = bytes[currentOffset++].toInt() and 0xFF - if (size == 0) { - return GifSubBlockScan( - nextOffset = currentOffset, - payloadBytes = payloadBytes, - payload = payload?.toByteArray() - ) - } - if (++blockCount > (maxBlocks ?: Int.MAX_VALUE)) return null - if (currentOffset + size > bytes.size) return null - payloadBytes += size.toLong() - payload?.write(bytes, currentOffset, size) - currentOffset += size - } - - return null - } - - private fun scanGifGraphicControlExtension(bytes: ByteArray, offset: Int): GifSubBlockScan? { - if (offset + 5 >= bytes.size || (bytes[offset].toInt() and 0xFF) != 4) return null - if (bytes[offset + 5].toInt() != 0) return null - return GifSubBlockScan(nextOffset = offset + 6, payloadBytes = 4, payload = null) - } - - private fun scanGifFixedHeaderExtension(bytes: ByteArray, offset: Int, fixedHeaderSize: Int): GifSubBlockScan? { - if (offset >= bytes.size || (bytes[offset].toInt() and 0xFF) != fixedHeaderSize) return null - val scanned = scanGifSubBlocks( - bytes = bytes, - offset = offset + fixedHeaderSize + 1, - maxBlocks = MAX_GIF_EXTENSION_SUB_BLOCKS, - collectPayload = false - ) ?: return null - return scanned.copy(payloadBytes = scanned.payloadBytes + fixedHeaderSize) - } - - private fun hasExactGifLzwPixelCount(data: ByteArray, minimumCodeSize: Int, expectedPixels: Long): Boolean { - if (minimumCodeSize !in MIN_GIF_LZW_CODE_SIZE..MAX_GIF_LZW_CODE_SIZE) return false - - val clearCode = 1 shl minimumCodeSize - val endOfInformationCode = clearCode + 1 - val codeLengths = IntArray(MAX_GIF_LZW_TABLE_SIZE) - repeat(clearCode) { codeLengths[it] = 1 } - val bitReader = GifLzwBitReader(data) - val maxCodes = expectedPixels * 2L + 2L - var codeCount = 0L - var codeSize = minimumCodeSize + 1 - var nextCode = endOfInformationCode + 1 - var previousCode = -1 - var decodedPixels = 0L - - while (++codeCount <= maxCodes) { - val code = bitReader.read(codeSize) ?: return false - when { - code == clearCode -> { - if (previousCode < 0 && codeCount > 1) return false - codeSize = minimumCodeSize + 1 - nextCode = endOfInformationCode + 1 - previousCode = -1 - } - code == endOfInformationCode -> { - return decodedPixels == expectedPixels && bitReader.hasOnlyPaddingBits() - } - previousCode < 0 -> { - if (code >= clearCode) return false - decodedPixels++ - if (decodedPixels > expectedPixels) return false - previousCode = code - } - else -> { - val decodedLength = when { - code < clearCode -> 1 - code < nextCode && codeLengths[code] > 0 -> codeLengths[code] - code == nextCode && nextCode < MAX_GIF_LZW_TABLE_SIZE -> codeLengths[previousCode] + 1 - else -> return false - } - decodedPixels += decodedLength - if (decodedPixels > expectedPixels) return false - - if (nextCode < MAX_GIF_LZW_TABLE_SIZE) { - codeLengths[nextCode++] = codeLengths[previousCode] + 1 - if (nextCode == (1 shl codeSize) && codeSize < MAX_GIF_LZW_BITS) codeSize++ - } - previousCode = code - } - } - } - - return false - } - - private fun validatedFrameCount(reader: javax.imageio.ImageReader, format: String, bytes: ByteArray): Int? { - if (format != "gif") { - return 1.takeIf { hasValidDimensions(width = reader.getWidth(0), height = reader.getHeight(0)) } - } - - if (bytes.size < GIF_LOGICAL_SCREEN_END_OFFSET) return null - val logicalWidth = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_WIDTH_OFFSET) - val logicalHeight = readLittleEndianUnsignedShort(bytes = bytes, offset = GIF_LOGICAL_SCREEN_HEIGHT_OFFSET) - if (!hasValidDimensions(width = logicalWidth, height = logicalHeight)) return null - - val frameCount = reader.getNumImages(true) - if (frameCount < 1) return null - val hasInvalidFrame = (0 until frameCount).any { index -> - !hasValidDimensions(width = reader.getWidth(index), height = reader.getHeight(index)) - } - if (hasInvalidFrame) return null - return frameCount - } - - private fun hasValidDimensions(width: Int, height: Int): Boolean { - return width in 1..MAX_IMAGE_DIMENSION && - height in 1..MAX_IMAGE_DIMENSION && - width.toLong() * height <= MAX_IMAGE_PIXELS - } - - private fun readLittleEndianUnsignedShort(bytes: ByteArray, offset: Int): Int { - return (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) - } - - private fun readBigEndianInt(bytes: ByteArray, offset: Int): Int { - return ((bytes[offset].toInt() and 0xFF) shl 24) or - ((bytes[offset + 1].toInt() and 0xFF) shl 16) or - ((bytes[offset + 2].toInt() and 0xFF) shl 8) or - (bytes[offset + 3].toInt() and 0xFF) - } - - private fun normalizeFormat(format: String): String? { - return when (format.lowercase()) { - "jpeg" -> "jpg" - "png", "jpg", "gif" -> format.lowercase() - else -> null - } - } - - private fun invalidImage(errorProperty: String): SodaException { - return SodaException( - messageKey = "admin.chat.character.image_format_invalid", - errorProperty = errorProperty.ifBlank { "image" }, - httpStatus = HttpStatus.BAD_REQUEST - ) - } - - companion object { - private const val MAX_IMAGE_BYTES = 10L * 1024L * 1024L - private const val MAX_IMAGE_METADATA_BYTES = 1024 * 1024 - private const val MAX_IMAGE_DIMENSION = 20_000 - private const val MAX_IMAGE_PIXELS = 40_000_000L - private const val MAX_PNG_CHUNKS = 4096 - private const val MAX_GIF_FRAMES = 500 - private const val MAX_GIF_EXTENSIONS = 1024 - private const val MAX_GIF_EXTENSION_SUB_BLOCKS = 64 - private const val MIN_GIF_LZW_CODE_SIZE = 2 - private const val MAX_GIF_LZW_CODE_SIZE = 8 - private const val MAX_GIF_LZW_BITS = 12 - private const val MAX_GIF_LZW_TABLE_SIZE = 1 shl MAX_GIF_LZW_BITS - private const val PNG_CHUNK_HEADER_BYTES = 8 - private const val PNG_CHUNK_CRC_BYTES = 4 - private const val GIF_LOGICAL_SCREEN_WIDTH_OFFSET = 6 - private const val GIF_LOGICAL_SCREEN_HEIGHT_OFFSET = 8 - private const val GIF_LOGICAL_SCREEN_END_OFFSET = 10 - private const val GIF_IMAGE_DESCRIPTOR_BYTES = 9 - private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) - } -} - -private data class GifSubBlockScan( - val nextOffset: Int, - val payloadBytes: Long, - val payload: ByteArray? -) - -private class GifLzwBitReader( - private val data: ByteArray -) { - private var offset = 0 - private var bitBuffer = 0L - private var bufferedBits = 0 - - fun read(bitCount: Int): Int? { - while (bufferedBits < bitCount) { - if (offset >= data.size) return null - bitBuffer = bitBuffer or ((data[offset++].toInt() and 0xFF).toLong() shl bufferedBits) - bufferedBits += 8 - } - - val code = (bitBuffer and ((1L shl bitCount) - 1L)).toInt() - bitBuffer = bitBuffer ushr bitCount - bufferedBits -= bitCount - return code - } - - fun hasOnlyPaddingBits(): Boolean { - return offset == data.size && bufferedBits < 8 - } -} - -data class AdminValidatedImage( - val bytes: ByteArray, - val contentType: String, - val extension: String -) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt deleted file mode 100644 index ec24537f..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParser.kt +++ /dev/null @@ -1,49 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode -import kr.co.vividnext.sodalive.common.SodaException -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Component - -@Component -class AdminJsonRequestParser(private val objectMapper: ObjectMapper) { - fun parseRequiredObject(rawJson: String, requiredKeys: Set): ObjectNode { - val node: JsonNode = try { - objectMapper.reader() - .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) - .readTree(rawJson) - } catch (e: Exception) { - throw SodaException( - messageKey = "common.error.invalid_request", - errorProperty = "request", - httpStatus = HttpStatus.BAD_REQUEST - ) - } - - return parseRequiredObject(node = node, requiredKeys = requiredKeys) - } - - fun parseRequiredObject(node: JsonNode, requiredKeys: Set): ObjectNode { - if (!node.isObject) { - throw SodaException( - messageKey = "common.error.invalid_request", - errorProperty = "request", - httpStatus = HttpStatus.BAD_REQUEST - ) - } - - val objectNode = node as ObjectNode - requiredKeys.firstOrNull { key -> !objectNode.has(key) }?.let { key -> - throw SodaException( - messageKey = "common.error.invalid_request", - errorProperty = key, - httpStatus = HttpStatus.BAD_REQUEST - ) - } - - return objectNode - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt deleted file mode 100644 index 4b48824a..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandler.kt +++ /dev/null @@ -1,106 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import com.fasterxml.jackson.databind.JsonMappingException -import kr.co.vividnext.sodalive.common.ApiResponse -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import org.slf4j.LoggerFactory -import org.springframework.core.Ordered -import org.springframework.core.annotation.Order -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.http.converter.HttpMessageNotReadableException -import org.springframework.security.access.AccessDeniedException -import org.springframework.web.bind.MissingServletRequestParameterException -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException -import org.springframework.web.multipart.MultipartException -import org.springframework.web.multipart.support.MissingServletRequestPartException - -@RestControllerAdvice(basePackages = ["kr.co.vividnext.sodalive.v2.admin.aicharacter"]) -@Order(Ordered.HIGHEST_PRECEDENCE) -class AiCharacterAdminExceptionHandler( - private val langContext: LangContext, - private val messageSource: SodaMessageSource -) { - private val logger = LoggerFactory.getLogger(javaClass) - - @ExceptionHandler(SodaException::class) - fun handleSodaException(e: SodaException): ResponseEntity> { - val message = resolveMessage(e, langContext.lang) - logger.warn( - "AI character admin API error status={} errorProperty={} error={}", - e.httpStatus ?: HttpStatus.BAD_REQUEST, - e.errorProperty, - e.javaClass.simpleName - ) - return ResponseEntity - .status(e.httpStatus ?: HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(message = message, errorProperty = e.errorProperty)) - } - - @ExceptionHandler(AccessDeniedException::class) - fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity> { - val message = messageSource.getMessage("common.error.access_denied", langContext.lang) ?: "You do not have permission." - logger.warn("AI character admin API access denied error={}", e.javaClass.simpleName) - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(message = message)) - } - - @ExceptionHandler( - HttpMessageNotReadableException::class, - MethodArgumentTypeMismatchException::class, - MissingServletRequestParameterException::class, - MissingServletRequestPartException::class, - MultipartException::class - ) - fun handleBadRequestException(e: Exception): ResponseEntity> { - val message = messageSource.getMessage("common.error.invalid_request", langContext.lang) ?: "Invalid request." - val errorProperty = resolveBadRequestErrorProperty(e) - logger.warn("AI character admin bad request error={} errorProperty={}", e.javaClass.simpleName, errorProperty) - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(message = message, errorProperty = errorProperty)) - } - - @ExceptionHandler(Exception::class) - fun handleException(e: Exception): ResponseEntity> { - val message = messageSource.getMessage("common.error.unknown", langContext.lang) ?: DEFAULT_UNKNOWN_MESSAGE - logger.error("AI character admin API error error={}", e.javaClass.simpleName) - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error(message = message)) - } - - private fun resolveBadRequestErrorProperty(e: Exception): String? { - return when (e) { - is MethodArgumentTypeMismatchException -> e.name - is MissingServletRequestParameterException -> e.parameterName - is MissingServletRequestPartException -> e.requestPartName - is HttpMessageNotReadableException -> resolveUnreadableMessageErrorProperty(e) - else -> null - } - } - - private fun resolveUnreadableMessageErrorProperty(e: HttpMessageNotReadableException): String { - val mappingException = generateSequence(e.cause) { it.cause } - .filterIsInstance() - .firstOrNull() - return mappingException?.path - ?.asSequence() - ?.mapNotNull { it.fieldName?.takeIf(String::isNotBlank) } - ?.firstOrNull() - ?: "request" - } - - private fun resolveMessage(e: SodaException, lang: Lang): String { - return e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, lang) } - ?: e.message?.takeIf { it.isNotBlank() }.orEmpty().ifBlank { null } - ?: messageSource.getMessage("common.error.unknown", lang) - ?: DEFAULT_UNKNOWN_MESSAGE - } - - companion object { - private const val DEFAULT_UNKNOWN_MESSAGE = "An unknown error occurred." - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt deleted file mode 100644 index fe2c45ed..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicy.kt +++ /dev/null @@ -1,26 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.application - -import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageRequest - -object AdminPagePolicy { - fun normalize(page: Int?, size: Int?): AdminPageRequest { - val normalizedPage = page?.coerceAtLeast(0) ?: 0 - val normalizedSize = when { - size == null -> DEFAULT_SIZE - size < MIN_SIZE -> DEFAULT_SIZE - size > MAX_SIZE -> MAX_SIZE - else -> size - } - - return AdminPageRequest( - page = normalizedPage, - size = normalizedSize, - offset = normalizedPage.toLong() * normalizedSize, - limit = normalizedSize.toLong() - ) - } - - private const val DEFAULT_SIZE = 20 - private const val MIN_SIZE = 1 - private const val MAX_SIZE = 50 -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt deleted file mode 100644 index b6a52684..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLogger.kt +++ /dev/null @@ -1,128 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.application - -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component - -@Component -class AiCharacterAdminAuditLogger { - private val logger = LoggerFactory.getLogger(javaClass) - - fun logSuccess(context: AiCharacterAdminAuditContext) { - logger.info( - "aiCharacterAdminAudit result={} adminMemberId={} characterId={} " + - "creatorMemberId={} action={} resourceType={} resourceId={}", - AiCharacterAdminAuditResult.SUCCESS, - context.adminMemberId, - context.characterId, - context.creatorMemberId, - context.action, - context.resourceType, - context.resourceId - ) - } - - fun logFailure(context: AiCharacterAdminAuditContext, exception: Exception) { - logger.warn( - "aiCharacterAdminAudit result={} adminMemberId={} characterId={} " + - "creatorMemberId={} action={} resourceType={} resourceId={} error={}", - AiCharacterAdminAuditResult.FAILURE, - context.adminMemberId, - context.characterId, - context.creatorMemberId, - context.action, - context.resourceType, - context.resourceId, - exception.javaClass.simpleName - ) - } -} - -class AiCharacterAdminAuditContext private constructor( - val adminMemberId: Long, - val characterId: Long?, - val creatorMemberId: Long?, - val action: AiCharacterAdminAuditAction, - val resourceType: AiCharacterAdminAuditResourceType, - val resourceId: Long? -) { - companion object { - fun globalOriginalWork( - adminMemberId: Long, - action: AiCharacterAdminAuditAction, - resourceType: AiCharacterAdminAuditResourceType, - resourceId: Long? - ): AiCharacterAdminAuditContext { - require(resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK) - require( - action == AiCharacterAdminAuditAction.CREATE || - action == AiCharacterAdminAuditAction.UPDATE || - action == AiCharacterAdminAuditAction.DELETE - ) - return AiCharacterAdminAuditContext( - adminMemberId = adminMemberId, - characterId = null, - creatorMemberId = null, - action = action, - resourceType = resourceType, - resourceId = resourceId - ) - } - - fun characterScoped( - adminMemberId: Long, - characterId: Long, - creatorMemberId: Long?, - action: AiCharacterAdminAuditAction, - resourceType: AiCharacterAdminAuditResourceType, - resourceId: Long? - ): AiCharacterAdminAuditContext { - val isAssignmentAction = - action == AiCharacterAdminAuditAction.ASSIGN || action == AiCharacterAdminAuditAction.UNASSIGN - val isOriginalWorkCharacter = resourceType == AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER - require(resourceType != AiCharacterAdminAuditResourceType.ORIGINAL_WORK) - require(isAssignmentAction == isOriginalWorkCharacter) - require(action == AiCharacterAdminAuditAction.UNASSIGN || creatorMemberId != null) - return AiCharacterAdminAuditContext( - adminMemberId = adminMemberId, - characterId = characterId, - creatorMemberId = creatorMemberId, - action = action, - resourceType = resourceType, - resourceId = resourceId - ) - } - } -} - -enum class AiCharacterAdminAuditAction { - CREATE, - UPDATE, - DELETE, - ASSIGN, - UNASSIGN, - PIN, - READ -} - -enum class AiCharacterAdminAuditResourceType { - CHARACTER, - ORIGINAL_WORK, - ORIGINAL_WORK_CHARACTER, - CONTENT, - CONTENT_COMMENT, - CONTENT_CATEGORY, - SERIES, - COMMUNITY_POST, - COMMUNITY_COMMENT, - FAN_TALK, - FAN_TALK_REPLY, - NOTICE, - CHANNEL_NOTICE, - CREATOR_TAG, - CHANNEL_PROFILE -} - -enum class AiCharacterAdminAuditResult { - SUCCESS, - FAILURE -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt deleted file mode 100644 index 5e38b8b6..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/dto/AdminCommonDtos.kt +++ /dev/null @@ -1,37 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.dto - -data class AdminPageRequest( - val page: Int, - val size: Int, - val offset: Long, - val limit: Long -) - -data class AdminPageResponse( - val totalCount: Long, - val items: List, - val page: Int, - val size: Int, - val hasNext: Boolean -) { - companion object { - fun of(totalCount: Long, content: List, page: Int, size: Int): AdminPageResponse { - return AdminPageResponse( - totalCount = totalCount, - items = content, - page = page, - size = size, - hasNext = ((page.toLong() + 1L) * size.toLong()) < totalCount - ) - } - } -} - -data class AdminMutationResponse( - val id: Long, - val isActive: Boolean -) - -data class AdminCommentUpdateRequest( - val content: String -) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt deleted file mode 100644 index 0ff32708..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapter.kt +++ /dev/null @@ -1,51 +0,0 @@ -package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence - -import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.member.MemberKind -import kr.co.vividnext.sodalive.member.MemberRole -import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget -import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import javax.persistence.EntityManager -import javax.persistence.NoResultException - -@Component -class DefaultAiCharacterPersistenceAdapter( - private val entityManager: EntityManager -) : AiCharacterPersistencePort { - @Transactional(readOnly = true) - override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? { - val character = findCharacter(characterId) ?: return null - val creatorMember = character.creatorMember ?: return null - if (creatorMember.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) { - return null - } - - return AiCharacterAdminTarget( - characterId = character.id!!, - creatorMemberId = creatorMember.id!!, - characterIsActive = character.isActive, - creatorMemberIsActive = creatorMember.isActive, - creatorRole = creatorMember.role, - memberKind = creatorMember.memberKind - ) - } - - private fun findCharacter(characterId: Long): ChatCharacter? { - return try { - entityManager.createQuery( - """ - select c - from ChatCharacter c - left join fetch c.creatorMember - where c.id = :characterId - """.trimIndent(), - ChatCharacter::class.java - ).setParameter("characterId", characterId) - .singleResult - } catch (e: NoResultException) { - null - } - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt deleted file mode 100644 index a3f2c5f4..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolver.kt +++ /dev/null @@ -1,44 +0,0 @@ -package kr.co.vividnext.sodalive.v2.aicharacter.application - -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.member.MemberKind -import kr.co.vividnext.sodalive.member.MemberRole -import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget -import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service - -@Service -class AiCharacterAdminTargetResolver( - private val persistencePort: AiCharacterPersistencePort -) { - fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { - val target = resolveExistingTarget(characterId) - if (!target.characterIsActive || !target.creatorMemberIsActive) { - throw SodaException( - messageKey = "common.error.invalid_request", - errorProperty = "characterId", - httpStatus = HttpStatus.CONFLICT - ) - } - - return target - } - - fun resolveExistingTarget(characterId: Long): AiCharacterAdminTarget { - val target = persistencePort.findAdminTarget(characterId) ?: throwNotFound() - if (target.creatorRole != MemberRole.CREATOR || target.memberKind != MemberKind.AI_CHARACTER) { - throwNotFound() - } - - return target - } - - private fun throwNotFound(): Nothing { - throw SodaException( - messageKey = "admin.chat.character.not_found", - errorProperty = "characterId", - httpStatus = HttpStatus.NOT_FOUND - ) - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt deleted file mode 100644 index 43cd2f40..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/domain/AiCharacterAdminTarget.kt +++ /dev/null @@ -1,13 +0,0 @@ -package kr.co.vividnext.sodalive.v2.aicharacter.domain - -import kr.co.vividnext.sodalive.member.MemberKind -import kr.co.vividnext.sodalive.member.MemberRole - -data class AiCharacterAdminTarget( - val characterId: Long, - val creatorMemberId: Long, - val characterIsActive: Boolean, - val creatorMemberIsActive: Boolean, - val creatorRole: MemberRole, - val memberKind: MemberKind -) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt deleted file mode 100644 index 5c91b11d..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/port/out/AiCharacterPersistencePort.kt +++ /dev/null @@ -1,7 +0,0 @@ -package kr.co.vividnext.sodalive.v2.aicharacter.port.out - -import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget - -interface AiCharacterPersistencePort { - fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt new file mode 100644 index 00000000..5cfa8b0f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AiCharacterAdminTargetResolver( + private val chatCharacterRepository: ChatCharacterRepository +) { + @Transactional(readOnly = true) + fun resolve(characterId: Long): AiCharacterAdminTarget { + val chatCharacter = chatCharacterRepository.findByIdWithCreatorMember(characterId) + ?: throw invalidTarget() + val creatorMember = chatCharacter.creatorMember + + if (creatorMember?.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) { + throw invalidTarget() + } + + return AiCharacterAdminTarget( + characterId = characterId, + chatCharacter = chatCharacter, + creatorMember = creatorMember + ) + } + + private fun invalidTarget(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} + +data class AiCharacterAdminTarget( + val characterId: Long, + val chatCharacter: ChatCharacter, + val creatorMember: Member +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt new file mode 100644 index 00000000..68e3dae5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterController.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.HttpMediaTypeNotSupportedException +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +@RestController +@RequestMapping("/api/v2/admin/ai-characters") +class AiCharacterAdminCharacterController( + private val facade: AiCharacterAdminCharacterFacade +) { + @GetMapping + fun list( + @RequestParam(required = false) searchTerm: String?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(searchTerm, page, size)) + } + + @GetMapping("/original-works/search") + fun searchOriginalWorks( + @RequestParam("searchTerm") searchTerm: String + ): ApiResponse> { + return ApiResponse.ok(facade.searchOriginalWorks(searchTerm)) + } + + @GetMapping("/{characterId:[0-9]+}") + fun detail(@PathVariable characterId: Long): ApiResponse { + return ApiResponse.ok(facade.detail(characterId)) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.create(image, request) + return ApiResponse.ok(null) + } + + @PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun update( + @PathVariable characterId: Long, + @RequestPart(value = "image", required = false) image: MultipartFile?, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, image, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) { + if (multipartRequest.fileMap.keys.any { it !in CHARACTER_MULTIPART_PARTS } || + multipartRequest.parts.any { it.name !in CHARACTER_MULTIPART_PARTS } + ) { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + } + + private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) { + val contentType = multipartRequest.getMultipartHeaders("request")?.contentType + ?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType) + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON)) + } + } + + companion object { + private val CHARACTER_MULTIPART_PARTS = setOf("image", "request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt new file mode 100644 index 00000000..d012c263 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterDto.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest + +typealias AiCharacterAdminListResponse = ChatCharacterListPageResponse +typealias AiCharacterAdminCharacterListItemResponse = ChatCharacterListResponse +typealias AiCharacterAdminCharacterResponse = ChatCharacterDetailResponse +typealias AiCharacterAdminCharacterCreateRequest = ChatCharacterRegisterRequest + +data class AiCharacterAdminCharacterUpdateRequest( + val name: String? = null, + val systemPrompt: String? = null, + val description: String? = null, + val age: String? = null, + val gender: String? = null, + val mbti: String? = null, + val speechPattern: String? = null, + val speechStyle: String? = null, + val appearance: String? = null, + val originalTitle: String? = null, + val originalLink: String? = null, + val originalWorkId: Long? = null, + val characterType: String? = null, + val isActive: Boolean? = null, + val tags: List? = null, + val hobbies: List? = null, + val values: List? = null, + val goals: List? = null, + val relationships: List? = null, + val personalities: List? = null, + val backgrounds: List? = null, + val memories: List? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt new file mode 100644 index 00000000..b97fb542 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterExternalApiClient.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClientException +import org.springframework.web.client.RestTemplate + +@Component +class AiCharacterAdminCharacterExternalApiClient( + @Value("\${weraser.api-key}") private val apiKey: String, + @Value("\${weraser.api-url}") private val apiUrl: String +) { + fun create(request: AiCharacterAdminCharacterCreateRequest): String { + val body = mutableMapOf( + "name" to request.name, + "systemPrompt" to request.systemPrompt, + "description" to request.description, + "region" to request.region + ) + request.age?.let { body["age"] = it } + request.gender?.let { body["gender"] = mapGender(request.region, it) } + request.mbti?.let { body["mbti"] = it } + request.speechPattern?.let { body["speechPattern"] = it } + request.speechStyle?.let { body["speechStyle"] = it } + request.appearance?.let { body["appearance"] = it } + if (request.tags.isNotEmpty()) body["tags"] = request.tags + if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies + if (request.values.isNotEmpty()) body["values"] = request.values + if (request.goals.isNotEmpty()) body["goals"] = request.goals + if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships + if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities + if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds + if (request.memories.isNotEmpty()) body["memories"] = request.memories + + return exchange(HttpMethod.POST, "/api/characters", body) ?: throw invalidRequest() + } + + fun update( + chatCharacter: ChatCharacter, + request: AiCharacterAdminCharacterUpdateRequest, + requestName: String? + ) { + val body = mutableMapOf() + if (request.isActive == false) { + val inactiveName = java.util.UUID.randomUUID().toString().replace("-", "") + body["name"] = "inactive_${requestName}_$inactiveName" + } else { + request.name?.let { body["name"] = it } + request.systemPrompt?.let { body["systemPrompt"] = it } + request.description?.let { body["description"] = it } + request.age?.let { body["age"] = it } + request.gender?.let { body["gender"] = it } + request.mbti?.let { body["mbti"] = it } + request.speechPattern?.let { body["speechPattern"] = it } + request.speechStyle?.let { body["speechStyle"] = it } + request.appearance?.let { body["appearance"] = it } + request.tags?.let { body["tags"] = it } + request.hobbies?.let { body["hobbies"] = it } + request.values?.let { body["values"] = it } + request.goals?.let { body["goals"] = it } + request.relationships?.let { body["relationships"] = it } + request.personalities?.let { body["personalities"] = it } + request.backgrounds?.let { body["backgrounds"] = it } + request.memories?.let { body["memories"] = it } + } + + exchange(HttpMethod.PUT, "/api/characters/${chatCharacter.characterUUID}", body) + } + + private fun exchange(method: HttpMethod, path: String, body: Map): String? { + val headers = HttpHeaders().apply { + set("x-api-key", apiKey) + contentType = MediaType.APPLICATION_JSON + } + val response = try { + createRestTemplate().exchange( + "$apiUrl$path", + method, + HttpEntity(body, headers), + ExternalCharacterResponse::class.java + ) + } catch (_: RestClientException) { + throw invalidRequest() + } + + val externalResponse = response.body ?: throw invalidRequest() + if (!externalResponse.success) { + throw invalidRequest() + } + return externalResponse.data?.id + } + + private fun createRestTemplate(): RestTemplate { + return RestTemplate( + SimpleClientHttpRequestFactory().apply { + setConnectTimeout(EXTERNAL_API_TIMEOUT_MILLIS) + setReadTimeout(EXTERNAL_API_TIMEOUT_MILLIS) + } + ) + } + + private fun mapGender(region: String, gender: String): String { + if (!region.equals("JP", ignoreCase = true)) return gender + return when (gender) { + "여성" -> "女性" + "남성" -> "男性" + "기타" -> "その他" + else -> gender + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class ExternalCharacterResponse( + val success: Boolean, + val data: ExternalCharacterData? = null + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + private data class ExternalCharacterData( + val id: String + ) + + companion object { + private const val EXTERNAL_API_TIMEOUT_MILLIS = 20_000 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt new file mode 100644 index 00000000..25ecdff4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterFacade.kt @@ -0,0 +1,191 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +@Service +class AiCharacterAdminCharacterFacade( + private val objectMapper: ObjectMapper, + private val chatCharacterService: ChatCharacterService, + private val chatCharacterRepository: ChatCharacterRepository, + private val targetResolver: AiCharacterAdminTargetResolver, + private val externalApiClient: AiCharacterAdminCharacterExternalApiClient, + private val imageStorage: AiCharacterAdminCharacterImageStorage, + private val characterMapper: AiCharacterAdminCharacterMapper, + private val originalWorkService: AdminOriginalWorkService, + private val creatorMemberService: ChatCharacterCreatorMemberService, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + @Transactional(readOnly = true) + fun list(searchTerm: String?, page: Int, size: Int): AiCharacterAdminListResponse { + val normalizedPage = page.coerceAtLeast(0) + val normalizedSize = size.coerceAtLeast(MINIMUM_PAGE_SIZE) + val characters = chatCharacterRepository.searchAiCharacters( + searchTerm = searchTerm?.trim().orEmpty(), + role = MemberRole.CREATOR, + memberKind = MemberKind.AI_CHARACTER, + pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt")) + ) + + return AiCharacterAdminListResponse( + totalCount = characters.totalElements, + content = characters.content.map(characterMapper::toListItemResponse) + ) + } + + @Transactional(readOnly = true) + fun detail(characterId: Long): AiCharacterAdminCharacterResponse { + return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter) + } + + @Transactional(readOnly = true) + fun searchOriginalWorks(searchTerm: String): List { + return originalWorkService.searchOriginalWorksAll(searchTerm) + .map { OriginalWorkResponse.from(it, imageHost) } + } + + @Transactional + fun create(image: MultipartFile?, requestString: String) { + if (image?.isEmpty == true) throw invalidRequest() + val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java) + val characterType = characterMapper.toCharacterType(request) + if (chatCharacterService.findByName(request.name) != null) { + throw invalidRequest() + } + request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork) + + val chatCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = externalApiClient.create(request), + name = request.name, + description = request.description, + systemPrompt = request.systemPrompt, + age = request.age?.toIntOrNull(), + gender = request.gender, + mbti = request.mbti, + speechPattern = request.speechPattern, + speechStyle = request.speechStyle, + appearance = request.appearance, + originalTitle = request.originalTitle, + originalLink = request.originalLink, + characterType = characterType, + region = request.region, + tags = request.tags, + values = request.values, + hobbies = request.hobbies, + goals = request.goals, + memories = request.memories.map { Triple(it.title, it.content, it.emotion) }, + personalities = request.personalities.map { Pair(it.trait, it.description) }, + backgrounds = request.backgrounds.map { Pair(it.topic, it.description) }, + relationships = request.relationships + ) + + if (image?.isEmpty == false) { + chatCharacter.imagePath = imageStorage.upload(chatCharacter.id ?: throw invalidRequest(), image) + chatCharacterService.saveChatCharacter(chatCharacter) + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter) + } + + request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, chatCharacter.id ?: throw invalidRequest()) } + if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = chatCharacter.id ?: throw invalidRequest(), + query = chatCharacter.description, + targetType = LanguageDetectTargetType.CHARACTER + ) + ) + } + } + + @Transactional + fun update(characterId: Long, image: MultipartFile?, requestString: String) { + val target = targetResolver.resolve(characterId) + val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java) + if (request.isActive == false && image?.isEmpty == false) { + throw invalidRequest() + } + val effectiveRequest = if (request.isActive == false) { + AiCharacterAdminCharacterUpdateRequest(isActive = false) + } else { + request + } + characterMapper.validateCharacterType(effectiveRequest) + if ( + !characterMapper.hasExternalChanges(effectiveRequest) && + !characterMapper.hasDbOnlyChanges(effectiveRequest) && + image?.isEmpty != false + ) { + throw invalidRequest() + } + if ( + effectiveRequest.name != null && effectiveRequest.name != target.chatCharacter.name && + chatCharacterService.findByName(effectiveRequest.name) != null + ) { + throw invalidRequest() + } + effectiveRequest.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork) + + val requestName = if (effectiveRequest.isActive == false) target.chatCharacter.name else effectiveRequest.name + if (characterMapper.hasExternalChanges(effectiveRequest)) { + externalApiClient.update(target.chatCharacter, effectiveRequest, requestName) + } + + val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null + chatCharacterService.updateChatCharacterWithDetails( + imagePath = imagePath, + request = characterMapper.toLegacyRequest(effectiveRequest, characterId, requestName) + ) + effectiveRequest.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) } + chatCharacterRepository.flush() + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = characterId, + targetType = LanguageTranslationTargetType.CHARACTER, + waitTransactionCommit = true + ) + ) + } + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + companion object { + private const val MINIMUM_PAGE_SIZE = 1 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt new file mode 100644 index 00000000..508aa4cb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterImageStorage.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.amazonaws.services.s3.model.ObjectMetadata +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.utils.generateFileName +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile + +@Component +class AiCharacterAdminCharacterImageStorage( + private val s3Uploader: S3Uploader, + @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String +) { + fun upload(characterId: Long, image: MultipartFile): String { + val metadata = ObjectMetadata().apply { contentLength = image.size } + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = "characters/$characterId/${generateFileName(prefix = "character")}", + metadata = metadata + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt new file mode 100644 index 00000000..06f07de4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterMapper.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest +import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component + +@Component +class AiCharacterAdminCharacterMapper( + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String +) { + fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse { + return ChatCharacterListResponse.from(chatCharacter, imageHost) + } + + fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse { + return ChatCharacterDetailResponse.from(chatCharacter, imageHost) + } + + fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType { + return request.characterType?.let { toCharacterType(it) } ?: CharacterType.Character + } + + fun validateCharacterType(request: AiCharacterAdminCharacterUpdateRequest) { + request.characterType?.let { toCharacterType(it) } + } + + fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return hasRegularExternalChanges(request) || request.isActive != null + } + + private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return request.name != null || request.systemPrompt != null || request.description != null || request.age != null || + request.gender != null || request.mbti != null || request.speechPattern != null || request.speechStyle != null || + request.appearance != null || request.tags != null || request.hobbies != null || + request.values != null || request.goals != null || request.relationships != null || request.personalities != null || + request.backgrounds != null || request.memories != null + } + + fun hasDbOnlyChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean { + return request.originalTitle != null || request.originalLink != null || request.characterType != null || + request.originalWorkId != null + } + + fun toLegacyRequest( + request: AiCharacterAdminCharacterUpdateRequest, + characterId: Long, + requestName: String? + ): ChatCharacterUpdateRequest { + return ChatCharacterUpdateRequest( + id = characterId, + name = requestName, + systemPrompt = request.systemPrompt, + description = request.description, + age = request.age, + gender = request.gender, + mbti = request.mbti, + speechPattern = request.speechPattern, + speechStyle = request.speechStyle, + appearance = request.appearance, + originalTitle = request.originalTitle, + originalLink = request.originalLink, + originalWorkId = request.originalWorkId, + characterType = request.characterType, + isActive = request.isActive, + tags = request.tags, + hobbies = request.hobbies, + values = request.values, + goals = request.goals, + relationships = request.relationships, + personalities = request.personalities, + backgrounds = request.backgrounds, + memories = request.memories + ) + } + + private fun toCharacterType(characterType: String): CharacterType { + return runCatching { CharacterType.valueOf(characterType) }.getOrElse { throw invalidRequest() } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt new file mode 100644 index 00000000..6c8cc3db --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostController.kt @@ -0,0 +1,138 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.HttpMediaTypeNotSupportedException +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +@RestController +@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/community-posts") +class AiCharacterAdminCommunityPostController( + private val facade: AiCharacterAdminCommunityPostFacade +) { + @GetMapping + fun list( + @PathVariable characterId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, page, size)) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @PathVariable characterId: Long, + @RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?, + @RequestPart(value = "postImage", required = false) postImage: MultipartFile?, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + facade.create(characterId, audioFile, postImage, request) + return ApiResponse.ok(null) + } + + @PutMapping("/{postId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun update( + @PathVariable characterId: Long, + @PathVariable postId: Long, + @RequestPart(value = "postImage", required = false) postImage: MultipartFile?, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, postId, postImage, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts( + multipartRequest: MultipartHttpServletRequest, + allowedParts: Set + ) { + if (multipartRequest.fileMap.keys.any { it !in allowedParts } || + multipartRequest.parts.any { it.name !in allowedParts } + ) { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + } + + private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) { + val contentType = multipartRequest.getMultipartHeaders("request")?.contentType + ?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType) + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON)) + } + } + + companion object { + private val CREATE_MULTIPART_PARTS = setOf("audioFile", "postImage", "request") + private val UPDATE_MULTIPART_PARTS = setOf("postImage", "request") + } + + @GetMapping("/{postId:[0-9]+}/comments") + fun comments( + @PathVariable characterId: Long, + @PathVariable postId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.comments(characterId, postId, page, size)) + } + + @PostMapping("/{postId:[0-9]+}/comments", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun createComment( + @PathVariable characterId: Long, + @PathVariable postId: Long, + @RequestBody request: String + ): ApiResponse { + facade.createComment(characterId, postId, request) + return ApiResponse.ok(null) + } + + @PutMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun updateComment( + @PathVariable characterId: Long, + @PathVariable postId: Long, + @PathVariable commentId: Long, + @RequestBody request: String + ): ApiResponse { + facade.updateComment(characterId, postId, commentId, request) + return ApiResponse.ok(null) + } + + @DeleteMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}") + fun deleteComment( + @PathVariable characterId: Long, + @PathVariable postId: Long, + @PathVariable commentId: Long + ): ApiResponse { + facade.deleteComment(characterId, postId, commentId) + return ApiResponse.ok(null) + } + + @GetMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}/replies") + fun commentReplies( + @PathVariable characterId: Long, + @PathVariable postId: Long, + @PathVariable commentId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.commentReplies(characterId, postId, commentId, page, size)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt new file mode 100644 index 00000000..cbc8681a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostDto.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListResponse + +typealias AiCharacterAdminCommunityPostDto = GetCommunityPostListResponse +typealias AiCharacterAdminCommunityPostCommentListResponse = GetCommunityPostCommentListResponse + +data class AiCharacterAdminCommunityPostListResponse( + val totalCount: Long, + val page: Int, + val size: Int, + val hasNext: Boolean, + val items: List +) + +data class AiCharacterAdminCommunityPostUpdateRequest( + val content: String? = null, + val isCommentAvailable: Boolean? = null, + val isAdult: Boolean? = null, + val isActive: Boolean? = null, + val isFixed: Boolean? = null +) + +data class AiCharacterAdminCommunityPostCommentCreateRequest( + val comment: String, + val parentId: Long? = null, + val isSecret: Boolean = false +) + +data class AiCharacterAdminCommunityPostCommentUpdateRequest( + val comment: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt new file mode 100644 index 00000000..08366137 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt @@ -0,0 +1,286 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreateCommunityPostRequest +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.ModifyCommunityPostRequest +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.UpdateCommunityPostFixedRequest +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository +import kr.co.vividnext.sodalive.extensions.getTimeAgoString +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.time.ZoneId + +@Service +class AiCharacterAdminCommunityPostFacade( + private val targetResolver: AiCharacterAdminTargetResolver, + private val legacyService: CreatorCommunityService, + private val objectMapper: ObjectMapper, + private val repository: AiCharacterAdminCommunityPostRepository, + private val memberRepository: MemberRepository, + private val likeRepository: CreatorCommunityLikeRepository, + private val commentRepository: CreatorCommunityCommentRepository, + private val audioContentCloudFront: AudioContentCloudFront, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String +) { + @Transactional + fun create(characterId: Long, audioFile: MultipartFile?, postImage: MultipartFile?, request: String) { + val target = resolveActiveTarget(characterId) + readRequest(request, CreateCommunityPostRequest::class.java) + legacyService.createCommunityPost(audioFile, postImage, request, target.creatorMember) + } + + @Transactional + fun update(characterId: Long, postId: Long, postImage: MultipartFile?, requestString: String) { + val target = resolveActiveTarget(characterId) + val creatorMember = target.creatorMember + val creatorMemberId = creatorMember.id ?: throw invalidRequest() + repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest() + + rejectExplicitNull(requestString, "isFixed") + val request = readRequest(requestString, AiCharacterAdminCommunityPostUpdateRequest::class.java) + val modifyRequest = ModifyCommunityPostRequest( + creatorCommunityId = postId, + content = request.content, + isCommentAvailable = request.isCommentAvailable, + isAdult = request.isAdult, + isActive = request.isActive + ) + + if (request.isActive != false && request.isFixed != null) { + memberRepository.findByIdForUpdate(creatorMemberId) ?: throw invalidRequest() + legacyService.updateCommunityPostFixed( + UpdateCommunityPostFixedRequest(postId = postId, isFixed = request.isFixed), + creatorMember + ) + } + + legacyService.modifyCommunityPost(postImage, objectMapper.writeValueAsString(modifyRequest), creatorMember) + } + + @Transactional(readOnly = true) + fun list( + characterId: Long, + page: Int, + size: Int + ): AiCharacterAdminCommunityPostListResponse { + val target = resolveActiveTarget(characterId) + if (page < 0 || size < 1) throw invalidRequest() + + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val pageable = PageRequest.of(page, size) + val items = repository.findActiveByCreatorMemberId(creatorMemberId, pageable) + .map { post -> post.toResponse(target.creatorMember, creatorMemberId) } + val totalCount = repository.countActiveByCreatorMemberId(creatorMemberId) + + return AiCharacterAdminCommunityPostListResponse( + totalCount = totalCount, + page = page, + size = size, + hasNext = (page + 1L) * size < totalCount, + items = items + ) + } + + @Transactional(readOnly = true) + fun comments( + characterId: Long, + postId: Long, + page: Int, + size: Int + ): AiCharacterAdminCommunityPostCommentListResponse { + val target = resolveOwnedActivePost(characterId, postId) + validateListRequest(page, size) + val pageable = PageRequest.of(page, size) + return utcCommentDates( + legacyService.getCommunityPostCommentList( + postId = postId, + memberId = target.creatorMember.id ?: throw invalidRequest(), + timezone = UTC_TIMEZONE, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + isAdult = true + ), + postId + ) + } + + @Transactional(readOnly = true) + fun commentReplies( + characterId: Long, + postId: Long, + commentId: Long, + page: Int, + size: Int + ): AiCharacterAdminCommunityPostCommentListResponse { + val target = resolveOwnedActivePost(characterId, postId) + validateListRequest(page, size) + repository.findActiveRootCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest() + val pageable = PageRequest.of(page, size) + return utcCommentDates( + legacyService.getCommentReplyList( + commentId = commentId, + memberId = target.creatorMember.id ?: throw invalidRequest(), + timezone = UTC_TIMEZONE, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + isAdult = true + ), + postId + ) + } + + @Transactional + fun createComment(characterId: Long, postId: Long, requestString: String) { + val target = resolveOwnedActivePost(characterId, postId) + val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentCreateRequest::class.java) + request.parentId?.let { parentId -> + repository.findActiveRootCommentByIdAndPostId(parentId, postId) ?: throw invalidRequest() + } + legacyService.createCommunityPostComment( + member = target.creatorMember, + comment = request.comment, + postId = postId, + parentId = request.parentId, + isSecret = request.isSecret, + isAdult = true + ) + } + + @Transactional + fun updateComment(characterId: Long, postId: Long, commentId: Long, requestString: String) { + val target = resolveOwnedActivePost(characterId, postId) + val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentUpdateRequest::class.java) + val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest() + if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest() + legacyService.modifyCommunityPostComment( + ModifyCommunityPostCommentRequest(commentId = commentId, comment = request.comment, isActive = null), + target.creatorMember + ) + } + + @Transactional + fun deleteComment(characterId: Long, postId: Long, commentId: Long) { + val target = resolveOwnedActivePost(characterId, postId) + val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest() + if (!comment.isActive) return + legacyService.modifyCommunityPostComment( + ModifyCommunityPostCommentRequest(commentId = commentId, comment = null, isActive = false), + target.creatorMember + ) + } + + private fun CreatorCommunity.toResponse(member: Member, creatorMemberId: Long): AiCharacterAdminCommunityPostDto { + val postId = id ?: throw invalidRequest() + val createdAt = createdAt ?: throw invalidRequest() + val audioUrl = audioPath?.let { + audioContentCloudFront.generateSignedURL(it, AUDIO_URL_EXPIRATION_MILLIS) + } + val commentCount = if (isCommentAvailable) { + commentRepository.totalCountCommentByPostId( + postId = postId, + memberId = creatorMemberId, + isContentCreator = true + ) + } else { + 0 + } + + return AiCharacterAdminCommunityPostDto( + postId = postId, + creatorId = creatorMemberId, + creatorNickname = member.nickname, + creatorProfileUrl = "$imageHost/${member.profileImage ?: DEFAULT_PROFILE_IMAGE_PATH}", + imageUrl = imagePath?.let { "$imageHost/$it" }, + audioUrl = audioUrl, + content = content, + price = price, + date = createdAt.getTimeAgoString(), + dateUtc = createdAt.atZone(ZoneId.of("UTC")).toInstant().toString(), + isCommentAvailable = isCommentAvailable, + isAdult = isAdult, + isFixed = isFixed, + isLike = false, + existOrdered = true, + likeCount = likeRepository.totalCountCommunityPostLikeByPostId(postId), + commentCount = commentCount, + firstComment = null + ) + } + + private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + return target + } + + private fun resolveOwnedActivePost(characterId: Long, postId: Long): AiCharacterAdminTarget { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest() + return target + } + + private fun validateListRequest(page: Int, size: Int) { + if (page < 0 || size < 1) throw invalidRequest() + } + + private fun utcCommentDates( + response: AiCharacterAdminCommunityPostCommentListResponse, + postId: Long + ): AiCharacterAdminCommunityPostCommentListResponse { + return response.copy(items = response.items.map { it.withUtcDate(postId) }) + } + + private fun GetCommunityPostCommentListItem.withUtcDate(postId: Long): GetCommunityPostCommentListItem { + val createdAt = repository.findCommentByIdAndPostId(id, postId)?.createdAt ?: throw invalidRequest() + return copy(date = createdAt.toUtcIso()) + } + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun rejectExplicitNull(requestString: String, fieldName: String) { + try { + if (objectMapper.readTree(requestString).path(fieldName).isNull) throw invalidRequest() + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + private companion object { + const val AUDIO_URL_EXPIRATION_MILLIS = 1_800_000L + const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default_profile.png" + const val UTC_TIMEZONE = "UTC" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt new file mode 100644 index 00000000..283f6d5a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostRepository.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Repository + +@Repository +class AiCharacterAdminCommunityPostRepository( + private val queryFactory: JPAQueryFactory +) { + fun findActiveByCreatorMemberId(creatorMemberId: Long, pageable: Pageable): List { + return queryFactory + .selectFrom(creatorCommunity) + .where( + creatorCommunity.member.id.eq(creatorMemberId) + .and(creatorCommunity.isActive.isTrue) + ) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy( + creatorCommunity.isFixed.desc(), + creatorCommunity.fixedAt.desc().nullsLast(), + creatorCommunity.createdAt.desc() + ) + .fetch() + } + + fun countActiveByCreatorMemberId(creatorMemberId: Long): Long { + return queryFactory + .select(creatorCommunity.count()) + .from(creatorCommunity) + .where( + creatorCommunity.member.id.eq(creatorMemberId) + .and(creatorCommunity.isActive.isTrue) + ) + .fetchOne() ?: 0L + } + + fun findActiveByIdAndCreatorMemberId(postId: Long, creatorMemberId: Long): CreatorCommunity? { + return queryFactory + .selectFrom(creatorCommunity) + .where( + creatorCommunity.id.eq(postId) + .and(creatorCommunity.member.id.eq(creatorMemberId)) + .and(creatorCommunity.isActive.isTrue) + ) + .fetchOne() + } + + fun findCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? { + return queryFactory + .selectFrom(creatorCommunityComment) + .where( + creatorCommunityComment.id.eq(commentId) + .and(creatorCommunityComment.creatorCommunity.id.eq(postId)) + ) + .fetchOne() + } + + fun findActiveRootCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? { + return queryFactory + .selectFrom(creatorCommunityComment) + .where( + creatorCommunityComment.id.eq(commentId) + .and(creatorCommunityComment.creatorCommunity.id.eq(postId)) + .and(creatorCommunityComment.parent.isNull) + .and(creatorCommunityComment.isActive.isTrue) + ) + .fetchOne() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt new file mode 100644 index 00000000..503e366a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentController.kt @@ -0,0 +1,164 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.util.MultiValueMap +import org.springframework.web.HttpMediaTypeNotSupportedException +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +@RestController +@RequestMapping("/api/v2/admin/ai-characters") +class AiCharacterAdminAudioContentController( + private val facade: AiCharacterAdminAudioContentFacade +) { + @GetMapping("/audio-content-themes") + fun themes(): ApiResponse> { + return ApiResponse.ok(facade.themes()) + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents") + fun list( + @PathVariable characterId: Long, + @RequestParam(value = "search_word", required = false) searchWord: String?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, searchWord, page, size)) + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}") + fun detail( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @RequestParam queryParameters: MultiValueMap + ): ApiResponse { + return ApiResponse.ok(facade.detail(characterId, contentId, queryParameters.keys)) + } + + @PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @PathVariable characterId: Long, + @RequestPart("coverImage") coverImage: MultipartFile, + @RequestPart("contentFile") contentFile: MultipartFile, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + return ApiResponse.ok(facade.create(characterId, coverImage, contentFile, request)) + } + + @PutMapping( + "/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}", + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE] + ) + fun update( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, contentId, coverImage, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts( + multipartRequest: MultipartHttpServletRequest, + allowedParts: Set + ) { + if (multipartRequest.fileMap.keys.any { it !in allowedParts } || + multipartRequest.parts.any { it.name !in allowedParts } + ) { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + } + + private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) { + val contentType = multipartRequest.getMultipartHeaders("request")?.contentType + ?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType) + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON)) + } + } + + companion object { + private val CREATE_MULTIPART_PARTS = setOf("coverImage", "contentFile", "request") + private val UPDATE_MULTIPART_PARTS = setOf("coverImage", "request") + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments") + fun comments( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @RequestParam queryParameters: MultiValueMap, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.comments(characterId, contentId, queryParameters.keys, page, size)) + } + + @PostMapping( + "/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments", + consumes = [MediaType.APPLICATION_JSON_VALUE] + ) + fun createComment( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @RequestBody request: String + ): ApiResponse { + facade.createComment(characterId, contentId, request) + return ApiResponse.ok(null) + } + + @PutMapping( + "/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}", + consumes = [MediaType.APPLICATION_JSON_VALUE] + ) + fun updateComment( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @PathVariable commentId: Long, + @RequestBody request: String + ): ApiResponse { + facade.updateComment(characterId, contentId, commentId, request) + return ApiResponse.ok(null) + } + + @DeleteMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}") + fun deleteComment( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @PathVariable commentId: Long + ): ApiResponse { + facade.deleteComment(characterId, contentId, commentId) + return ApiResponse.ok(null) + } + + @GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}/replies") + fun replies( + @PathVariable characterId: Long, + @PathVariable contentId: Long, + @PathVariable commentId: Long, + @RequestParam queryParameters: MultiValueMap, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.replies(characterId, contentId, commentId, queryParameters.keys, page, size)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt new file mode 100644 index 00000000..d0950374 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentDto.kt @@ -0,0 +1,70 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.content.CreateAudioContentRequest +import kr.co.vividnext.sodalive.content.CreateAudioContentResponse +import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse +import kr.co.vividnext.sodalive.content.PurchaseOption +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse +import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse +import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListItem +import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListResponse + +typealias AiCharacterAdminAudioContentListResponse = GetCreatorAdminContentListResponse +typealias AiCharacterAdminAudioContentListItem = GetCreatorAdminContentListItem +typealias AiCharacterAdminAudioContentResponse = GetAudioContentDetailResponse +typealias AiCharacterAdminAudioContentThemeResponse = GetAudioContentThemeResponse +typealias AiCharacterAdminAudioContentCreateResponse = CreateAudioContentResponse +typealias AiCharacterAdminAudioContentCommentListResponse = GetAudioContentCommentListResponse + +data class AiCharacterAdminAudioContentCreateRequest( + val title: String, + val detail: String, + val tags: String, + val price: Int, + val purchaseOption: PurchaseOption = PurchaseOption.BOTH, + val limited: Int? = null, + val releaseDate: String? = null, + val themeId: Long = 0, + val isAdult: Boolean = false, + val isGeneratePreview: Boolean = false, + val isOnlyRental: Boolean = false, + val isPointAvailable: Boolean = false, + val isCommentAvailable: Boolean = false, + val isFullDetailVisible: Boolean = true, + val previewStartTime: String? = null, + val previewEndTime: String? = null, + val languageCode: String? = null +) { + fun toLegacyRequest(): CreateAudioContentRequest { + return CreateAudioContentRequest( + title = title, + detail = detail, + tags = tags, + price = price, + purchaseOption = purchaseOption, + limited = limited, + timezone = "UTC", + themeId = themeId, + isAdult = isAdult, + isGeneratePreview = isGeneratePreview, + isOnlyRental = isOnlyRental, + isPointAvailable = isPointAvailable, + isCommentAvailable = isCommentAvailable, + isFullDetailVisible = isFullDetailVisible, + previewStartTime = previewStartTime, + previewEndTime = previewEndTime, + languageCode = languageCode + ) + } +} + +data class AiCharacterAdminAudioContentUpdateRequest( + val title: String? = null, + val detail: String? = null, + val tags: String? = null, + val price: Int? = null, + val isAdult: Boolean? = null, + val isActive: Boolean? = null, + val isPointAvailable: Boolean? = null, + val isCommentAvailable: Boolean? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt new file mode 100644 index 00000000..e4fbe1b2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt @@ -0,0 +1,315 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem +import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse +import kr.co.vividnext.sodalive.content.comment.ModifyCommentRequest +import kr.co.vividnext.sodalive.content.comment.RegisterCommentRequest +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.creator.admin.content.UpdateCreatorAdminContentRequest +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.time.DateTimeException +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +@Service +class AiCharacterAdminAudioContentFacade( + private val objectMapper: ObjectMapper, + private val targetResolver: AiCharacterAdminTargetResolver, + private val repository: AiCharacterAdminAudioContentRepository, + private val themeQueryRepository: AudioContentThemeQueryRepository, + private val mapper: AiCharacterAdminAudioContentMapper, + private val audioContentService: AudioContentService, + private val creatorAdminContentService: CreatorAdminContentService, + private val audioContentCommentService: AudioContentCommentService +) { + @Transactional(readOnly = true) + fun themes(): List { + return themeQueryRepository.getActiveThemes() + } + + @Transactional(readOnly = true) + fun list( + characterId: Long, + searchWord: String?, + page: Int, + size: Int + ): AiCharacterAdminAudioContentListResponse { + val target = targetResolver.resolve(characterId) + val normalizedPage = page.coerceAtLeast(0) + val pageable = PageRequest.of(normalizedPage, size.coerceAtLeast(MINIMUM_PAGE_SIZE)) + return if (searchWord == null) { + creatorAdminContentService.getAudioContentList(pageable, target.creatorMember) + } else { + creatorAdminContentService.searchAudioContent(searchWord, target.creatorMember, pageable) + } + } + + @Transactional(readOnly = true) + fun detail( + characterId: Long, + contentId: Long, + queryParameterNames: Set + ): AiCharacterAdminAudioContentResponse { + if (queryParameterNames.isNotEmpty()) throw invalidRequest() + val target = targetResolver.resolve(characterId) + val content = repository.findByIdAndCreatorMemberId( + contentId = contentId, + creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + ) ?: throw invalidRequest() + + return mapper.toResponse(content) + } + + @Transactional(readOnly = true) + fun comments( + characterId: Long, + contentId: Long, + queryParameterNames: Set, + page: Int, + size: Int + ): GetAudioContentCommentListResponse { + val target = resolveOwnedActiveContent(characterId, contentId) + validateCommentQuery(queryParameterNames, page, size) + return utcCommentDates( + audioContentCommentService.getCommentList( + audioContentId = contentId, + memberId = target.creatorMember.id ?: throw invalidRequest(), + timezone = UTC_TIMEZONE, + pageable = PageRequest.of(page, size) + ), + contentId + ) + } + + @Transactional + fun createComment(characterId: Long, contentId: Long, requestString: String) { + val target = resolveOwnedActiveContent(characterId, contentId) + val request = readCommentCreateRequest(requestString, contentId) + request.parentId?.let { parentId -> + repository.findActiveRootCommentByIdAndContentId(parentId, contentId) ?: throw invalidRequest() + } + audioContentCommentService.registerComment( + member = target.creatorMember, + comment = request.comment, + audioContentId = contentId, + parentId = request.parentId, + isSecret = request.isSecret, + languageCode = request.languageCode + ) + } + + @Transactional + fun updateComment(characterId: Long, contentId: Long, commentId: Long, requestString: String) { + val target = resolveOwnedActiveContent(characterId, contentId) + val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest() + if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest() + + val request = readCommentUpdateRequest(requestString, commentId) + audioContentCommentService.modifyComment(request, target.creatorMember) + } + + @Transactional + fun deleteComment(characterId: Long, contentId: Long, commentId: Long) { + val target = resolveOwnedActiveContent(characterId, contentId) + val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest() + if (!comment.isActive) return + + audioContentCommentService.modifyComment( + ModifyCommentRequest(commentId = commentId, isActive = false), + target.creatorMember + ) + } + + @Transactional(readOnly = true) + fun replies( + characterId: Long, + contentId: Long, + commentId: Long, + queryParameterNames: Set, + page: Int, + size: Int + ): GetAudioContentCommentListResponse { + val target = resolveOwnedActiveContent(characterId, contentId) + validateCommentQuery(queryParameterNames, page, size) + repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest() + return utcCommentDates( + audioContentCommentService.getCommentReplyList( + commentId = commentId, + memberId = target.creatorMember.id ?: throw invalidRequest(), + timezone = UTC_TIMEZONE, + pageable = PageRequest.of(page, size) + ), + contentId + ) + } + + @Transactional + fun create( + characterId: Long, + coverImage: MultipartFile, + contentFile: MultipartFile, + requestString: String + ): AiCharacterAdminAudioContentCreateResponse { + val target = targetResolver.resolve(characterId) + if (coverImage.isEmpty || contentFile.isEmpty) throw invalidRequest() + val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java) + val releaseDate = parseUtcReleaseDate(request.releaseDate) ?: LocalDateTime.now(ZoneOffset.UTC) + return audioContentService.createAudioContent( + contentFile = contentFile, + coverImage = coverImage, + request = request.toLegacyRequest(), + releaseDate = releaseDate, + member = target.creatorMember + ) + } + + @Transactional + fun update( + characterId: Long, + contentId: Long, + coverImage: MultipartFile?, + requestString: String + ) { + val target = targetResolver.resolve(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest() + val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty } + + val request = readRequest(requestString, AiCharacterAdminAudioContentUpdateRequest::class.java) + creatorAdminContentService.updateAudioContent( + coverImage = normalizedCoverImage, + requestString = objectMapper.writeValueAsString( + UpdateCreatorAdminContentRequest( + id = contentId, + title = request.title, + detail = request.detail, + tags = request.tags, + price = request.price, + isAdult = request.isAdult, + isActive = request.isActive, + isPointAvailable = request.isPointAvailable, + isCommentAvailable = request.isCommentAvailable + ) + ), + member = target.creatorMember + ) + } + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun parseUtcReleaseDate(releaseDate: String?): LocalDateTime? { + if (releaseDate == null) return null + if (!releaseDate.endsWith("Z")) throw invalidRequest() + return try { + LocalDateTime.ofInstant(Instant.parse(releaseDate), ZoneOffset.UTC) + } catch (_: DateTimeException) { + throw invalidRequest() + } + } + + private fun resolveOwnedActiveContent(characterId: Long, contentId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + repository.findActiveByIdAndCreatorMemberId( + contentId = contentId, + creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + ) ?: throw invalidRequest() + return target + } + + private fun validateCommentQuery(queryParameterNames: Set, page: Int, size: Int) { + if (!COMMENT_QUERY_PARAMETERS.containsAll(queryParameterNames) || page < 0 || size < MINIMUM_PAGE_SIZE) { + throw invalidRequest() + } + } + + private fun utcCommentDates( + response: GetAudioContentCommentListResponse, + contentId: Long + ): GetAudioContentCommentListResponse { + return response.copy(items = response.items.map { it.withUtcDate(contentId) }) + } + + private fun GetAudioContentCommentListItem.withUtcDate(contentId: Long): GetAudioContentCommentListItem { + val createdAt = repository.findCommentByIdAndContentId(id, contentId)?.createdAt ?: throw invalidRequest() + return copy(date = createdAt.toUtcIso()) + } + + private fun readCommentCreateRequest(requestString: String, contentId: Long): RegisterCommentRequest { + val serializedRequest = injectCommentPathId( + requestString = requestString, + allowedFields = CREATE_COMMENT_FIELDS, + idField = "contentId", + id = contentId + ) + return readRequest(serializedRequest, RegisterCommentRequest::class.java) + } + + private fun readCommentUpdateRequest(requestString: String, commentId: Long): ModifyCommentRequest { + val serializedRequest = injectCommentPathId( + requestString = requestString, + allowedFields = UPDATE_COMMENT_FIELDS, + idField = "commentId", + id = commentId + ) + val request = readRequest(serializedRequest, ModifyCommentRequest::class.java) + if (request.comment == null) throw invalidRequest() + return request + } + + private fun injectCommentPathId( + requestString: String, + allowedFields: Set, + idField: String, + id: Long + ): String { + return try { + val request = objectMapper.readTree(requestString) as? com.fasterxml.jackson.databind.node.ObjectNode + ?: throw invalidRequest() + if (request.fieldNames().asSequence().any { it !in allowedFields } || !request.hasNonNull("comment")) { + throw invalidRequest() + } + request.put(idField, id) + objectMapper.writeValueAsString(request) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + + companion object { + private const val MINIMUM_PAGE_SIZE = 1 + private const val UTC_TIMEZONE = "UTC" + private val COMMENT_QUERY_PARAMETERS = setOf("page", "size") + private val CREATE_COMMENT_FIELDS = setOf("comment", "parentId", "isSecret", "languageCode") + private val UPDATE_COMMENT_FIELDS = setOf("comment") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt new file mode 100644 index 00000000..ee744732 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentCreator +import kr.co.vividnext.sodalive.content.PurchaseOption +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneOffset + +@Component +class AiCharacterAdminAudioContentMapper( + private val audioContentCloudFront: AudioContentCloudFront, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String +) { + fun toResponse(content: AudioContent): AiCharacterAdminAudioContentResponse { + val owner = content.member ?: throw invalidRequest() + return AiCharacterAdminAudioContentResponse( + contentId = content.id ?: throw invalidRequest(), + title = content.title, + detail = content.detail, + coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}", + contentUrl = signedUrl(content).orEmpty(), + languageCode = content.languageCode, + themeStr = content.theme?.theme ?: "", + tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "), + price = content.price, + duration = content.duration ?: "", + isAdult = content.isAdult, + releaseDate = releaseDate(content), + totalContentCount = content.limited, + remainingContentCount = content.remaining, + orderSequence = null, + isActivePreview = content.isGeneratePreview, + isMosaic = false, + isOnlyRental = content.purchaseOption == PurchaseOption.RENT_ONLY || content.isOnlyRental, + existOrdered = false, + purchaseOption = if (content.isOnlyRental) PurchaseOption.RENT_ONLY else content.purchaseOption, + orderType = null, + remainingTime = null, + creatorOtherContentList = emptyList(), + sameThemeOtherContentList = emptyList(), + isCommentAvailable = content.isCommentAvailable, + isLike = false, + likeCount = 0, + commentList = emptyList(), + commentCount = 0, + isPin = false, + isAvailablePin = false, + creator = AudioContentCreator( + creatorId = owner.id ?: throw invalidRequest(), + nickname = owner.nickname, + profileImageUrl = if (owner.profileImage != null) { + "$imageHost/${owner.profileImage}" + } else { + "$imageHost/profile/default-profile.png" + }, + isFollowing = false, + isFollow = false, + isNotify = false + ), + previousContent = null, + nextContent = null, + buyerList = emptyList(), + isAvailableUsePoint = content.isPointAvailable, + translated = null + ) + } + + private fun signedUrl(content: AudioContent): String? { + val resourcePath = content.content ?: return null + val duration = content.duration ?: return null + val expirationTime = 1000 * 60 * 60 * (duration.split(":")[0].toLong() + 2) + return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime) + } + + private fun releaseDate(content: AudioContent): String? { + return content.releaseDate + ?.takeIf { it > LocalDateTime.now(ZoneOffset.UTC) } + ?.toUtcIso() + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt new file mode 100644 index 00000000..acd4b2ca --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentRepository.kt @@ -0,0 +1,56 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.stereotype.Repository + +@Repository +class AiCharacterAdminAudioContentRepository( + private val queryFactory: JPAQueryFactory +) { + fun findByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .innerJoin(audioContent.member, member) + .where(audioContent.id.eq(contentId).and(member.id.eq(creatorMemberId))) + .fetchOne() + } + + fun findActiveByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? { + return queryFactory + .selectFrom(audioContent) + .innerJoin(audioContent.member, member) + .where( + audioContent.id.eq(contentId) + .and(member.id.eq(creatorMemberId)) + .and(audioContent.isActive.isTrue) + ) + .fetchOne() + } + + fun findCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? { + return queryFactory + .selectFrom(audioContentComment) + .where( + audioContentComment.id.eq(commentId) + .and(audioContentComment.audioContent.id.eq(contentId)) + ) + .fetchOne() + } + + fun findActiveRootCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? { + return queryFactory + .selectFrom(audioContentComment) + .where( + audioContentComment.id.eq(commentId) + .and(audioContentComment.audioContent.id.eq(contentId)) + .and(audioContentComment.parent.isNull) + .and(audioContentComment.isActive.isTrue) + ) + .fetchOne() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt new file mode 100644 index 00000000..7e729f8c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error + +import org.springframework.http.HttpStatus + +class AiCharacterAdminApiException( + val status: HttpStatus, + val messageKey: String +) : RuntimeException(messageKey) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt new file mode 100644 index 00000000..dec17323 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class AiCharacterAdminErrorResponseWriter( + private val objectMapper: ObjectMapper, + private val messageSource: SodaMessageSource +) { + fun createResponse(request: HttpServletRequest, messageKey: String): ApiResponse { + val lang = Lang.fromAcceptLanguage(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE)) + val message = messageSource.getMessage(messageKey, lang) + ?: messageSource.getMessage("common.error.unknown", lang) + return ApiResponse.error(message = message) + } + + fun write( + request: HttpServletRequest, + response: HttpServletResponse, + status: HttpStatus, + messageKey: String + ) { + response.status = status.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write(objectMapper.writeValueAsString(createResponse(request, messageKey))) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt new file mode 100644 index 00000000..d880c775 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt @@ -0,0 +1,104 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error + +import kr.co.vividnext.sodalive.common.SodaException +import org.slf4j.LoggerFactory +import org.springframework.core.Ordered +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.validation.BindException +import org.springframework.web.HttpMediaTypeNotAcceptableException +import org.springframework.web.HttpMediaTypeNotSupportedException +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingPathVariableException +import org.springframework.web.bind.ServletRequestBindingException +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.multipart.MultipartException +import org.springframework.web.multipart.support.MissingServletRequestPartException +import org.springframework.web.servlet.HandlerExceptionResolver +import org.springframework.web.servlet.ModelAndView +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class AiCharacterAdminExceptionHandler( + private val responseWriter: AiCharacterAdminErrorResponseWriter, + private val requestMatcher: RequestMatcher +) : HandlerExceptionResolver, Ordered { + private val logger = LoggerFactory.getLogger(this::class.java) + + override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + + override fun resolveException( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any?, + exception: Exception + ): ModelAndView? { + if (!requestMatcher.matches(request)) return null + + val error = resolveError(exception) + if (error.status.is5xxServerError) { + logger.error("Unexpected AI character admin API error", exception) + } else { + logger.debug("AI character admin API request error", exception) + } + writeStandardHeaders(request, response, exception) + responseWriter.write(request, response, error.status, error.messageKey) + return ModelAndView() + } + + private fun resolveError(exception: Exception): ResolvedError { + return when (exception) { + is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey) + is SodaException -> ResolvedError(HttpStatus.BAD_REQUEST, exception.messageKey ?: "common.error.invalid_request") + is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied") + is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED) + is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + is HttpMediaTypeNotAcceptableException -> invalidRequest(HttpStatus.NOT_ACCEPTABLE) + is MissingPathVariableException -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown") + is BindException, + is HttpMessageNotReadableException, + is MethodArgumentNotValidException, + is MethodArgumentTypeMismatchException, + is ServletRequestBindingException, + is MultipartException, + is MissingServletRequestPartException -> invalidRequest(HttpStatus.BAD_REQUEST) + else -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown") + } + } + + private fun writeStandardHeaders( + request: HttpServletRequest, + response: HttpServletResponse, + exception: Exception + ) { + when (exception) { + is HttpRequestMethodNotSupportedException -> { + exception.supportedMethods?.let { response.setHeader(HttpHeaders.ALLOW, it.joinToString(", ")) } + } + is HttpMediaTypeNotSupportedException -> { + if (exception.supportedMediaTypes.isNotEmpty()) { + val supportedMediaTypes = MediaType.toString(exception.supportedMediaTypes) + response.setHeader(HttpHeaders.ACCEPT, supportedMediaTypes) + if (request.method == HttpMethod.PATCH.name) { + response.setHeader(HttpHeaders.ACCEPT_PATCH, supportedMediaTypes) + } + } + } + } + } + + private fun invalidRequest(status: HttpStatus): ResolvedError { + return ResolvedError(status, "common.error.invalid_request") + } + + private data class ResolvedError( + val status: HttpStatus, + val messageKey: String + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt new file mode 100644 index 00000000..25c084e0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkController.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/fan-talks") +class AiCharacterAdminFanTalkController( + private val facade: AiCharacterAdminFanTalkFacade +) { + @GetMapping + fun list( + @PathVariable characterId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, page, size)) + } + + @PostMapping("/{fanTalkId:[0-9]+}/replies", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun createReply( + @PathVariable characterId: Long, + @PathVariable fanTalkId: Long, + @RequestBody request: String + ): ApiResponse { + return ApiResponse.ok(facade.createReply(characterId, fanTalkId, request)) + } + + @PutMapping("/{fanTalkId:[0-9]+}/replies/{replyId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun updateReply( + @PathVariable characterId: Long, + @PathVariable fanTalkId: Long, + @PathVariable replyId: Long, + @RequestBody request: String + ): ApiResponse { + return ApiResponse.ok(facade.updateReply(characterId, fanTalkId, replyId, request)) + } + + @DeleteMapping("/{fanTalkId:[0-9]+}") + fun delete( + @PathVariable characterId: Long, + @PathVariable fanTalkId: Long + ): ApiResponse { + facade.delete(characterId, fanTalkId) + return ApiResponse.ok(null) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt new file mode 100644 index 00000000..0aade731 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDto.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import java.time.LocalDateTime + +data class AiCharacterAdminFanTalkRecord( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImagePath: String?, + val content: String, + val createdAt: LocalDateTime +) + +data class AiCharacterAdminFanTalkReplyRecord( + val fanTalkId: Long, + val parentFanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImagePath: String?, + val content: String, + val createdAt: LocalDateTime +) + +data class AiCharacterAdminFanTalkReplyRequest( + val content: String +) + +data class AiCharacterAdminFanTalkReplyUpdateRequest( + val content: String? = null, + val isActive: Boolean? = null +) + +data class AiCharacterAdminFanTalkReplyResponse( + val fanTalkId: Long, + val replyId: Long, + val creatorMemberId: Long, + val content: String, + val createdAtUtc: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt new file mode 100644 index 00000000..ffb2fe74 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkFacade.kt @@ -0,0 +1,178 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AiCharacterAdminFanTalkFacade( + private val objectMapper: ObjectMapper, + private val targetResolver: AiCharacterAdminTargetResolver, + private val repository: AiCharacterAdminFanTalkRepository, + private val creatorCheersRepository: CreatorCheersRepository, + private val queryPolicy: CreatorChannelFanTalkQueryPolicy, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String +) { + @Transactional(readOnly = true) + fun list(characterId: Long, page: Int, size: Int): CreatorChannelFanTalkTabResponse { + val normalizedPage = queryPolicy.createPage(page, size) + + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val fetchedRoots = repository.findActiveRoots( + creatorMemberId = creatorMemberId, + offset = normalizedPage.page.toLong() * normalizedPage.size, + limit = normalizedPage.size + 1 + ) + val roots = queryPolicy.limitItems(fetchedRoots, normalizedPage) + val repliesByRootId = repository.findActiveCreatorReplies( + creatorMemberId = creatorMemberId, + parentFanTalkIds = roots.map { it.fanTalkId } + ).groupBy( + keySelector = { it.parentFanTalkId }, + valueTransform = { it.toResponse() } + ) + + return CreatorChannelFanTalkTabResponse( + fanTalkCount = repository.countActiveRoots(creatorMemberId), + fanTalks = roots.map { it.toResponse(repliesByRootId[it.fanTalkId].orEmpty()) }, + page = normalizedPage.page, + size = normalizedPage.size, + hasNext = queryPolicy.hasNext(fetchedRoots, normalizedPage) + ) + } + + @Transactional + fun createReply( + characterId: Long, + fanTalkId: Long, + requestString: String + ): AiCharacterAdminFanTalkReplyResponse { + val request = readRequest(requestString, AiCharacterAdminFanTalkReplyRequest::class.java) + if (request.content.isBlank()) throw invalidRequest() + + val target = resolveActiveTarget(characterId) + val creator = target.creatorMember + val creatorMemberId = creator.id ?: throw invalidRequest() + val parent = repository.findActiveRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest() + val savedReply = creatorCheersRepository.save( + CreatorCheers(cheers = request.content, languageCode = null).apply { + member = creator + this.creator = creator + this.parent = parent + } + ) + val replyId = savedReply.id ?: throw invalidRequest() + val createdAtUtc = savedReply.createdAt?.toUtcIso() ?: throw invalidRequest() + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = replyId, + query = request.content, + targetType = LanguageDetectTargetType.CREATOR_CHEERS + ) + ) + + return AiCharacterAdminFanTalkReplyResponse( + fanTalkId = fanTalkId, + replyId = replyId, + creatorMemberId = creatorMemberId, + content = request.content, + createdAtUtc = createdAtUtc + ) + } + + @Transactional + fun delete(characterId: Long, fanTalkId: Long) { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val fanRoot = repository.findFanRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest() + + fanRoot.isActive = false + } + + @Transactional + fun updateReply( + characterId: Long, + fanTalkId: Long, + replyId: Long, + requestString: String + ): CreatorChannelFanTalkResponse { + val request = readRequest(requestString, AiCharacterAdminFanTalkReplyUpdateRequest::class.java) + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val reply = repository.findUpdatableReply(creatorMemberId, fanTalkId, replyId) ?: throw invalidRequest() + + request.content?.let { reply.cheers = it } + request.isActive?.let { reply.isActive = it } + + return CreatorChannelFanTalkResponse.from(reply, cloudFrontHost) + } + + private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + return target + } + + private fun AiCharacterAdminFanTalkRecord.toResponse( + creatorReplies: List + ): CreatorChannelFanTalkResponse { + return CreatorChannelFanTalkResponse( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + content = content, + createdAtUtc = createdAt.toUtcIso(), + creatorReplies = creatorReplies + ) + } + + private fun AiCharacterAdminFanTalkReplyRecord.toResponse(): CreatorChannelFanTalkReplyResponse { + return CreatorChannelFanTalkReplyResponse( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + content = content, + createdAtUtc = createdAt.toUtcIso() + ) + } + + private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png" + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt new file mode 100644 index 00000000..57151417 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkRepository.kt @@ -0,0 +1,127 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.querydsl.core.types.Projections +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers +import org.springframework.stereotype.Repository + +@Repository +class AiCharacterAdminFanTalkRepository( + private val queryFactory: JPAQueryFactory +) { + fun countActiveRoots(creatorMemberId: Long): Int { + return queryFactory + .select(creatorCheers.id.count()) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.isNull + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun findActiveRoots( + creatorMemberId: Long, + offset: Long, + limit: Int + ): List { + return queryFactory + .select( + Projections.constructor( + AiCharacterAdminFanTalkRecord::class.java, + creatorCheers.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.isNull + ) + .orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc()) + .offset(offset) + .limit(limit.toLong()) + .fetch() + } + + fun findActiveCreatorReplies( + creatorMemberId: Long, + parentFanTalkIds: List + ): List { + if (parentFanTalkIds.isEmpty()) return emptyList() + + return queryFactory + .select( + Projections.constructor( + AiCharacterAdminFanTalkReplyRecord::class.java, + creatorCheers.id, + creatorCheers.parent.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.member.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.id.`in`(parentFanTalkIds) + ) + .orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc()) + .fetch() + } + + fun findActiveRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? { + return queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.id.eq(fanTalkId), + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.isNull + ) + .fetchOne() + } + + fun findFanRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? { + return queryFactory + .selectFrom(creatorCheers) + .where( + creatorCheers.id.eq(fanTalkId), + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.member.id.ne(creatorMemberId), + creatorCheers.parent.isNull + ) + .fetchOne() + } + + fun findUpdatableReply(creatorMemberId: Long, fanTalkId: Long, replyId: Long): CreatorCheers? { + val root = QCreatorCheers("root") + return queryFactory + .selectFrom(creatorCheers) + .join(creatorCheers.parent, root) + .where( + creatorCheers.id.eq(replyId), + creatorCheers.creator.id.eq(creatorMemberId), + creatorCheers.member.id.eq(creatorMemberId), + root.id.eq(fanTalkId), + root.creator.id.eq(creatorMemberId), + root.isActive.isTrue, + root.parent.isNull + ) + .fetchOne() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt new file mode 100644 index 00000000..7bce71a5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt @@ -0,0 +1,83 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security + +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.security.web.firewall.DefaultRequestRejectedHandler +import org.springframework.security.web.firewall.RequestRejectedException +import org.springframework.security.web.firewall.RequestRejectedHandler +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.DefaultCorsProcessor +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletRequestWrapper +import javax.servlet.http.HttpServletResponse + +class AiCharacterAdminSecurityErrorHandler( + private val responseWriter: AiCharacterAdminErrorResponseWriter, + private val aiCharacterAdminRequestMatcher: RequestMatcher, + private val aiCharacterAdminCorsConfiguration: CorsConfiguration +) : AuthenticationEntryPoint, AccessDeniedHandler, RequestRejectedHandler { + private val defaultRequestRejectedHandler = DefaultRequestRejectedHandler() + private val corsProcessor = DefaultCorsProcessor() + + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + responseWriter.write( + request = request, + response = response, + status = HttpStatus.UNAUTHORIZED, + messageKey = "common.error.bad_credentials" + ) + } + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + responseWriter.write( + request = request, + response = response, + status = HttpStatus.FORBIDDEN, + messageKey = "common.error.access_denied" + ) + } + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + requestRejectedException: RequestRejectedException + ) { + if (!aiCharacterAdminRequestMatcher.matches(request)) { + defaultRequestRejectedHandler.handle(request, response, requestRejectedException) + return + } + + // Spring 5.3 CORS processor only recognizes HttpMethod enums, so use GET for nonstandard method checks. + val corsRequest = if (HttpMethod.resolve(request.method) == null) { + object : HttpServletRequestWrapper(request) { + override fun getMethod(): String = HttpMethod.GET.name + } + } else { + request + } + if (!corsProcessor.processRequest(aiCharacterAdminCorsConfiguration, corsRequest, response)) { + return + } + + responseWriter.write( + request = request, + response = response, + status = HttpStatus.BAD_REQUEST, + messageKey = "common.error.invalid_request" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt new file mode 100644 index 00000000..73251bfd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesController.kt @@ -0,0 +1,133 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.HttpMediaTypeNotSupportedException +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +@RestController +@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/series") +class AiCharacterAdminSeriesController( + private val facade: AiCharacterAdminSeriesFacade +) { + @GetMapping + fun list( + @PathVariable characterId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ): ApiResponse { + return ApiResponse.ok(facade.list(characterId, page, size)) + } + + @GetMapping("/{seriesId:[0-9]+}") + fun detail( + @PathVariable characterId: Long, + @PathVariable seriesId: Long + ): ApiResponse { + return ApiResponse.ok(facade.detail(characterId, seriesId)) + } + + @GetMapping("/{seriesId:[0-9]+}/contents") + fun contents( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = ApiResponse.ok(facade.contents(characterId, seriesId, page, size)) + + @GetMapping("/{seriesId:[0-9]+}/contents/search") + fun searchContents( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestParam("search_word") searchWord: String + ) = ApiResponse.ok(facade.searchContents(characterId, seriesId, searchWord)) + + @PostMapping("/{seriesId:[0-9]+}/contents", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun addContents( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestBody request: String + ): ApiResponse { + facade.addContents(characterId, seriesId, request) + return ApiResponse.ok(null) + } + + @DeleteMapping("/{seriesId:[0-9]+}/contents/{contentId:[0-9]+}") + fun removeContent( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @PathVariable contentId: Long + ): ApiResponse { + facade.removeContent(characterId, seriesId, contentId) + return ApiResponse.ok(null) + } + + @PutMapping("/orders", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun updateOrders( + @PathVariable characterId: Long, + @RequestBody request: String + ): ApiResponse { + facade.updateOrders(characterId, request) + return ApiResponse.ok(null) + } + + @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun create( + @PathVariable characterId: Long, + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.create(characterId, image, request) + return ApiResponse.ok(null) + } + + @PutMapping("/{seriesId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun update( + @PathVariable characterId: Long, + @PathVariable seriesId: Long, + @RequestPart(value = "image", required = false) image: MultipartFile?, + @RequestPart("request") request: String, + multipartRequest: MultipartHttpServletRequest + ): ApiResponse { + requireAllowedMultipartParts(multipartRequest) + requireJsonRequestPart(multipartRequest) + facade.update(characterId, seriesId, image, request) + return ApiResponse.ok(null) + } + + private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) { + if (multipartRequest.fileMap.keys.any { it !in SERIES_MULTIPART_PARTS } || + multipartRequest.parts.any { it.name !in SERIES_MULTIPART_PARTS } + ) { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } + } + + private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) { + val contentType = multipartRequest.getMultipartHeaders("request")?.contentType + ?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType) + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON)) + } + } + + companion object { + private val SERIES_MULTIPART_PARTS = setOf("image", "request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt new file mode 100644 index 00000000..3803997b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesDto.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListResponse +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState + +typealias AiCharacterAdminSeriesListResponse = GetCreatorAdminContentSeriesListResponse +typealias AiCharacterAdminSeriesListItem = GetCreatorAdminContentSeriesListItem +typealias AiCharacterAdminSeriesDetailResponse = GetCreatorAdminContentSeriesListItem +typealias AiCharacterAdminSeriesCreateRequest = CreateSeriesRequest + +data class AiCharacterAdminSeriesUpdateRequest( + val title: String? = null, + val introduction: String? = null, + val publishedDaysOfWeek: Set? = null, + val genreId: Long? = null, + val isAdult: Boolean? = null, + val state: SeriesState? = null, + val isActive: Boolean? = null, + val writer: String? = null, + val studio: String? = null +) + +data class AiCharacterAdminSeriesContentAddRequest( + val contentIdList: List? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt new file mode 100644 index 00000000..69fbd5ef --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesFacade.kt @@ -0,0 +1,220 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreService +import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesContentResponse +import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem +import kr.co.vividnext.sodalive.creator.admin.content.series.ModifySeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.UpdateOrdersRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +@Service +class AiCharacterAdminSeriesFacade( + private val objectMapper: ObjectMapper, + private val targetResolver: AiCharacterAdminTargetResolver, + private val repository: AiCharacterAdminSeriesRepository, + private val legacyService: CreatorAdminContentSeriesService, + private val genreService: AdminContentSeriesGenreService, + @Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String +) { + @Transactional(readOnly = true) + fun genres(): List { + return genreService.getSeriesGenreList() + } + + @Transactional(readOnly = true) + fun list(characterId: Long, page: Int, size: Int): AiCharacterAdminSeriesListResponse { + val target = resolveActiveTarget(characterId) + if (page < 0 || size < 1) throw invalidRequest() + + val pageable = PageRequest.of(page, size) + return legacyService.getSeriesList( + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + creatorId = target.creatorMember.id ?: throw invalidRequest() + ) + } + + @Transactional(readOnly = true) + fun detail(characterId: Long, seriesId: Long): AiCharacterAdminSeriesDetailResponse { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) + ?: throw invalidRequest() + return GetCreatorAdminContentSeriesListItem( + seriesId = series.id!!, + title = series.title, + introduction = series.introduction, + coverImageUrl = "$coverImageHost/${series.coverImage!!}", + publishedDaysOfWeek = series.publishedDaysOfWeek.toList(), + genreId = series.genre!!.id!!, + isAdult = series.isAdult, + state = series.state, + isActive = series.isActive, + writer = series.writer, + studio = series.studio + ) + } + + @Transactional(readOnly = true) + fun contents( + characterId: Long, + seriesId: Long, + page: Int, + size: Int + ): GetCreatorAdminContentSeriesContentResponse { + val target = resolveOwnedActiveSeries(characterId, seriesId) + if (page < 0 || size < 1) throw invalidRequest() + + val pageable = PageRequest.of(page, size) + return legacyService.getSeriesContent( + seriesId = seriesId, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + creatorId = target.creatorMember.id ?: throw invalidRequest() + ) + } + + @Transactional(readOnly = true) + fun searchContents(characterId: Long, seriesId: Long, searchWord: String): List { + val target = resolveOwnedActiveSeries(characterId, seriesId) + return legacyService.searchContentNotInSeries( + seriesId = seriesId, + searchWord = searchWord, + memberId = target.creatorMember.id ?: throw invalidRequest() + ) + } + + @Transactional + fun addContents(characterId: Long, seriesId: Long, requestString: String) { + val request = readRequest(requestString, AiCharacterAdminSeriesContentAddRequest::class.java) + val target = resolveOwnedActiveSeries(characterId, seriesId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val contentIds = request.contentIdList ?: throw invalidRequest() + if (contentIds.isEmpty()) { + legacyService.addingContentToTheSeries( + AddingContentToTheSeriesRequest(seriesId, contentIds), + creatorMemberId + ) + return + } + if (contentIds.size != contentIds.toSet().size) throw invalidRequest() + + val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest() + val linkedContentIds = series.contentList.mapNotNull { it.content?.id }.toSet() + contentIds.forEach { contentId -> + if (contentId in linkedContentIds || + repository.findEligibleContentByIdAndCreatorMemberId(contentId, creatorMemberId) == null + ) { + throw invalidRequest() + } + } + legacyService.addingContentToTheSeries(AddingContentToTheSeriesRequest(seriesId, contentIds), creatorMemberId) + } + + @Transactional + fun removeContent(characterId: Long, seriesId: Long, contentId: Long?) { + val target = resolveOwnedActiveSeries(characterId, seriesId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val resolvedContentId = contentId ?: throw invalidRequest() + val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest() + if (series.contentList.none { it.content?.id == resolvedContentId && it.content?.member?.id == creatorMemberId }) { + throw invalidRequest() + } + legacyService.removeContentInTheSeries(RemoveContentToTheSeriesRequest(seriesId, resolvedContentId), creatorMemberId) + } + + @Transactional + fun updateOrders(characterId: Long, requestString: String) { + val request = readRequest(requestString, UpdateOrdersRequest::class.java) + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + val ids = request.ids + if (ids.isEmpty() || ids.size != ids.toSet().size) throw invalidRequest() + val lockedIds = repository.findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId, ids).map { it.id }.toSet() + if (lockedIds != ids.toSet()) throw invalidRequest() + legacyService.updateSeriesOrders(ids) + } + + @Transactional + fun create(characterId: Long, image: MultipartFile, requestString: String) { + val target = resolveActiveTarget(characterId) + if (image.isEmpty) throw invalidRequest() + val request = readRequest(requestString, AiCharacterAdminSeriesCreateRequest::class.java) + rejectMissingActiveGenre(request.genreId) + legacyService.createSeries(image, requestString, target.creatorMember) + } + + @Transactional + fun update(characterId: Long, seriesId: Long, image: MultipartFile?, requestString: String) { + val target = resolveOwnedActiveSeries(characterId, seriesId) + val normalizedImage = image?.takeUnless { it.isEmpty } + val request = readRequest(requestString, AiCharacterAdminSeriesUpdateRequest::class.java) + request.genreId?.let(::rejectMissingActiveGenre) + legacyService.modifySeries( + coverImage = normalizedImage, + requestString = objectMapper.writeValueAsString( + ModifySeriesRequest( + seriesId = seriesId, + title = request.title, + introduction = request.introduction, + publishedDaysOfWeek = request.publishedDaysOfWeek, + genreId = request.genreId, + isAdult = request.isAdult, + state = request.state, + isActive = request.isActive, + writer = request.writer, + studio = request.studio + ) + ), + member = target.creatorMember + ) + } + + private fun resolveOwnedActiveSeries(characterId: Long, seriesId: Long): AiCharacterAdminTarget { + val target = resolveActiveTarget(characterId) + val creatorMemberId = target.creatorMember.id ?: throw invalidRequest() + repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest() + return target + } + + private fun readRequest(requestString: String, requestClass: Class): T { + return try { + objectMapper.readerFor(requestClass) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .readValue(requestString) + } catch (_: JsonProcessingException) { + throw invalidRequest() + } + } + + private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget { + val target = targetResolver.resolve(characterId) + if (!target.chatCharacter.isActive) throw invalidRequest() + return target + } + + private fun rejectMissingActiveGenre(genreId: Long) { + if (genreId <= 0 || !repository.existsActiveGenre(genreId)) throw invalidRequest() + } + + private fun invalidRequest(): AiCharacterAdminApiException { + return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt new file mode 100644 index 00000000..1822158d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesReferenceController.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse +import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin/ai-characters") +class AiCharacterAdminSeriesReferenceController( + private val facade: AiCharacterAdminSeriesFacade +) { + @GetMapping("/series-genres") + fun genres(): ApiResponse> { + return ApiResponse.ok(facade.genres()) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt new file mode 100644 index 00000000..b8cd8c05 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesRepository.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository +import org.springframework.stereotype.Repository + +@Repository +class AiCharacterAdminSeriesRepository( + private val repository: CreatorAdminContentSeriesRepository, + private val genreRepository: CreatorAdminContentSeriesGenreRepository, + private val audioContentRepository: AudioContentRepository +) { + fun findActiveByIdAndCreatorMemberId(seriesId: Long, creatorMemberId: Long): Series? { + return repository.findByIdAndCreatorId(seriesId, creatorMemberId)?.takeIf { it.isActive } + } + + fun findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId: Long, ids: List): List { + return repository.findActiveByCreatorIdAndIdInForUpdate(creatorMemberId, ids.sorted()) + } + + fun existsActiveGenre(genreId: Long): Boolean { + return genreRepository.findById(genreId).orElse(null)?.isActive == true + } + + fun findEligibleContentByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? { + return audioContentRepository.findByIdAndCreatorId(contentId, creatorMemberId) + ?.takeIf { it.duration != null && (it.isActive || it.releaseDate != null) } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt index e36933d8..765cfd77 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt @@ -60,7 +60,7 @@ data class MainContentAudioResponse( imageUrl = audio.imageUrl, price = audio.price, isAdult = audio.isAdult, - isPointAvailable = audio.isPointAvailable, + isPointAvailable = audio.isPointAvailable && audio.price > 0, isFirstContent = audio.isFirstContent, isOriginalSeries = audio.isOriginalSeries, creatorNickname = audio.creatorNickname diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt index c7a69ac9..649d3595 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt @@ -46,7 +46,7 @@ data class ContentOverviewItemResponse( title = audio.title, coverImage = audio.imageUrl, price = audio.price, - isPointAvailable = audio.isPointAvailable, + isPointAvailable = audio.isPointAvailable && audio.price > 0, creatorNickname = audio.creatorNickname, isAdult = audio.isAdult, isFirstContent = audio.isFirstContent, @@ -65,7 +65,7 @@ data class ContentOverviewItemResponse( title = audio.title, coverImage = coverImage, price = audio.price, - isPointAvailable = audio.isPointAvailable, + isPointAvailable = audio.isPointAvailable && audio.price > 0, creatorNickname = audio.creatorNickname, isAdult = isAdult, isFirstContent = true, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt index 08cede3a..a3d18dce 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt @@ -69,7 +69,7 @@ data class AudioCardResponse( imageUrl = audio.imageUrl, price = audio.price, isAdult = audio.isAdult, - isPointAvailable = audio.isPointAvailable, + isPointAvailable = audio.isPointAvailable && audio.price > 0, isFirstContent = audio.isFirstContent, isOriginalSeries = audio.isOriginalSeries, creatorNickname = audio.creatorNickname diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt index b1d9c7f4..9efbce19 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt @@ -32,7 +32,7 @@ data class CreatorChannelAudioContentResponse( imageUrl = audioContent.imageUrl, price = audioContent.price, isAdult = audioContent.isAdult, - isPointAvailable = audioContent.isPointAvailable, + isPointAvailable = audioContent.isPointAvailable && audioContent.price > 0, isFirstContent = audioContent.isFirstContent, seriesName = audioContent.seriesName, isOriginalSeries = audioContent.isOriginalSeries, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt index e0205ad6..8c96e13b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChann import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime +import java.time.ZoneOffset @Service @Transactional(readOnly = true) @@ -15,7 +16,7 @@ class CreatorChannelHomeFacade( fun getHome( creatorId: Long, viewer: Member, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ): CreatorChannelHomeResponse { return CreatorChannelHomeResponse.from( creatorChannelHomeQueryService.getHome( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index a462f09b..e2935b9b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -245,6 +245,7 @@ class HomeRecommendationFacade( } private fun RecentlyActiveCreatorRecord.toItem() = HomeActiveCreatorItem( + creatorId = creatorId, creatorNickname = creatorNickname, creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage), activityType = activityType.name, @@ -266,7 +267,7 @@ class HomeRecommendationFacade( title = title, price = price, coverImage = imageUrl(cloudFrontHost, coverImage), - isPointAvailable = isPointAvailable + isPointAvailable = isPointAvailable && price > 0 ) private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt index 3f90003d..cc19db1a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt @@ -36,6 +36,7 @@ data class HomeLiveItem( ) data class HomeActiveCreatorItem( + val creatorId: Long, val creatorNickname: String, val creatorProfileImage: String, val activityType: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt deleted file mode 100644 index dc955ccb..00000000 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutor.kt +++ /dev/null @@ -1,34 +0,0 @@ -package kr.co.vividnext.sodalive.v2.common.application - -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.transaction.support.TransactionSynchronization -import org.springframework.transaction.support.TransactionSynchronizationManager - -@Component -class AfterCommitExecutor { - private val logger = LoggerFactory.getLogger(javaClass) - - fun executeAfterCommit(callback: () -> Unit) { - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - executeCallback(callback) - return - } - - TransactionSynchronizationManager.registerSynchronization( - object : TransactionSynchronization { - override fun afterCommit() { - executeCallback(callback) - } - } - ) - } - - private fun executeCallback(callback: () -> Unit) { - try { - callback() - } catch (e: Exception) { - logger.warn("afterCommit callback failed error={}", e.javaClass.simpleName) - } - } -} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt index 1327ce4c..484bd4ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt @@ -406,7 +406,7 @@ class DefaultMainContentAllQueryRepository( } private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? { - return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null + return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) else null } private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 556d0d88..10fdd418 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -135,7 +135,12 @@ class DefaultAudioRecommendationQueryRepository( now: LocalDateTime ): List { val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") - val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) { + val rows = audioRows( + memberId, + canViewAdultContent, + now, + audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) + ) { orderBy(randomTieBreaker.asc()).limit(limit.toLong()) } return rows.toAudioCards(now, canViewAdultContent) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 54ff412f..26aaef32 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.order.QOrder.order +import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers @@ -269,7 +270,7 @@ class DefaultCreatorChannelHomeQueryRepository( viewerId: Long?, limit: Int ): List { - val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit) + val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true) val contentIds = rows.map { itAudioId(it) } val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent) val seriesByContentId = audioSeriesByContentIds(contentIds) @@ -435,21 +436,33 @@ class DefaultCreatorChannelHomeQueryRepository( now: LocalDateTime, excludedContentId: Long?, canViewAdultContent: Boolean, - limit: Int - ) = queryFactory - .select( - audioContent.id, - audioContent.title, - audioContent.duration, - audioContent.coverImage, - audioContent.price, - audioContent.isAdult, - audioContent.isPointAvailable, - audioContent.releaseDate, - audioContent.createdAt - ) - .from(audioContent) - .where( + limit: Int, + pinFirst: Boolean = false + ): List { + val query = queryFactory + .select( + audioContent.id, + audioContent.title, + audioContent.duration, + audioContent.coverImage, + audioContent.price, + audioContent.isAdult, + audioContent.isPointAvailable, + audioContent.releaseDate, + audioContent.createdAt + ) + .from(audioContent) + + if (pinFirst) { + query.leftJoin(pinContent) + .on( + pinContent.member.id.eq(creatorId), + pinContent.content.id.eq(audioContent.id), + pinContent.isActive.isTrue + ) + } + + query.where( audioContent.member.id.eq(creatorId), audioContent.member.isActive.isTrue, audioContent.isActive.isTrue, @@ -459,9 +472,22 @@ class DefaultCreatorChannelHomeQueryRepository( excludedContentId?.let { audioContent.id.ne(it) }, adultAudioCondition(canViewAdultContent) ) - .orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()) - .limit(limit.toLong()) - .fetch() + + if (pinFirst) { + query.orderBy( + pinContent.isActive.desc(), + pinContent.updatedAt.desc(), + audioContent.releaseDate.desc(), + audioContent.id.desc() + ) + } else { + query.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()) + } + + return query + .limit(limit.toLong()) + .fetch() + } private fun itAudioId(row: com.querydsl.core.Tuple): Long = row.get(audioContent.id)!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt index 522fa585..c9ceeb16 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.home.application import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Gender @@ -36,6 +37,7 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime +import java.time.ZoneOffset @Service @Transactional(readOnly = true) @@ -52,7 +54,7 @@ class CreatorChannelHomeQueryService( fun getHome( creatorId: Long, viewer: Member, - now: LocalDateTime = LocalDateTime.now() + now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ): CreatorChannelHome { val viewerId = viewer.id!! val creator = queryPort.findCreator(creatorId, viewerId) @@ -68,8 +70,8 @@ class CreatorChannelHomeQueryService( validateCreatorRole(creator) val preference = memberContentPreferenceService.getStoredPreference(viewer) - val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer) - val isViewerCreator = viewerId == creatorId + val canViewAdultContent = preference.isAdult + val isViewerCreator = viewer.role == MemberRole.CREATOR val effectiveViewerGender = viewer.effectiveGender() val latestAudioContent = queryPort .findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId) @@ -187,7 +189,7 @@ class CreatorChannelHomeQueryService( ) private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( - nickname = nickname, + nickname = nickname.removeDeletedNicknamePrefix(), profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), can = can, message = message, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt index d5d33206..9be531ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt @@ -11,7 +11,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.block.QBlockMember @@ -22,11 +24,16 @@ import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeF import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshot +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshotJob +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -64,7 +71,13 @@ class DefaultHomeFollowingQueryRepository( } } - override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List { + override fun findOnAirLives( + memberId: Long, + canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, + limit: Int + ): List { val creator = QMember("onAirCreator") return queryFactory .select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime) @@ -80,6 +93,8 @@ class DefaultHomeFollowingQueryRepository( creator.isActive.isTrue, creator.role.eq(MemberRole.CREATOR), adultLiveCondition(canViewAdultContent), + genderLiveCondition(memberId, effectiveViewerGender), + creatorJoinLiveCondition(memberId, isViewerCreator), notBlockedCreatorCondition(memberId, creator.id) ) .orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc()) @@ -99,11 +114,19 @@ class DefaultHomeFollowingQueryRepository( override fun findMonthlySchedules( memberId: Long, canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, now: LocalDateTime, limit: Int ): List { val window = monthlyScheduleWindow(now) - val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window) + val liveSchedules = findLiveSchedules( + memberId, + canViewAdultContent, + effectiveViewerGender, + isViewerCreator, + window + ) val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window) return (liveSchedules + audioSchedules) .sortedWith( @@ -141,7 +164,7 @@ class DefaultHomeFollowingQueryRepository( .from(homeFollowingNewsInbox) .join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId)) .leftJoin(newsAudioContent).on( - homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT), + homeFollowingNewsInbox.newsType.`in`(FollowingNewsType.AUDIO_CONTENT, FollowingNewsType.CONTENT_RANKING), newsAudioContent.id.eq(homeFollowingNewsInbox.targetId) ) .leftJoin(newsCommunity).on( @@ -157,7 +180,7 @@ class DefaultHomeFollowingQueryRepository( activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId), adultNewsCondition(canViewAdultContent), notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId), - activeNewsTargetCondition(canViewAdultContent) + activeNewsTargetCondition(memberId, canViewAdultContent, nowUtc) ) .orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc()) .limit(limit.toLong()) @@ -209,6 +232,16 @@ class DefaultHomeFollowingQueryRepository( } else { null }, + contentRanking = if (type == FollowingNewsType.CONTENT_RANKING && rank != null) { + HomeFollowingContentRankingNews( + rank = rank, + contentId = targetId, + contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost), + title = get(newsAudioContent.title)!! + ) + } else { + null + }, communityPost = if (type == FollowingNewsType.COMMUNITY_POST) { HomeFollowingCommunityPostNews( postId = targetId, @@ -229,6 +262,8 @@ class DefaultHomeFollowingQueryRepository( private fun findLiveSchedules( memberId: Long, canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, window: ScheduleWindow ): List { val creator = QMember("scheduleLiveCreator") @@ -254,6 +289,8 @@ class DefaultHomeFollowingQueryRepository( creator.isActive.isTrue, creator.role.eq(MemberRole.CREATOR), adultLiveCondition(canViewAdultContent), + genderLiveCondition(memberId, effectiveViewerGender), + creatorJoinLiveCondition(memberId, isViewerCreator), notBlockedCreatorCondition(memberId, creator.id) ) .fetch() @@ -340,6 +377,20 @@ class DefaultHomeFollowingQueryRepository( return if (canViewAdultContent) null else liveRoom.isAdult.isFalse } + private fun genderLiveCondition(viewerId: Long, effectiveViewerGender: Gender): BooleanExpression? { + val genderCondition = when (effectiveViewerGender) { + Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY) + Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY) + Gender.NONE -> return null + } + return genderCondition.or(liveRoom.member.id.eq(viewerId)) + } + + private fun creatorJoinLiveCondition(viewerId: Long, isViewerCreator: Boolean): BooleanExpression? { + if (!isViewerCreator) return null + return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId)) + } + private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? { return if (canViewAdultContent) null else audioContent.isAdult.isFalse } @@ -385,7 +436,11 @@ class DefaultHomeFollowingQueryRepository( } } - private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression { + private fun activeNewsTargetCondition( + memberId: Long, + canViewAdultContent: Boolean, + nowUtc: LocalDateTime + ): BooleanExpression { val newsAudioContent = QAudioContent("newsAudioContent") val newsCommunity = QCreatorCommunity("newsCommunity") val activeAudioExists = JPAExpressions @@ -410,10 +465,84 @@ class DefaultHomeFollowingQueryRepository( return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING) .and(homeFollowingNewsInbox.rank.isNotNull) + .and(latestVisibleCreatorRankingBatchCondition(nowUtc)) .or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists)) + .or( + homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CONTENT_RANKING) + .and(homeFollowingNewsInbox.rank.isNotNull) + .and(activeAudioExists) + .and(latestContentRankingNewsCondition(memberId, canViewAdultContent, nowUtc)) + ) .or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists)) } + private fun latestVisibleCreatorRankingBatchCondition(nowUtc: LocalDateTime): BooleanExpression { + val latestCreatorRankingSnapshot = QCreatorRankingSnapshot("latestCreatorRankingSnapshot") + val latestCreatorRankingJob = QCreatorRankingSnapshotJob("latestCreatorRankingJob") + val anyCreatorRankingJob = QCreatorRankingSnapshotJob("anyCreatorRankingJob") + val latestJobVisibleFromAt = JPAExpressions + .select(latestCreatorRankingJob.visibleFromAtUtc.max()) + .from(latestCreatorRankingJob) + .where( + latestCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY), + latestCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE), + latestCreatorRankingJob.visibleFromAtUtc.loe(nowUtc) + ) + val hasRankingJob = JPAExpressions + .selectOne() + .from(anyCreatorRankingJob) + .where( + anyCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY), + anyCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE), + anyCreatorRankingJob.visibleFromAtUtc.loe(nowUtc) + ) + .exists() + val latestSnapshotVisibleFromAt = JPAExpressions + .select(latestCreatorRankingSnapshot.visibleFromAtUtc.max()) + .from(latestCreatorRankingSnapshot) + .where( + latestCreatorRankingSnapshot.rankingType.eq(CreatorRankingType.WEEKLY), + latestCreatorRankingSnapshot.visibleFromAtUtc.loe(nowUtc) + ) + + return homeFollowingNewsInbox.visibleFromAtUtc.eq(latestJobVisibleFromAt).or( + hasRankingJob.not().and(homeFollowingNewsInbox.visibleFromAtUtc.eq(latestSnapshotVisibleFromAt)) + ) + } + + private fun latestContentRankingNewsCondition( + memberId: Long, + canViewAdultContent: Boolean, + nowUtc: LocalDateTime + ): BooleanExpression { + val newerContentRanking = QHomeFollowingNewsInbox("newerContentRanking") + return JPAExpressions + .selectOne() + .from(newerContentRanking) + .where( + newerContentRanking.memberId.eq(memberId), + newerContentRanking.newsType.eq(FollowingNewsType.CONTENT_RANKING), + newerContentRanking.targetId.eq(homeFollowingNewsInbox.targetId), + newerContentRanking.isActive.isTrue, + newerContentRanking.rank.isNotNull, + adultNewerContentRankingCondition(canViewAdultContent, newerContentRanking), + newerContentRanking.visibleFromAtUtc.loe(nowUtc), + newerContentRanking.visibleFromAtUtc.gt(homeFollowingNewsInbox.visibleFromAtUtc) + .or( + newerContentRanking.visibleFromAtUtc.eq(homeFollowingNewsInbox.visibleFromAtUtc) + .and(newerContentRanking.id.gt(homeFollowingNewsInbox.id)) + ) + ) + .notExists() + } + + private fun adultNewerContentRankingCondition( + canViewAdultContent: Boolean, + newerContentRanking: QHomeFollowingNewsInbox + ): BooleanExpression? { + return if (canViewAdultContent) null else newerContentRanking.isAdult.isFalse + } + private fun adultAudioNewsTargetCondition( canViewAdultContent: Boolean, newsAudioContent: QAudioContent diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt index 479eedb2..9b035941 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt @@ -52,6 +52,7 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository - fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List + fun findOnAirLives( + memberId: Long, + canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, + limit: Int + ): List fun findMonthlySchedules( memberId: Long, canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, now: LocalDateTime, limit: Int ): List diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 829560ef..84ea3df4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType @@ -127,7 +128,8 @@ class DefaultHomeRecommendationQueryRepository( includeAdultActivities: Boolean ): List { val sql = """ - select ranked.creator_nickname, + select ranked.creator_id, + ranked.creator_nickname, ranked.creator_profile_image, ranked.activity_type, ranked.activity_at, @@ -144,7 +146,7 @@ class DefaultHomeRecommendationQueryRepository( m.profile_image as creator_profile_image, 'LIVE' as activity_type, lr.begin_date_time as activity_at, - null as target_id, + case when lr.is_active = true then lr.id else null end as target_id, lr.id as target_sort_id from live_room lr join member m on m.id = lr.member_id @@ -152,6 +154,7 @@ class DefaultHomeRecommendationQueryRepository( and lr.channel_name <> '' and (:includeAdultActivities = true or lr.is_adult = false) and m.is_active = true + and m.role = 'CREATOR' union all select m.id as creator_id, m.nickname as creator_nickname, @@ -167,6 +170,7 @@ class DefaultHomeRecommendationQueryRepository( and ac.release_date is not null and (:includeAdultActivities = true or ac.is_adult = false) and m.is_active = true + and m.role = 'CREATOR' union all select m.id as creator_id, m.nickname as creator_nickname, @@ -180,6 +184,7 @@ class DefaultHomeRecommendationQueryRepository( where cc.is_active = true and (:includeAdultActivities = true or cc.is_adult = false) and m.is_active = true + and m.role = 'CREATOR' ) activities ) ranked where ranked.creator_rank = 1 @@ -199,11 +204,12 @@ class DefaultHomeRecommendationQueryRepository( return rows.map { row -> RecentlyActiveCreatorRecord( - creatorNickname = row[0] as String, - creatorProfileImage = row[1] as String?, - activityType = CreatorActivityType.valueOf(row[2] as String), - activityAt = toLocalDateTime(row[3]), - targetId = (row[4] as Number?)?.toLong() + creatorId = (row[0] as Number).toLong(), + creatorNickname = row[1] as String, + creatorProfileImage = row[2] as String?, + activityType = CreatorActivityType.valueOf(row[3] as String), + activityAt = toLocalDateTime(row[4]), + targetId = (row[5] as Number?)?.toLong() ) } } @@ -585,8 +591,7 @@ class DefaultHomeRecommendationQueryRepository( union all select lr.member_id as creator_id, lr.begin_date_time as debut_at from live_room lr - where lr.is_active = true - and lr.channel_name is not null + where lr.channel_name is not null and lr.channel_name <> '' and lr.begin_date_time <= :snapshotAt ) debut_events @@ -814,7 +819,13 @@ class DefaultHomeRecommendationQueryRepository( ) ) .from(member) - .where(member.isActive.isTrue, member.id.`in`(creatorIds), notBlockedCreatorCondition(memberId, member.id)) + .where( + member.isActive.isTrue, + member.id.`in`(creatorIds), + notViewerCondition(memberId, member.id), + notActiveFollowedCreatorCondition(memberId, member.id), + notBlockedCreatorCondition(memberId, member.id) + ) .fetch() } @@ -1276,6 +1287,24 @@ class DefaultHomeRecommendationQueryRepository( .notExists() } + private fun notViewerCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + return memberId?.let { Expressions.booleanTemplate("{0} <> {1}", creatorIdPath, it) } + } + + private fun notActiveFollowedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + if (memberId == null) return null + val creatorFollowing = QCreatorFollowing("recommendationCreatorFollowing") + return JPAExpressions + .selectOne() + .from(creatorFollowing) + .where( + creatorFollowing.isActive.isTrue, + creatorFollowing.member.id.eq(memberId), + creatorFollowing.creator.id.eq(creatorIdPath) + ) + .notExists() + } + private fun orderedCommunityPostCondition(memberId: Long?): BooleanExpression { if (memberId == null) return Expressions.FALSE return JPAExpressions diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt index f1f2817c..ec980494 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt @@ -92,24 +92,38 @@ open class RecommendationSnapshotRefreshService( open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int { val startedAt = System.currentTimeMillis() val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc) - val snapshots = queryPort.findCheerCreatorSnapshots( - window.startUtc, - window.endExclusiveUtc, - CHEER_CREATOR_SNAPSHOT_LIMIT - ) - snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots) - afterCommit { - log.info( - "event=cheer_creator_recommendation_snapshot_refresh_success " + - "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}", + try { + val snapshots = queryPort.findCheerCreatorSnapshots( + window.startUtc, + window.endExclusiveUtc, + CHEER_CREATOR_SNAPSHOT_LIMIT + ) + snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots) + afterCommit { + log.info( + "event=cheer_creator_recommendation_snapshot_refresh_success " + + "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}", + window.snapshotAt, + window.startUtc, + window.endExclusiveUtc, + snapshots.size, + System.currentTimeMillis() - startedAt + ) + } + return snapshots.size + } catch (ex: Exception) { + log.warn( + "event=cheer_creator_recommendation_snapshot_refresh_failure " + + "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} elapsedMs={} error={}", window.snapshotAt, window.startUtc, window.endExclusiveUtc, - snapshots.size, - System.currentTimeMillis() - startedAt + System.currentTimeMillis() - startedAt, + ex.message, + ex ) + throw ex } - return snapshots.size } @Transactional diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index 30e0300e..5a45bc0b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -103,6 +103,7 @@ data class HomeBannerRecommendationRecord( ) data class RecentlyActiveCreatorRecord( + val creatorId: Long, val creatorNickname: String, val creatorProfileImage: String?, val activityType: CreatorActivityType, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt index ebc5e5b6..cd4932e3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterControllerTest.kt @@ -1,54 +1,14 @@ package kr.co.vividnext.sodalive.admin.chat.character -import com.amazonaws.services.s3.AmazonS3Client -import com.fasterxml.jackson.databind.ObjectMapper -import com.sun.net.httpserver.HttpServer -import kr.co.vividnext.sodalive.admin.chat.character.dto.BackgroundResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest -import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest -import kr.co.vividnext.sodalive.admin.chat.character.dto.MemoryResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkBriefResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.PersonalityResponse -import kr.co.vividnext.sodalive.admin.chat.character.dto.RelationshipResponse import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService import kr.co.vividnext.sodalive.aws.s3.S3Uploader -import kr.co.vividnext.sodalive.chat.character.CharacterType -import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test -import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher -import org.springframework.data.domain.PageImpl -import org.springframework.data.domain.PageRequest -import org.springframework.http.MediaType -import org.springframework.mock.web.MockMultipartFile -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestPart -import org.springframework.web.multipart.MultipartFile -import java.net.InetSocketAddress -import java.net.URL -import java.util.concurrent.CopyOnWriteArrayList class AdminChatCharacterControllerTest { private val controller = AdminChatCharacterController( @@ -75,523 +35,6 @@ class AdminChatCharacterControllerTest { return method.invoke(controller, region, gender) as String } - @Test - fun shouldKeepLegacyAdminCharacterBaseContract() { - val classMapping = AdminChatCharacterController::class.java.getAnnotation(RequestMapping::class.java) - val preAuthorize = AdminChatCharacterController::class.java.getAnnotation(PreAuthorize::class.java) - - assertEquals("/admin/chat/character", classMapping.value.single()) - assertEquals("hasRole('ADMIN')", preAuthorize.value) - } - - @Test - fun shouldKeepLegacyAdminCharacterRoutes() { - assertEquals( - "/list", - method("getCharacterList", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single() - ) - assertEquals( - "/search", - method("searchCharacters", String::class.java, Int::class.java, Int::class.java) - .getAnnotation(GetMapping::class.java).value.single() - ) - assertEquals( - "/{characterId}", - method("getCharacterDetail", Long::class.java).getAnnotation(GetMapping::class.java).value.single() - ) - assertEquals( - "/register", - method("registerCharacter", MultipartFile::class.java, String::class.java) - .getAnnotation(PostMapping::class.java).value.single() - ) - assertEquals( - "/update", - method("updateCharacter", MultipartFile::class.java, String::class.java) - .getAnnotation(PutMapping::class.java).value.single() - ) - } - - @Test - fun shouldKeepLegacyAdminCharacterMultipartRequestParts() { - val register = method("registerCharacter", MultipartFile::class.java, String::class.java) - val update = method("updateCharacter", MultipartFile::class.java, String::class.java) - - assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value) - assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value) - assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value) - assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required) - assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value) - } - - @Test - fun shouldKeepLegacyAdminCharacterUpdateRequestFields() { - val request = ChatCharacterUpdateRequest( - id = 1L, - name = "character", - systemPrompt = "prompt", - description = "description", - age = null, - gender = null, - mbti = null, - speechPattern = null, - speechStyle = null, - appearance = null, - originalTitle = "title", - originalLink = null, - originalWorkId = null, - characterType = null, - isActive = null, - tags = emptyList(), - hobbies = emptyList(), - values = emptyList(), - goals = emptyList(), - relationships = emptyList(), - personalities = emptyList(), - backgrounds = emptyList(), - memories = emptyList() - ) - - assertEquals(1L, request.id) - assertEquals("character", request.name) - assertEquals("prompt", request.systemPrompt) - assertEquals("description", request.description) - assertEquals("title", request.originalTitle) - assertEquals(emptyList(), request.tags) - assertEquals(emptyList(), request.memories) - } - - @Test - fun shouldKeepLegacyAdminCharacterListResponseSurface() { - val adminService = Mockito.mock(AdminChatCharacterService::class.java) - Mockito.`when`(adminService.createDefaultPageRequest(0, 20)).thenReturn(PageRequest.of(0, 20)) - Mockito.`when`(adminService.getActiveChatCharacters(PageRequest.of(0, 20), "https://cdn.example.com")) - .thenReturn( - ChatCharacterListPageResponse( - totalCount = 1, - content = listOf(characterListResponse()) - ) - ) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build() - - mockMvc.perform(get("/admin/chat/character/list").param("page", "0").param("size", "20")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.content[0].id").value(1)) - .andExpect(jsonPath("$.data.content[0].name").value("character")) - .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png")) - .andExpect(jsonPath("$.data.content[0].description").value("description")) - .andExpect(jsonPath("$.data.content[0].gender").value("여성")) - .andExpect(jsonPath("$.data.content[0].age").value(20)) - .andExpect(jsonPath("$.data.content[0].mbti").value("INTJ")) - .andExpect(jsonPath("$.data.content[0].speechStyle").value("calm")) - .andExpect(jsonPath("$.data.content[0].speechPattern").value("polite")) - .andExpect(jsonPath("$.data.content[0].region").value("KR")) - .andExpect(jsonPath("$.data.content[0].tags[0]").value("tag")) - .andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00")) - .andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01")) - } - - @Test - @DisplayName("legacy 캐릭터 검색은 query와 비기본 pagination을 service에 전달하고 기존 응답을 유지한다") - fun shouldKeepLegacyAdminCharacterSearchRequestAndResponseSurface() { - val adminService = Mockito.mock(AdminChatCharacterService::class.java) - val pageable = PageRequest.of(2, 7) - Mockito.`when`(adminService.createDefaultPageRequest(2, 7)).thenReturn(pageable) - Mockito.`when`(adminService.searchCharacters("character", pageable, "https://cdn.example.com")) - .thenReturn(PageImpl(listOf(characterListResponse()), pageable, 15)) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build() - - mockMvc.perform( - get("/admin/chat/character/search") - .param("searchTerm", "character") - .param("page", "2") - .param("size", "7") - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.totalCount").value(15)) - .andExpect(jsonPath("$.data.content").isArray) - .andExpect(jsonPath("$.data.content[0].id").value(1)) - .andExpect(jsonPath("$.data.content[0].name").value("character")) - .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.example.com/characters/1.png")) - .andExpect(jsonPath("$.data.content[0].description").value("description")) - .andExpect(jsonPath("$.data.content[0].gender").value("여성")) - .andExpect(jsonPath("$.data.content[0].age").value(20)) - .andExpect(jsonPath("$.data.content[0].mbti").value("INTJ")) - .andExpect(jsonPath("$.data.content[0].speechStyle").value("calm")) - .andExpect(jsonPath("$.data.content[0].speechPattern").value("polite")) - .andExpect(jsonPath("$.data.content[0].region").value("KR")) - .andExpect(jsonPath("$.data.content[0].tags[0]").value("tag")) - .andExpect(jsonPath("$.data.content[0].createdAt").value("2026-07-21 12:00:00")) - .andExpect(jsonPath("$.data.content[0].updatedAt").value("2026-07-21 12:00:01")) - - Mockito.verify(adminService).createDefaultPageRequest(2, 7) - Mockito.verify(adminService).searchCharacters("character", pageable, "https://cdn.example.com") - } - - @Test - fun shouldKeepLegacyAdminCharacterDetailResponseSurface() { - val adminService = Mockito.mock(AdminChatCharacterService::class.java) - Mockito.`when`(adminService.getChatCharacterDetail(1L, "https://cdn.example.com")) - .thenReturn( - ChatCharacterDetailResponse( - id = 1L, - characterUUID = "uuid", - name = "character", - imageUrl = "https://cdn.example.com/characters/1.png", - description = "description", - systemPrompt = "prompt", - characterType = "Character", - age = 20, - gender = "여성", - mbti = "INTJ", - speechPattern = "polite", - speechStyle = "calm", - appearance = "appearance", - region = "KR", - isActive = true, - tags = listOf("tag"), - hobbies = listOf("hobby"), - values = listOf("value"), - goals = listOf("goal"), - relationships = listOf( - RelationshipResponse( - personName = "person", - relationshipName = "friend", - description = "relationship description", - importance = 5, - relationshipType = "ally", - currentStatus = "active" - ) - ), - personalities = listOf(PersonalityResponse(trait = "kind", description = "personality description")), - backgrounds = listOf(BackgroundResponse(topic = "past", description = "background description")), - memories = listOf(MemoryResponse(title = "memory", content = "memory content", emotion = "happy")), - originalWork = OriginalWorkBriefResponse( - id = 10L, - imageUrl = "https://cdn.example.com/originals/10.png", - title = "original title" - ) - ) - ) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(adminService = adminService)).build() - - mockMvc.perform(get("/admin/chat/character/1")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.id").value(1)) - .andExpect(jsonPath("$.data.characterUUID").value("uuid")) - .andExpect(jsonPath("$.data.name").value("character")) - .andExpect(jsonPath("$.data.imageUrl").value("https://cdn.example.com/characters/1.png")) - .andExpect(jsonPath("$.data.description").value("description")) - .andExpect(jsonPath("$.data.systemPrompt").value("prompt")) - .andExpect(jsonPath("$.data.characterType").value("Character")) - .andExpect(jsonPath("$.data.age").value(20)) - .andExpect(jsonPath("$.data.gender").value("여성")) - .andExpect(jsonPath("$.data.mbti").value("INTJ")) - .andExpect(jsonPath("$.data.speechPattern").value("polite")) - .andExpect(jsonPath("$.data.speechStyle").value("calm")) - .andExpect(jsonPath("$.data.appearance").value("appearance")) - .andExpect(jsonPath("$.data.region").value("KR")) - .andExpect(jsonPath("$.data.isActive").value(true)) - .andExpect(jsonPath("$.data.tags[0]").value("tag")) - .andExpect(jsonPath("$.data.hobbies[0]").value("hobby")) - .andExpect(jsonPath("$.data.values[0]").value("value")) - .andExpect(jsonPath("$.data.goals[0]").value("goal")) - .andExpect(jsonPath("$.data.relationships[0].personName").value("person")) - .andExpect(jsonPath("$.data.relationships[0].relationshipName").value("friend")) - .andExpect(jsonPath("$.data.relationships[0].description").value("relationship description")) - .andExpect(jsonPath("$.data.relationships[0].importance").value(5)) - .andExpect(jsonPath("$.data.relationships[0].relationshipType").value("ally")) - .andExpect(jsonPath("$.data.relationships[0].currentStatus").value("active")) - .andExpect(jsonPath("$.data.personalities[0].trait").value("kind")) - .andExpect(jsonPath("$.data.personalities[0].description").value("personality description")) - .andExpect(jsonPath("$.data.backgrounds[0].topic").value("past")) - .andExpect(jsonPath("$.data.backgrounds[0].description").value("background description")) - .andExpect(jsonPath("$.data.memories[0].title").value("memory")) - .andExpect(jsonPath("$.data.memories[0].content").value("memory content")) - .andExpect(jsonPath("$.data.memories[0].emotion").value("happy")) - .andExpect(jsonPath("$.data.originalWork.id").value(10)) - .andExpect(jsonPath("$.data.originalWork.imageUrl").value("https://cdn.example.com/originals/10.png")) - .andExpect(jsonPath("$.data.originalWork.title").value("original title")) - } - - @Test - fun shouldKeepLegacyAdminCharacterRegisterResponseSurface() { - val outboundRequests = CopyOnWriteArrayList() - val server = externalCharacterApiServer(outboundRequests) - try { - val service = chatCharacterServiceFake() - val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java) - val mockMvc = MockMvcBuilders.standaloneSetup( - controllerForMutation(server, service, originalWorkService) - ).build() - val request = """ - { - "name":"character", - "systemPrompt":"prompt", - "description":"description", - "age":"20", - "gender":"여성", - "mbti":"INTJ", - "speechPattern":"polite", - "speechStyle":"calm", - "appearance":"appearance", - "region":"KR", - "originalTitle":"original title", - "originalLink":"https://original.test", - "originalWorkId":10, - "characterType":"Character", - "tags":["tag"], - "hobbies":["hobby"], - "values":["value"], - "goals":["goal"], - "relationships":[{ - "personName":"person", - "relationshipName":"friend", - "description":"relationship description", - "importance":5, - "relationshipType":"ally", - "currentStatus":"active" - }], - "personalities":[{"trait":"kind","description":"personality description"}], - "backgrounds":[{"topic":"past","description":"background description"}], - "memories":[{"title":"memory","content":"memory content","emotion":"happy"}] - } - """.trimIndent() - - mockMvc.perform( - multipart("/admin/chat/character/register") - .file(imageFile()) - .file(jsonPart(request)) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - - val arguments = Mockito.mockingDetails(service).invocations - .single { it.method.name == "createChatCharacterWithDetails" } - .arguments - assertEquals("remote-id", arguments[0]) - assertEquals("character", arguments[1]) - assertEquals("description", arguments[2]) - assertEquals("prompt", arguments[3]) - assertEquals(20, arguments[4]) - assertEquals("여성", arguments[5]) - assertEquals("INTJ", arguments[6]) - assertEquals("polite", arguments[7]) - assertEquals("calm", arguments[8]) - assertEquals("appearance", arguments[9]) - assertEquals("original title", arguments[10]) - assertEquals("https://original.test", arguments[11]) - assertEquals(CharacterType.Character, arguments[12]) - assertEquals("KR", arguments[13]) - assertEquals(listOf("tag"), arguments[14]) - assertEquals(listOf("value"), arguments[15]) - assertEquals(listOf("hobby"), arguments[16]) - assertEquals(listOf("goal"), arguments[17]) - assertEquals(listOf(Triple("memory", "memory content", "happy")), arguments[18]) - assertEquals(listOf(Pair("kind", "personality description")), arguments[19]) - assertEquals(listOf(Pair("past", "background description")), arguments[20]) - assertEquals( - listOf( - ChatCharacterRelationshipRequest( - personName = "person", - relationshipName = "friend", - description = "relationship description", - importance = 5, - relationshipType = "ally", - currentStatus = "active" - ) - ), - arguments[21] - ) - Mockito.verify(originalWorkService).assignOneCharacter(10L, 1L) - assertRecordedRequest( - request = outboundRequests.single(), - method = "POST", - path = "/api/characters", - expectedBody = """ - { - "name":"character", - "systemPrompt":"prompt", - "description":"description", - "region":"KR", - "age":"20", - "gender":"여성", - "mbti":"INTJ", - "speechPattern":"polite", - "speechStyle":"calm", - "appearance":"appearance", - "tags":["tag"], - "hobbies":["hobby"], - "values":["value"], - "goals":["goal"], - "relationships":[{ - "personName":"person", - "relationshipName":"friend", - "description":"relationship description", - "importance":5, - "relationshipType":"ally", - "currentStatus":"active" - }], - "personalities":[{"trait":"kind","description":"personality description"}], - "backgrounds":[{"topic":"past","description":"background description"}], - "memories":[{"title":"memory","content":"memory content","emotion":"happy"}] - } - """.trimIndent() - ) - } finally { - server.stop(0) - } - } - - @Test - fun shouldKeepLegacyAdminCharacterUpdateResponseSurface() { - val outboundRequests = CopyOnWriteArrayList() - val server = externalCharacterApiServer(outboundRequests) - try { - val service = chatCharacterServiceFake() - val originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java) - val mockMvc = MockMvcBuilders.standaloneSetup( - controllerForMutation(server, service, originalWorkService) - ).build() - val request = """ - { - "id":1, - "name":"updated character", - "systemPrompt":"updated prompt", - "description":"updated description", - "age":"21", - "gender":"남성", - "mbti":"ENTP", - "speechPattern":"casual", - "speechStyle":"bright", - "appearance":"updated appearance", - "originalTitle":"updated original title", - "originalLink":"https://updated-original.test", - "originalWorkId":11, - "characterType":"Character", - "isActive":true, - "tags":["updated tag"], - "hobbies":["updated hobby"], - "values":["updated value"], - "goals":["updated goal"], - "relationships":[{ - "personName":"updated person", - "relationshipName":"rival", - "description":"updated relationship description", - "importance":4, - "relationshipType":"opponent", - "currentStatus":"tense" - }], - "personalities":[{"trait":"bold","description":"updated personality description"}], - "backgrounds":[{"topic":"future","description":"updated background description"}], - "memories":[{"title":"updated memory","content":"updated memory content","emotion":"hopeful"}] - } - """.trimIndent() - - mockMvc.perform( - multipart("/admin/chat/character/update") - .file(jsonPart(request)) - .with { requestBuilder -> - requestBuilder.method = "PUT" - requestBuilder - } - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - - val requestCaptor = ArgumentCaptor.forClass(ChatCharacterUpdateRequest::class.java) - Mockito.verify(service).updateChatCharacterWithDetails(Mockito.isNull(), captureRequest(requestCaptor)) - assertEquals( - ChatCharacterUpdateRequest( - id = 1L, - name = "updated character", - systemPrompt = "updated prompt", - description = "updated description", - age = "21", - gender = "남성", - mbti = "ENTP", - speechPattern = "casual", - speechStyle = "bright", - appearance = "updated appearance", - originalTitle = "updated original title", - originalLink = "https://updated-original.test", - originalWorkId = 11L, - characterType = "Character", - isActive = true, - tags = listOf("updated tag"), - hobbies = listOf("updated hobby"), - values = listOf("updated value"), - goals = listOf("updated goal"), - relationships = listOf( - ChatCharacterRelationshipRequest( - personName = "updated person", - relationshipName = "rival", - description = "updated relationship description", - importance = 4, - relationshipType = "opponent", - currentStatus = "tense" - ) - ), - personalities = listOf( - ChatCharacterPersonalityRequest("bold", "updated personality description") - ), - backgrounds = listOf( - ChatCharacterBackgroundRequest("future", "updated background description") - ), - memories = listOf( - ChatCharacterMemoryRequest("updated memory", "updated memory content", "hopeful") - ) - ), - requestCaptor.value - ) - Mockito.verify(originalWorkService).assignOneCharacter(11L, 1L) - assertRecordedRequest( - request = outboundRequests.single(), - method = "PUT", - path = "/api/characters/remote-id", - expectedBody = """ - { - "name":"updated character", - "systemPrompt":"updated prompt", - "description":"updated description", - "age":"21", - "gender":"남성", - "mbti":"ENTP", - "speechPattern":"casual", - "speechStyle":"bright", - "appearance":"updated appearance", - "tags":["updated tag"], - "hobbies":["updated hobby"], - "values":["updated value"], - "goals":["updated goal"], - "relationships":[{ - "personName":"updated person", - "relationshipName":"rival", - "description":"updated relationship description", - "importance":4, - "relationshipType":"opponent", - "currentStatus":"tense" - }], - "personalities":[{"trait":"bold","description":"updated personality description"}], - "backgrounds":[{"topic":"future","description":"updated background description"}], - "memories":[{ - "title":"updated memory", - "content":"updated memory content", - "emotion":"hopeful" - }] - } - """.trimIndent() - ) - } finally { - server.stop(0) - } - } - @Test fun shouldMapFemaleToJapaneseWhenRegionIsJp() { val mappedGender = mapGender(region = "JP", gender = "여성") @@ -619,143 +62,4 @@ class AdminChatCharacterControllerTest { assertEquals("여성", mappedGender) } - - private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method { - return AdminChatCharacterController::class.java.getDeclaredMethod(name, *parameterTypes) - } - - private fun controller( - adminService: AdminChatCharacterService = Mockito.mock(AdminChatCharacterService::class.java) - ): AdminChatCharacterController { - return AdminChatCharacterController( - service = Mockito.mock(ChatCharacterService::class.java), - adminService = adminService, - s3Uploader = Mockito.mock(S3Uploader::class.java), - originalWorkService = Mockito.mock(AdminOriginalWorkService::class.java), - creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java), - applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java), - apiKey = "test-api-key", - apiUrl = "https://example.com", - s3Bucket = "test-bucket", - imageHost = "https://cdn.example.com" - ) - } - - private fun controllerForMutation( - server: HttpServer? = null, - service: ChatCharacterService = chatCharacterServiceFake(), - originalWorkService: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java) - ): AdminChatCharacterController { - val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation -> - if (invocation.method.name == "getUrl") { - URL("https://cdn.example.com/characters/1/character.png") - } else { - null - } - } - return AdminChatCharacterController( - service = service, - adminService = Mockito.mock(AdminChatCharacterService::class.java), - s3Uploader = S3Uploader(amazonS3Client), - originalWorkService = originalWorkService, - creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java), - applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java), - apiKey = "test-api-key", - apiUrl = server?.let { "http://127.0.0.1:${it.address.port}" } ?: "https://example.com", - s3Bucket = "test-bucket", - imageHost = "https://cdn.example.com" - ) - } - - private fun chatCharacterServiceFake(): ChatCharacterService { - val character = chatCharacter(id = 1L, uuid = "remote-id") - return Mockito.mock(ChatCharacterService::class.java) { invocation -> - when (invocation.method.name) { - "findByName" -> null - "findById" -> character - "createChatCharacterWithDetails" -> character - "saveChatCharacter" -> invocation.arguments[0] - "updateChatCharacterWithDetails" -> character - else -> null - } - } - } - - private fun chatCharacter(id: Long, uuid: String): ChatCharacter { - return ChatCharacter( - characterUUID = uuid, - name = "character", - description = "description", - systemPrompt = "prompt", - characterType = CharacterType.Character - ).apply { this.id = id } - } - - private fun characterListResponse(): ChatCharacterListResponse { - return ChatCharacterListResponse( - id = 1L, - name = "character", - imageUrl = "https://cdn.example.com/characters/1.png", - description = "description", - gender = "여성", - age = 20, - mbti = "INTJ", - speechStyle = "calm", - speechPattern = "polite", - region = "KR", - tags = listOf("tag"), - createdAt = "2026-07-21 12:00:00", - updatedAt = "2026-07-21 12:00:01" - ) - } - - private fun captureRequest(captor: ArgumentCaptor): ChatCharacterUpdateRequest { - return captor.capture() ?: ChatCharacterUpdateRequest(id = 0L) - } - - private fun externalCharacterApiServer(requests: MutableList): HttpServer { - val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - server.createContext("/api/characters") { exchange -> - requests += RecordedHttpRequest( - method = exchange.requestMethod, - path = exchange.requestURI.path, - body = exchange.requestBody.bufferedReader().use { it.readText() } - ) - val response = """{"success":true,"data":{"id":"remote-id"}}""".toByteArray() - exchange.sendResponseHeaders(200, response.size.toLong()) - exchange.responseBody.use { it.write(response) } - } - server.start() - return server - } - - private fun assertRecordedRequest( - request: RecordedHttpRequest, - method: String, - path: String, - expectedBody: String - ) { - assertEquals(method, request.method) - assertEquals(path, request.path) - assertEquals(ObjectMapper().readTree(expectedBody), ObjectMapper().readTree(request.body)) - } - - private fun imageFile(): MockMultipartFile { - return MockMultipartFile("image", "character.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3)) - } - - private fun jsonPart(json: String): MockMultipartFile { - return MockMultipartFile("request", "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray()) - } - - private data class RecordedHttpRequest( - val method: String, - val path: String, - val body: String - ) - - companion object { - private const val LEGACY_EMPTY_SUCCESS_RESPONSE = - """{"success":true,"message":null,"data":null,"errorProperty":null}""" - } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt deleted file mode 100644 index c6daa1b7..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkControllerContractTest.kt +++ /dev/null @@ -1,469 +0,0 @@ -package kr.co.vividnext.sodalive.admin.chat.original - -import com.amazonaws.services.s3.AmazonS3Client -import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest -import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest -import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest -import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService -import kr.co.vividnext.sodalive.aws.s3.S3Uploader -import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.chat.original.OriginalWork -import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink -import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag -import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.ArgumentCaptor -import org.mockito.Mockito -import org.springframework.data.domain.PageImpl -import org.springframework.data.domain.PageRequest -import org.springframework.http.MediaType -import org.springframework.mock.web.MockMultipartFile -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestPart -import org.springframework.web.multipart.MultipartFile -import java.net.URL - -class AdminOriginalWorkControllerContractTest { - @Test - @DisplayName("legacy 원작 관리자 API는 기존 base path와 ADMIN 권한 계약을 유지한다") - fun shouldKeepLegacyAdminOriginalWorkBaseContract() { - val classMapping = AdminOriginalWorkController::class.java.getAnnotation(RequestMapping::class.java) - val preAuthorize = AdminOriginalWorkController::class.java.getAnnotation(PreAuthorize::class.java) - - assertEquals("/admin/chat/original", classMapping.value.single()) - assertEquals("hasRole('ADMIN')", preAuthorize.value) - } - - @Test - @DisplayName("legacy 원작 관리자 mutation 경로와 method를 유지한다") - fun shouldKeepLegacyAdminOriginalWorkMutationRoutes() { - assertEquals( - "/register", - method("register", MultipartFile::class.java, String::class.java) - .getAnnotation(PostMapping::class.java).value.single() - ) - assertEquals( - "/update", - method("update", MultipartFile::class.java, String::class.java) - .getAnnotation(PutMapping::class.java).value.single() - ) - assertEquals( - "/{id}", - method("delete", Long::class.java).getAnnotation(DeleteMapping::class.java).value.single() - ) - assertEquals( - "/{id}/assign-characters", - method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) - .getAnnotation(PostMapping::class.java).value.single() - ) - assertEquals( - "/{id}/unassign-characters", - method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) - .getAnnotation(PostMapping::class.java).value.single() - ) - } - - @Test - @DisplayName("legacy 원작 관리자 mutation request annotation을 유지한다") - fun shouldKeepLegacyAdminOriginalWorkMutationRequestAnnotations() { - val register = method("register", MultipartFile::class.java, String::class.java) - val update = method("update", MultipartFile::class.java, String::class.java) - val delete = method("delete", Long::class.java) - val assign = method("assignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) - val unassign = method("unassignCharacters", Long::class.java, OriginalWorkAssignCharactersRequest::class.java) - - assertEquals("image", register.parameters[0].getAnnotation(RequestPart::class.java).value) - assertEquals("request", register.parameters[1].getAnnotation(RequestPart::class.java).value) - assertEquals("image", update.parameters[0].getAnnotation(RequestPart::class.java).value) - assertEquals(false, update.parameters[0].getAnnotation(RequestPart::class.java).required) - assertEquals("request", update.parameters[1].getAnnotation(RequestPart::class.java).value) - assertEquals(true, delete.parameters[0].isAnnotationPresent(PathVariable::class.java)) - assertEquals(true, assign.parameters[0].isAnnotationPresent(PathVariable::class.java)) - assertEquals(true, assign.parameters[1].isAnnotationPresent(RequestBody::class.java)) - assertEquals(true, unassign.parameters[0].isAnnotationPresent(PathVariable::class.java)) - assertEquals(true, unassign.parameters[1].isAnnotationPresent(RequestBody::class.java)) - } - - @Test - @DisplayName("legacy 원작 관리자 조회 경로와 request DTO 기본 field를 유지한다") - fun shouldKeepLegacyAdminOriginalWorkReadRoutesAndDtos() { - assertEquals( - "/list", - method("list", Int::class.java, Int::class.java).getAnnotation(GetMapping::class.java).value.single() - ) - assertEquals( - "/search", - method("search", String::class.java).getAnnotation(GetMapping::class.java).value.single() - ) - assertEquals( - "/{id}", - method("detail", Long::class.java).getAnnotation(GetMapping::class.java).value.single() - ) - assertEquals( - "/{id}/characters", - method("listCharactersOfOriginal", Long::class.java, Int::class.java, Int::class.java) - .getAnnotation(GetMapping::class.java).value.single() - ) - - val register = OriginalWorkRegisterRequest(title = "title", contentType = "type", category = "category") - val registerWithAllFields = OriginalWorkRegisterRequest( - title = "title", - contentType = "type", - category = "category", - isAdult = true, - description = "description", - originalWork = "source", - originalLink = "https://source.test", - writer = "writer", - studio = "studio", - originalLinks = listOf("https://link.test"), - tags = listOf("tag") - ) - val update = OriginalWorkUpdateRequest( - id = 1L, - title = "title", - contentType = "type", - category = "category", - isAdult = null, - description = "description", - originalWork = "source", - originalLink = "https://source.test", - writer = "writer", - studio = "studio", - originalLinks = listOf("https://link.test"), - tags = listOf("tag") - ) - - assertEquals("title", register.title) - assertEquals(false, register.isAdult) - assertEquals(true, registerWithAllFields.isAdult) - assertEquals("source", registerWithAllFields.originalWork) - assertEquals("https://source.test", registerWithAllFields.originalLink) - assertEquals("writer", registerWithAllFields.writer) - assertEquals("studio", registerWithAllFields.studio) - assertEquals(listOf("https://link.test"), registerWithAllFields.originalLinks) - assertEquals(listOf("tag"), registerWithAllFields.tags) - assertEquals(1L, update.id) - assertEquals("type", update.contentType) - assertEquals("category", update.category) - assertEquals(null, update.isAdult) - assertEquals("description", update.description) - assertEquals("source", update.originalWork) - assertEquals("https://source.test", update.originalLink) - assertEquals("writer", update.writer) - assertEquals("studio", update.studio) - assertEquals(listOf("https://link.test"), update.originalLinks) - assertEquals(listOf("tag"), update.tags) - } - - @Test - @DisplayName("legacy 원작 관리자 목록은 기존 성공 응답 surface를 유지한다") - fun shouldKeepLegacyAdminOriginalWorkListResponseSurface() { - val service = Mockito.mock(AdminOriginalWorkService::class.java) - Mockito.`when`(service.getOriginalWorkPage(0, 20)) - .thenReturn(PageImpl(listOf(originalWork()), PageRequest.of(0, 20), 1)) - val controller = AdminOriginalWorkController( - originalWorkService = service, - s3Uploader = Mockito.mock(S3Uploader::class.java), - s3Bucket = "test-bucket", - imageHost = "https://cdn.test" - ) - val mockMvc = MockMvcBuilders.standaloneSetup(controller).build() - - mockMvc.perform(get("/admin/chat/original/list").param("page", "0").param("size", "20")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.content").isArray) - .andExpect(jsonPath("$.data.content[0].id").value(1)) - .andExpect(jsonPath("$.data.content[0].title").value("title")) - .andExpect(jsonPath("$.data.content[0].contentType").value("webtoon")) - .andExpect(jsonPath("$.data.content[0].category").value("romance")) - .andExpect(jsonPath("$.data.content[0].isAdult").value(false)) - .andExpect(jsonPath("$.data.content[0].description").value("description")) - .andExpect(jsonPath("$.data.content[0].originalWork").value("source")) - .andExpect(jsonPath("$.data.content[0].originalLink").value("https://source.test")) - .andExpect(jsonPath("$.data.content[0].writer").value("writer")) - .andExpect(jsonPath("$.data.content[0].studio").value("studio")) - .andExpect(jsonPath("$.data.content[0].originalLinks[0]").value("https://link.test")) - .andExpect(jsonPath("$.data.content[0].tags[0]").value("tag")) - .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png")) - } - - @Test - @DisplayName("legacy 원작 검색은 searchTerm을 service에 전달하고 기존 성공 응답을 유지한다") - fun shouldKeepLegacyAdminOriginalWorkSearchRequestAndResponseSurface() { - val service = Mockito.mock(AdminOriginalWorkService::class.java) - Mockito.`when`(service.searchOriginalWorksAll("title")).thenReturn(listOf(originalWork())) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build() - - mockMvc.perform(get("/admin/chat/original/search").param("searchTerm", "title")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data").isArray) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andExpect(jsonPath("$.data[0].title").value("title")) - .andExpect(jsonPath("$.data[0].contentType").value("webtoon")) - .andExpect(jsonPath("$.data[0].category").value("romance")) - .andExpect(jsonPath("$.data[0].isAdult").value(false)) - .andExpect(jsonPath("$.data[0].description").value("description")) - .andExpect(jsonPath("$.data[0].originalWork").value("source")) - .andExpect(jsonPath("$.data[0].originalLink").value("https://source.test")) - .andExpect(jsonPath("$.data[0].writer").value("writer")) - .andExpect(jsonPath("$.data[0].studio").value("studio")) - .andExpect(jsonPath("$.data[0].originalLinks[0]").value("https://link.test")) - .andExpect(jsonPath("$.data[0].tags[0]").value("tag")) - .andExpect(jsonPath("$.data[0].imageUrl").value("https://cdn.test/originals/1.png")) - - Mockito.verify(service).searchOriginalWorksAll("title") - } - - @Test - @DisplayName("legacy 원작 관리자 상세은 기존 성공 응답 surface를 유지한다") - fun shouldKeepLegacyAdminOriginalWorkDetailResponseSurface() { - val service = Mockito.mock(AdminOriginalWorkService::class.java) - Mockito.`when`(service.getOriginalWork(1L)).thenReturn(originalWork()) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build() - - mockMvc.perform(get("/admin/chat/original/1")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.id").value(1)) - .andExpect(jsonPath("$.data.title").value("title")) - .andExpect(jsonPath("$.data.contentType").value("webtoon")) - .andExpect(jsonPath("$.data.category").value("romance")) - .andExpect(jsonPath("$.data.isAdult").value(false)) - .andExpect(jsonPath("$.data.description").value("description")) - .andExpect(jsonPath("$.data.originalWork").value("source")) - .andExpect(jsonPath("$.data.originalLink").value("https://source.test")) - .andExpect(jsonPath("$.data.writer").value("writer")) - .andExpect(jsonPath("$.data.studio").value("studio")) - .andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test")) - .andExpect(jsonPath("$.data.tags[0]").value("tag")) - .andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png")) - } - - @Test - @DisplayName("legacy 원작 연결 캐릭터 목록은 기존 성공 응답 surface를 유지한다") - fun shouldKeepLegacyAdminOriginalWorkCharactersResponseSurface() { - val service = Mockito.mock(AdminOriginalWorkService::class.java) - val character = ChatCharacter( - characterUUID = "uuid", - name = "character", - description = "description", - systemPrompt = "prompt" - ).apply { - id = 10L - imagePath = "characters/10.png" - } - Mockito.`when`(service.getCharactersOfOriginalWorkPage(1L, 0, 20)) - .thenReturn(PageImpl(listOf(character), PageRequest.of(0, 20), 1)) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service)).build() - - mockMvc.perform(get("/admin/chat/original/1/characters").param("page", "0").param("size", "20")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.content").isArray) - .andExpect(jsonPath("$.data.content[0].id").value(10)) - .andExpect(jsonPath("$.data.content[0].name").value("character")) - .andExpect(jsonPath("$.data.content[0].imagePath").value("https://cdn.test/characters/10.png")) - } - - @Test - @DisplayName("legacy 원작 관리자 mutation은 기존 성공 응답 surface를 유지한다") - fun shouldKeepLegacyAdminOriginalWorkMutationResponseSurface() { - val service = Mockito.mock(AdminOriginalWorkService::class.java) - val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) { invocation -> - if (invocation.method.name == "getUrl") { - URL("https://cdn.test/originals/1/original.png") - } else { - null - } - } - val s3Uploader = S3Uploader(amazonS3Client) - val saved = OriginalWork(title = "title", contentType = "type", category = "category").apply { id = 1L } - val createRequest = OriginalWorkRegisterRequest( - title = "title", - contentType = "type", - category = "category", - isAdult = false, - description = "", - originalWork = "source", - originalLink = "https://source.test", - writer = "writer", - studio = "studio", - originalLinks = listOf("https://link.test"), - tags = listOf("tag") - ) - Mockito.`when`(service.createOriginalWork(createRequest)).thenReturn(saved) - val mockMvc = MockMvcBuilders.standaloneSetup(controller(service = service, s3Uploader = s3Uploader)).build() - - val registerRequest = """ - { - "title":"title", - "contentType":"type", - "category":"category", - "isAdult":false, - "description":"", - "originalWork":"source", - "originalLink":"https://source.test", - "writer":"writer", - "studio":"studio", - "originalLinks":["https://link.test"], - "tags":["tag"] - } - """.trimIndent() - mockMvc.perform(multipart("/admin/chat/original/register").file(imageFile()).file(jsonPart("request", registerRequest))) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - - val updateRequest = """ - { - "id":1, - "title":"title", - "contentType":"type", - "category":"category", - "isAdult":null, - "description":"description", - "originalWork":"source", - "originalLink":"https://source.test", - "writer":"writer", - "studio":"studio", - "originalLinks":["https://link.test"], - "tags":["tag"] - } - """.trimIndent() - mockMvc.perform( - multipart("/admin/chat/original/update") - .file(jsonPart("request", updateRequest)) - .with { request -> - request.method = "PUT" - request - } - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - - val updateRequestCaptor = ArgumentCaptor.forClass(OriginalWorkUpdateRequest::class.java) - Mockito.verify(service).updateOriginalWork(captureUpdateRequest(updateRequestCaptor), Mockito.isNull()) - assertEquals( - OriginalWorkUpdateRequest( - id = 1L, - title = "title", - contentType = "type", - category = "category", - isAdult = null, - description = "description", - originalWork = "source", - originalLink = "https://source.test", - writer = "writer", - studio = "studio", - originalLinks = listOf("https://link.test"), - tags = listOf("tag") - ), - updateRequestCaptor.value - ) - - mockMvc.perform(delete("/admin/chat/original/1")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - Mockito.verify(service).deleteOriginalWork(1L) - - val assignBody = """{"characterIds":[1]}""" - mockMvc.perform( - post("/admin/chat/original/1/assign-characters") - .contentType(MediaType.APPLICATION_JSON) - .content(assignBody) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - Mockito.verify(service).assignCharacters(1L, listOf(1L)) - - mockMvc.perform( - post("/admin/chat/original/1/unassign-characters") - .contentType(MediaType.APPLICATION_JSON) - .content(assignBody) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(content().json(LEGACY_EMPTY_SUCCESS_RESPONSE, true)) - Mockito.verify(service).unassignCharacters(1L, listOf(1L)) - } - - private fun controller( - service: AdminOriginalWorkService = Mockito.mock(AdminOriginalWorkService::class.java), - s3Uploader: S3Uploader = Mockito.mock(S3Uploader::class.java) - ): AdminOriginalWorkController { - return AdminOriginalWorkController( - originalWorkService = service, - s3Uploader = s3Uploader, - s3Bucket = "test-bucket", - imageHost = "https://cdn.test" - ) - } - - private fun imageFile(): MockMultipartFile { - return MockMultipartFile("image", "original.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1, 2, 3)) - } - - private fun originalWork(): OriginalWork { - val originalWork = OriginalWork( - title = "title", - contentType = "webtoon", - category = "romance", - isAdult = false, - description = "description", - originalWork = "source", - originalLink = "https://source.test", - writer = "writer", - studio = "studio" - ).apply { - id = 1L - imagePath = "originals/1.png" - } - originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork) - originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag")) - return originalWork - } - - private fun jsonPart(name: String, json: String): MockMultipartFile { - return MockMultipartFile(name, "", MediaType.APPLICATION_JSON_VALUE, json.toByteArray()) - } - - private fun method(name: String, vararg parameterTypes: Class<*>): java.lang.reflect.Method { - return AdminOriginalWorkController::class.java.getDeclaredMethod(name, *parameterTypes) - } - - private fun captureUpdateRequest( - captor: ArgumentCaptor - ): OriginalWorkUpdateRequest { - return captor.capture() ?: OriginalWorkUpdateRequest(id = 0L) - } - - companion object { - private const val LEGACY_EMPTY_SUCCESS_RESPONSE = - """{"success":true,"message":null,"data":null,"errorProperty":null}""" - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt index 2572a80b..0ebfbbb5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberLoginServiceTest.kt @@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.login.LoginRequest -import kr.co.vividnext.sodalive.member.token.MemberToken import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows @@ -19,22 +18,19 @@ import org.springframework.security.crypto.password.PasswordEncoder class AdminMemberLoginServiceTest { private lateinit var repository: AdminMemberRepository - private lateinit var memberRepository: MemberRepository private lateinit var passwordEncoder: PasswordEncoder private lateinit var tokenRepository: MemberTokenRepository - private lateinit var tokenProvider: TokenProvider private lateinit var service: AdminMemberLoginService @BeforeEach fun setup() { repository = mock() - memberRepository = mock() passwordEncoder = mock() tokenRepository = mock() - tokenProvider = TokenProvider( + val tokenProvider = TokenProvider( secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", tokenValidityInSeconds = 3600, - repository = memberRepository, + repository = mock(), tokenRepository = tokenRepository ) tokenProvider.afterPropertiesSet() @@ -58,27 +54,6 @@ class AdminMemberLoginServiceTest { assertEquals(MemberRole.ADMIN, response.role) } - @Test - @DisplayName("관리자 로그인 token은 기존 TokenProvider로 검증하고 인증 정보를 복원할 수 있다") - fun shouldCreateUsableAdminToken() { - val member = createMember(id = 1L, role = MemberRole.ADMIN) - var savedToken: MemberToken? = null - Mockito.`when`(repository.findByEmail("admin@test.com")).thenReturn(member) - Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(java.util.Optional.of(member)) - Mockito.`when`(tokenRepository.findById(Mockito.eq(1L))).thenAnswer { - java.util.Optional.ofNullable(savedToken) - } - Mockito.`when`(tokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation -> - (invocation.arguments[0] as MemberToken).also { savedToken = it } - } - Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true) - - val response = service.login(LoginRequest(email = "admin@test.com", password = "password")) - - assertTrue(tokenProvider.validateToken(response.token)) - assertEquals(member.email, tokenProvider.getAuthentication(response.token).name) - } - @Test @DisplayName("콘텐츠 관리자는 관리자 로그인 API로 token과 role을 받는다") fun shouldLoginContentManager() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt deleted file mode 100644 index 030061b3..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkControllerContractTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -package kr.co.vividnext.sodalive.chat.original.controller - -import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository -import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository -import kr.co.vividnext.sodalive.chat.original.OriginalWork -import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink -import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag -import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping -import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService -import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService -import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository -import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.configs.SecurityConfig -import kr.co.vividnext.sodalive.content.ContentType -import kr.co.vividnext.sodalive.i18n.Lang -import kr.co.vividnext.sodalive.i18n.LangContext -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider -import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService -import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Import -import org.springframework.core.MethodParameter -import org.springframework.data.domain.PageImpl -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.core.annotation.AuthenticationPrincipal -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.support.WebDataBinderFactory -import org.springframework.web.context.request.NativeWebRequest -import org.springframework.web.method.support.HandlerMethodArgumentResolver -import org.springframework.web.method.support.ModelAndViewContainer -import java.time.LocalDateTime - -@WebMvcTest(OriginalWorkController::class) -@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) -class OriginalWorkControllerContractTest @Autowired constructor( - private val securityMockMvc: MockMvc -) { - @MockBean - private lateinit var securedQueryService: OriginalWorkQueryService - - @MockBean - private lateinit var securedCharacterImageRepository: CharacterImageRepository - - @MockBean - private lateinit var securedMemberContentPreferenceService: MemberContentPreferenceService - - @MockBean - private lateinit var securedLangContext: LangContext - - @MockBean - private lateinit var securedOriginalWorkTranslationService: OriginalWorkTranslationService - - @MockBean - private lateinit var securedOriginalWorkTranslationRepository: OriginalWorkTranslationRepository - - @MockBean - private lateinit var securedAiCharacterTranslationRepository: AiCharacterTranslationRepository - - @MockBean - private lateinit var tokenProvider: TokenProvider - - @MockBean - private lateinit var countryContext: CountryContext - - @MockBean - private lateinit var sodaMessageSource: SodaMessageSource - - @Test - @DisplayName("일반 사용자 원작 API는 기존 base path와 공개 목록 경로를 유지한다") - fun shouldKeepConsumerOriginalWorkListRoute() { - val classMapping = OriginalWorkController::class.java.getAnnotation(RequestMapping::class.java) - val list = OriginalWorkController::class.java.getDeclaredMethod( - "list", - Int::class.java, - Int::class.java, - Member::class.java - ) - - assertEquals("/api/chat/original", classMapping.value.single()) - assertEquals("/list", list.getAnnotation(GetMapping::class.java).value.single()) - assertNull(list.getAnnotation(PreAuthorize::class.java)) - } - - @Test - @DisplayName("일반 사용자 원작 상세 API는 기존 경로를 유지한다") - fun shouldKeepConsumerOriginalWorkDetailRoute() { - val detail = OriginalWorkController::class.java.getDeclaredMethod("detail", Long::class.java, Member::class.java) - - assertEquals("/{id}", detail.getAnnotation(GetMapping::class.java).value.single()) - } - - @Test - @DisplayName("production security matcher는 익명 원작 목록은 허용하고 상세는 거부한다") - fun shouldApplyProductionSecurityMatcherToAnonymousOriginalWorkRequests() { - Mockito.`when`(securedQueryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(emptyList())) - - securityMockMvc.perform(get("/api/chat/original/list").with(anonymous())) - .andExpect(status().isOk) - - securityMockMvc.perform(get("/api/chat/original/1").with(anonymous())) - .andExpect(status().isUnauthorized) - } - - @Test - @DisplayName("일반 사용자 원작 목록은 기존 성공 응답 surface를 유지한다") - fun shouldKeepConsumerOriginalWorkListResponseSurface() { - val queryService = Mockito.mock(OriginalWorkQueryService::class.java) - val originalWork = originalWork() - Mockito.`when`(queryService.listForAppPage(false, 0, 20)).thenReturn(PageImpl(listOf(originalWork))) - val langContext = Mockito.mock(LangContext::class.java) - Mockito.`when`(langContext.lang).thenReturn(Lang.KO) - val originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java) - Mockito.`when`(originalWorkTranslationRepository.findByOriginalWorkIdInAndLocale(setOf(1L), "ko")) - .thenReturn(emptyList()) - val controller = OriginalWorkController( - queryService = queryService, - characterImageRepository = Mockito.mock(CharacterImageRepository::class.java), - memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), - langContext = langContext, - originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java), - originalWorkTranslationRepository = originalWorkTranslationRepository, - aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java), - imageHost = "https://cdn.test" - ) - val mockMvc = MockMvcBuilders.standaloneSetup(controller) - .setCustomArgumentResolvers(AnonymousMemberArgumentResolver()) - .build() - - mockMvc.perform(get("/api/chat/original/list").param("page", "0").param("size", "20")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.totalCount").value(1)) - .andExpect(jsonPath("$.data.content").isArray) - .andExpect(jsonPath("$.data.content[0].id").value(1)) - .andExpect(jsonPath("$.data.content[0].imageUrl").value("https://cdn.test/originals/1.png")) - .andExpect(jsonPath("$.data.content[0].title").value("title")) - .andExpect(jsonPath("$.data.content[0].contentType").value("webtoon")) - } - - @Test - @DisplayName("일반 사용자 원작 상세는 기존 성공 응답 field를 유지한다") - fun shouldKeepConsumerOriginalWorkDetailResponseSurface() { - val queryService = Mockito.mock(OriginalWorkQueryService::class.java) - val originalWork = originalWork() - val character = ChatCharacter( - characterUUID = "character-uuid", - name = "character", - description = "character description", - systemPrompt = "prompt" - ).apply { - id = 10L - imagePath = "characters/10.png" - } - Mockito.`when`(queryService.getOriginalWork(1L)).thenReturn(originalWork) - Mockito.`when`(queryService.getActiveCharactersPage(1L, 0, 20)).thenReturn(PageImpl(listOf(character))) - val characterImageRepository = Mockito.mock(CharacterImageRepository::class.java) - Mockito.`when`( - characterImageRepository.findCharacterIdsWithRecentImages( - Mockito.eq(listOf(10L)) ?: listOf(10L), - Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() - ) - ) - .thenReturn(listOf(10L)) - val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - val member = Member(email = "user@test.com", password = "password", nickname = "user").apply { id = 20L } - Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)) - .thenReturn(ViewerContentPreference("KR", true, ContentType.ALL, true)) - val langContext = Mockito.mock(LangContext::class.java) - Mockito.`when`(langContext.lang).thenReturn(Lang.KO) - val originalWorkTranslationService = Mockito.mock(OriginalWorkTranslationService::class.java) - Mockito.`when`(originalWorkTranslationService.ensureTranslated(originalWork, "ko")).thenReturn(null) - val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java) - Mockito.`when`(aiCharacterTranslationRepository.findByCharacterIdInAndLocale(listOf(10L), "ko")) - .thenReturn(emptyList()) - val controller = OriginalWorkController( - queryService = queryService, - characterImageRepository = characterImageRepository, - memberContentPreferenceService = memberContentPreferenceService, - langContext = langContext, - originalWorkTranslationService = originalWorkTranslationService, - originalWorkTranslationRepository = Mockito.mock(OriginalWorkTranslationRepository::class.java), - aiCharacterTranslationRepository = aiCharacterTranslationRepository, - imageHost = "https://cdn.test" - ) - val mockMvc = MockMvcBuilders.standaloneSetup(controller) - .setCustomArgumentResolvers(MemberArgumentResolver(member)) - .build() - - mockMvc.perform(get("/api/chat/original/1")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.imageUrl").value("https://cdn.test/originals/1.png")) - .andExpect(jsonPath("$.data.title").value("title")) - .andExpect(jsonPath("$.data.contentType").value("webtoon")) - .andExpect(jsonPath("$.data.category").value("romance")) - .andExpect(jsonPath("$.data.isAdult").value(false)) - .andExpect(jsonPath("$.data.description").value("description")) - .andExpect(jsonPath("$.data.originalWork").value("source")) - .andExpect(jsonPath("$.data.originalLink").value("https://source.test")) - .andExpect(jsonPath("$.data.writer").value("writer")) - .andExpect(jsonPath("$.data.studio").value("studio")) - .andExpect(jsonPath("$.data.originalLinks[0]").value("https://link.test")) - .andExpect(jsonPath("$.data.tags[0]").value("tag")) - .andExpect(jsonPath("$.data.characters[0].characterId").value(10)) - .andExpect(jsonPath("$.data.characters[0].name").value("character")) - .andExpect(jsonPath("$.data.characters[0].description").value("character description")) - .andExpect(jsonPath("$.data.characters[0].imageUrl").value("https://cdn.test/characters/10.png")) - .andExpect(jsonPath("$.data.characters[0].isNew").value(true)) - .andExpect(jsonPath("$.data.translated").doesNotExist()) - } - - private class AnonymousMemberArgumentResolver : HandlerMethodArgumentResolver { - override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) - } - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? - ): Any? = null - } - - private class MemberArgumentResolver(private val member: Member) : HandlerMethodArgumentResolver { - override fun supportsParameter(parameter: MethodParameter): Boolean { - return parameter.hasParameterAnnotation(AuthenticationPrincipal::class.java) - } - - override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? - ): Any = member - } - - private fun originalWork(): OriginalWork { - val originalWork = OriginalWork( - title = "title", - contentType = "webtoon", - category = "romance", - isAdult = false, - description = "description", - originalWork = "source", - originalLink = "https://source.test", - writer = "writer", - studio = "studio" - ).apply { - id = 1L - imagePath = "originals/1.png" - } - originalWork.originalLinks += OriginalWorkLink("https://link.test", originalWork) - originalWork.tagMappings += OriginalWorkTagMapping(originalWork, OriginalWorkTag("tag")) - return originalWork - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt new file mode 100644 index 00000000..7c3cf18b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt @@ -0,0 +1,127 @@ +package kr.co.vividnext.sodalive.content + +import kr.co.vividnext.sodalive.content.pin.PinContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Transactional +class AudioContentPinConcurrencyTest @Autowired constructor( + private val service: AudioContentService, + private val entityManager: EntityManager, + private val transactionManager: PlatformTransactionManager +) { + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동시 상단 고정 요청에서도 활성 고정은 9개를 넘지 않는다") + fun shouldKeepNineActivePinsWhenPinRequestsRace() { + val transactionTemplate = TransactionTemplate(transactionManager) + val fixture = transactionTemplate.execute { + val creator = saveMember("pin-race-creator", MemberRole.CREATOR) + val existingContents = (1..8).map { saveAudioContent(creator, "existing-$it") } + existingContents.forEach { savePinContent(creator, it) } + val first = saveAudioContent(creator, "first-race") + val second = saveAudioContent(creator, "second-race") + entityManager.flush() + PinRaceFixture(creator.id!!, first.id!!, second.id!!) + } ?: throw IllegalStateException("fixture creation failed") + + val start = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + try { + val first = executor.submit { + start.await() + service.pinToTheTop(fixture.firstContentId, memberRef(fixture.creatorId)) + } + val second = executor.submit { + start.await() + service.pinToTheTop(fixture.secondContentId, memberRef(fixture.creatorId)) + } + + start.countDown() + first.get(5, TimeUnit.SECONDS) + second.get(5, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + } + + val activeContentIds = transactionTemplate.execute { activePinnedContentIds(fixture.creatorId) }.orEmpty() + assertEquals(9, activeContentIds.size) + assertEquals(activeContentIds.size, activeContentIds.distinct().size) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveAudioContent(creator: Member, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true) + entityManager.persist(theme) + val content = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = LocalDateTime.now().minusDays(1), + isAdult = false + ) + content.member = creator + content.theme = theme + content.isActive = true + content.coverImage = "$title.png" + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun savePinContent(creator: Member, content: AudioContent): PinContent { + val pinContent = PinContent(isActive = true) + pinContent.member = creator + pinContent.content = content + entityManager.persist(pinContent) + return pinContent + } + + private fun memberRef(memberId: Long): Member { + val member = Member(email = "creator-$memberId@test.com", password = "password", nickname = "creator-$memberId") + member.id = memberId + return member + } + + private fun activePinnedContentIds(creatorId: Long): List { + return entityManager.createQuery( + "select p.content.id from PinContent p where p.member.id = :creatorId and p.isActive = true", + java.lang.Long::class.java + ).setParameter("creatorId", creatorId).resultList.map { it.toLong() } + } + + private data class PinRaceFixture( + val creatorId: Long, + val firstContentId: Long, + val secondContentId: Long + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt index 1f06fe31..c28289a1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.pin.PinContent import kr.co.vividnext.sodalive.content.pin.PinContentRepository import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository @@ -20,10 +21,12 @@ import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue @@ -52,6 +55,7 @@ class AudioContentServiceTest { private lateinit var commentRepository: AudioContentCommentRepository private lateinit var audioContentLikeRepository: AudioContentLikeRepository private lateinit var pinContentRepository: PinContentRepository + private lateinit var memberRepository: MemberRepository private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler private lateinit var contentTranslationRepository: ContentTranslationRepository private lateinit var s3Uploader: S3Uploader @@ -76,6 +80,7 @@ class AudioContentServiceTest { commentRepository = Mockito.mock(AudioContentCommentRepository::class.java) audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java) pinContentRepository = Mockito.mock(PinContentRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java) contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java) s3Uploader = Mockito.mock(S3Uploader::class.java) @@ -97,6 +102,7 @@ class AudioContentServiceTest { commentRepository = commentRepository, audioContentLikeRepository = audioContentLikeRepository, pinContentRepository = pinContentRepository, + memberRepository = memberRepository, resourceTranslationJobScheduler = resourceTranslationJobScheduler, contentTranslationRepository = contentTranslationRepository, s3Uploader = s3Uploader, @@ -277,6 +283,50 @@ class AudioContentServiceTest { assertTrue(output.out.contains("contentId=${audioContent.id}")) } + @Test + @DisplayName("콘텐츠 상세는 가격과 저장값을 함께 반영해 포인트 사용 가능 여부를 반환한다") + fun shouldReturnEffectivePointAvailabilityForAudioDetail() { + val viewer = createMember(id = 1004L, nickname = "free-detail-viewer") + val creator = createMember(id = 2004L, nickname = "free-detail-creator") + val audioContent = createAudioContent(creator) + audioContent.price = 0 + audioContent.isPointAvailable = true + stubSuccessfulDetailDependencies(viewer, creator, audioContent) + + val response = service.getDetail( + id = audioContent.id!!, + member = viewer, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + + assertFalse(response.isAvailableUsePoint) + + audioContent.price = 100 + audioContent.isPointAvailable = false + + val paidUnavailableResponse = service.getDetail( + id = audioContent.id!!, + member = viewer, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + + assertFalse(paidUnavailableResponse.isAvailableUsePoint) + + audioContent.price = 100 + audioContent.isPointAvailable = true + + val paidAvailableResponse = service.getDetail( + id = audioContent.id!!, + member = viewer, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + + assertTrue(paidAvailableResponse.isAvailableUsePoint) + } + @Test @DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다") fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() { @@ -450,6 +500,145 @@ class AudioContentServiceTest { ) } + @Test + @DisplayName("오디오 상세의 상단 고정 가능 여부는 활성 고정 9개 상한을 따른다") + fun shouldExposeAvailablePinByNineActivePinLimit() { + val creator = createMember(id = 2400L, nickname = "pin-limit-creator") + val audioContent = createAudioContent(creator = creator) + stubSuccessfulDetailDependencies(creator, creator, audioContent) + Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)) + .thenReturn(createPinContents(8)) + .thenReturn(createPinContents(9)) + + val availableResponse = service.getDetail( + id = audioContent.id!!, + member = creator, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + val unavailableResponse = service.getDetail( + id = audioContent.id!!, + member = creator, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + + assertTrue(availableResponse.isAvailablePin) + assertEquals(false, unavailableResponse.isAvailablePin) + } + + @Test + @DisplayName("10번째 상단 고정은 가장 오래된 활성 고정을 새 콘텐츠로 교체한다") + fun shouldReuseOldestActivePinWhenPinningTenthContent() { + val creator = createMember(id = 2410L, nickname = "pin-reuse-creator") + val audioContent = createAudioContent(creator = creator) + val oldestPinContent = createPinContents(1).single() + Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)) + .thenReturn(audioContent) + Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator) + Mockito.`when`( + pinContentRepository.findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = creator.id!! + ) + ).thenReturn(null) + Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)) + .thenReturn(listOf(oldestPinContent) + createPinContents(8)) + + service.pinToTheTop(contentId = audioContent.id!!, member = creator) + + Mockito.inOrder(memberRepository, repository, pinContentRepository).apply { + verify(memberRepository).findByIdForUpdate(creator.id!!) + verify(repository).findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!) + verify(pinContentRepository).findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = creator.id!! + ) + verify(pinContentRepository).getPinContentList(memberId = creator.id!!, active = true) + } + assertTrue(oldestPinContent.isActive) + assertEquals(creator, oldestPinContent.member) + assertEquals(audioContent, oldestPinContent.content) + Mockito.verify(pinContentRepository).save(oldestPinContent) + } + + @Test + @DisplayName("상단 고정 해제는 고정 행 조회 전에 크리에이터를 잠근다") + fun shouldLockCreatorBeforeFindingPinWhenUnpinningContent() { + val creator = createMember(id = 2415L, nickname = "pin-unpin-lock-creator") + val audioContent = createAudioContent(creator = creator) + val pinContent = PinContent(isActive = true) + pinContent.member = creator + pinContent.content = audioContent + Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator) + Mockito.`when`( + pinContentRepository.findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = creator.id!! + ) + ).thenReturn(pinContent) + + service.unpinAtTheTop(contentId = audioContent.id!!, member = creator) + + Mockito.inOrder(memberRepository, pinContentRepository).apply { + verify(memberRepository).findByIdForUpdate(creator.id!!) + verify(pinContentRepository).findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = creator.id!! + ) + } + assertEquals(false, pinContent.isActive) + } + + @Test + @DisplayName("비활성 고정을 다시 활성화할 때도 활성 고정은 9개를 넘지 않는다") + fun shouldKeepNineActivePinsWhenReactivatingInactivePin() { + val creator = createMember(id = 2420L, nickname = "pin-reactivate-creator") + val audioContent = createAudioContent(creator = creator) + val inactivePinContent = PinContent(isActive = false) + inactivePinContent.member = creator + inactivePinContent.content = audioContent + val oldestActivePinContent = createPinContents(1).single() + Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)) + .thenReturn(audioContent) + Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator) + Mockito.`when`( + pinContentRepository.findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = creator.id!! + ) + ).thenReturn(inactivePinContent) + Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)) + .thenReturn(listOf(oldestActivePinContent) + createPinContents(8)) + + service.pinToTheTop(contentId = audioContent.id!!, member = creator) + + assertEquals(false, oldestActivePinContent.isActive) + assertTrue(inactivePinContent.isActive) + } + + @Test + @DisplayName("콘텐츠 삭제 시 해당 콘텐츠의 상단 고정도 함께 비활성화한다") + fun shouldDeactivatePinContentWhenDeletingAudioContent() { + val creator = createMember(id = 2430L, nickname = "pin-delete-creator") + val audioContent = createAudioContent(creator = creator) + val pinContent = PinContent(isActive = true) + pinContent.member = creator + pinContent.content = audioContent + Mockito.`when`(repository.findByIdAndCreatorId(audioContent.id!!, creator.id!!)).thenReturn(audioContent) + Mockito.`when`( + pinContentRepository.findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = creator.id!! + ) + ).thenReturn(pinContent) + + service.deleteAudioContent(audioContentId = audioContent.id!!, member = creator) + + assertEquals(false, audioContent.isActive) + assertEquals(false, pinContent.isActive) + } + private fun createMember(id: Long, nickname: String): Member { val member = Member( email = "$nickname@test.com", @@ -496,6 +685,10 @@ class AudioContentServiceTest { return audioContent } + private fun createPinContents(count: Int): List { + return (1..count).map { PinContent() } + } + private fun stubSuccessfulDetailDependencies(viewer: Member, creator: Member, audioContent: AudioContent) { Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt deleted file mode 100644 index 57db0037..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentUploadCompletionContractTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package kr.co.vividnext.sodalive.content - -import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.configs.SecurityConfig -import kr.co.vividnext.sodalive.i18n.LangContext -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider -import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberAdapter -import kr.co.vividnext.sodalive.member.MemberRole -import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService -import org.hamcrest.Matchers.anEmptyMap -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.mockito.Mockito.verify -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Import -import org.springframework.http.MediaType -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestMapping - -@WebMvcTest(AudioContentController::class) -@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) -class AudioContentUploadCompletionContractTest @Autowired constructor( - private val mockMvc: MockMvc -) { - @MockBean - private lateinit var service: AudioContentService - - @MockBean - private lateinit var memberContentPreferenceService: MemberContentPreferenceService - - @MockBean - private lateinit var tokenProvider: TokenProvider - - @MockBean - private lateinit var countryContext: CountryContext - - @MockBean - private lateinit var langContext: LangContext - - @MockBean - private lateinit var sodaMessageSource: SodaMessageSource - - @Test - @DisplayName("upload-complete callback은 기존 PUT 경로와 ADMIN/BOT 권한 계약을 유지한다") - fun shouldKeepUploadCompleteRouteAndRoles() { - val classMapping = AudioContentController::class.java.getAnnotation(RequestMapping::class.java) - val method = AudioContentController::class.java.getDeclaredMethod( - "uploadComplete", - UploadCompleteRequest::class.java, - kr.co.vividnext.sodalive.member.Member::class.java - ) - val putMapping = method.getAnnotation(PutMapping::class.java) - val preAuthorize = method.getAnnotation(PreAuthorize::class.java) - - assertEquals("/audio-content", classMapping.value.single()) - assertEquals("/upload-complete", putMapping.value.single()) - assertEquals("hasAnyRole('ADMIN', 'BOT')", preAuthorize.value) - } - - @Test - @DisplayName("upload-complete request는 기존 contentId/contentPath/duration field를 유지한다") - fun shouldKeepUploadCompleteRequestFields() { - val request = UploadCompleteRequest(contentId = 1L, contentPath = "1/output.mp3", duration = "00:01:00") - - assertEquals(1L, request.contentId) - assertEquals("1/output.mp3", request.contentPath) - assertEquals("00:01:00", request.duration) - } - - @Test - @DisplayName("upload-complete callback은 기존 성공 응답 surface를 유지한다") - fun shouldKeepUploadCompleteSuccessResponseSurface() { - givenToken("bot-token", MemberRole.BOT) - - mockMvc.perform( - put("/audio-content/upload-complete") - .header("Authorization", "Bearer bot-token") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}""") - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data").value(anEmptyMap())) - .andExpect(content().json(LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE, true)) - - verify(service).uploadComplete(1L, "1/output.mp3", "00:01:00") - } - - @Test - @DisplayName("upload-complete callback은 ADMIN/BOT JWT만 허용하고 USER/invalid JWT/익명 요청은 거부한다") - fun shouldAuthorizeUploadCompleteWithProductionSecurityChain() { - givenToken("admin-token", MemberRole.ADMIN) - givenToken("bot-token", MemberRole.BOT) - givenToken("user-token", MemberRole.USER) - Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false) - - mockMvc.perform(uploadCompleteRequest("user-token")) - .andExpect(status().isForbidden) - - mockMvc.perform(uploadCompleteRequest("invalid-token")) - .andExpect(status().isUnauthorized) - - mockMvc.perform(uploadCompleteRequest("admin-token")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - - mockMvc.perform(uploadCompleteRequest("bot-token")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - - mockMvc.perform( - put("/audio-content/upload-complete") - .contentType(MediaType.APPLICATION_JSON) - .content(UPLOAD_COMPLETE_REQUEST) - ) - .andExpect(status().isUnauthorized) - } - - private fun uploadCompleteRequest(token: String) = put("/audio-content/upload-complete") - .header("Authorization", "Bearer $token") - .contentType(MediaType.APPLICATION_JSON) - .content(UPLOAD_COMPLETE_REQUEST) - - private fun givenToken(token: String, role: MemberRole) { - val member = Member( - email = "${role.name.lowercase()}@test.com", - password = "password", - nickname = role.name.lowercase(), - role = role - ).apply { id = role.ordinal.toLong() + 1 } - val authentication = UsernamePasswordAuthenticationToken( - MemberAdapter(member), - token, - MemberAdapter(member).authorities - ) - Mockito.`when`(tokenProvider.validateToken(token)).thenReturn(true) - Mockito.`when`(tokenProvider.getAuthentication(token)).thenReturn(authentication) - } - - companion object { - private const val UPLOAD_COMPLETE_REQUEST = - """{"contentId":1,"contentPath":"1/output.mp3","duration":"00:01:00"}""" - - private const val LEGACY_UPLOAD_COMPLETE_SUCCESS_RESPONSE = - """{"success":true,"message":null,"data":{},"errorProperty":null}""" - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt new file mode 100644 index 00000000..240f66f6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt @@ -0,0 +1,138 @@ +package kr.co.vividnext.sodalive.jwt + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberToken +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import java.util.Base64 +import java.util.Date +import java.util.Optional + +class TokenProviderTest { + private lateinit var tokenProvider: TokenProvider + private lateinit var memberRepository: MemberRepository + private lateinit var tokenRepository: MemberTokenRepository + + @BeforeEach + fun setUp() { + memberRepository = Mockito.mock(MemberRepository::class.java) + tokenRepository = Mockito.mock(MemberTokenRepository::class.java) + tokenProvider = TokenProvider( + secret = SECRET, + tokenValidityInSeconds = 3600, + repository = memberRepository, + tokenRepository = tokenRepository + ) + tokenProvider.afterPropertiesSet() + } + + @Test + @DisplayName("subject가 없는 서명 JWT는 bad_credentials로 변환된다") + fun shouldRejectSignedJwtWithoutSubjectAsBadCredentials() { + val token = signedToken(subject = null) + + val exception = assertThrows(SodaException::class.java) { + tokenProvider.getAuthentication(token) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("숫자가 아닌 subject의 서명 JWT는 bad_credentials로 변환된다") + fun shouldRejectSignedJwtWithNonNumericSubjectAsBadCredentials() { + val token = signedToken(subject = "not-a-number") + + val exception = assertThrows(SodaException::class.java) { + tokenProvider.getAuthentication(token) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("auth claim이 없는 서명 JWT는 bad_credentials로 변환된다") + fun shouldRejectSignedJwtWithoutAuthoritiesClaimAsBadCredentials() { + val token = signedToken(subject = MEMBER_ID.toString(), authorities = null) + allowStoredToken(token) + + val exception = assertThrows(SodaException::class.java) { + tokenProvider.getAuthentication(token) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", "ROLE_ADMIN,", ",ROLE_ADMIN", "ROLE_ADMIN,,ROLE_USER"]) + @DisplayName("비어 있는 authority를 포함한 서명 JWT는 bad_credentials로 변환된다") + fun shouldRejectSignedJwtWithBlankAuthorityAsBadCredentials(authorities: String) { + val token = signedToken(subject = MEMBER_ID.toString(), authorities = authorities) + allowStoredToken(token) + + val exception = assertThrows(SodaException::class.java) { + tokenProvider.getAuthentication(token) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("문자열이 아닌 auth claim의 서명 JWT는 bad_credentials로 변환된다") + fun shouldRejectSignedJwtWithNonStringAuthoritiesClaimAsBadCredentials() { + val token = signedToken(subject = MEMBER_ID.toString(), authorities = 1) + allowStoredToken(token) + + val exception = assertThrows(SodaException::class.java) { + tokenProvider.getAuthentication(token) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + private fun allowStoredToken(token: String) { + val member = Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ).apply { id = MEMBER_ID } + Mockito.`when`(tokenRepository.findById(MEMBER_ID)) + .thenReturn(Optional.of(MemberToken(MEMBER_ID, mutableSetOf(token)))) + Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)) + } + + private fun signedToken(subject: String?, authorities: Any? = "ROLE_ADMIN"): String { + val builder = Jwts.builder() + .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET)), SignatureAlgorithm.HS512) + .setExpiration(Date(System.currentTimeMillis() + 60_000)) + + if (subject != null) { + builder.setSubject(subject) + } + if (authorities != null) { + builder.claim("auth", authorities) + } + + return builder.compact() + } + + companion object { + private const val MEMBER_ID = 1L + private val SECRET = Base64.getEncoder().encodeToString("a".repeat(64).toByteArray()) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt deleted file mode 100644 index 40f5824c..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacyAdminSearchQueryContractTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package kr.co.vividnext.sodalive.legacy - -import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService -import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService -import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag -import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository -import kr.co.vividnext.sodalive.chat.original.OriginalWork -import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository -import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository -import kr.co.vividnext.sodalive.configs.QueryDslConfig -import kr.co.vividnext.sodalive.member.Member -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.ApplicationEventPublisher -import org.springframework.context.annotation.Import -import java.time.LocalDateTime -import javax.persistence.EntityManager - -@DataJpaTest( - properties = [ - "spring.cache.type=none", - "spring.datasource.url=jdbc:h2:mem:legacy-admin-search-contract;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" - ] -) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(QueryDslConfig::class) -class LegacyAdminSearchQueryContractTest @Autowired constructor( - private val chatCharacterRepository: ChatCharacterRepository, - private val originalWorkRepository: OriginalWorkRepository, - private val entityManager: EntityManager -) { - private val characterService = AdminChatCharacterService(chatCharacterRepository) - private val originalWorkService = AdminOriginalWorkService( - originalWorkRepository = originalWorkRepository, - chatCharacterRepository = chatCharacterRepository, - originalWorkTagRepository = Mockito.mock(OriginalWorkTagRepository::class.java), - applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) - ) - - @Test - @DisplayName("legacy 캐릭터 검색은 이름, 설명, MBTI, 태그를 검색하고 비활성 캐릭터를 제외한다") - fun shouldSearchActiveLegacyCharactersByEverySupportedField() { - val nameId = saveCharacter(name = "NameNeedle", description = "plain-name-description").id!! - val descriptionId = saveCharacter(name = "description-character", description = "DescriptionNeedle").id!! - val mbtiId = saveCharacter( - name = "mbti-character", - description = "plain-mbti-description", - mbti = "MbtiNeedle" - ).id!! - val tagId = saveCharacter(name = "tag-character", description = "plain-tag-description", tag = "TagNeedle").id!! - saveCharacter(name = "InactiveNeedle", description = "inactive-description", isActive = false) - entityManager.clear() - - assertEquals(listOf(nameId), searchCharacterIds("nameneedle")) - assertEquals(listOf(descriptionId), searchCharacterIds("descriptionneedle")) - assertEquals(listOf(mbtiId), searchCharacterIds("mbtineedle")) - assertEquals(listOf(tagId), searchCharacterIds("tagneedle")) - assertEquals(emptyList(), searchCharacterIds("inactiveneedle")) - } - - @Test - @DisplayName("legacy 캐릭터 검색은 최신순과 비기본 page, size, totalCount 계약을 유지한다") - fun shouldPageLegacyCharacterSearchByCreatedAtDescending() { - val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0) - repeat(5) { index -> - saveCharacter( - name = "paging-needle-$index", - description = "paging-description-$index", - createdAt = baseTime.plusMinutes(index.toLong()) - ) - } - saveCharacter( - name = "paging-needle-inactive", - description = "paging-inactive-description", - isActive = false, - createdAt = baseTime.plusMinutes(10) - ) - entityManager.clear() - - val pageable = characterService.createDefaultPageRequest(page = 1, size = 2) - val result = characterService.searchCharacters("paging-needle", pageable) - - assertEquals(5L, result.totalElements) - assertEquals(1, result.number) - assertEquals(2, result.size) - assertEquals(listOf("paging-needle-2", "paging-needle-1"), result.content.map { it.name }) - } - - @Test - @DisplayName("legacy 원작 검색은 제목, 콘텐츠 타입, 카테고리를 검색하고 삭제 원작을 제외한다") - fun shouldSearchNonDeletedLegacyOriginalWorksByEverySupportedField() { - val titleId = saveOriginalWork( - title = "TitleNeedle", - contentType = "plain-title-type", - category = "plain-title-category" - ).id!! - val contentTypeId = saveOriginalWork( - title = "content-type-work", - contentType = "ContentTypeNeedle", - category = "plain-content-type-category" - ).id!! - val categoryId = saveOriginalWork( - title = "category-work", - contentType = "plain-category-type", - category = "CategoryNeedle" - ).id!! - saveOriginalWork( - title = "DeletedNeedle", - contentType = "deleted-type", - category = "deleted-category", - isDeleted = true - ) - entityManager.clear() - - assertEquals(listOf(titleId), searchOriginalWorkIds("titleneedle")) - assertEquals(listOf(contentTypeId), searchOriginalWorkIds("contenttypeneedle")) - assertEquals(listOf(categoryId), searchOriginalWorkIds("categoryneedle")) - assertEquals(emptyList(), searchOriginalWorkIds("deletedneedle")) - } - - @Test - @DisplayName("legacy 원작 검색은 최신순 무페이징 목록 계약을 유지한다") - fun shouldReturnAllLegacyOriginalWorkSearchResultsByCreatedAtDescending() { - val baseTime = LocalDateTime.of(2026, 7, 21, 0, 0) - repeat(3) { index -> - saveOriginalWork( - title = "ordering-needle-$index", - contentType = "ordering-type-$index", - category = "ordering-category-$index", - createdAt = baseTime.plusMinutes(index.toLong()) - ) - } - entityManager.clear() - - val result = originalWorkService.searchOriginalWorksAll("ordering-needle") - - assertEquals( - listOf("ordering-needle-2", "ordering-needle-1", "ordering-needle-0"), - result.map { it.title } - ) - } - - private fun searchCharacterIds(searchTerm: String): List { - val pageable = characterService.createDefaultPageRequest(page = 0, size = 20) - return characterService.searchCharacters(searchTerm, pageable).content.map { it.id } - } - - private fun searchOriginalWorkIds(searchTerm: String): List { - return originalWorkService.searchOriginalWorksAll(searchTerm).map { it.id!! } - } - - private fun saveCharacter( - name: String, - description: String, - mbti: String? = null, - tag: String? = null, - isActive: Boolean = true, - createdAt: LocalDateTime? = null - ): ChatCharacter { - val character = ChatCharacter( - characterUUID = "$name-uuid", - name = name, - description = description, - systemPrompt = "$name-system-prompt", - mbti = mbti, - isActive = isActive - ) - character.creatorMember = Member( - email = "$name@test.com", - password = "password", - nickname = "$name-creator" - ).also(entityManager::persist) - tag?.let { tagName -> - val tagEntity = ChatCharacterTag(tagName).also(entityManager::persist) - character.addTag(tagEntity) - } - chatCharacterRepository.saveAndFlush(character) - if (createdAt != null) { - character.createdAt = createdAt - entityManager.flush() - } - return character - } - - private fun saveOriginalWork( - title: String, - contentType: String, - category: String, - isDeleted: Boolean = false, - createdAt: LocalDateTime? = null - ): OriginalWork { - val originalWork = OriginalWork( - title = title, - contentType = contentType, - category = category - ).apply { this.isDeleted = isDeleted } - originalWorkRepository.saveAndFlush(originalWork) - if (createdAt != null) { - originalWork.createdAt = createdAt - entityManager.flush() - } - return originalWork - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt deleted file mode 100644 index 8a819553..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/legacy/LegacySodaExceptionHttpStatusContractTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package kr.co.vividnext.sodalive.legacy - -import kr.co.vividnext.sodalive.common.ApiResponse -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Import -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import org.springframework.test.annotation.DirtiesContext -import org.springframework.test.context.ContextConfiguration -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.multipart.MaxUploadSizeExceededException -import org.springframework.web.multipart.MultipartException -import org.springframework.web.multipart.MultipartResolver -import org.springframework.web.servlet.DispatcherServlet -import javax.servlet.http.HttpServletRequest - -@SpringBootTest( - properties = [ - "cloud.aws.cloud-front.host=https://cdn.test", - "spring.cache.type=none", - "spring.datasource.url=jdbc:h2:mem:legacy-soda-exception-http-contract;" + - "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" - ] -) -@AutoConfigureMockMvc -@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) -@Import(LegacySodaExceptionHttpStatusContractTest.TestLegacyController::class) -class LegacySodaExceptionHttpStatusContractTest @Autowired constructor( - private val mockMvc: MockMvc -) { - @MockBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME) - private lateinit var multipartResolver: MultipartResolver - - @Test - @DisplayName("legacy route의 SodaException은 기존 HTTP 200 오류 envelope를 유지한다") - fun shouldPreserveLegacySodaExceptionHttp200() { - mockMvc.perform(get("/legacy-ai-character-admin-test").with(user("admin").roles("ADMIN"))) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(false)) - } - - @Test - @DisplayName("legacy route는 httpStatus가 있는 SodaException도 HTTP 200 오류 envelope를 유지한다") - fun shouldPreserveLegacySodaExceptionHttp200WhenExceptionHasHttpStatus() { - mockMvc.perform(get("/legacy-ai-character-admin-test/conflict").with(user("admin").roles("ADMIN"))) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(false)) - } - - @Test - @DisplayName("admin ai-character multipart 크기 오류는 handler 선택 전에도 HTTP 400을 반환한다") - fun shouldReturnBadRequestForAiCharacterAdminMaxUploadSizeExceeded() { - givenMultipartResolutionFailure(MaxUploadSizeExceededException(1L)) - - mockMvc.perform( - post("/admin/ai-characters/upload-size-test") - .with(user("admin").roles("ADMIN")) - .contentType(MediaType.MULTIPART_FORM_DATA) - ) - .andExpect(status().isBadRequest) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("파일용량은 최대 1024MB까지 저장할 수 있습니다.")) - - Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java)) - } - - @Test - @DisplayName("legacy multipart 형식 오류는 handler 선택 전에도 기존 HTTP 200 unknown 오류를 유지한다") - fun shouldPreserveLegacyUnknownMessageForMalformedMultipartBeforeHandlerSelection() { - givenMultipartResolutionFailure(MultipartException("malformed multipart")) - - mockMvc.perform( - post("/legacy-ai-character-admin-test/upload") - .with(user("admin").roles("ADMIN")) - .contentType(MediaType.MULTIPART_FORM_DATA) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")) - - Mockito.verify(multipartResolver).resolveMultipart(Mockito.any(HttpServletRequest::class.java)) - } - - private fun givenMultipartResolutionFailure(exception: MultipartException) { - Mockito.`when`(multipartResolver.isMultipart(Mockito.any(HttpServletRequest::class.java))).thenReturn(true) - Mockito.`when`(multipartResolver.resolveMultipart(Mockito.any(HttpServletRequest::class.java))).thenThrow(exception) - } - - @RestController - @RequestMapping("/legacy-ai-character-admin-test") - class TestLegacyController { - @GetMapping - fun legacy(): ApiResponse { - throw SodaException(messageKey = "common.error.invalid_request") - } - - @GetMapping("/conflict") - fun legacyWithHttpStatus(): ApiResponse { - throw SodaException(messageKey = "common.error.invalid_request", httpStatus = HttpStatus.CONFLICT) - } - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt index c8625b6e..ca3c1bb8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt @@ -1,12 +1,14 @@ package kr.co.vividnext.sodalive.member import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository import kr.co.vividnext.sodalive.member.login.LoginRequest import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -23,6 +25,7 @@ import javax.persistence.EntityManager class MemberServiceTest @Autowired constructor( private val service: MemberService, private val memberRepository: MemberRepository, + private val creatorFollowingRepository: CreatorFollowingRepository, private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository, private val entityManager: EntityManager ) { @@ -94,4 +97,88 @@ class MemberServiceTest @Autowired constructor( assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive) } + + @Test + @DisplayName("통합 팔로우 API에서 isActive=false이고 기존 관계가 없으면 active 팔로우를 생성하지 않는다") + fun shouldNotCreateActiveFollowingWhenCreatorFollowIsInactiveWithoutExistingRelation() { + val member = memberRepository.save( + Member( + email = "inactive-follower@test.com", + password = "password", + nickname = "inactive-follower" + ) + ) + val creator = memberRepository.save( + Member( + email = "inactive-creator@test.com", + password = "password", + nickname = "inactive-creator", + role = MemberRole.CREATOR + ) + ) + + service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!) + entityManager.flush() + entityManager.clear() + + assertNull(creatorFollowingRepository.findByCreatorIdAndMemberId(creatorId = creator.id!!, memberId = member.id!!)) + } + + @Test + @DisplayName("통합 팔로우 API에서 isActive=false이면 기존 inbox를 비활성화하고 재팔로우해도 복구하지 않는다") + fun shouldDeactivateFollowingNewsInboxWhenCreatorFollowIsInactiveAndKeepInactiveAfterRefollow() { + val member = memberRepository.save( + Member( + email = "integrated-follower@test.com", + password = "password", + nickname = "integrated-follower" + ) + ) + val creator = memberRepository.save( + Member( + email = "integrated-creator@test.com", + password = "password", + nickname = "integrated-creator", + role = MemberRole.CREATOR + ) + ) + service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!) + val inbox = homeFollowingNewsInboxJpaRepository.save( + HomeFollowingNewsInbox( + memberId = member.id!!, + creatorId = creator.id!!, + newsType = FollowingNewsType.COMMUNITY_POST, + sourceKey = "COMMUNITY_POST:2", + targetId = 2L, + occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0), + creatorNickname = "integrated-creator", + creatorProfileImagePath = null, + title = "title", + body = "body", + thumbnailImagePath = null, + rank = null, + isAdult = false + ) + ) + entityManager.flush() + entityManager.clear() + + service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!) + entityManager.flush() + entityManager.clear() + + val inactiveFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId( + creatorId = creator.id!!, + memberId = member.id!! + ) + assertEquals(false, inactiveFollowing?.isActive) + assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive) + + service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!) + entityManager.flush() + entityManager.clear() + + assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt index eb0a446d..d30246b8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -29,6 +29,11 @@ class H2MysqlDateFunctions { .replace("%Y", "yyyy") .replace("%m", "MM") .replace("%d", "dd") + .replace("%H", "HH") + .replace("%W", "EEEE") + .replace("%h", "hh") + .replace("%i", "mm") + .replace("%p", "a") return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt deleted file mode 100644 index 4c87f55f..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminImagePartValidatorTest.kt +++ /dev/null @@ -1,1054 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import kr.co.vividnext.sodalive.common.SodaException -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.mock.web.MockMultipartFile -import org.springframework.web.multipart.MultipartFile -import java.awt.Rectangle -import java.awt.image.BufferedImage -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.util.Locale -import java.util.zip.CRC32 -import java.util.zip.DeflaterOutputStream -import javax.imageio.IIOException -import javax.imageio.ImageIO -import javax.imageio.ImageReadParam -import javax.imageio.ImageReader -import javax.imageio.ImageTypeSpecifier -import javax.imageio.metadata.IIOMetadata -import javax.imageio.spi.IIORegistry -import javax.imageio.spi.ImageReaderSpi -import javax.imageio.stream.ImageInputStream - -class AdminImagePartValidatorTest { - private val validator = AdminImagePartValidator() - - @Test - @DisplayName("PNG 이미지는 실제 bytes 기준으로 통과한다") - fun shouldAcceptPngByActualBytes() { - val image = multipartImage(name = "image", extension = "png", contentType = MediaType.TEXT_PLAIN_VALUE) - - val validated = validator.validate(image = image, allowGif = false) - - assertEquals("image/png", validated.contentType) - assertEquals("png", validated.extension) - } - - @Test - @DisplayName("JPEG 이미지는 실제 bytes 기준으로 통과한다") - fun shouldAcceptJpegByActualBytes() { - val image = multipartImage(name = "image", extension = "jpg", contentType = MediaType.TEXT_PLAIN_VALUE) - - val validated = validator.validate(image = image, allowGif = false) - - assertEquals("image/jpeg", validated.contentType) - assertEquals("jpg", validated.extension) - } - - @Test - @DisplayName("GIF는 allowGif가 false이면 거부한다") - fun shouldRejectGifWhenGifIsNotAllowed() { - val image = multipartImage(name = "image", extension = "gif", contentType = MediaType.IMAGE_GIF_VALUE) - - assertThrows(SodaException::class.java) { - validator.validate(image = image, allowGif = false) - } - } - - @Test - @DisplayName("GIF는 allowGif가 true이면 통과한다") - fun shouldAcceptGifWhenGifIsAllowed() { - val image = multipartImage(name = "image", extension = "gif", contentType = MediaType.IMAGE_GIF_VALUE) - - val validated = validator.validate(image = image, allowGif = true) - - assertEquals("image/gif", validated.contentType) - assertEquals("gif", validated.extension) - } - - @Test - @DisplayName("LZW dictionary 크기가 증가하는 유효 GIF도 통과한다") - fun shouldAcceptGifWithGrowingLzwDictionary() { - val bufferedImage = BufferedImage(128, 128, BufferedImage.TYPE_INT_RGB) - repeat(bufferedImage.height) { y -> - repeat(bufferedImage.width) { x -> - val shade = (x * 37 + y * 17) and 0xFF - bufferedImage.setRGB(x, y, (shade shl 16) or (shade shl 8) or shade) - } - } - val output = ByteArrayOutputStream() - ImageIO.write(bufferedImage, "gif", output) - val image = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, output.toByteArray()) - - val validated = validator.validate(image = image, allowGif = true) - - assertEquals("gif", validated.extension) - } - - @Test - @DisplayName("이미지가 아닌 bytes는 거부한다") - fun shouldRejectNonImageBytes() { - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, "not-image".toByteArray()) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - } - - @Test - @DisplayName("손상된 JPEG bytes는 거부한다") - fun shouldRejectCorruptJpegBytes() { - val file = MockMultipartFile("image", "image.jpg", MediaType.IMAGE_JPEG_VALUE, byteArrayOf(0xFF.toByte(), 0xD8.toByte())) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - } - - @Test - @DisplayName("frame이 없는 GIF는 400 invalid image로 거부한다") - fun shouldRejectGifWithoutImageFrameAsBadRequest() { - val file = multipartGif(logicalWidth = 1, logicalHeight = 1) - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) - } - - @Test - @DisplayName("ImageIO reader의 입력 유래 IllegalArgumentException은 400 invalid image로 거부한다") - fun shouldRejectInputCausedIllegalArgumentExceptionAsBadRequest() { - val provider = OnePixelRegionImageReaderSpi(dimensionFailure = IllegalArgumentException("malformed dimensions")) - withImageReaderProvider(provider) { - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, TRACKING_IMAGE_BYTES) - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = false) - } - - assertTrue(provider.widthRequested) - assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) - } - } - - @Test - @DisplayName("ImageIO reader의 입력 유래 RuntimeException은 400 invalid image로 거부한다") - fun shouldRejectInputCausedRuntimeExceptionAsBadRequest() { - val provider = OnePixelRegionImageReaderSpi(readFailure = IllegalStateException("malformed raster")) - withImageReaderProvider(provider) { - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, TRACKING_IMAGE_BYTES) - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = false) - } - - assertTrue(provider.readRequested) - assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) - } - } - - @Test - @DisplayName("format 확인은 metadata를 읽지 않고 1x1 source region만 디코딩한다") - fun shouldDecodeOnlyOnePixelRegionWhenDetectingFormat() { - val provider = OnePixelRegionImageReaderSpi() - withImageReaderProvider(provider) { - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, TRACKING_IMAGE_BYTES) - - val validated = validator.validate(image = file, allowGif = false) - - assertEquals("png", validated.extension) - assertEquals(true, provider.ignoreMetadataRequested) - } - } - - @Test - @DisplayName("압축 PNG metadata는 payload 상한 안에서 유효 이미지로 처리한다") - fun shouldAcceptPngWithCompressedTextMetadata() { - val file = MockMultipartFile( - "image", - "image.png", - MediaType.IMAGE_PNG_VALUE, - pngWithCompressedTextChunk(uncompressedSize = 2 * 1024 * 1024) - ) - - val validated = validator.validate(image = file, allowGif = false) - - assertEquals("png", validated.extension) - } - - @Test - @DisplayName("1KB를 넘는 유효 PNG ancillary payload는 통과한다") - fun shouldAcceptValidPngWithAncillaryPayloadOverOneKilobyte() { - val file = MockMultipartFile( - "image", - "image.png", - MediaType.IMAGE_PNG_VALUE, - pngWithTextChunk(payloadSize = 1_025) - ) - - val validated = validator.validate(image = file, allowGif = false) - - assertEquals("png", validated.extension) - } - - @Test - @DisplayName("PNG ancillary chunk payload가 상한을 넘으면 ImageIO 전에 거부한다") - fun shouldRejectPngWithOversizedAncillaryChunkBeforeImageIo() { - val provider = OnePixelRegionImageReaderSpi() - withImageReaderProvider(provider) { - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithOversizedTextChunk()) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = false) - } - - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("PNG ancillary payload가 정확히 1MB이면 통과한다") - fun shouldAcceptPngWithMaximumAncillaryPayload() { - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithTextChunk(payloadSize = 1024 * 1024)) - - assertEquals("png", validator.validate(file, allowGif = false).extension) - } - - @Test - @DisplayName("PNG chunk가 정확히 4096개이면 통과하고 4097개이면 ImageIO 전에 거부한다") - fun shouldEnforcePngChunkCountBoundaryBeforeImageIo() { - val acceptedProvider = OnePixelRegionImageReaderSpi() - withImageReaderProvider(acceptedProvider) { - val accepted = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithTotalChunkCount(4_096)) - assertEquals("png", validator.validate(accepted, allowGif = false).extension) - } - - val rejectedProvider = OnePixelRegionImageReaderSpi() - withImageReaderProvider(rejectedProvider) { - val rejected = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, pngWithTotalChunkCount(4_097)) - assertThrows(SodaException::class.java) { validator.validate(rejected, allowGif = false) } - assertEquals(false, rejectedProvider.decodeInputRequested) - } - } - - @Test - @DisplayName("PNG chunk 길이가 overflow되는 malformed 입력도 400 invalid image로 거부한다") - fun shouldRejectPngWithOverflowingChunkLengthAsBadRequest() { - val output = ByteArrayOutputStream() - output.write(PNG_SIGNATURE_BYTES) - output.writeInt(Int.MAX_VALUE) - output.write("IHDR".toByteArray(Charsets.US_ASCII)) - output.writeInt(0) - val file = MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, output.toByteArray()) - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = false) - } - - assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) - } - - @Test - @DisplayName("GIF extension payload가 상한을 넘으면 ImageIO 전에 거부한다") - fun shouldRejectGifWithOversizedExtensionPayloadBeforeImageIo() { - val provider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(provider) { - val file = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithOversizedCommentExtension()) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF extension payload가 정확히 1MB이면 통과하고 1MB 초과면 거부한다") - fun shouldEnforceGifExtensionPayloadBoundary() { - val acceptedProvider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(acceptedProvider) { - val accepted = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithCommentPayload(1024 * 1024)) - assertEquals("gif", validator.validate(accepted, allowGif = true).extension) - } - - val rejectedProvider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(rejectedProvider) { - val rejected = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithCommentPayload(1024 * 1024 + 1) - ) - assertThrows(SodaException::class.java) { validator.validate(rejected, allowGif = true) } - assertEquals(false, rejectedProvider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF extension 1024개와 sub-block 64개는 통과한다") - fun shouldAcceptGifAtExtensionAndSubBlockBoundaries() { - val extensionProvider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(extensionProvider) { - val file = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithCommentExtensions(1_024, 0)) - assertEquals("gif", validator.validate(file, allowGif = true).extension) - } - - val subBlockProvider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(subBlockProvider) { - val file = MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, gifWithCommentExtensions(1, 64)) - assertEquals("gif", validator.validate(file, allowGif = true).extension) - } - } - - @Test - @DisplayName("GIF frame 500개는 통과하고 501개는 ImageIO 전에 거부한다") - fun shouldEnforceGifFrameCountBoundaryBeforeImageIo() { - val acceptedProvider = TrackingGifImageReaderSpi(frameCount = 500) - withImageReaderProvider(acceptedProvider) { - val file = multipartGif(1, 1, *Array(500) { 1 to 1 }) - assertEquals("gif", validator.validate(file, allowGif = true).extension) - } - - val rejectedProvider = TrackingGifImageReaderSpi(frameCount = 501) - withImageReaderProvider(rejectedProvider) { - val file = multipartGif(1, 1, *Array(501) { 1 to 1 }) - assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } - assertEquals(false, rejectedProvider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF extension 개수가 상한을 넘으면 ImageIO 전에 거부한다") - fun shouldRejectGifWithTooManyExtensionsBeforeImageIo() { - val provider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithCommentExtensions(extensionCount = 1_025, subBlocksPerExtension = 0) - ) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF extension의 sub-block 개수가 상한을 넘으면 ImageIO 전에 거부한다") - fun shouldRejectGifWithTooManyExtensionSubBlocksBeforeImageIo() { - val provider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithCommentExtensions(extensionCount = 1, subBlocksPerExtension = 65) - ) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF frame 누적 픽셀이 40000000px를 넘으면 ImageIO 전에 거부한다") - fun shouldRejectGifWhoseCumulativeFramePixelsExceedMaximumBeforeImageIo() { - val provider = TrackingGifImageReaderSpi(frameCount = 2) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithRepeatedPixelFrames(5_000 to 5_000, 5_000 to 5_000) - ) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF frame 누적 픽셀이 정확히 40000000px이면 통과한다") - fun shouldAcceptGifWhoseCumulativeFramePixelsEqualMaximum() { - val provider = TrackingGifImageReaderSpi(frameCount = 2) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithRepeatedPixelFrames(5_000 to 4_000, 5_000 to 4_000) - ) - - val validated = validator.validate(image = file, allowGif = true) - - assertEquals("gif", validated.extension) - } - } - - @Test - @DisplayName("GIF LZW 출력이 선언 pixel 수를 넘으면 ImageIO 전에 거부한다") - fun shouldRejectGifWhoseLzwOutputExceedsDeclaredPixelsBeforeImageIo() { - val provider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithFrame(width = 1, height = 1, lzwData = packGifLzwCodes(listOf(4, 0, 0, 5))) - ) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF LZW 출력이 선언 pixel 수보다 부족하면 ImageIO 전에 거부한다") - fun shouldRejectGifWhoseLzwOutputIsShorterThanDeclaredPixelsBeforeImageIo() { - val provider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithFrame(2, 1, packGifLzwCodes(listOf(4, 0, 5))) - ) - assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } - assertEquals(false, provider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF LZW의 연속 clear와 EOI 뒤 data는 거부한다") - fun shouldRejectGifLzwStateMismatchesBeforeImageIo() { - val consecutiveClearProvider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(consecutiveClearProvider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithFrame(1, 1, packGifLzwCodes(listOf(4, 4, 0, 5))) - ) - assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } - assertEquals(false, consecutiveClearProvider.decodeInputRequested) - } - - val trailingDataProvider = TrackingGifImageReaderSpi(frameCount = 1) - withImageReaderProvider(trailingDataProvider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - gifWithFrame(1, 1, packGifLzwCodes(listOf(4, 0, 5)) + byteArrayOf(1)) - ) - assertThrows(SodaException::class.java) { validator.validate(file, allowGif = true) } - assertEquals(false, trailingDataProvider.decodeInputRequested) - } - } - - @Test - @DisplayName("GIF 모든 frame은 1x1 source region으로 디코딩한다") - fun shouldDecodeEveryGifFrameWithOnePixelRegion() { - val provider = TrackingGifImageReaderSpi(frameCount = 2) - withImageReaderProvider(provider) { - val file = MockMultipartFile( - "image", - "image.gif", - MediaType.IMAGE_GIF_VALUE, - multipartGif(1, 1, 1 to 1, 1 to 1).bytes - ) - - val validated = validator.validate(image = file, allowGif = true) - - assertEquals("gif", validated.extension) - assertEquals(listOf(Rectangle(0, 0, 1, 1), Rectangle(0, 0, 1, 1)), provider.sourceRegions) - } - } - - @Test - @DisplayName("단일 변이 20000px를 초과하는 이미지는 작은 raster로도 거부한다") - fun shouldRejectImageExceedingMaximumDimension() { - val file = multipartPng(width = 20_001, height = 1) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - } - - @Test - @DisplayName("총 픽셀이 40000000px를 초과하는 이미지는 작은 raster로도 거부한다") - fun shouldRejectImageExceedingMaximumPixels() { - val file = multipartPng(width = 5_000, height = 8_001) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - } - - @Test - @DisplayName("첫 frame이 작아도 GIF logical canvas가 20000px를 초과하면 거부한다") - fun shouldRejectGifWhoseLogicalCanvasExceedsMaximumDimension() { - val file = multipartGif( - logicalWidth = 20_001, - logicalHeight = 1, - 1 to 1 - ) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - } - - @Test - @DisplayName("첫 frame이 작아도 GIF 후속 frame이 40000000px를 초과하면 거부한다") - fun shouldRejectGifWhoseLaterFrameExceedsMaximumPixels() { - val file = multipartGif( - logicalWidth = 1, - logicalHeight = 1, - 1 to 1, - 5_000 to 8_001 - ) - - assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - } - - @Test - @DisplayName("첫 frame이 정상이더라도 손상된 GIF 후속 frame은 400 invalid image로 거부한다") - fun shouldRejectGifWhoseLaterFrameIsMalformed() { - val file = multipartGifWithMalformedSecondFrame() - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals(HttpStatus.BAD_REQUEST, exception.httpStatus) - } - - @Test - @DisplayName("이미지 검증 실패 errorProperty는 multipart field name을 반환한다") - fun shouldReturnMultipartFieldNameForInvalidImage() { - val file = MockMultipartFile("thumbnailImage", "image.png", MediaType.IMAGE_PNG_VALUE, "not-image".toByteArray()) - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals("thumbnailImage", exception.errorProperty) - } - - @Test - @DisplayName("이미지 전용 상한 초과 파일은 bytes를 읽기 전에 거부한다") - fun shouldRejectOversizedImageBeforeReadingBytes() { - val file = OversizedMultipartFile(name = "image") - - val exception = assertThrows(SodaException::class.java) { - validator.validate(image = file, allowGif = true) - } - - assertEquals("image", exception.errorProperty) - } - - private fun multipartImage(name: String, extension: String, contentType: String): MockMultipartFile { - val output = ByteArrayOutputStream() - ImageIO.write(BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), extension, output) - return MockMultipartFile(name, "image.$extension", contentType, output.toByteArray()) - } - - private fun multipartPng(width: Int, height: Int): MockMultipartFile { - val output = ByteArrayOutputStream() - ImageIO.write(BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY), "png", output) - return MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, output.toByteArray()) - } - - private fun multipartGif(logicalWidth: Int, logicalHeight: Int, vararg frames: Pair): MockMultipartFile { - val output = ByteArrayOutputStream() - output.write("GIF89a".toByteArray(Charsets.US_ASCII)) - output.writeLittleEndianShort(logicalWidth) - output.writeLittleEndianShort(logicalHeight) - output.write(byteArrayOf(0x80.toByte(), 0, 0)) - output.write(byteArrayOf(0, 0, 0, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) - - frames.forEach { (width, height) -> - output.write(0x2C) - output.writeLittleEndianShort(0) - output.writeLittleEndianShort(0) - output.writeLittleEndianShort(width) - output.writeLittleEndianShort(height) - output.write(0) - output.write(2) - output.write(2) - output.write(0x44) - output.write(0x01) - output.write(0) - } - - output.write(0x3B) - return MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, output.toByteArray()) - } - - private fun multipartGifWithMalformedSecondFrame(): MockMultipartFile { - val firstFrameGif = multipartGif(logicalWidth = 1, logicalHeight = 1, 1 to 1).bytes - val output = ByteArrayOutputStream() - output.write(firstFrameGif, 0, firstFrameGif.size - 1) - output.write(0x2C) - repeat(2) { output.writeLittleEndianShort(0) } - repeat(2) { output.writeLittleEndianShort(1) } - output.write(0) - output.write(9) - output.write(1) - output.write(0) - output.write(0) - output.write(0x3B) - return MockMultipartFile("image", "image.gif", MediaType.IMAGE_GIF_VALUE, output.toByteArray()) - } - - private fun pngWithOversizedTextChunk(): ByteArray { - return pngWithTextChunk(payloadSize = 1024 * 1024 + 1) - } - - private fun pngWithTextChunk(payloadSize: Int): ByteArray { - require(payloadSize >= 8) - val basePng = multipartImage(name = "image", extension = "png", contentType = MediaType.IMAGE_PNG_VALUE).bytes - val iendOffset = basePng.indexOfPngChunk("IEND") - val textData = "Comment\u0000".toByteArray(Charsets.ISO_8859_1) + ByteArray(payloadSize - 8) { 'a'.code.toByte() } - val textChunk = pngChunk(type = "tEXt", data = textData) - - val output = ByteArrayOutputStream() - output.write(basePng, 0, iendOffset) - output.write(textChunk) - output.write(basePng, iendOffset, basePng.size - iendOffset) - return output.toByteArray() - } - - private fun pngWithCompressedTextChunk(uncompressedSize: Int): ByteArray { - val compressedText = ByteArrayOutputStream().also { compressed -> - DeflaterOutputStream(compressed).use { deflater -> - deflater.write(ByteArray(uncompressedSize) { 'a'.code.toByte() }) - } - }.toByteArray() - val data = "Comment\u0000".toByteArray(Charsets.ISO_8859_1) + byteArrayOf(0) + compressedText - return pngWithAncillaryChunk(type = "zTXt", data = data) - } - - private fun pngWithTotalChunkCount(totalChunkCount: Int): ByteArray { - val basePng = multipartImage("image", "png", MediaType.IMAGE_PNG_VALUE).bytes - val iendOffset = basePng.indexOfPngChunk("IEND") - val currentChunkCount = basePng.countPngChunks() - val output = ByteArrayOutputStream() - output.write(basePng, 0, iendOffset) - repeat(totalChunkCount - currentChunkCount) { - output.write(pngChunk("tEXt", byteArrayOf('K'.code.toByte(), 0))) - } - output.write(basePng, iendOffset, basePng.size - iendOffset) - return output.toByteArray() - } - - private fun ByteArray.countPngChunks(): Int { - var offset = PNG_SIGNATURE_BYTES.size - var count = 0 - while (offset + 12 <= size) { - val length = ((this[offset].toInt() and 0xFF) shl 24) or - ((this[offset + 1].toInt() and 0xFF) shl 16) or - ((this[offset + 2].toInt() and 0xFF) shl 8) or - (this[offset + 3].toInt() and 0xFF) - count++ - offset += 12 + length - if (copyOfRange(offset - length - 8, offset - length - 4).decodeToString() == "IEND") break - } - return count - } - - private fun pngWithAncillaryChunk(type: String, data: ByteArray): ByteArray { - val basePng = multipartImage(name = "image", extension = "png", contentType = MediaType.IMAGE_PNG_VALUE).bytes - val iendOffset = basePng.indexOfPngChunk("IEND") - val output = ByteArrayOutputStream() - output.write(basePng, 0, iendOffset) - output.write(pngChunk(type = type, data = data)) - output.write(basePng, iendOffset, basePng.size - iendOffset) - return output.toByteArray() - } - - private fun ByteArray.indexOfPngChunk(type: String): Int { - val typeBytes = type.toByteArray(Charsets.US_ASCII) - for (index in PNG_SIGNATURE_BYTES.size until size - 4) { - if (copyOfRange(index, index + 4).contentEquals(typeBytes)) return index - 4 - } - error("PNG chunk $type not found") - } - - private fun pngChunk(type: String, data: ByteArray): ByteArray { - val output = ByteArrayOutputStream() - val typeBytes = type.toByteArray(Charsets.US_ASCII) - output.writeInt(data.size) - output.write(typeBytes) - output.write(data) - val crc = CRC32() - crc.update(typeBytes) - crc.update(data) - output.writeInt(crc.value.toInt()) - return output.toByteArray() - } - - private fun gifWithOversizedCommentExtension(): ByteArray { - return gifWithCommentPayload(1024 * 1024 + 1) - } - - private fun gifWithCommentPayload(payloadSize: Int): ByteArray { - val output = ByteArrayOutputStream() - output.writeGifHeader(width = 1, height = 1) - var remaining = payloadSize - while (remaining > 0) { - output.write(0x21) - output.write(0xFE) - var blocks = 0 - while (remaining > 0 && blocks < 64) { - val blockSize = minOf(255, remaining) - output.write(blockSize) - repeat(blockSize) { output.write('a'.code) } - remaining -= blockSize - blocks++ - } - output.write(0) - } - output.writeGifFrame(width = 1, height = 1, lzwData = packGifLzwCodes(listOf(4, 0, 5))) - output.write(0x3B) - return output.toByteArray() - } - - private fun gifWithCommentExtensions(extensionCount: Int, subBlocksPerExtension: Int): ByteArray { - val output = ByteArrayOutputStream() - output.writeGifHeader(width = 1, height = 1) - repeat(extensionCount) { - output.write(0x21) - output.write(0xFE) - repeat(subBlocksPerExtension) { - output.write(1) - output.write('a'.code) - } - output.write(0) - } - output.writeGifFrame(width = 1, height = 1, lzwData = packGifLzwCodes(listOf(4, 0, 5))) - output.write(0x3B) - return output.toByteArray() - } - - private fun gifWithRepeatedPixelFrames(vararg frames: Pair): ByteArray { - val output = ByteArrayOutputStream() - val logicalWidth = frames.maxOf { it.first } - val logicalHeight = frames.maxOf { it.second } - output.writeGifHeader(width = logicalWidth, height = logicalHeight) - frames.forEach { (width, height) -> - output.writeGifFrame( - width = width, - height = height, - lzwData = repeatedZeroGifLzwData(width.toLong() * height) - ) - } - output.write(0x3B) - return output.toByteArray() - } - - private fun gifWithFrame(width: Int, height: Int, lzwData: ByteArray): ByteArray { - val output = ByteArrayOutputStream() - output.writeGifHeader(width = width, height = height) - output.writeGifFrame(width = width, height = height, lzwData = lzwData) - output.write(0x3B) - return output.toByteArray() - } - - private fun ByteArrayOutputStream.writeGifHeader(width: Int, height: Int) { - write("GIF89a".toByteArray(Charsets.US_ASCII)) - writeLittleEndianShort(width) - writeLittleEndianShort(height) - write(byteArrayOf(0x80.toByte(), 0, 0)) - write(byteArrayOf(0, 0, 0, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) - } - - private fun ByteArrayOutputStream.writeGifFrame(width: Int, height: Int, lzwData: ByteArray) { - write(0x2C) - repeat(2) { writeLittleEndianShort(0) } - writeLittleEndianShort(width) - writeLittleEndianShort(height) - write(0) - write(2) - lzwData.asList().chunked(255).forEach { block -> - write(block.size) - block.forEach { write(it.toInt() and 0xFF) } - } - write(0) - } - - private fun repeatedZeroGifLzwData(pixelCount: Long): ByteArray { - require(pixelCount > 0) - val codes = mutableListOf() - var remaining = pixelCount - while (remaining > 0) { - codes += 4 - var runLength = 1L - while (runLength <= 4_091L && remaining >= runLength) { - codes += if (runLength == 1L) 0 else (runLength + 4L).toInt() - remaining -= runLength - runLength++ - } - if (remaining in 1 until runLength) { - codes += if (remaining == 1L) 0 else (remaining + 4L).toInt() - remaining = 0 - } - } - codes += 5 - return packGifLzwCodes(codes) - } - - private fun packGifLzwCodes(codes: List): ByteArray { - val output = ByteArrayOutputStream() - var bitBuffer = 0L - var bufferedBits = 0 - var codeSize = 3 - var nextCode = 6 - var previousCode = -1 - - codes.forEach { code -> - bitBuffer = bitBuffer or (code.toLong() shl bufferedBits) - bufferedBits += codeSize - while (bufferedBits >= 8) { - output.write((bitBuffer and 0xFF).toInt()) - bitBuffer = bitBuffer ushr 8 - bufferedBits -= 8 - } - - when (code) { - 4 -> { - codeSize = 3 - nextCode = 6 - previousCode = -1 - } - 5 -> Unit - else -> { - if (previousCode >= 0 && nextCode < 4_096) { - nextCode++ - if (nextCode == 1 shl codeSize && codeSize < 12) codeSize++ - } - previousCode = code - } - } - } - if (bufferedBits > 0) output.write(bitBuffer.toInt() and 0xFF) - return output.toByteArray() - } - - private fun ByteArrayOutputStream.writeLittleEndianShort(value: Int) { - require(value in 0..0xFFFF) - write(value and 0xFF) - write(value ushr 8 and 0xFF) - } - - private fun ByteArrayOutputStream.writeInt(value: Int) { - write(value ushr 24 and 0xFF) - write(value ushr 16 and 0xFF) - write(value ushr 8 and 0xFF) - write(value and 0xFF) - } - - private fun withImageReaderProvider(provider: ImageReaderSpi, block: () -> Unit) { - val registry = IIORegistry.getDefaultInstance() - registry.registerServiceProvider(provider) - val orderedAfter = registry.getServiceProviders(ImageReaderSpi::class.java, true).asSequence() - .filter { it !== provider } - .toList() - orderedAfter.forEach { registry.setOrdering(ImageReaderSpi::class.java, provider, it) } - - try { - block() - } finally { - orderedAfter.forEach { registry.unsetOrdering(ImageReaderSpi::class.java, provider, it) } - registry.deregisterServiceProvider(provider) - } - } - - private class OnePixelRegionImageReaderSpi( - private val dimensionFailure: IllegalArgumentException? = null, - private val readFailure: RuntimeException? = null - ) : ImageReaderSpi() { - var widthRequested: Boolean = false - private set - var readRequested: Boolean = false - private set - var decodeInputRequested: Boolean = false - private set - var ignoreMetadataRequested: Boolean? = null - private set - - override fun canDecodeInput(source: Any): Boolean { - if (source !is ImageInputStream) return false - decodeInputRequested = true - val position = source.streamPosition - return try { - source.readInt() == TRACKING_IMAGE_MAGIC - } finally { - source.seek(position) - } - } - - override fun createReaderInstance(extension: Any?): ImageReader { - return OnePixelRegionImageReader( - provider = this, - readFailure = readFailure, - onGetWidth = { - widthRequested = true - dimensionFailure?.let { throw it } - }, - onRead = { readRequested = true }, - onSetInput = { ignoreMetadataRequested = it } - ) - } - - override fun getInputTypes(): Array> = arrayOf(ImageInputStream::class.java) - - override fun getFormatNames(): Array = arrayOf("png") - - override fun getDescription(locale: Locale?): String = "One-pixel region test image reader" - } - - private class OnePixelRegionImageReader( - provider: ImageReaderSpi, - private val readFailure: RuntimeException? = null, - private val onGetWidth: () -> Unit = {}, - private val onRead: () -> Unit = {}, - private val onSetInput: (Boolean) -> Unit = {} - ) : ImageReader(provider) { - override fun setInput(input: Any?, seekForwardOnly: Boolean, ignoreMetadata: Boolean) { - super.setInput(input, seekForwardOnly, ignoreMetadata) - onSetInput(ignoreMetadata) - } - - override fun getNumImages(allowSearch: Boolean): Int = 1 - - override fun getWidth(imageIndex: Int): Int { - onGetWidth() - return 2 - } - - override fun getHeight(imageIndex: Int): Int = 2 - - override fun getImageTypes(imageIndex: Int): MutableIterator { - return mutableListOf(ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB)).iterator() - } - - override fun getStreamMetadata(): IIOMetadata? = null - - override fun getImageMetadata(imageIndex: Int): IIOMetadata? = null - - override fun read(imageIndex: Int, param: ImageReadParam?): BufferedImage { - onRead() - readFailure?.let { throw it } - if (param?.sourceRegion != Rectangle(0, 0, 1, 1)) { - throw IIOException("full image read requested") - } - return BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) - } - } - - private class TrackingGifImageReaderSpi( - private val frameCount: Int - ) : ImageReaderSpi() { - var decodeInputRequested: Boolean = false - private set - val sourceRegions = mutableListOf() - - override fun canDecodeInput(source: Any): Boolean { - if (source !is ImageInputStream) return false - decodeInputRequested = true - val position = source.streamPosition - return try { - source.readByte() == 'G'.code.toByte() && - source.readByte() == 'I'.code.toByte() && - source.readByte() == 'F'.code.toByte() - } finally { - source.seek(position) - } - } - - override fun createReaderInstance(extension: Any?): ImageReader { - return TrackingGifImageReader(this, frameCount, sourceRegions) - } - - override fun getInputTypes(): Array> = arrayOf(ImageInputStream::class.java) - - override fun getFormatNames(): Array = arrayOf("gif") - - override fun getDescription(locale: Locale?): String = "Tracking GIF image reader" - } - - private class TrackingGifImageReader( - provider: ImageReaderSpi, - private val frameCount: Int, - private val sourceRegions: MutableList - ) : ImageReader(provider) { - override fun getNumImages(allowSearch: Boolean): Int = frameCount - - override fun getWidth(imageIndex: Int): Int = 2 - - override fun getHeight(imageIndex: Int): Int = 2 - - override fun getImageTypes(imageIndex: Int): MutableIterator { - return mutableListOf(ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB)).iterator() - } - - override fun getStreamMetadata(): IIOMetadata? = null - - override fun getImageMetadata(imageIndex: Int): IIOMetadata? = null - - override fun read(imageIndex: Int, param: ImageReadParam?): BufferedImage { - sourceRegions.add(param?.sourceRegion) - return BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB) - } - } - - private class OversizedMultipartFile( - private val name: String - ) : MultipartFile { - override fun getName(): String = name - override fun getOriginalFilename(): String = "oversized.png" - override fun getContentType(): String = MediaType.IMAGE_PNG_VALUE - override fun isEmpty(): Boolean = false - override fun getSize(): Long = 10L * 1024L * 1024L + 1L - override fun getBytes(): ByteArray = error("oversized image bytes must not be loaded") - override fun getInputStream(): InputStream = error("oversized image stream must not be opened") - override fun transferTo(dest: java.io.File) = error("oversized image must not be transferred") - } - - companion object { - private const val TRACKING_IMAGE_MAGIC = 0x89504E47.toInt() - private val PNG_SIGNATURE_BYTES = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) - private val TRACKING_IMAGE_BYTES = PNG_SIGNATURE_BYTES + byteArrayOf( - 0, 0, 0, 0, - 'I'.code.toByte(), 'E'.code.toByte(), 'N'.code.toByte(), 'D'.code.toByte(), - 0, 0, 0, 0 - ) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt deleted file mode 100644 index 3ba358e8..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AdminJsonRequestParserTest.kt +++ /dev/null @@ -1,93 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import com.fasterxml.jackson.databind.ObjectMapper -import kr.co.vividnext.sodalive.common.SodaException -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -class AdminJsonRequestParserTest { - private val parser = AdminJsonRequestParser(ObjectMapper()) - - @Test - @DisplayName("multipart request JSON은 명시적 null key를 누락으로 보지 않는다") - fun shouldKeepExplicitNullRequiredKey() { - val node = parser.parseRequiredObject( - rawJson = """{"name":null,"tags":[]}""", - requiredKeys = setOf("name", "tags") - ) - - assertTrue(node.has("name")) - assertTrue(node.get("name").isNull) - } - - @Test - @DisplayName("multipart request JSON은 필수 key 누락을 errorProperty로 반환한다") - fun shouldRejectMissingRequiredKey() { - val exception = assertThrows(SodaException::class.java) { - parser.parseRequiredObject(rawJson = """{"name":"Soda"}""", requiredKeys = setOf("name", "tags")) - } - - assertEquals("tags", exception.errorProperty) - } - - @Test - @DisplayName("multipart request JSON 뒤에 다른 root가 이어지면 거부한다") - fun shouldRejectTrailingJsonRoot() { - val exception = assertThrows(SodaException::class.java) { - parser.parseRequiredObject( - rawJson = """{"name":"Soda"}{"name":"Pop"}""", - requiredKeys = setOf("name") - ) - } - - assertEquals("request", exception.errorProperty) - } - - @Test - @DisplayName("multipart request JSON 뒤에 garbage가 이어지면 거부한다") - fun shouldRejectTrailingGarbage() { - val exception = assertThrows(SodaException::class.java) { - parser.parseRequiredObject( - rawJson = """{"name":"Soda"} trailing""", - requiredKeys = setOf("name") - ) - } - - assertEquals("request", exception.errorProperty) - } - - @Test - @DisplayName("빈 multipart request JSON은 잘못된 요청으로 거부한다") - fun shouldRejectBlankJson() { - val exception = assertThrows(SodaException::class.java) { - parser.parseRequiredObject(rawJson = " ", requiredKeys = setOf("name")) - } - - assertEquals("request", exception.errorProperty) - } - - @Test - @DisplayName("일반 JsonNode body도 같은 필수 key 계약을 사용한다") - fun shouldParseJsonNodeWithSameRequiredKeyPolicy() { - val node = ObjectMapper().readTree("""{"comment":null}""") - - val parsed = parser.parseRequiredObject(node = node, requiredKeys = setOf("comment")) - - assertTrue(parsed.get("comment").isNull) - } - - @Test - @DisplayName("일반 JsonNode body도 필수 key가 없으면 errorProperty로 반환한다") - fun shouldRejectJsonNodeMissingRequiredKey() { - val node = ObjectMapper().readTree("""{"comment":"hello"}""") - - val exception = assertThrows(SodaException::class.java) { - parser.parseRequiredObject(node = node, requiredKeys = setOf("comment", "author")) - } - - assertEquals("author", exception.errorProperty) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt deleted file mode 100644 index 0247a148..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminExceptionHandlerTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.JsonMappingException -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.i18n.LangContext -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest -import org.junit.jupiter.api.Assertions.assertAll -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.springframework.http.HttpStatus -import org.springframework.http.converter.HttpMessageNotReadableException -import org.springframework.mock.http.MockHttpInputMessage -import org.springframework.web.bind.MissingServletRequestParameterException -import org.springframework.web.multipart.support.MissingServletRequestPartException - -class AiCharacterAdminExceptionHandlerTest { - private val handler = AiCharacterAdminExceptionHandler( - langContext = LangContext(), - messageSource = SodaMessageSource() - ) - - @Test - @DisplayName("SodaException은 지정 HTTP status와 errorProperty를 ApiResponse로 반환한다") - fun shouldReturnConfiguredStatusAndErrorProperty() { - listOf( - HttpStatus.BAD_REQUEST, - HttpStatus.NOT_FOUND, - HttpStatus.CONFLICT, - HttpStatus.INTERNAL_SERVER_ERROR, - HttpStatus.BAD_GATEWAY - ).forEach { status -> - val response = handler.handleSodaException( - SodaException( - messageKey = "common.error.invalid_request", - errorProperty = "characterId", - httpStatus = status - ) - ) - - assertAll( - { assertEquals(status, response.statusCode) }, - { assertEquals(false, response.body?.success) }, - { assertEquals("characterId", response.body?.errorProperty) } - ) - } - } - - @Test - @DisplayName("HTTP status가 없는 SodaException은 관리자 경계에서 400으로 응답한다") - fun shouldDefaultSodaExceptionToBadRequest() { - val response = handler.handleSodaException(SodaException(messageKey = "common.error.invalid_request")) - - assertAll( - { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, - { assertEquals(false, response.body?.success) } - ) - } - - @Test - @DisplayName("알 수 없는 예외는 500 ApiResponse로 응답한다") - fun shouldReturnInternalServerErrorForUnknownException() { - val response = handler.handleException(IllegalStateException("sensitive-body")) - - assertAll( - { assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.statusCode) }, - { assertEquals(false, response.body?.success) } - ) - } - - @Test - @DisplayName("잘못된 request parameter는 400 errorProperty를 반환한다") - fun shouldReturnErrorPropertyForMissingRequestParameter() { - val response = handler.handleBadRequestException(MissingServletRequestParameterException("page", "Int")) - - assertAll( - { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, - { assertEquals(false, response.body?.success) }, - { assertEquals("page", response.body?.errorProperty) } - ) - } - - @Test - @DisplayName("누락된 multipart part는 400 errorProperty를 반환한다") - fun shouldReturnErrorPropertyForMissingRequestPart() { - val response = handler.handleBadRequestException(MissingServletRequestPartException("image")) - - assertAll( - { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, - { assertEquals(false, response.body?.success) }, - { assertEquals("image", response.body?.errorProperty) } - ) - } - - @Test - @DisplayName("읽을 수 없는 JSON body는 Jackson path의 field를 errorProperty로 반환한다") - fun shouldReturnJsonBodyFieldNameForUnreadableBody() { - val cause = JsonMappingException.from(null as JsonParser?, "missing content") - .also { it.prependPath(AdminCommentUpdateRequest::class.java, "content") } - val exception = HttpMessageNotReadableException("bad request", cause, MockHttpInputMessage(ByteArray(0))) - - val response = handler.handleBadRequestException(exception) - - assertAll( - { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, - { assertEquals(false, response.body?.success) }, - { assertEquals("content", response.body?.errorProperty) } - ) - } - - @Test - @DisplayName("읽을 수 없는 JSON body에 Jackson field path가 없으면 request를 errorProperty로 반환한다") - fun shouldFallbackToRequestForUnreadableBodyWithoutJsonPath() { - val exception = HttpMessageNotReadableException("bad request", MockHttpInputMessage(ByteArray(0))) - - val response = handler.handleBadRequestException(exception) - - assertAll( - { assertEquals(HttpStatus.BAD_REQUEST, response.statusCode) }, - { assertEquals(false, response.body?.success) }, - { assertEquals("request", response.body?.errorProperty) } - ) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt deleted file mode 100644 index 5827a765..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminLoginJwtIntegrationTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginController -import kr.co.vividnext.sodalive.admin.member.AdminMemberLoginService -import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository -import kr.co.vividnext.sodalive.common.ApiResponse -import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.configs.SecurityConfig -import kr.co.vividnext.sodalive.i18n.LangContext -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider -import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberRepository -import kr.co.vividnext.sodalive.member.MemberRole -import kr.co.vividnext.sodalive.member.token.MemberToken -import kr.co.vividnext.sodalive.member.token.MemberTokenRepository -import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler -import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Import -import org.springframework.http.MediaType -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.test.context.TestPropertySource -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.Optional - -@WebMvcTest( - controllers = [ - AdminMemberLoginController::class, - AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class - ] -) -@Import( - SecurityConfig::class, - JwtAuthenticationEntryPoint::class, - JwtAccessDeniedHandler::class, - AiCharacterAdminAuthenticationEntryPoint::class, - AiCharacterAdminAccessDeniedHandler::class, - AiCharacterAdminExceptionHandler::class, - AiCharacterAdminLoginJwtIntegrationTest.TestAiCharacterAdminController::class, - AdminMemberLoginService::class, - TokenProvider::class, - LangContext::class, - SodaMessageSource::class -) -@TestPropertySource( - properties = [ - "jwt.secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", - "jwt.token-validity-in-seconds=3600" - ] -) -class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor( - private val mockMvc: MockMvc -) { - @MockBean - private lateinit var adminMemberRepository: AdminMemberRepository - - @MockBean - private lateinit var memberRepository: MemberRepository - - @MockBean - private lateinit var memberTokenRepository: MemberTokenRepository - - @MockBean - private lateinit var passwordEncoder: PasswordEncoder - - @MockBean - private lateinit var countryContext: CountryContext - - @Test - @DisplayName("관리자 로그인으로 받은 실제 JWT는 신규 관리자 API 인증에 사용된다") - fun shouldCallAiCharacterAdminApiWithTokenFromAdminLogin() { - val member = Member(email = "admin@test.com", password = "encoded-password", nickname = "admin", role = MemberRole.ADMIN) - .apply { id = 1L } - var savedToken: MemberToken? = null - Mockito.`when`(adminMemberRepository.findByEmail("admin@test.com")).thenReturn(member) - Mockito.`when`(memberRepository.findById(Mockito.eq(1L))).thenReturn(Optional.of(member)) - Mockito.`when`(memberTokenRepository.findById(Mockito.eq(1L))).thenAnswer { Optional.ofNullable(savedToken) } - Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation -> - (invocation.arguments[0] as MemberToken).also { savedToken = it } - } - Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true) - - val loginResult = mockMvc.perform( - post("/admin/member/login") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"email":"admin@test.com","password":"password"}""") - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.role").value("ADMIN")) - .andReturn() - - val token = Regex(""""token":"([^"]+)"""") - .find(loginResult.response.contentAsString) - ?.groupValues - ?.get(1) - - mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data").value("ok")) - } - - @Test - @DisplayName("CONTENT_MANAGER 로그인으로 받은 실제 JWT는 신규 관리자 API에서 403으로 거부된다") - fun shouldRejectContentManagerTokenFromAdminLoginForAiCharacterAdminApi() { - val member = Member( - email = "content@test.com", - password = "encoded-password", - nickname = "content-manager", - role = MemberRole.CONTENT_MANAGER - ).apply { id = 2L } - var savedToken: MemberToken? = null - Mockito.`when`(adminMemberRepository.findByEmail("content@test.com")).thenReturn(member) - Mockito.`when`(memberRepository.findById(Mockito.eq(2L))).thenReturn(Optional.of(member)) - Mockito.`when`(memberTokenRepository.findById(Mockito.eq(2L))).thenAnswer { Optional.ofNullable(savedToken) } - Mockito.`when`(memberTokenRepository.save(Mockito.any(MemberToken::class.java))).thenAnswer { invocation -> - (invocation.arguments[0] as MemberToken).also { savedToken = it } - } - Mockito.`when`(passwordEncoder.matches("password", "encoded-password")).thenReturn(true) - - val loginResult = mockMvc.perform( - post("/admin/member/login") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"email":"content@test.com","password":"password"}""") - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.role").value("CONTENT_MANAGER")) - .andReturn() - - val token = Regex(""""token":"([^"]+)"""") - .find(loginResult.response.contentAsString) - ?.groupValues - ?.get(1) - - mockMvc.perform(get("/admin/ai-characters/login-jwt-test").header("Authorization", "Bearer $token")) - .andExpect(status().isForbidden) - .andExpect(jsonPath("$.success").value(false)) - } - - @RestController - @RequestMapping("/admin/ai-characters/login-jwt-test") - @PreAuthorize("hasRole('ADMIN')") - class TestAiCharacterAdminController { - @GetMapping - fun ok(): ApiResponse = ApiResponse.ok("ok") - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt deleted file mode 100644 index 8eecab1b..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/adapter/in/web/AiCharacterAdminSecurityIntegrationTest.kt +++ /dev/null @@ -1,215 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.web - -import kr.co.vividnext.sodalive.common.ApiResponse -import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.configs.SecurityConfig -import kr.co.vividnext.sodalive.i18n.LangContext -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider -import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberAdapter -import kr.co.vividnext.sodalive.member.MemberRole -import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAccessDeniedHandler -import kr.co.vividnext.sodalive.v2.admin.aicharacter.adapter.`in`.security.AiCharacterAdminAuthenticationEntryPoint -import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest -import org.hamcrest.Matchers.containsString -import org.hamcrest.Matchers.not -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Import -import org.springframework.http.HttpStatus -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous -import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.stream.Stream - -@WebMvcTest( - controllers = [ - AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class, - AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class - ] -) -@Import( - SecurityConfig::class, - JwtAuthenticationEntryPoint::class, - JwtAccessDeniedHandler::class, - AiCharacterAdminAuthenticationEntryPoint::class, - AiCharacterAdminAccessDeniedHandler::class, - AiCharacterAdminExceptionHandler::class, - AiCharacterAdminSecurityIntegrationTest.TestAiCharacterAdminController::class, - AiCharacterAdminSecurityIntegrationTest.TestAdjacentAdminController::class, - LangContext::class, - SodaMessageSource::class -) -class AiCharacterAdminSecurityIntegrationTest @Autowired constructor( - private val mockMvc: MockMvc -) { - @MockBean - private lateinit var tokenProvider: TokenProvider - - @MockBean - private lateinit var countryContext: CountryContext - - @Test - @DisplayName("/admin/ai-characters 전용 API는 ADMIN 권한이면 통과한다") - fun shouldAllowAdminRole() { - mockMvc.perform(get("/admin/ai-characters/test").with(user("admin").roles("ADMIN"))) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data").value("ok")) - } - - @Test - @DisplayName("기존 JWT 인증 결과가 ADMIN이면 신규 관리자 API를 호출할 수 있다") - fun shouldAllowAdminJwtAuthentication() { - val admin = Member(email = "admin@test.com", password = "password", nickname = "admin", role = MemberRole.ADMIN) - .apply { id = 1L } - val authentication = UsernamePasswordAuthenticationToken( - MemberAdapter(admin), - "admin-token", - MemberAdapter(admin).authorities - ) - Mockito.`when`(tokenProvider.validateToken("admin-token")).thenReturn(true) - Mockito.`when`(tokenProvider.getAuthentication("admin-token")).thenReturn(authentication) - - mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer admin-token")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - } - - @Test - @DisplayName("/admin/ai-characters 전용 API는 비로그인을 401 ApiResponse로 거부한다") - fun shouldRejectAnonymousWithJson401() { - mockMvc.perform(get("/admin/ai-characters/test").with(anonymous())) - .andExpect(status().isUnauthorized) - .andExpect(jsonPath("$.success").value(false)) - } - - @Test - @DisplayName("/admin/ai-characters 전용 API는 ADMIN 외 권한을 403 ApiResponse로 거부한다") - fun shouldRejectNonAdminWithJson403() { - Stream.of("USER", "CREATOR", "AGENT", "CONTENT_MANAGER").forEach { role -> - mockMvc.perform(get("/admin/ai-characters/test").with(user(role.lowercase()).roles(role))) - .andExpect(status().isForbidden) - .andExpect(jsonPath("$.success").value(false)) - } - } - - @Test - @DisplayName("ADMIN 외 권한은 request body 역직렬화 전에 403으로 거부된다") - fun shouldRejectNonAdminBeforeRequestBodyBinding() { - mockMvc.perform( - post("/admin/ai-characters/test/body") - .with(user("user").roles("USER")) - .contentType("application/json") - .content("{") - ) - .andExpect(status().isForbidden) - .andExpect(jsonPath("$.success").value(false)) - } - - @Test - @DisplayName("잘못된 JWT는 401 ApiResponse로 거부한다") - fun shouldRejectInvalidJwtWithJson401() { - Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false) - - mockMvc.perform(get("/admin/ai-characters/test").header("Authorization", "Bearer invalid-token")) - .andExpect(status().isUnauthorized) - .andExpect(jsonPath("$.success").value(false)) - } - - @Test - @DisplayName("/admin/ai-characters 인접 prefix는 신규 관리자 오류 handler 대상이 아니다") - fun shouldNotUseAdminHandlerForAdjacentPrefix() { - mockMvc.perform(get("/admin/ai-characters-shadow/test").with(anonymous())) - .andExpect(status().isUnauthorized) - .andExpect(content().string(not(containsString("success")))) - } - - @Test - @DisplayName("/admin/ai-characters 전용 예외는 지정 HTTP status와 errorProperty를 응답한다") - fun shouldUseAdminExceptionBoundary() { - mockMvc.perform(get("/admin/ai-characters/test/conflict").with(user("admin").roles("ADMIN"))) - .andExpect(status().isConflict) - .andExpect(jsonPath("$.success").value(false)) - .andExpect(jsonPath("$.errorProperty").value("characterId")) - } - - @Test - @DisplayName("/admin/ai-characters 전용 예외는 400/404/500/502 status를 보존한다") - fun shouldUseAdminExceptionBoundaryForCommonStatuses() { - mapOf( - "bad-request" to HttpStatus.BAD_REQUEST, - "not-found" to HttpStatus.NOT_FOUND, - "server-error" to HttpStatus.INTERNAL_SERVER_ERROR, - "bad-gateway" to HttpStatus.BAD_GATEWAY - ).forEach { (path, status) -> - mockMvc.perform(get("/admin/ai-characters/test/$path").with(user("admin").roles("ADMIN"))) - .andExpect(status().`is`(status.value())) - .andExpect(jsonPath("$.success").value(false)) - } - } - - @RestController - @RequestMapping("/admin/ai-characters/test") - @PreAuthorize("hasRole('ADMIN')") - class TestAiCharacterAdminController { - @GetMapping - fun ok(): ApiResponse = ApiResponse.ok("ok") - - @PostMapping("/body") - fun body(@RequestBody request: AdminCommentUpdateRequest): ApiResponse = ApiResponse.ok(request.content) - - @GetMapping("/conflict") - fun conflict(): ApiResponse { - throw SodaException( - messageKey = "common.error.invalid_request", - errorProperty = "characterId", - httpStatus = HttpStatus.CONFLICT - ) - } - - @GetMapping("/bad-request") - fun badRequest(): ApiResponse = throwStatus(HttpStatus.BAD_REQUEST) - - @GetMapping("/not-found") - fun notFound(): ApiResponse = throwStatus(HttpStatus.NOT_FOUND) - - @GetMapping("/server-error") - fun serverError(): ApiResponse = throwStatus(HttpStatus.INTERNAL_SERVER_ERROR) - - @GetMapping("/bad-gateway") - fun badGateway(): ApiResponse = throwStatus(HttpStatus.BAD_GATEWAY) - - private fun throwStatus(status: HttpStatus): ApiResponse { - throw SodaException(messageKey = "common.error.invalid_request", httpStatus = status) - } - } - - @RestController - @RequestMapping("/admin/ai-characters-shadow/test") - @PreAuthorize("hasRole('ADMIN')") - class TestAdjacentAdminController { - @GetMapping - fun ok(): ApiResponse = ApiResponse.ok("shadow") - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt deleted file mode 100644 index d618a8bf..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AdminPagePolicyTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.application - -import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminCommentUpdateRequest -import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminMutationResponse -import kr.co.vividnext.sodalive.v2.admin.aicharacter.dto.AdminPageResponse -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test - -class AdminPagePolicyTest { - @Test - @DisplayName("관리자 목록 page와 size 기본값을 정규화한다") - fun shouldNormalizeDefaultPageRequest() { - val request = AdminPagePolicy.normalize(page = null, size = null) - - assertEquals(0, request.page) - assertEquals(20, request.size) - assertEquals(0, request.offset) - assertEquals(20, request.limit) - } - - @Test - @DisplayName("관리자 목록 page와 size 경계값을 정규화한다") - fun shouldNormalizePageRequestBoundaries() { - val lowerBound = AdminPagePolicy.normalize(page = -1, size = 0) - val upperBound = AdminPagePolicy.normalize(page = 2, size = 51) - - assertEquals(0, lowerBound.page) - assertEquals(20, lowerBound.size) - assertEquals(2, upperBound.page) - assertEquals(50, upperBound.size) - assertEquals(100, upperBound.offset) - assertEquals(50, upperBound.limit) - } - - @Test - @DisplayName("관리자 목록 page 0과 size 1/20/50 유효 경계값을 유지한다") - fun shouldKeepValidPageRequestBoundaries() { - listOf(1, 20, 50).forEach { size -> - val request = AdminPagePolicy.normalize(page = 0, size = size) - - assertEquals(0, request.page) - assertEquals(size, request.size) - assertEquals(0, request.offset) - assertEquals(size.toLong(), request.limit) - } - } - - @Test - @DisplayName("관리자 page 응답은 hasNext를 계산한다") - fun shouldCalculateHasNextForPageResponse() { - val firstPage = AdminPageResponse.of(totalCount = 3, content = listOf(1, 2), page = 0, size = 2) - val lastPage = AdminPageResponse.of(totalCount = 3, content = listOf(3), page = 1, size = 2) - - assertTrue(firstPage.hasNext) - assertFalse(lastPage.hasNext) - assertEquals(listOf(1, 2), firstPage.items) - } - - @Test - @DisplayName("관리자 page 응답은 Int overflow 없이 hasNext를 계산한다") - fun shouldCalculateHasNextWithoutIntOverflow() { - val response = AdminPageResponse.of( - totalCount = Long.MAX_VALUE, - content = emptyList(), - page = Int.MAX_VALUE, - size = Int.MAX_VALUE - ) - - assertTrue(response.hasNext) - } - - @Test - @DisplayName("관리자 공통 mutation 응답과 댓글 수정 요청 DTO 계약을 유지한다") - fun shouldKeepAdminMutationDtoContract() { - val mutationResponse = AdminMutationResponse(id = 1L, isActive = true) - val commentUpdateRequest = AdminCommentUpdateRequest(content = "updated") - - assertEquals(1L, mutationResponse.id) - assertTrue(mutationResponse.isActive) - assertEquals("updated", commentUpdateRequest.content) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt deleted file mode 100644 index f7d44de9..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/aicharacter/application/AiCharacterAdminAuditLoggerTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -package kr.co.vividnext.sodalive.v2.admin.aicharacter.application - -import org.junit.jupiter.api.Assertions.assertDoesNotThrow -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.springframework.boot.test.system.CapturedOutput -import org.springframework.boot.test.system.OutputCaptureExtension - -@ExtendWith(OutputCaptureExtension::class) -class AiCharacterAdminAuditLoggerTest { - private val auditLogger = AiCharacterAdminAuditLogger() - - @Test - @DisplayName("global 원작 mutation audit은 character field null을 허용한다") - fun shouldAllowGlobalOriginalWorkAuditContext(output: CapturedOutput) { - auditLogger.logSuccess( - context = AiCharacterAdminAuditContext.globalOriginalWork( - adminMemberId = 1L, - action = AiCharacterAdminAuditAction.CREATE, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, - resourceId = 10L - ) - ) - - assertTrue(output.out.contains("aiCharacterAdminAudit result=SUCCESS adminMemberId=1")) - assertTrue(output.out.contains("characterId=null")) - assertTrue(output.out.contains("creatorMemberId=null")) - assertTrue(output.out.contains("action=CREATE")) - assertTrue(output.out.contains("resourceType=ORIGINAL_WORK")) - assertTrue(output.out.contains("resourceId=10")) - } - - @Test - @DisplayName("character-scoped mutation audit은 character와 creator field를 명시한다") - fun shouldAllowCharacterScopedAuditContext(output: CapturedOutput) { - auditLogger.logFailure( - context = AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = 3L, - action = AiCharacterAdminAuditAction.UPDATE, - resourceType = AiCharacterAdminAuditResourceType.CHARACTER, - resourceId = 2L - ), - exception = IllegalStateException("failed-sensitive-body") - ) - - assertTrue(output.out.contains("result=FAILURE")) - assertTrue(output.out.contains("characterId=2")) - assertTrue(output.out.contains("creatorMemberId=3")) - assertTrue(output.out.contains("action=UPDATE")) - assertTrue(output.out.contains("resourceType=CHARACTER")) - assertTrue(output.out.contains("resourceId=2")) - assertTrue(output.out.contains("error=IllegalStateException")) - assertFalse(output.out.contains("failed-sensitive-body")) - } - - @Test - @DisplayName("원작 배정 audit은 global context로 만들 수 없다") - fun shouldRejectAssignmentAuditInGlobalOriginalWorkContext() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.globalOriginalWork( - adminMemberId = 1L, - action = AiCharacterAdminAuditAction.ASSIGN, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("global 원작 audit은 create/update/delete action만 허용한다") - fun shouldRejectNonMutationActionInGlobalOriginalWorkContext() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.globalOriginalWork( - adminMemberId = 1L, - action = AiCharacterAdminAuditAction.READ, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("원작 배정 audit은 캐릭터별 ORIGINAL_WORK_CHARACTER context만 허용한다") - fun shouldRejectAssignmentAuditWithoutOriginalWorkCharacterResourceType() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = 3L, - action = AiCharacterAdminAuditAction.ASSIGN, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("ORIGINAL_WORK_CHARACTER resource는 assign/unassign action만 허용한다") - fun shouldRejectOriginalWorkCharacterResourceForNonAssignmentAction() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = 3L, - action = AiCharacterAdminAuditAction.UPDATE, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("원작 배정 audit은 creator member id를 필수로 요구한다") - fun shouldRejectAssignmentWithoutCreatorMemberId() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = null, - action = AiCharacterAdminAuditAction.ASSIGN, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("global 원작 resource는 character-scoped context로 만들 수 없다") - fun shouldRejectGlobalOriginalWorkResourceInCharacterScopedContext() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = 3L, - action = AiCharacterAdminAuditAction.UPDATE, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("원작 해제가 아닌 character-scoped audit은 creator member id가 필요하다") - fun shouldRejectMissingCreatorMemberIdOutsideUnassignment() { - assertThrows(IllegalArgumentException::class.java) { - AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = null, - action = AiCharacterAdminAuditAction.UPDATE, - resourceType = AiCharacterAdminAuditResourceType.CHARACTER, - resourceId = 2L - ) - } - } - - @Test - @DisplayName("원작 해제 audit은 creator member id null을 허용한다") - fun shouldAllowMissingCreatorMemberIdForUnassignment() { - assertDoesNotThrow { - AiCharacterAdminAuditContext.characterScoped( - adminMemberId = 1L, - characterId = 2L, - creatorMemberId = null, - action = AiCharacterAdminAuditAction.UNASSIGN, - resourceType = AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER, - resourceId = 10L - ) - } - } - - @Test - @DisplayName("audit context는 factory 검증을 우회하는 public copy를 노출하지 않는다") - fun shouldNotExposePublicCopyMethod() { - assertFalse(AiCharacterAdminAuditContext::class.java.methods.any { method -> method.name == "copy" }) - } - - @Test - @DisplayName("audit resource type은 원작 배정과 댓글/카테고리/공지/FanTalk 답글을 표현한다") - fun shouldExposeRequiredAuditResourceTypes() { - assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.ORIGINAL_WORK_CHARACTER)) - assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_COMMENT)) - assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CONTENT_CATEGORY)) - assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.FAN_TALK_REPLY)) - assertTrue(AiCharacterAdminAuditResourceType.values().contains(AiCharacterAdminAuditResourceType.CHANNEL_NOTICE)) - } - - @Test - @DisplayName("audit action은 콘텐츠 고정까지 표현한다") - fun shouldExposePinAuditAction() { - assertTrue(AiCharacterAdminAuditAction.values().contains(AiCharacterAdminAuditAction.PIN)) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt deleted file mode 100644 index ddbf55a9..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/adapter/out/persistence/DefaultAiCharacterPersistenceAdapterTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package kr.co.vividnext.sodalive.v2.aicharacter.adapter.out.persistence - -import kr.co.vividnext.sodalive.chat.character.CharacterType -import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.configs.QueryDslConfig -import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberKind -import kr.co.vividnext.sodalive.member.MemberRole -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager -import org.springframework.context.annotation.Import -import javax.persistence.EntityManager -import javax.persistence.TypedQuery - -@DataJpaTest(properties = ["spring.cache.type=none"]) -@Import(QueryDslConfig::class) -class DefaultAiCharacterPersistenceAdapterTest @Autowired constructor( - private val entityManager: TestEntityManager, - jpaEntityManager: EntityManager -) { - private val adapter = DefaultAiCharacterPersistenceAdapter(jpaEntityManager) - - @Test - @DisplayName("AI 캐릭터 관리자 대상은 ChatCharacter와 AI creator Member를 함께 반환한다") - fun shouldFindAiCharacterAdminTarget() { - val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = true) - val character = persistCharacter(creator = creator, isActive = true) - - val target = adapter.findAdminTarget(character.id!!) - - assertEquals(character.id, target?.characterId) - assertEquals(creator.id, target?.creatorMemberId) - assertEquals(true, target?.characterIsActive) - assertEquals(true, target?.creatorMemberIsActive) - assertEquals(MemberRole.CREATOR, target?.creatorRole) - assertEquals(MemberKind.AI_CHARACTER, target?.memberKind) - } - - @Test - @DisplayName("존재하지 않는 캐릭터는 관리자 대상이 아니다") - fun shouldReturnNullForMissingCharacter() { - assertNull(adapter.findAdminTarget(-1L)) - } - - @Test - @DisplayName("creator Member가 없으면 관리자 대상이 아니다") - fun shouldReturnNullWhenCreatorMemberIsMissing() { - val entityManager = Mockito.mock(EntityManager::class.java) - val query = Mockito.mock(TypedQuery::class.java) as TypedQuery - Mockito.`when`(entityManager.createQuery(Mockito.anyString(), Mockito.eq(ChatCharacter::class.java))) - .thenReturn(query) - Mockito.`when`(query.setParameter("characterId", 1L)).thenReturn(query) - Mockito.`when`(query.singleResult).thenReturn(characterWithoutCreator()) - - assertNull(DefaultAiCharacterPersistenceAdapter(entityManager).findAdminTarget(1L)) - } - - @Test - @DisplayName("creator Member가 AI 캐릭터가 아니면 관리자 대상이 아니다") - fun shouldIgnoreHumanCreatorMember() { - val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.HUMAN, isActive = true) - val character = persistCharacter(creator = creator, isActive = true) - - assertNull(adapter.findAdminTarget(character.id!!)) - } - - @Test - @DisplayName("비활성 캐릭터와 creator는 조회 대상에 포함하되 상태를 반환한다") - fun shouldReturnInactiveStateForExistingTarget() { - val creator = persistMember(role = MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER, isActive = false) - val character = persistCharacter(creator = creator, isActive = false) - - val target = adapter.findAdminTarget(character.id!!) - - assertEquals(false, target?.characterIsActive) - assertEquals(false, target?.creatorMemberIsActive) - } - - private fun persistMember(role: MemberRole, memberKind: MemberKind, isActive: Boolean): Member { - return entityManager.persistAndFlush( - Member( - email = null, - password = "", - nickname = "creator-${System.nanoTime()}", - role = role, - memberKind = memberKind, - isActive = isActive - ) - ) - } - - private fun persistCharacter(creator: Member?, isActive: Boolean): ChatCharacter { - val character = ChatCharacter( - characterUUID = "uuid-${System.nanoTime()}", - name = "Soda", - description = "desc", - systemPrompt = "prompt", - characterType = CharacterType.Character, - isActive = isActive - ) - character.creatorMember = creator - return entityManager.persistAndFlush(character) - } - - private fun characterWithoutCreator(): ChatCharacter { - return ChatCharacter( - characterUUID = "uuid-${System.nanoTime()}", - name = "Soda", - description = "desc", - systemPrompt = "prompt", - characterType = CharacterType.Character, - isActive = true - ) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt deleted file mode 100644 index b6df348e..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/aicharacter/application/AiCharacterAdminTargetResolverTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -package kr.co.vividnext.sodalive.v2.aicharacter.application - -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.member.MemberKind -import kr.co.vividnext.sodalive.member.MemberRole -import kr.co.vividnext.sodalive.v2.aicharacter.domain.AiCharacterAdminTarget -import kr.co.vividnext.sodalive.v2.aicharacter.port.out.AiCharacterPersistencePort -import org.junit.jupiter.api.Assertions.assertAll -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.springframework.http.HttpStatus - -class AiCharacterAdminTargetResolverTest { - @Test - @DisplayName("존재하지 않는 관리자 대상 캐릭터는 404로 거부한다") - fun shouldRejectMissingTarget() { - val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = null)) - - val exception = assertThrows(SodaException::class.java) { - resolver.resolveActiveTarget(characterId = 1L) - } - - assertAll( - { assertEquals("characterId", exception.errorProperty) }, - { assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) } - ) - } - - @Test - @DisplayName("AI 캐릭터 creator가 아니면 404로 거부한다") - fun shouldRejectNonAiCreatorTargetAsMissing() { - listOf( - target(creatorRole = MemberRole.USER), - target(memberKind = MemberKind.HUMAN) - ).forEach { target -> - val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target)) - - val exception = assertThrows(SodaException::class.java) { - resolver.resolveActiveTarget(characterId = 1L) - } - - assertAll( - { assertEquals("characterId", exception.errorProperty) }, - { assertEquals(HttpStatus.NOT_FOUND, exception.httpStatus) } - ) - } - } - - @Test - @DisplayName("생성/수정 대상 해석은 비활성 캐릭터 또는 creator를 409로 거부한다") - fun shouldRejectInactiveTargetForMutation() { - listOf( - target(isCharacterActive = false, isCreatorActive = true), - target(isCharacterActive = true, isCreatorActive = false) - ).forEach { target -> - val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target)) - - val exception = assertThrows(SodaException::class.java) { - resolver.resolveActiveTarget(characterId = 1L) - } - - assertAll( - { assertEquals("characterId", exception.errorProperty) }, - { assertEquals(HttpStatus.CONFLICT, exception.httpStatus) } - ) - } - } - - @Test - @DisplayName("삭제와 상태 조회는 비활성 관리자 대상을 반환한다") - fun shouldReturnInactiveTargetForStatusOrDelete() { - val target = target(isCharacterActive = false, isCreatorActive = false) - val resolver = AiCharacterAdminTargetResolver(FakeAiCharacterPersistencePort(target = target)) - - val resolved = resolver.resolveExistingTarget(characterId = 1L) - - assertEquals(target, resolved) - } - - @Test - @DisplayName("대상 해석은 요청 characterId를 persistence port에 그대로 전달한다") - fun shouldPassRequestedCharacterIdToPersistencePort() { - val port = FakeAiCharacterPersistencePort(target = target(characterId = 99L)) - val resolver = AiCharacterAdminTargetResolver(port) - - val resolved = resolver.resolveActiveTarget(characterId = 99L) - - assertAll( - { assertEquals(99L, port.requestedCharacterId) }, - { assertEquals(99L, resolved.characterId) } - ) - } - - private fun target( - characterId: Long = 1L, - isCharacterActive: Boolean = true, - isCreatorActive: Boolean = true, - creatorRole: MemberRole = MemberRole.CREATOR, - memberKind: MemberKind = MemberKind.AI_CHARACTER - ): AiCharacterAdminTarget { - return AiCharacterAdminTarget( - characterId = characterId, - creatorMemberId = 10L, - characterIsActive = isCharacterActive, - creatorMemberIsActive = isCreatorActive, - creatorRole = creatorRole, - memberKind = memberKind - ) - } - - private class FakeAiCharacterPersistencePort( - private val target: AiCharacterAdminTarget? - ) : AiCharacterPersistencePort { - var requestedCharacterId: Long? = null - private set - - override fun findAdminTarget(characterId: Long): AiCharacterAdminTarget? { - requestedCharacterId = characterId - return target - } - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt new file mode 100644 index 00000000..a25e2360 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt @@ -0,0 +1,79 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestComponent +import org.springframework.context.annotation.Import +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootTest +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Import( + AiCharacterAdminAccessDeniedErrorContractTest.AiCharacterAdminAccessDeniedSampleController::class +) +class AiCharacterAdminAccessDeniedErrorContractTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @ParameterizedTest + @CsvSource( + "ko,권한이 없습니다.", + "en,You do not have permission.", + "ja,権限がありません。" + ) + @DisplayName("신규 prefix controller AccessDeniedException은 403 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForControllerAccessDenied(language: String, message: String) { + mockMvc.perform( + get("/api/v2/admin/ai-characters/access-denied-contract") + .header("Accept-Language", language) + .with(authentication(createAuthentication())) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.errorProperty").doesNotExist()) + } + + private fun createAuthentication() = + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + + @TestComponent + @RestController + class AiCharacterAdminAccessDeniedSampleController { + @GetMapping("/api/v2/admin/ai-characters/access-denied-contract") + fun accessDenied(): ApiResponse { + throw AccessDeniedException("admin only") + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt new file mode 100644 index 00000000..10c4e00d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt @@ -0,0 +1,260 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestComponent +import org.springframework.context.annotation.Import +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@SpringBootTest +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Import( + AiCharacterAdminAuthorizationTest.AiCharacterAdminAuthorizationSampleController::class, + AiCharacterAdminAuthorizationTest.AiCharacterAdminLegacySampleController::class +) +class AiCharacterAdminAuthorizationTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @Test + @DisplayName("AI 캐릭터 관리자 API는 비회원 요청을 거부한다") + fun shouldRejectAnonymousUser() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/sample") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("AI 캐릭터 관리자 API는 JWT role이 ADMIN이 아니면 현재 DB role이 ADMIN이어도 거부한다") + fun shouldRejectNonAdminJwtRole() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/sample") + .with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN))) + ) + .andExpect(status().isForbidden) + } + + @Test + @DisplayName("AI 캐릭터 관리자 API는 JWT가 ADMIN이어도 현재 DB role이 강등되면 거부한다") + fun shouldRejectStaleAdminClaimAfterCurrentRoleDowngrade() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/sample") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER))) + ) + .andExpect(status().isForbidden) + } + + @Test + @DisplayName("AI 캐릭터 관리자 API는 ADMIN authority가 있어도 MemberAdapter principal이 아니면 거부한다") + fun shouldRejectAdminAuthorityWithoutMemberAdapterPrincipal() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/sample") + .with(user("admin").roles("ADMIN")) + ) + .andExpect(status().isForbidden) + } + + @Test + @DisplayName("AI 캐릭터 관리자 API는 JWT와 현재 DB role이 모두 ADMIN이면 허용한다") + fun shouldAllowCurrentAdminWithAdminJwtRole() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/sample") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("ok")) + } + + @Test + @DisplayName("현재 DB role 추가 검증은 신규 AI 캐릭터 관리자 prefix 밖에 적용하지 않는다") + fun shouldKeepLegacyAuthenticatedRuleOutsideAiCharacterAdminPrefix() { + mockMvc.perform( + get("/phase1-legacy-sample") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + } + + @Test + @DisplayName("시리즈 endpoint는 JWT role과 현재 DB role이 모두 ADMIN이어야 접근할 수 있다") + fun shouldRequireAdminJwtAndCurrentAdminRoleForSeriesEndpoints() { + seriesEndpointRequests().forEach { endpoint -> + mockMvc.perform( + endpoint.request() + .with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN))) + ).andExpect(status().isForbidden) + + mockMvc.perform( + endpoint.request() + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER))) + ).andExpect(status().isForbidden) + } + } + + @Test + @DisplayName("커뮤니티 endpoint는 JWT role과 현재 DB role이 모두 ADMIN이어야 접근할 수 있다") + fun shouldRequireAdminJwtAndCurrentAdminRoleForCommunityEndpoints() { + communityEndpointRequests().forEach { endpoint -> + mockMvc.perform( + endpoint.request() + .with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN))) + ).andExpect(status().isForbidden) + + mockMvc.perform( + endpoint.request() + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER))) + ).andExpect(status().isForbidden) + } + } + + @Test + @DisplayName("FanTalk endpoint는 JWT role과 현재 DB role이 모두 ADMIN이어야 접근할 수 있다") + fun shouldRequireAdminJwtAndCurrentAdminRoleForFanTalkEndpoints() { + fanTalkEndpointRequests().forEach { endpoint -> + mockMvc.perform( + endpoint.request() + .with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN))) + ).andExpect(status().isForbidden) + + mockMvc.perform( + endpoint.request() + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER))) + ).andExpect(status().isForbidden) + } + } + + private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) = + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = currentRole + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}")) + ) + + private fun seriesEndpointRequests() = listOf( + SeriesEndpointRequest("series list") { + get("/api/v2/admin/ai-characters/1/series") + }, + SeriesEndpointRequest("series detail") { + get("/api/v2/admin/ai-characters/1/series/2") + }, + SeriesEndpointRequest("series contents") { + get("/api/v2/admin/ai-characters/1/series/2/contents") + }, + SeriesEndpointRequest("series content search") { + get("/api/v2/admin/ai-characters/1/series/2/contents/search") + .param("search_word", "night") + }, + SeriesEndpointRequest("series content add") { + post("/api/v2/admin/ai-characters/1/series/2/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[3]}""") + }, + SeriesEndpointRequest("series content remove") { + delete("/api/v2/admin/ai-characters/1/series/2/contents/3") + }, + SeriesEndpointRequest("series order") { + put("/api/v2/admin/ai-characters/1/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[2,3]}""") + }, + SeriesEndpointRequest("series create") { + multipart("/api/v2/admin/ai-characters/1/series") + }, + SeriesEndpointRequest("series update") { + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/series/2") + }, + SeriesEndpointRequest("series delete") { + delete("/api/v2/admin/ai-characters/1/series/2") + } + ) + + private fun communityEndpointRequests() = listOf( + SeriesEndpointRequest("community list") { + get("/api/v2/admin/ai-characters/1/community-posts") + .param("timezone", "UTC") + }, + SeriesEndpointRequest("community create") { + multipart("/api/v2/admin/ai-characters/1/community-posts") + }, + SeriesEndpointRequest("community update") { + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/community-posts/2") + } + ) + + private fun fanTalkEndpointRequests() = listOf( + SeriesEndpointRequest("FanTalk list") { + get("/api/v2/admin/ai-characters/1/fan-talks") + }, + SeriesEndpointRequest("FanTalk reply") { + post("/api/v2/admin/ai-characters/1/fan-talks/2/replies") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"reply"}""") + }, + SeriesEndpointRequest("FanTalk reply update") { + put("/api/v2/admin/ai-characters/1/fan-talks/2/replies/3") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"reply","isActive":true}""") + }, + SeriesEndpointRequest("FanTalk delete") { + delete("/api/v2/admin/ai-characters/1/fan-talks/2") + } + ) + + private data class SeriesEndpointRequest( + val name: String, + val request: () -> MockHttpServletRequestBuilder + ) + + @TestComponent + @RestController + @RequestMapping("/api/v2/admin/ai-characters/sample") + class AiCharacterAdminAuthorizationSampleController { + @GetMapping + fun sample(): ApiResponse = ApiResponse.ok("ok") + } + + @TestComponent + @RestController + class AiCharacterAdminLegacySampleController { + @GetMapping("/phase1-legacy-sample") + fun sample(): ApiResponse = ApiResponse.ok("legacy-ok") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt new file mode 100644 index 00000000..f94ccbbd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt @@ -0,0 +1,907 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Header +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestComponent +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.core.MethodParameter +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.security.authentication.InternalAuthenticationServiceException +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.validation.BeanPropertyBindingResult +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MaxUploadSizeExceededException +import org.springframework.web.multipart.MultipartException +import org.springframework.web.multipart.support.MissingServletRequestPartException + +@SpringBootTest +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Import( + AiCharacterAdminErrorContractTest.AiCharacterAdminErrorContractSampleController::class, + AiCharacterAdminErrorContractTest.AiCharacterAdminLegacyErrorContractSampleController::class +) +class AiCharacterAdminErrorContractTest @Autowired constructor( + private val mockMvc: MockMvc +) { + companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + private const val CREATOR_ORIGIN = "https://creator.sodalive.net" + } + + @MockBean + private lateinit var tokenProvider: TokenProvider + + @MockBean + private lateinit var chatCharacterRepository: ChatCharacterRepository + + @ParameterizedTest + @CsvSource( + "ko,로그인 정보를 확인해주세요.", + "en,Please check your login information.", + "ja,ログイン情報を確認してください。" + ) + @DisplayName("비회원 401 오류는 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForAnonymousRequest(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Accept-Language", language) + .with(anonymous()) + ) + + result.andExpectApiError(401, message) + } + + @ParameterizedTest + @CsvSource( + "ko,권한이 없습니다.", + "en,You do not have permission.", + "ja,権限がありません。" + ) + @DisplayName("비ADMIN 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForNonAdminRequest(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN))) + ) + + result.andExpectApiError(403, message) + } + + @ParameterizedTest + @CsvSource( + "ko,권한이 없습니다.", + "en,You do not have permission.", + "ja,権限がありません。" + ) + @DisplayName("stale ADMIN claim 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForStaleAdminClaim(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER))) + ) + + result.andExpectApiError(403, message) + } + + @ParameterizedTest + @CsvSource( + "ko,로그인 정보를 확인해주세요.", + "en,Please check your login information.", + "ja,ログイン情報を確認してください。" + ) + @DisplayName("잘못된 JWT의 401 오류도 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForInvalidJwt(language: String, message: String) { + Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Authorization", "Bearer invalid-token") + .header("Accept-Language", language) + ) + + result.andExpectApiError(401, message) + } + + @ParameterizedTest + @CsvSource( + "ko,로그인 정보를 확인해주세요.", + "en,Please check your login information.", + "ja,ログイン情報を確認してください。" + ) + @DisplayName("폐기된 JWT filter 예외도 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForRevokedJwt(language: String, message: String) { + Mockito.`when`(tokenProvider.validateToken("revoked-token")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("revoked-token")) + .thenThrow(SodaException(messageKey = "common.error.bad_credentials")) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Authorization", "Bearer revoked-token") + .header("Accept-Language", language) + ) + + result.andExpectApiError(401, message) + } + + @ParameterizedTest + @CsvSource( + "ko,로그인 정보를 확인해주세요.", + "en,Please check your login information.", + "ja,ログイン情報を確認してください。" + ) + @DisplayName("JWT 검증 직후 만료된 token도 401 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseWhenJwtExpiresBetweenParses(language: String, message: String) { + Mockito.`when`(tokenProvider.validateToken("expired-between-parses")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("expired-between-parses")) + .thenThrow( + ExpiredJwtException( + Mockito.mock(Header::class.java), + Mockito.mock(Claims::class.java), + "expired" + ) + ) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Authorization", "Bearer expired-between-parses") + .header("Accept-Language", language) + ) + + result.andExpectApiError(401, message) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @DisplayName("인증 저장소 장애는 500 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForAuthenticationInfrastructureError(language: String, message: String) { + Mockito.`when`(tokenProvider.validateToken("authentication-service-error")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("authentication-service-error")) + .thenThrow(InternalAuthenticationServiceException("authentication backend unavailable")) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Authorization", "Bearer authentication-service-error") + .header("Accept-Language", language) + ) + + result.andExpectApiError(500, message) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @DisplayName("JWT filter의 예상하지 못한 오류는 500 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForUnexpectedJwtFilterError(language: String, message: String) { + Mockito.`when`(tokenProvider.validateToken("filter-error-token")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("filter-error-token")) + .thenThrow(IllegalStateException("internal detail must not be exposed")) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .header("Authorization", "Bearer filter-error-token") + .header("Accept-Language", language) + ) + + result.andExpectApiError(500, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("잘못된 target 400 오류는 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForInvalidTarget(language: String, message: String) { + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/invalid-target") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("잘못된 request binding은 400 ApiResponse와 i18n 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForInvalidRequestBinding(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/binding") + .param("value", "not-a-number") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("읽을 수 없는 JSON body는 400 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForUnreadableJson(language: String, message: String) { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/body-binding") + .contentType(MediaType.APPLICATION_JSON) + .content("{") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + assertEquals(HttpMessageNotReadableException::class.java, result.andReturn().resolvedException?.javaClass) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("MethodArgumentNotValidException은 400 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForMethodArgumentNotValid(language: String, message: String) { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/method-argument-not-valid") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":1}") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + assertEquals(MethodArgumentNotValidException::class.java, result.andReturn().resolvedException?.javaClass) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("필수 multipart part 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForMissingMultipartPart(language: String, message: String) { + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/error-contract/required-part") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + assertEquals(MissingServletRequestPartException::class.java, result.andReturn().resolvedException?.javaClass) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("필수 request header 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForMissingRequiredHeader(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/required-header") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @DisplayName("예상하지 못한 오류는 500 ApiResponse와 i18n 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForUnexpectedError(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/unexpected") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(500, message) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @DisplayName("등록되지 않은 message key는 status를 유지하고 unknown 메시지로 fallback한다") + fun shouldFallbackToLocalizedUnknownMessageForMissingMessageKey(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/missing-message-key") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("지원하지 않는 HTTP method는 405 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForUnsupportedMethod(language: String, message: String) { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/success") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(405, message) + result.andExpect(header().string(HttpHeaders.ALLOW, "GET")) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("지원하지 않는 media type은 415 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForUnsupportedMediaType(language: String, message: String) { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/media-type") + .contentType(MediaType.TEXT_PLAIN) + .content("text") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(415, message) + result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + } + + @Test + @DisplayName("PATCH의 지원하지 않는 media type은 Accept와 Accept-Patch header를 반환한다") + fun shouldReturnAcceptHeadersForUnsupportedPatchMediaType() { + val result = mockMvc.perform( + patch("/api/v2/admin/ai-characters/error-contract/media-type") + .contentType(MediaType.TEXT_PLAIN) + .content("text") + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(415, "Invalid request.") + result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + result.andExpect(header().string(HttpHeaders.ACCEPT_PATCH, MediaType.APPLICATION_JSON_VALUE)) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("지원하지 않는 응답 media type은 406 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForNotAcceptableMediaType(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .accept(MediaType.APPLICATION_XML) + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(406, message) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 응답 media type 406에는 CORS header와 ApiResponse가 함께 반환된다") + fun shouldApplyCorsToNotAcceptableMediaTypeError() { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .accept(MediaType.APPLICATION_XML) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(406, "Invalid request.") + result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @DisplayName("누락된 path variable은 500 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForMissingPathVariable(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/missing-path-variable") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(500, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("multipart 파싱 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForMultipartError(language: String, message: String) { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/multipart-error") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("multipart 용량 초과 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForMaxUploadSizeError(language: String, message: String) { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/max-upload-size-error") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("신규 prefix의 미매핑 경로는 404 ApiResponse와 요청 언어 메시지를 반환한다") + fun shouldReturnLocalizedApiResponseForUnmappedPath(language: String, message: String) { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/unmapped-path") + .header("Accept-Language", language) + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(404, message) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 미매핑 경로 404에는 CORS header와 ApiResponse가 함께 반환된다") + fun shouldApplyCorsToUnmappedPathError() { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/unmapped-path") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(404, "Invalid request.") + result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 method 405에는 CORS header와 ApiResponse가 함께 반환된다") + fun shouldApplyCorsToUnsupportedMethodError() { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/success") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(405, "Invalid request.") + result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 media type 415에는 CORS header와 ApiResponse가 함께 반환된다") + fun shouldApplyCorsToUnsupportedMediaTypeError() { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/media-type") + .contentType(MediaType.TEXT_PLAIN) + .content("text") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(415, "Invalid request.") + result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 multipart 오류 400에는 CORS header와 ApiResponse가 함께 반환된다") + fun shouldApplyCorsToMultipartError() { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/multipart-error") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, "Invalid request.") + result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "http://localhost:8888", + "https://test-character-admin.sodalive.net", + "https://character-admin.sodalive.net" + ] + ) + @DisplayName("캐릭터 관리자 Origin의 신규 prefix preflight를 CORS filter가 허용한다") + fun shouldAllowCorsPreflightForAiCharacterAdminPrefix(origin: String) { + mockMvc.perform( + options("/api/v2/admin/ai-characters/unmapped-path") + .header(HttpHeaders.ORIGIN, origin) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type")) + } + + @ParameterizedTest + @ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"]) + @DisplayName("캐릭터 관리자 Origin의 실제 mapped endpoint write preflight를 CORS filter가 허용한다") + fun shouldAllowCorsPreflightForMappedAiCharacterAdminWriteMethods(method: String) { + mockMvc.perform( + options("/api/v2/admin/ai-characters/error-contract/write-preflight") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method)) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type")) + } + + @ParameterizedTest + @ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"]) + @DisplayName("캐릭터 관리자 Origin의 fallback write preflight를 CORS filter가 허용한다") + fun shouldAllowCorsPreflightForFallbackAiCharacterAdminWriteMethods(method: String) { + mockMvc.perform( + options("/api/v2/admin/ai-characters/unmapped-path") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method)) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type")) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", + "https://admin.sodalive.net", + "https://test-admin.sodalive.net" + ] + ) + @DisplayName("신규 prefix preflight는 범용 관리자와 creator Origin을 허용하지 않는다") + fun shouldRejectOtherAdminOriginPreflightForAiCharacterAdminPrefix(origin: String) { + mockMvc.perform( + options("/api/v2/admin/ai-characters/unmapped-path") + .header(HttpHeaders.ORIGIN, origin) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("신규 prefix 일반 요청도 creator Origin이면 CORS 단계에서 거부된다") + fun shouldNotApplyCreatorOriginCorsToAiCharacterAdminError() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/unmapped-path") + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("비ADMIN 요청은 request body 역직렬화 전에 403으로 거부된다") + fun shouldRejectNonAdminBeforeRequestBodyBinding() { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/error-contract/body-binding") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"value\":\"not-a-number\"}") + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN))) + ) + + result.andExpectApiError(403, "You do not have permission.") + } + + @Test + @DisplayName("인접 prefix에는 AI 캐릭터 관리자 전용 fallback handler가 적용되지 않는다") + fun shouldNotApplyAiCharacterAdminHandlerToAdjacentPrefix() { + mockMvc.perform( + delete("/api/v2/admin/ai-characters-shadow") + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + .andExpect(status().isNotFound) + .andExpect(content().string("")) + } + + @Test + @DisplayName("Accept-Language가 없으면 신규 security 오류 메시지는 한국어로 fallback한다") + fun shouldFallbackToKoreanForMissingAcceptLanguage() { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/success") + .with(anonymous()) + ) + + result.andExpectApiError(401, "로그인 정보를 확인해주세요.") + } + + @Test + @DisplayName("지원하지 않는 Accept-Language는 신규 MVC 오류 메시지를 한국어로 fallback한다") + fun shouldFallbackToKoreanForUnsupportedAcceptLanguage() { + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null) + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/error-contract/invalid-target") + .header("Accept-Language", "fr") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + + result.andExpectApiError(400, "잘못된 요청입니다.") + } + + @Test + @DisplayName("신규 prefix 밖 비회원 오류는 기존 빈 401 응답을 유지한다") + fun shouldKeepLegacyAnonymousErrorResponse() { + mockMvc.perform( + get("/phase1-legacy-error-contract") + .header("Accept-Language", "en") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + .andExpect(content().string("")) + } + + @Test + @DisplayName("신규 prefix 밖 JWT filter 예외는 기존 한국어 ApiResponse를 유지한다") + fun shouldKeepLegacyRevokedJwtErrorResponse() { + Mockito.`when`(tokenProvider.validateToken("legacy-revoked-token")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("legacy-revoked-token")) + .thenThrow(SodaException(messageKey = "common.error.bad_credentials")) + + mockMvc.perform( + get("/phase1-legacy-error-contract") + .header("Authorization", "Bearer legacy-revoked-token") + .header("Accept-Language", "en") + ) + .andExpect(status().isUnauthorized) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요.")) + } + + @Test + @DisplayName("신규 prefix 밖 지원하지 않는 HTTP method 오류는 기존 200 응답을 유지한다") + fun shouldKeepLegacyUnsupportedMethodErrorResponse() { + mockMvc.perform( + post("/phase1-legacy-error-contract") + .header("Accept-Language", "en") + .with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN))) + ) + .andExpect(status().isOk) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")) + } + + private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) { + andExpect(status().`is`(httpStatus)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) = + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = currentRole + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}")) + ) + + @TestComponent + @RestController + @RequestMapping("/api/v2/admin/ai-characters/error-contract") + class AiCharacterAdminErrorContractSampleController( + private val targetResolver: AiCharacterAdminTargetResolver + ) { + @GetMapping("/success") + fun success(): ApiResponse = ApiResponse.ok("ok") + + @GetMapping("/invalid-target") + fun invalidTarget(): ApiResponse { + targetResolver.resolve(404L) + return ApiResponse.ok("unexpected") + } + + @GetMapping("/binding") + fun binding(@RequestParam value: Long): ApiResponse = ApiResponse.ok(value) + + @GetMapping("/required-header") + fun requiredHeader(@RequestHeader("X-Required") value: String): ApiResponse = ApiResponse.ok(value) + + @GetMapping("/unexpected") + fun unexpected(): ApiResponse { + throw IllegalStateException("internal detail must not be exposed") + } + + @GetMapping("/missing-message-key") + fun missingMessageKey(): ApiResponse { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "missing.message.key") + } + + @PostMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun mediaType(): ApiResponse = ApiResponse.ok("ok") + + @PatchMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun patchMediaType(): ApiResponse = ApiResponse.ok("ok") + + @PostMapping("/write-preflight") + fun postWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @PutMapping("/write-preflight") + fun putWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @PatchMapping("/write-preflight") + fun patchWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @DeleteMapping("/write-preflight") + fun deleteWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @GetMapping("/missing-path-variable") + fun missingPathVariable(@PathVariable("missing") value: Long): ApiResponse = ApiResponse.ok(value) + + @PostMapping("/body-binding", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun bodyBinding(@RequestBody request: BodyBindingRequest): ApiResponse = ApiResponse.ok(request.value) + + @PostMapping("/method-argument-not-valid", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun methodArgumentNotValid(@RequestBody request: BodyBindingRequest): ApiResponse { + val method = AiCharacterAdminErrorContractSampleController::class.java + .getDeclaredMethod("methodArgumentNotValid", BodyBindingRequest::class.java) + val bindingResult = BeanPropertyBindingResult(request, "request") + bindingResult.rejectValue("value", "invalid") + throw MethodArgumentNotValidException(MethodParameter(method, 0), bindingResult) + } + + @PostMapping("/required-part", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + fun requiredPart(@RequestPart("file") value: String): ApiResponse = ApiResponse.ok(value) + + @PostMapping("/multipart-error") + fun multipartError(): ApiResponse { + throw MultipartException("malformed multipart request") + } + + @PostMapping("/max-upload-size-error") + fun maxUploadSizeError(): ApiResponse { + throw MaxUploadSizeExceededException(1L) + } + } + + @TestComponent + @RestController + class AiCharacterAdminLegacyErrorContractSampleController { + @GetMapping("/phase1-legacy-error-contract") + fun sample(): ApiResponse = ApiResponse.ok("legacy-ok") + } +} + +data class BodyBindingRequest( + val value: Long +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt new file mode 100644 index 00000000..e4ffa4fe --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt @@ -0,0 +1,298 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter + +import com.jayway.jsonpath.JsonPath +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestComponent +import org.springframework.context.annotation.Import +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.web.firewall.RequestRejectedException +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@Import(AiCharacterAdminLoginJwtIntegrationTest.AiCharacterAdminLoginJwtSampleController::class) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor( + private val mockMvc: MockMvc, + private val memberRepository: MemberRepository, + private val memberTokenRepository: MemberTokenRepository, + private val passwordEncoder: PasswordEncoder +) { + companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + private const val GENERAL_ADMIN_ORIGIN = "https://admin.sodalive.net" + private const val UNTRUSTED_ORIGIN = "https://not-allowed.example.com" + } + + @AfterEach + fun cleanUpMemberTokens() { + memberTokenRepository.deleteAll() + } + + @Test + @DisplayName("관리자 로그인 JWT는 신규 AI 캐릭터 관리자 prefix를 통과하고 현재 DB role 강등 시 거부된다") + fun shouldAuthorizeAiCharacterAdminPrefixWithActualAdminLoginJwtAndCurrentRole() { + val admin = memberRepository.saveAndFlush(createMember(MemberRole.ADMIN)) + val token = loginAdmin() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/login-jwt/success") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value("ok")) + + admin.role = MemberRole.USER + memberRepository.saveAndFlush(admin) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/login-jwt/success") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + ) + .andExpect(status().isForbidden) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("You do not have permission.")) + } + + @Test + @DisplayName("실제 Spring context에서 신규 prefix 비회원 오류는 localized ApiResponse 401이다") + fun shouldReturnLocalizedUnauthorizedApiResponseInSpringContext() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/login-jwt/success") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Please check your login information.")) + } + + @Test + @DisplayName("실제 Spring context에서 신규 prefix는 캐릭터 관리자 Origin preflight만 허용한다") + fun shouldAllowOnlyCharacterAdminOriginPreflightInSpringContext() { + mockMvc.perform( + options("/api/v2/admin/ai-characters/login-jwt/success") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options("/api/v2/admin/ai-characters/login-jwt/success") + .header(HttpHeaders.ORIGIN, GENERAL_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "http://localhost:8888", + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", + "https://test-admin.sodalive.net", + GENERAL_ADMIN_ORIGIN, + "https://test-character-admin.sodalive.net", + CHARACTER_ADMIN_ORIGIN + ] + ) + @DisplayName("공유 관리자 로그인과 로그아웃은 기존 관리자와 캐릭터 관리자 Origin을 허용한다") + fun shouldAllowAdminOriginsForSharedAuthenticationEndpoints(origin: String) { + mockMvc.perform( + options("/admin/member/login") + .header(HttpHeaders.ORIGIN, origin) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) + + mockMvc.perform( + options("/member/logout") + .header(HttpHeaders.ORIGIN, origin) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin)) + } + + @Test + @DisplayName("공유 관리자 로그인과 로그아웃은 미등록 Origin을 거부한다") + fun shouldRejectUntrustedOriginForSharedAuthenticationEndpoints() { + listOf("/admin/member/login", "/member/logout").forEach { path -> + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + } + + @Test + @DisplayName("캐릭터 관리자 Origin은 공유 인증 외 legacy/public 경로로 확산되지 않는다") + fun shouldNotApplyCharacterAdminCorsToOtherEndpoints() { + listOf("/api/v2/home/recommendations/ai-characters", "/api/v2/admin/ai-characters-shadow").forEach { path -> + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + } + + @Test + @DisplayName("캐릭터 관리자 Origin에서 기존 관리자 로그인과 공통 로그아웃을 호출한다") + fun shouldLoginAndLogoutFromCharacterAdminOrigin() { + memberRepository.saveAndFlush(createMember(MemberRole.ADMIN)) + val token = loginAdmin() + + mockMvc.perform( + post("/member/logout") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/login-jwt/success") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + ) + .andExpect(status().isUnauthorized) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Please check your login information.")) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 비표준 method는 localized 400 ApiResponse와 CORS를 반환한다") + fun shouldReturnLocalizedBadRequestForRejectedMethodFromCharacterAdminOrigin() { + mockMvc.perform( + request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + ) + .andExpect(status().isBadRequest) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + } + + @Test + @DisplayName("허용된 캐릭터 관리자 Origin의 위험 URL은 400 ApiResponse와 CORS를 반환한다") + fun shouldReturnBadRequestForRejectedDangerousUrlFromCharacterAdminOrigin() { + mockMvc.perform( + request("GET", URI.create("/api/v2/admin/ai-characters//login-jwt/success")) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + ) + .andExpect(status().isBadRequest) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("미허용 Origin의 신규 prefix 비표준 method는 CORS 정책 403으로 종료한다") + fun shouldRejectRejectedMethodFromUntrustedOriginWithoutCorsHeader() { + mockMvc.perform( + request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success")) + .header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN) + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("legacy 경로의 비표준 method는 기존 firewall fallback을 유지한다") + fun shouldKeepLegacyFirewallFallbackForRejectedMethod() { + assertThrows { + mockMvc.perform(request("PROPFIND", URI.create("/api/v2/home/recommendations"))) + } + } + + private fun loginAdmin(): String { + val result = mockMvc.perform( + post("/admin/member/login") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .content("""{"email":"admin-jwt@test.com","password":"password"}""") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + .andExpect(jsonPath("$.success").value(true)) + .andReturn() + + return JsonPath.read(result.response.contentAsString, "$.data.token") + } + + private fun createMember(role: MemberRole): Member { + return Member( + email = "admin-jwt@test.com", + password = passwordEncoder.encode("password"), + nickname = "admin-jwt", + role = role + ) + } + + @TestComponent + @RestController + @RequestMapping("/api/v2/admin/ai-characters/login-jwt") + class AiCharacterAdminLoginJwtSampleController { + @GetMapping("/success") + fun success(): ApiResponse = ApiResponse.ok("ok") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt new file mode 100644 index 00000000..82c6d463 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt @@ -0,0 +1,152 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.hibernate.Hibernate +import org.hibernate.SessionFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpStatus +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager +import javax.persistence.EntityManagerFactory + +@SpringBootTest(properties = ["spring.jpa.properties.hibernate.generate_statistics=true"]) +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminTargetResolverIntegrationTest @Autowired constructor( + private val resolver: AiCharacterAdminTargetResolver, + private val memberRepository: MemberRepository, + private val chatCharacterRepository: ChatCharacterRepository, + private val entityManager: EntityManager, + private val entityManagerFactory: EntityManagerFactory +) { + @Test + @DisplayName("요청한 캐릭터와 연결된 AI 캐릭터 Member를 실제 DB에서 해석한다") + fun shouldResolveRequestedCharacterCreatorMemberFromDatabase() { + val requestedMember = memberRepository.save(createMember("requested")) + val otherMember = memberRepository.save(createMember("other")) + val requestedCharacter = chatCharacterRepository.save(createCharacter("requested", requestedMember)) + val otherCharacter = chatCharacterRepository.save(createCharacter("other", otherMember)) + entityManager.flush() + entityManager.clear() + + val target = resolver.resolve(requestedCharacter.id!!) + + assertEquals(requestedCharacter.id, target.characterId) + assertEquals(requestedCharacter.id, target.chatCharacter.id) + assertEquals(requestedMember.id, target.creatorMember.id) + assertEquals(otherCharacter.id, chatCharacterRepository.findByCreatorMemberId(otherMember.id!!)?.id) + } + + @Test + @DisplayName("캐릭터 조회 시 creatorMember를 함께 조회한다") + fun shouldFetchCreatorMemberWithCharacter() { + val member = memberRepository.save(createMember("fetch")) + val character = chatCharacterRepository.save(createCharacter("fetch", member)) + entityManager.flush() + entityManager.clear() + + val found = chatCharacterRepository.findByIdWithCreatorMember(character.id!!) + + assertEquals(character.id, found?.id) + assertTrue(found != null && Hibernate.isInitialized(found.creatorMember)) + assertEquals(member.id, found?.creatorMember?.id) + } + + @Test + @DisplayName("유효하지 않은 creator를 거부하고 DB insert/update/delete를 만들지 않는다") + fun shouldRejectInvalidCreatorWithoutDatabaseMutation() { + val humanMember = memberRepository.save(createMember("human", memberKind = MemberKind.HUMAN)) + val character = chatCharacterRepository.save(createCharacter("human", humanMember)) + entityManager.flush() + entityManager.clear() + val memberCount = memberRepository.count() + val characterCount = chatCharacterRepository.count() + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val exception = assertThrows(AiCharacterAdminApiException::class.java) { + resolver.resolve(character.id!!) + } + entityManager.flush() + entityManager.clear() + + assertEquals(HttpStatus.BAD_REQUEST, exception.status) + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(0L, statistics.entityInsertCount) + assertEquals(0L, statistics.entityUpdateCount) + assertEquals(0L, statistics.entityDeleteCount) + assertEquals(memberCount, memberRepository.count()) + assertEquals(characterCount, chatCharacterRepository.count()) + assertEquals(MemberKind.HUMAN, memberRepository.findById(humanMember.id!!).orElseThrow().memberKind) + } + + @Test + @DisplayName("AI 캐릭터 Member라도 CREATOR role이 아니면 거부하고 DB를 변경하지 않는다") + fun shouldRejectWrongRoleAiCharacterMemberWithoutDatabaseMutation() { + val userMember = memberRepository.save(createMember("user", role = MemberRole.USER)) + val character = chatCharacterRepository.save(createCharacter("user", userMember)) + entityManager.flush() + entityManager.clear() + val memberCount = memberRepository.count() + val characterCount = chatCharacterRepository.count() + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val exception = assertThrows(AiCharacterAdminApiException::class.java) { + resolver.resolve(character.id!!) + } + entityManager.flush() + entityManager.clear() + + val savedMember = memberRepository.findById(userMember.id!!).orElseThrow() + assertEquals(HttpStatus.BAD_REQUEST, exception.status) + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(0L, statistics.entityInsertCount) + assertEquals(0L, statistics.entityUpdateCount) + assertEquals(0L, statistics.entityDeleteCount) + assertEquals(memberCount, memberRepository.count()) + assertEquals(characterCount, chatCharacterRepository.count()) + assertEquals(MemberRole.USER, savedMember.role) + assertEquals(MemberKind.AI_CHARACTER, savedMember.memberKind) + } + + private fun createCharacter(name: String, creatorMember: Member): ChatCharacter { + return ChatCharacter( + characterUUID = "character-$name-${System.nanoTime()}", + name = "character-$name", + description = "description-$name", + systemPrompt = "system-prompt-$name" + ).apply { + this.creatorMember = creatorMember + } + } + + private fun createMember( + name: String, + role: MemberRole = MemberRole.CREATOR, + memberKind: MemberKind = MemberKind.AI_CHARACTER + ): Member { + return Member( + email = if (memberKind == MemberKind.HUMAN) "$name@example.com" else null, + password = if (memberKind == MemberKind.HUMAN) "password" else "", + nickname = "member-$name", + role = role, + memberKind = memberKind + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt new file mode 100644 index 00000000..3d49fc56 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt @@ -0,0 +1,145 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.http.HttpStatus + +class AiCharacterAdminTargetResolverTest { + private val chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java) + private val resolver = AiCharacterAdminTargetResolver(chatCharacterRepository) + + @Test + @DisplayName("유효한 AI 캐릭터 target에서 creatorMember를 해석한다") + fun shouldResolveAiCharacterCreatorMemberForAdminTarget() { + val creatorMember = createMember(id = 9001L) + val character = createCharacter(id = 101L, creatorMember = creatorMember) + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(character) + + val target = resolver.resolve(101L) + + assertEquals(101L, target.characterId) + assertSame(character, target.chatCharacter) + assertSame(creatorMember, target.creatorMember) + } + + @Test + @DisplayName("존재하지 않는 캐릭터를 부작용 없이 거부한다") + fun shouldRejectMissingCharacterWithoutSideEffect() { + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null) + + val exception = assertThrows(AiCharacterAdminApiException::class.java) { + resolver.resolve(404L) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.status) + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(404L) + Mockito.verifyNoMoreInteractions(chatCharacterRepository) + } + + @Test + @DisplayName("CREATOR가 아닌 creatorMember를 부작용 없이 거부한다") + fun shouldRejectWrongRoleWithoutSideEffect() { + val member = createMember(id = 9001L, role = MemberRole.USER) + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)) + .thenReturn(createCharacter(id = 101L, creatorMember = member)) + + val exception = assertThrows(AiCharacterAdminApiException::class.java) { + resolver.resolve(101L) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.status) + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L) + Mockito.verifyNoMoreInteractions(chatCharacterRepository) + } + + @Test + @DisplayName("HUMAN creatorMember를 부작용 없이 거부한다") + fun shouldRejectHumanCreatorMemberWithoutSideEffect() { + val member = createMember(id = 9001L, memberKind = MemberKind.HUMAN) + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)) + .thenReturn(createCharacter(id = 101L, creatorMember = member)) + + val exception = assertThrows(AiCharacterAdminApiException::class.java) { + resolver.resolve(101L) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.status) + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L) + Mockito.verifyNoMoreInteractions(chatCharacterRepository) + } + + @Test + @DisplayName("creatorMember가 없는 캐릭터를 부작용 없이 거부한다") + fun shouldRejectMissingCreatorMemberWithoutSideEffect() { + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)) + .thenReturn(createCharacter(id = 101L, creatorMember = null)) + + val exception = assertThrows(AiCharacterAdminApiException::class.java) { + resolver.resolve(101L) + } + + assertEquals(HttpStatus.BAD_REQUEST, exception.status) + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L) + Mockito.verifyNoMoreInteractions(chatCharacterRepository) + } + + @Test + @DisplayName("여러 캐릭터 fixture 중 요청한 캐릭터만 해석한다") + fun shouldResolveOnlyRequestedCharacterFixture() { + val creatorMember = createMember(id = 9001L) + val otherCreatorMember = createMember(id = 9002L) + val requestedCharacter = createCharacter(id = 101L, creatorMember = creatorMember) + val otherCharacter = createCharacter(id = 202L, creatorMember = otherCreatorMember) + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(requestedCharacter) + Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(202L)).thenReturn(otherCharacter) + + val target = resolver.resolve(101L) + + assertEquals(101L, target.characterId) + assertEquals(9001L, target.creatorMember.id) + assertSame(requestedCharacter, target.chatCharacter) + Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L) + Mockito.verifyNoMoreInteractions(chatCharacterRepository) + } + + private fun createCharacter(id: Long, creatorMember: Member?): ChatCharacter { + return ChatCharacter( + characterUUID = "character-$id", + name = "character-$id", + description = "description-$id", + systemPrompt = "system-prompt-$id" + ).apply { + this.id = id + this.creatorMember = creatorMember + } + } + + private fun createMember( + id: Long, + role: MemberRole = MemberRole.CREATOR, + memberKind: MemberKind = MemberKind.AI_CHARACTER + ): Member { + return Member( + email = if (memberKind == MemberKind.HUMAN) "human-$id@example.com" else null, + password = "password", + nickname = "member-$id", + role = role, + memberKind = memberKind + ).apply { this.id = id } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt new file mode 100644 index 00000000..f9d7bdcf --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerMutationTest.kt @@ -0,0 +1,1641 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import com.fasterxml.jackson.databind.ObjectMapper +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget +import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicInteger +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCharacterControllerMutationTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val chatCharacterRepository: ChatCharacterRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, + private val objectMapper: ObjectMapper, + private val characterFacade: AiCharacterAdminCharacterFacade, + private val externalApiClient: AiCharacterAdminCharacterExternalApiClient, + private val characterMapper: AiCharacterAdminCharacterMapper, + private val originalWorkService: AdminOriginalWorkService, + private val creatorMemberService: ChatCharacterCreatorMemberService +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @SpyBean + private lateinit var chatCharacterRepositorySpy: ChatCharacterRepository + + @BeforeEach + fun resetCreateFlowObservers() { + externalRequestCount.set(0) + Mockito.reset(amazonS3Client) + Mockito.reset(applicationEventPublisher) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(java.net.URL("https", "cdn.example.com", "/image.png")) + } + + @Test + @DisplayName("생성은 필수 image와 레거시 전체 request를 반영하고 data null을 반환한다") + fun shouldCreateCharacterFromLegacyRequestAndReturnNullData() { + val originalWork = OriginalWork( + title = "v2-original-work", + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "description" + ) + entityManager.persist(originalWork) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"v2-created-character", + "systemPrompt":"prompt", + "description":"created description", + "age":"21", + "gender":"여성", + "mbti":"INFP", + "speechPattern":"polite", + "speechStyle":"soft", + "appearance":"silver hair", + "region":"JP", + "originalTitle":"legacy title", + "originalLink":"original-link", + "originalWorkId":${originalWork.id}, + "characterType":"Clone", + "tags":["tag-a","tag-b"], + "hobbies":["reading"], + "values":["honesty"], + "goals":["friendship"], + "relationships":[{ + "personName":"Mina", + "relationshipName":"friend", + "description":"best friend", + "importance":10, + "relationshipType":"ALLY", + "currentStatus":"ACTIVE" + }], + "personalities":[{"trait":"kind","description":"kind description"}], + "backgrounds":[{"topic":"hometown","description":"moon city"}], + "memories":[{"title":"memory title","content":"memory content","emotion":"happy"}] + } + """ + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(content().json(NULL_SUCCESS_RESPONSE, true)) + + entityManager.flush() + entityManager.clear() + val saved = chatCharacterRepository.findByName("v2-created-character")!! + val creatorMember = saved.creatorMember!! + + assertEquals(MemberRole.CREATOR, creatorMember.role) + assertEquals(MemberKind.AI_CHARACTER, creatorMember.memberKind) + assertEquals("v2-created-character", creatorMember.nickname) + assertEquals("created description", creatorMember.introduce) + assertEquals(originalWork.id, saved.originalWork!!.id) + assertEquals(21, saved.age) + assertEquals("여성", saved.gender) + assertEquals("INFP", saved.mbti) + assertEquals("polite", saved.speechPattern) + assertEquals("soft", saved.speechStyle) + assertEquals("silver hair", saved.appearance) + assertEquals("JP", saved.region) + assertEquals("legacy title", saved.originalTitle) + assertEquals("original-link", saved.originalLink) + assertEquals("Clone", saved.characterType.name) + assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted()) + assertEquals(listOf("reading"), saved.hobbyMappings.map { it.hobby.hobby }) + assertEquals(listOf("honesty"), saved.valueMappings.map { it.value.value }) + assertEquals(listOf("friendship"), saved.goalMappings.map { it.goal.goal }) + assertEquals(listOf("Mina"), saved.relationships.map { it.personName }) + assertEquals(listOf("kind"), saved.personalities.map { it.trait }) + assertEquals(listOf("hometown"), saved.backgrounds.map { it.topic }) + assertEquals(listOf("memory title"), saved.memories.map { it.title }) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("생성은 image part를 필수로 요구한다") + fun shouldRequireImageForCreate() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file( + requestPart( + """ + { + "name":"v2-missing-required-image", + "systemPrompt":"prompt", + "description":"description" + } + """ + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-missing-required-image")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 빈 필수 image를 외부 API 호출 전에 거부한다") + fun shouldRejectEmptyCreateImageBeforeSideEffects() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(MockMultipartFile("image", "empty.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf())) + .file( + requestPart( + """ + { + "name":"v2-empty-required-image", + "systemPrompt":"prompt", + "description":"description" + } + """ + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-empty-required-image")) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 관계 importance 누락을 외부 API 호출 전에 거부한다") + fun shouldRejectMissingRelationshipImportanceBeforeSideEffects() { + val characterName = "v2-missing-relationship-importance" + val originalWork = createOriginalWork("$characterName-original-work") + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"$characterName", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":${originalWork.id}, + "relationships":[{ + "personName":"Mina", + "relationshipName":"friend", + "description":"best friend", + "relationshipType":"ALLY", + "currentStatus":"ACTIVE" + }] + } + """ + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertNoCreatedCharacterState(characterName, originalWork.id!!) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 관계 importance null을 외부 API 호출 전에 거부한다") + fun shouldRejectNullRelationshipImportanceBeforeSideEffects() { + val characterName = "v2-null-relationship-importance" + val originalWork = createOriginalWork("$characterName-original-work") + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"$characterName", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":${originalWork.id}, + "relationships":[{ + "personName":"Mina", + "relationshipName":"friend", + "description":"best friend", + "importance":null, + "relationshipType":"ALLY", + "currentStatus":"ACTIVE" + }] + } + """ + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertNoCreatedCharacterState(characterName, originalWork.id!!) + } + + @Test + @DisplayName("생성 direct facade 호출은 언어 감지 event payload만 확인한다") + fun shouldPublishLanguageDetectEventOnCreate() { + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + + createFacadeForEventPayloadAssertion(publisher).create( + image = null, + requestString = """ + { + "name":"v2-language-detect-event", + "systemPrompt":"prompt", + "description":"event description" + } + """.trimIndent() + ) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(publisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageDetectEvent + assertEquals(chatCharacterRepository.findByName("v2-language-detect-event")!!.id, event.id) + assertEquals("event description", event.query) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 읽을 수 없는 request JSON을 요청 언어의 invalid request로 거부한다") + fun shouldReturnLocalizedErrorForUnreadableCreateRequest(language: String, message: String) { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + "{".toByteArray(StandardCharsets.UTF_8) + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 읽을 수 없는 request JSON을 요청 언어의 invalid request로 거부한다") + fun shouldReturnLocalizedErrorForUnreadableUpdateRequest(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-unreadable-update-request", + name = "v2-unreadable-update-request", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + "{".toByteArray(StandardCharsets.UTF_8) + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @ParameterizedTest + @CsvSource( + "POST,ko,잘못된 요청입니다.", + "POST,en,Invalid request.", + "POST,ja,無効なリクエストです。", + "PUT,ko,잘못된 요청입니다.", + "PUT,en,Invalid request.", + "PUT,ja,無効なリクエストです。" + ) + @DisplayName("생성·수정은 미정의 multipart part를 지역화된 400과 부작용 없음으로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects(method: String, language: String, message: String) { + val characterName = "v2-undefined-part-$method-$language" + val character = if (method == "PUT") { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = characterName, + name = characterName, + description = "before description", + systemPrompt = "prompt" + ) + } else { + null + } + entityManager.flush() + externalRequestCount.set(0) + val request = """ + { + "name":"$characterName", + "description":"after description", + "systemPrompt":"prompt" + } + """ + val unexpectedPart = MockMultipartFile( + "unexpected", + "unexpected.txt", + MediaType.TEXT_PLAIN_VALUE, + "unexpected".toByteArray(StandardCharsets.UTF_8) + ) + + val result = if (method == "POST") { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file(requestPart(request)) + .file(unexpectedPart) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } else { + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}") + .file(requestPart(request)) + .file(unexpectedPart) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } + + result + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + if (character == null) { + assertNull(chatCharacterRepository.findByName(characterName)) + } else { + assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description) + } + assertEquals(0, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @CsvSource( + "POST,ko,잘못된 요청입니다.", + "POST,en,Invalid request.", + "POST,ja,無効なリクエストです。", + "PUT,ko,잘못된 요청입니다.", + "PUT,en,Invalid request.", + "PUT,ja,無効なリクエストです。" + ) + @DisplayName("생성·수정은 filename 없는 미정의 multipart part를 mutation 전 400으로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects( + method: String, + language: String, + message: String + ) { + val characterName = "v2-filename-less-part-$method-$language" + val character = if (method == "PUT") { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = characterName, + name = characterName, + description = "before description", + systemPrompt = "prompt" + ) + } else { + null + } + entityManager.flush() + externalRequestCount.set(0) + val request = """ + { + "name":"$characterName", + "description":"after description", + "systemPrompt":"prompt" + } + """ + + val result = if (method == "POST") { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file(requestPart(request)) + .part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } else { + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}") + .file(requestPart(request)) + .part(MockPart("unexpected", "unexpected".toByteArray(StandardCharsets.UTF_8))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } + + result + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + if (character == null) { + assertNull(chatCharacterRepository.findByName(characterName)) + } else { + assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description) + } + assertEquals(0, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @CsvSource( + value = [ + "POST,text/plain,ko,잘못된 요청입니다.", + "POST,text/plain,en,Invalid request.", + "POST,text/plain,ja,無効なリクエストです。", + "POST,,ko,잘못된 요청입니다.", + "POST,,en,Invalid request.", + "POST,,ja,無効なリクエストです。", + "PUT,text/plain,ko,잘못된 요청입니다.", + "PUT,text/plain,en,Invalid request.", + "PUT,text/plain,ja,無効なリクエストです。", + "PUT,,ko,잘못된 요청입니다.", + "PUT,,en,Invalid request.", + "PUT,,ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("생성·수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonRequestPartBeforeSideEffects( + method: String, + requestContentType: String?, + language: String, + message: String + ) { + val characterName = "v2-non-json-$method-${requestContentType ?: "missing"}-$language" + val character = if (method == "PUT") { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = characterName, + name = characterName, + description = "before description", + systemPrompt = "prompt" + ) + } else { + null + } + val request = """ + { + "name":"$characterName", + "description":"after description", + "systemPrompt":"prompt" + } + """ + + val result = if (method == "POST") { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file(requestPart(request, requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } else { + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}") + .file(requestPart(request, requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } + + result + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + entityManager.clear() + if (character == null) { + assertNull(chatCharacterRepository.findByName(characterName)) + } else { + assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description) + } + assertEquals(0, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @ValueSource(strings = ["POST", "PUT"]) + @DisplayName("생성·수정은 request part 누락을 기존 400으로 유지한다") + fun shouldKeepMissingRequestPartAsBadRequest(method: String) { + val character = if (method == "PUT") { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-missing-request-part", + name = "v2-missing-request-part", + description = "before description", + systemPrompt = "prompt" + ) + } else { + null + } + + val result = if (method == "POST") { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .with(adminAuthentication()) + ) + } else { + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character!!.id}") + .with(adminAuthentication()) + ) + } + + result + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("생성은 서버 소유 externalCharacterId와 isActive 입력을 거부한다") + fun shouldRejectServerOwnedCreateFields() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"v2-server-owned-fields", + "systemPrompt":"prompt", + "description":"description", + "externalCharacterId":"client-id", + "isActive":false + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-server-owned-fields")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 invalid characterType을 외부 API 호출 전에 거부한다") + fun shouldRejectInvalidCharacterTypeBeforeExternalApi() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"v2-invalid-character-type", + "systemPrompt":"prompt", + "description":"description", + "characterType":"INVALID" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-invalid-character-type")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 중복 이름을 외부 API 호출 전에 거부한다") + fun shouldRejectDuplicateNameBeforeExternalApi() { + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "existing-character", + name = "v2-duplicate-character", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"v2-duplicate-character", + "systemPrompt":"prompt", + "description":"description" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("existing-character", chatCharacterRepository.findByName("v2-duplicate-character")!!.characterUUID) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성은 존재하지 않는 원작을 외부 API 호출 전에 거부한다") + fun shouldRejectMissingOriginalWorkBeforeExternalApi() { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"v2-missing-original-work", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":999999 + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-missing-original-work")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 외부 API 실패 시 DB, creatorMember와 이벤트를 남기지 않는다") + fun shouldLeaveNoDatabaseStateWhenExternalApiFails(language: String, message: String) { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"v2-external-api-failure-$language", + "systemPrompt":"prompt", + "description":"description" + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-external-api-failure-$language")) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 S3 실패 시 DB와 이벤트를 남기지 않고 외부 생성 호출만 남긴다") + fun shouldRollBackDatabaseStateWhenImageStorageFails(language: String, message: String) { + Mockito.doThrow(IllegalStateException("S3 failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))) + .file( + requestPart( + """ + { + "name":"v2-image-storage-failure-$language", + "systemPrompt":"prompt", + "description":"description" + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + assertNull(chatCharacterRepository.findByName("v2-image-storage-failure-$language")) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 생성과 S3 호출만 남긴다") + fun shouldRollBackCreateStateWhenEndpointEventPublishFails(language: String, message: String) { + val characterName = "v2-create-event-failure-$language" + val originalWork = createOriginalWork("v2-create-event-original-work-$language") + Mockito.doThrow(IllegalStateException("event failure")) + .`when`(applicationEventPublisher) + .publishEvent(Mockito.any(Any::class.java)) + + val originalPublisher = replaceCharacterFacadePublisher(applicationEventPublisher) + try { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"$characterName", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":${originalWork.id} + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } finally { + replaceCharacterFacadePublisher(originalPublisher) + } + + assertEquals(1, externalRequestCount.get()) + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + assertNoCreatedCharacterState(characterName, originalWork.id!!) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성 저장 실패는 endpoint transaction을 롤백하고 외부 생성만 남긴다") + fun shouldRollBackCreateStateWhenEndpointSaveFails(language: String, message: String) { + val characterName = "v2-create-save-failure-$language" + val originalWork = createOriginalWork("v2-create-save-original-work-$language") + Mockito.doThrow(IllegalStateException("save failure")) + .`when`(chatCharacterRepositorySpy) + .save(Mockito.any(ChatCharacter::class.java)) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters") + .file(createImagePart()) + .file( + requestPart( + """ + { + "name":"$characterName", + "systemPrompt":"prompt", + "description":"description", + "originalWorkId":${originalWork.id} + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + Mockito.verifyNoInteractions(applicationEventPublisher) + Mockito.verifyNoInteractions(amazonS3Client) + assertNoCreatedCharacterState(characterName, originalWork.id!!) + } + + @Test + @DisplayName("수정은 서버 소유 externalCharacterId를 일반 변경과 함께 받지 않는다") + fun shouldRejectServerOwnedExternalCharacterIdBeforeUpdateSideEffects() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-external-id", + name = "v2-update-external-id", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + requestPart( + """ + { + "externalCharacterId":"client-controlled-id", + "description":"after description" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("before description", chatCharacterRepository.findById(character.id!!).orElseThrow().description) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정의 isActive false는 다른 optional JSON 필드를 허용하고 비활성화만 반영한다") + fun shouldIgnoreOtherFieldsInMixedSoftDeleteRequest() { + val originalWork = createOriginalWork("v2-mixed-soft-delete-original") + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-mixed-soft-delete", + name = "v2-mixed-soft-delete", + description = "before description", + systemPrompt = "before prompt", + tags = listOf("before-tag") + ) + originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + requestPart( + """ + { + "isActive":false, + "systemPrompt":"ignored prompt", + "description":"ignored description", + "age":"99", + "characterType":"Clone", + "originalWorkId":999999, + "tags":["ignored-tag"] + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(content().json(NULL_SUCCESS_RESPONSE, true)) + + entityManager.flush() + entityManager.clear() + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertEquals(1, externalRequestCount.get()) + assertFalse(saved.isActive) + assertEquals("before description", saved.description) + assertEquals("before prompt", saved.systemPrompt) + assertNull(saved.age) + assertEquals("Character", saved.characterType.name) + assertEquals(originalWork.id, saved.originalWork!!.id) + assertEquals(listOf("before-tag"), saved.tagMappings.map { it.tag.tag }) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정의 isActive true 단독 요청은 레거시처럼 유효한 no-op mutation이다") + fun shouldAcceptIsActiveTrueOnlyUpdateAsLegacyNoOp() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-is-active-true-only", + name = "v2-is-active-true-only", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(requestPart("""{"isActive":true}""")) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(content().json(NULL_SUCCESS_RESPONSE, true)) + + entityManager.flush() + entityManager.clear() + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + assertTrue(saved.isActive) + assertEquals("before description", saved.description) + assertEquals(1, externalRequestCount.get()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 존재하지 않는 원작을 외부 API 호출 전에 거부한다") + fun shouldRejectMissingOriginalWorkBeforeExternalUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-missing-original-work", + name = "v2-update-missing-original-work", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + requestPart( + """ + { + "name":"v2-update-missing-original-work-updated", + "originalWorkId":999999 + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("v2-update-missing-original-work", chatCharacterRepository.findById(character.id!!).orElseThrow().name) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 invalid characterType을 외부 API 호출 전에 거부한다") + fun shouldRejectInvalidCharacterTypeBeforeExternalUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-invalid-character-type", + name = "v2-update-invalid-character-type", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(requestPart("""{"characterType":"INVALID"}""")) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(0, externalRequestCount.get()) + assertEquals("v2-update-invalid-character-type", chatCharacterRepository.findById(character.id!!).orElseThrow().name) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 이미지가 없으면 기존 이미지를 유지한다") + fun shouldRetainExistingImageWhenUpdateHasNoImage() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-retain-image", + name = "v2-update-retain-image", + description = "before description", + systemPrompt = "prompt" + ) + character.imagePath = "characters/${character.id}/existing.png" + chatCharacterService.saveChatCharacter(character) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(requestPart("""{"description":"after description"}""")) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(content().json(NULL_SUCCESS_RESPONSE, true)) + + entityManager.clear() + assertEquals( + "characters/${character.id}/existing.png", + chatCharacterRepository.findById(character.id!!).orElseThrow().imagePath + ) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정은 레거시 optional 필드를 반영하고 data null을 반환한다") + fun shouldUpdateCharacterFromLegacyOptionalFieldsAndReturnNullData() { + val originalWork = createOriginalWork("v2-update-original-work") + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-character-update", + name = "v2-character-update", + description = "before description", + systemPrompt = "before prompt", + tags = listOf("before-tag") + ) + entityManager.flush() + val beforeUpdatedAt = character.updatedAt!! + Thread.sleep(5) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))) + .file( + requestPart( + """ + { + "name":"v2-character-updated", + "systemPrompt":"after prompt", + "description":"after description", + "age":"29", + "gender":"여성", + "mbti":"ENTJ", + "speechPattern":"direct", + "speechStyle":"formal", + "appearance":"black hair", + "originalTitle":"updated title", + "originalLink":"updated-link", + "originalWorkId":${originalWork.id}, + "characterType":"Clone", + "tags":["after-tag"], + "hobbies":["running"], + "values":["courage"], + "goals":["success"], + "relationships":[{ + "personName":"Jin", + "relationshipName":"rival", + "description":"friendly rival", + "importance":7, + "relationshipType":"RIVAL", + "currentStatus":"ACTIVE" + }], + "personalities":[{"trait":"bold","description":"bold description"}], + "backgrounds":[{"topic":"school","description":"academy"}], + "memories":[{"title":"victory","content":"first win","emotion":"proud"}] + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(content().json(NULL_SUCCESS_RESPONSE, true)) + + entityManager.clear() + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow() + + assertEquals("v2-character-updated", saved.name) + assertEquals("after prompt", saved.systemPrompt) + assertEquals("after description", saved.description) + assertEquals(29, saved.age) + assertEquals("여성", saved.gender) + assertEquals("ENTJ", saved.mbti) + assertEquals("direct", saved.speechPattern) + assertEquals("formal", saved.speechStyle) + assertEquals("black hair", saved.appearance) + assertEquals("updated title", saved.originalTitle) + assertEquals("updated-link", saved.originalLink) + assertEquals(originalWork.id, saved.originalWork!!.id) + assertEquals("Clone", saved.characterType.name) + assertEquals(listOf("after-tag"), saved.tagMappings.map { it.tag.tag }) + assertEquals(listOf("running"), saved.hobbyMappings.map { it.hobby.hobby }) + assertEquals(listOf("courage"), saved.valueMappings.map { it.value.value }) + assertEquals(listOf("success"), saved.goalMappings.map { it.goal.goal }) + assertEquals(listOf("Jin"), saved.relationships.map { it.personName }) + assertEquals(listOf("bold"), saved.personalities.map { it.trait }) + assertEquals(listOf("school"), saved.backgrounds.map { it.topic }) + assertEquals(listOf("victory"), saved.memories.map { it.title }) + assertTrue(saved.updatedAt!!.isAfter(beforeUpdatedAt)) + assertEquals(saved.imagePath, creatorMember.profileImage) + assertEquals(saved.name, creatorMember.nickname) + assertEquals(saved.description, creatorMember.introduce) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("수정의 이미지 저장 실패는 DB와 번역 이벤트를 남기지 않고 외부 수정만 남긴다") + fun shouldLeaveOnlyExternalUpdateWhenImageStorageFails(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-image-storage-failure-$language", + name = "v2-update-image-storage-failure-$language", + description = "before description", + systemPrompt = "prompt" + ) + Mockito.doThrow(IllegalStateException("S3 failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + externalRequestCount.set(0) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file(MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1))) + .file(requestPart("""{"description":"after description"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + assertEquals( + "before description", + chatCharacterRepository.findById(character.id!!).orElseThrow().description + ) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("수정 이벤트 발행 실패는 endpoint transaction을 롤백하고 외부 수정만 남긴다") + fun shouldRollBackUpdateStateWhenEndpointEventPublishFails(language: String, message: String) { + val fixture = createUpdateFailureFixture("v2-update-event-failure-$language") + Mockito.doThrow(IllegalStateException("event failure")) + .`when`(applicationEventPublisher) + .publishEvent(Mockito.any(Any::class.java)) + externalRequestCount.set(0) + + val originalPublisher = replaceCharacterFacadePublisher(applicationEventPublisher) + try { + performUpdateFailureRequest(fixture, language) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } finally { + replaceCharacterFacadePublisher(originalPublisher) + } + + assertEquals(1, externalRequestCount.get()) + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) + Mockito.verifyNoInteractions(amazonS3Client) + assertUnchangedUpdateState(fixture) + } + + @ParameterizedTest + @CsvSource( + "ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + "en,An unknown error occurred. try again.", + "ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" + ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("수정 flush 실패는 endpoint transaction을 롤백하고 외부 수정만 남긴다") + fun shouldRollBackUpdateStateWhenEndpointFlushFails(language: String, message: String) { + val fixture = createUpdateFailureFixture("v2-update-flush-failure-$language") + Mockito.doThrow(IllegalStateException("flush failure")) + .`when`(chatCharacterRepositorySpy) + .flush() + externalRequestCount.set(0) + + performUpdateFailureRequest(fixture, language) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(1, externalRequestCount.get()) + Mockito.verifyNoInteractions(applicationEventPublisher) + Mockito.verifyNoInteractions(amazonS3Client) + assertUnchangedUpdateState(fixture) + } + + @Test + @DisplayName("수정 direct facade 호출은 번역 event payload만 확인한다") + fun shouldPublishTranslationEventOnUpdate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-update-translation-event", + name = "v2-update-translation-event", + description = "before description", + systemPrompt = "prompt" + ) + entityManager.flush() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val targetResolver = Mockito.mock(AiCharacterAdminTargetResolver::class.java) + Mockito.`when`(targetResolver.resolve(character.id!!)).thenReturn( + AiCharacterAdminTarget(character.id!!, character, character.creatorMember!!) + ) + + createFacadeForEventPayloadAssertion(publisher, targetResolver).update( + character.id!!, + null, + """{"description":"after description"}""" + ) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(publisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent + assertEquals(character.id, event.id) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("수정의 isActive false는 AI creatorMember와 콘텐츠를 유지하며 soft delete 한다") + fun shouldSoftDeleteCharacterAndRetainCreatorMember() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-soft-delete-character", + name = "v2-soft-delete-character", + description = "description", + systemPrompt = "prompt" + ) + val creatorMemberId = character.creatorMember!!.id!! + val audioContent = saveAudioContent(character.creatorMember!!) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"isActive":false}""".toByteArray(StandardCharsets.UTF_8) + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(content().json(NULL_SUCCESS_RESPONSE, true)) + + entityManager.flush() + entityManager.clear() + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertFalse(saved.isActive) + assertEquals(creatorMemberId, saved.creatorMember!!.id) + assertTrue(memberRepository.existsById(creatorMemberId)) + assertTrue(entityManager.find(AudioContent::class.java, audioContent.id!!) != null) + assertEquals(creatorMemberId, entityManager.find(AudioContent::class.java, audioContent.id!!).member!!.id) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun requestPart(request: String, contentType: String? = MediaType.APPLICATION_JSON_VALUE): MockMultipartFile { + return MockMultipartFile( + "request", + "request.json", + contentType, + request.trimIndent().toByteArray(StandardCharsets.UTF_8) + ) + } + + private fun createImagePart(): MockMultipartFile { + return MockMultipartFile("image", "image.png", MediaType.IMAGE_PNG_VALUE, byteArrayOf(1)) + } + + private fun saveAudioContent(creator: Member): AudioContent { + val theme = AudioContentTheme(theme = "update-theme-${creator.id}", image = "theme.png", isActive = true) + entityManager.persist(theme) + val content = AudioContent( + title = "update-audio-${creator.id}", + detail = "detail", + languageCode = "ko" + ) + content.member = creator + content.theme = theme + content.isActive = true + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun assertNoCreatedCharacterState(characterName: String, originalWorkId: Long) { + val state = transactionTemplate.execute { + entityManager.clear() + CreateRollbackState( + characterExists = chatCharacterRepository.findByName(characterName) != null, + creatorMemberExists = memberRepository.findByNickname(characterName) != null, + originalWorkCharacterCount = entityManager.createQuery( + "select count(c) from ChatCharacter c where c.originalWork.id = :originalWorkId" + ) + .setParameter("originalWorkId", originalWorkId) + .singleResult as Long + ) + }!! + + assertFalse(state.characterExists) + assertFalse(state.creatorMemberExists) + assertEquals(0L, state.originalWorkCharacterCount) + } + + private fun createOriginalWork(title: String): OriginalWork { + return transactionTemplate.execute { + OriginalWork( + title = title, + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "description" + ).also { + entityManager.persist(it) + entityManager.flush() + } + }!! + } + + private fun createUpdateFailureFixture(name: String): UpdateFailureFixture { + val originalWork = createOriginalWork("$name-original-work") + val updatedOriginalWork = createOriginalWork("$name-updated-original-work") + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "before description", + systemPrompt = "prompt" + ) + originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!) + + return UpdateFailureFixture( + characterId = character.id!!, + name = name, + description = "before description", + creatorNickname = name, + creatorIntroduce = "before description", + originalWorkId = originalWork.id!!, + updatedName = "$name-updated", + updatedDescription = "after description", + updatedOriginalWorkId = updatedOriginalWork.id!! + ) + } + + private fun performUpdateFailureRequest(fixture: UpdateFailureFixture, language: String): ResultActions { + return mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${fixture.characterId}") + .file( + requestPart( + """ + { + "name":"${fixture.updatedName}", + "description":"${fixture.updatedDescription}", + "originalWorkId":${fixture.updatedOriginalWorkId} + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + } + + private fun assertUnchangedUpdateState(fixture: UpdateFailureFixture) { + val state = transactionTemplate.execute { + entityManager.clear() + val saved = chatCharacterRepository.findById(fixture.characterId).orElseThrow() + UpdateRollbackState( + name = saved.name, + description = saved.description, + creatorNickname = saved.creatorMember!!.nickname, + creatorIntroduce = saved.creatorMember!!.introduce, + originalWorkId = saved.originalWork!!.id!! + ) + }!! + + assertEquals(fixture.name, state.name) + assertEquals(fixture.description, state.description) + assertEquals(fixture.creatorNickname, state.creatorNickname) + assertEquals(fixture.creatorIntroduce, state.creatorIntroduce) + assertEquals(fixture.originalWorkId, state.originalWorkId) + } + + private fun replaceCharacterFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(characterFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun createFacadeForEventPayloadAssertion( + publisher: ApplicationEventPublisher, + targetResolver: AiCharacterAdminTargetResolver = Mockito.mock(AiCharacterAdminTargetResolver::class.java) + ): AiCharacterAdminCharacterFacade { + return AiCharacterAdminCharacterFacade( + objectMapper = objectMapper, + chatCharacterService = chatCharacterService, + chatCharacterRepository = chatCharacterRepository, + targetResolver = targetResolver, + externalApiClient = externalApiClient, + imageStorage = Mockito.mock(AiCharacterAdminCharacterImageStorage::class.java), + characterMapper = characterMapper, + originalWorkService = originalWorkService, + creatorMemberService = creatorMemberService, + applicationEventPublisher = publisher, + imageHost = "https://cdn.example.com" + ) + } + + private data class CreateRollbackState( + val characterExists: Boolean, + val creatorMemberExists: Boolean, + val originalWorkCharacterCount: Long + ) + + private data class UpdateFailureFixture( + val characterId: Long, + val name: String, + val description: String, + val creatorNickname: String, + val creatorIntroduce: String, + val originalWorkId: Long, + val updatedName: String, + val updatedDescription: String, + val updatedOriginalWorkId: Long + ) + + private data class UpdateRollbackState( + val name: String, + val description: String, + val creatorNickname: String, + val creatorIntroduce: String, + val originalWorkId: Long + ) + + companion object { + private const val NULL_SUCCESS_RESPONSE = + """{"success":true,"message":null,"data":null,"errorProperty":null}""" + private val externalRequestCount = AtomicInteger() + private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/characters") { exchange -> + externalRequestCount.incrementAndGet() + val requestBody = exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8) + val response = if (requestBody.contains("v2-external-api-failure")) { + """{"success":false}""" + } else { + """{"success":true,"data":{"id":"external-v2-created-character"}}""" + } + .toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE) + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) { + registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopExternalCharacterApi() { + externalCharacterApi.stop(0) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt new file mode 100644 index 00000000..674e5396 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminCharacterControllerTest.kt @@ -0,0 +1,480 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.fasterxml.jackson.databind.ObjectMapper +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCharacterControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("목록은 레거시 totalCount와 content의 exact JSON 필드를 반환한다") + fun shouldReturnLegacyListContract() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-list-character", + name = "v2-list-character", + description = "description", + systemPrompt = "prompt", + age = 21, + gender = "여성", + mbti = "INFP", + speechPattern = "polite", + speechStyle = "soft", + tags = listOf("list-tag") + ) + entityManager.flush() + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("page", "0") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + val item = data.path("content").path(0) + + assertEquals(setOf("totalCount", "content"), data.fieldNames().asSequence().toSet()) + assertEquals( + setOf( + "id", + "name", + "imageUrl", + "description", + "gender", + "age", + "mbti", + "speechStyle", + "speechPattern", + "region", + "tags", + "createdAt", + "updatedAt" + ), + item.fieldNames().asSequence().toSet() + ) + assertEquals(character.id, item.path("id").asLong()) + assertEquals("v2-list-character", item.path("name").asText()) + assertEquals(listOf("list-tag"), item.path("tags").map { it.asText() }) + } + + @Test + @DisplayName("목록은 OpenAPI minimum인 size 1을 그대로 적용한다") + fun shouldHonorLegacyMinimumListSize() { + repeat(2) { index -> + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-list-size-$index", + name = "v2-list-size-$index", + description = "description", + systemPrompt = "prompt" + ) + } + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.content.length()").value(1)) + } + + @Test + @DisplayName("검색은 레거시 searchTerm query를 사용한다") + fun shouldSearchByLegacySearchTerm() { + val matchingCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-search-character", + name = "v2-search-character", + description = "find this character", + systemPrompt = "prompt" + ) + chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-non-matching-character", + name = "v2-non-matching-character", + description = "different description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("searchTerm", "find this") + .param("size", "100") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.content[0].id").value(matchingCharacter.id)) + .andExpect(jsonPath("$.data.content[0].name").value("v2-search-character")) + } + + @Test + @DisplayName("상세는 레거시 전체 필드와 nested 배열을 exact JSON으로 반환한다") + fun shouldReturnLegacyDetailContract() { + val originalWork = OriginalWork( + title = "detail original work", + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "original work description" + ) + entityManager.persist(originalWork) + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-detail-character", + name = "v2-detail-character", + description = "detail description", + systemPrompt = "detail prompt", + age = 22, + gender = "여성", + mbti = "ENFP", + speechPattern = "bright", + speechStyle = "friendly", + appearance = "silver hair", + region = "JP", + tags = listOf("detail-tag"), + hobbies = listOf("reading"), + values = listOf("honesty"), + goals = listOf("friendship"), + memories = listOf(Triple("memory title", "memory content", "happy")), + personalities = listOf(Pair("kind", "kind description")), + backgrounds = listOf(Pair("hometown", "moon city")), + relationships = listOf( + ChatCharacterRelationshipRequest( + personName = "Mina", + relationshipName = "friend", + description = "best friend", + importance = 10, + relationshipType = "ALLY", + currentStatus = "ACTIVE" + ) + ) + ) + character.imagePath = "characters/${character.id}/detail.png" + character.originalWork = originalWork + entityManager.flush() + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + + assertEquals( + setOf( + "id", + "characterUUID", + "name", + "imageUrl", + "description", + "systemPrompt", + "characterType", + "age", + "gender", + "mbti", + "speechPattern", + "speechStyle", + "appearance", + "region", + "isActive", + "tags", + "hobbies", + "values", + "goals", + "relationships", + "personalities", + "backgrounds", + "memories", + "originalWork" + ), + data.fieldNames().asSequence().toSet() + ) + assertEquals(character.id, data.path("id").asLong()) + assertEquals("v2-detail-character", data.path("characterUUID").asText()) + assertEquals("detail prompt", data.path("systemPrompt").asText()) + assertEquals("Character", data.path("characterType").asText()) + assertEquals(listOf("detail-tag"), data.path("tags").map { it.asText() }) + assertEquals( + setOf("personName", "relationshipName", "description", "importance", "relationshipType", "currentStatus"), + data.path("relationships").path(0).fieldNames().asSequence().toSet() + ) + assertEquals("Mina", data.path("relationships").path(0).path("personName").asText()) + assertEquals(setOf("trait", "description"), data.path("personalities").path(0).fieldNames().asSequence().toSet()) + assertEquals(setOf("topic", "description"), data.path("backgrounds").path(0).fieldNames().asSequence().toSet()) + assertEquals(setOf("title", "content", "emotion"), data.path("memories").path(0).fieldNames().asSequence().toSet()) + assertEquals(setOf("id", "imageUrl", "title"), data.path("originalWork").fieldNames().asSequence().toSet()) + assertEquals(originalWork.id, data.path("originalWork").path("id").asLong()) + assertTrue(data.path("imageUrl").asText().endsWith("characters/${character.id}/detail.png")) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("상세는 AI creatorMember가 아닌 target을 요청 언어의 invalid request로 거부한다") + fun shouldReturnLocalizedErrorForInvalidDetailTarget(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-invalid-target", + name = "v2-invalid-target", + description = "description", + systemPrompt = "prompt" + ) + character.creatorMember!!.memberKind = MemberKind.HUMAN + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("목록 binding 오류는 요청 언어의 invalid request를 반환한다") + fun shouldReturnLocalizedErrorForInvalidListBinding(language: String, message: String) { + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .param("page", "not-a-number") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + } + + @Test + @DisplayName("목록·상세·생성·수정 endpoint는 non-ADMIN JWT를 binding 전에 거부한다") + fun shouldRejectNonAdminJwtRoleForEveryCharacterEndpoint() { + actualCharacterEndpointRequests().forEach { request -> + mockMvc.perform( + request + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(nonAdminAuthentication()) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("You do not have permission.")) + } + } + + @Test + @DisplayName("목록·상세·생성·수정 endpoint는 stale ADMIN claim을 binding 전에 거부한다") + fun shouldRejectStaleAdminClaimForEveryCharacterEndpoint() { + actualCharacterEndpointRequests().forEach { request -> + mockMvc.perform( + request + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(staleAdminAuthentication()) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("You do not have permission.")) + } + } + + @Test + @DisplayName("실제 상세 endpoint preflight는 캐릭터 관리자 Origin을 허용한다") + fun shouldApplyCorsToDetailEndpointPreflight() { + mockMvc.perform( + options("/api/v2/admin/ai-characters/1") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + @ParameterizedTest + @CsvSource( + "/api/v2/admin/ai-characters,GET", + "/api/v2/admin/ai-characters/1,GET", + "/api/v2/admin/ai-characters,POST", + "/api/v2/admin/ai-characters/1,PUT" + ) + @DisplayName("실제 character endpoint preflight는 캐릭터 관리자 Origin만 허용한다") + fun shouldApplyCorsPreflightToEveryCharacterEndpoint(path: String, method: String) { + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("목록 endpoint는 ADMIN 인증을 상속한다") + fun shouldInheritAdminAuthorizationForListEndpoint() { + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + + mockMvc.perform( + get("/api/v2/admin/ai-characters") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun nonAdminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "user@example.com", + password = "password", + nickname = "user", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_USER")) + ) + ) + + private fun staleAdminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "stale-admin@example.com", + password = "password", + nickname = "stale-admin", + role = MemberRole.USER + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun actualCharacterEndpointRequests(): List { + return listOf( + get("/api/v2/admin/ai-characters"), + get("/api/v2/admin/ai-characters/1"), + post("/api/v2/admin/ai-characters"), + put("/api/v2/admin/ai-characters/1") + ) + } + + companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + private const val CREATOR_ORIGIN = "https://creator.sodalive.net" + + private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply { + createContext("/api/characters") { exchange -> + val response = """{"success":true,"data":{"id":"external-v2-created-character"}}""" + .toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE) + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) { + registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopExternalCharacterApi() { + externalCharacterApi.stop(0) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt new file mode 100644 index 00000000..0e7e0199 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/AiCharacterAdminOriginalWorkSearchTest.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminOriginalWorkSearchTest @Autowired constructor( + private val mockMvc: MockMvc, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("원작 검색은 제목·콘텐츠 타입·카테고리 부분 검색과 레거시 13개 필드 직접 배열을 반환한다") + fun shouldSearchOriginalWorksByLegacyFieldsAndReturnDirectArray() { + val titleMatch = saveOriginalWork("p2-r9 moon title", "webtoon", "romance") + val typeMatch = saveOriginalWork("another title", "p2-r9 moon audio", "fantasy") + val categoryMatch = saveOriginalWork("third title", "novel", "p2-r9 moon category") + saveOriginalWork("deleted p2-r9 moon title", "webtoon", "romance", isDeleted = true) + saveOriginalWork("not matched", "novel", "drama") + entityManager.flush() + entityManager.clear() + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/original-works/search") + .param("searchTerm", "p2-r9 moon") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.length()").value(3)) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + val ids = data.map { it.path("id").asLong() }.toSet() + + assertEquals(setOf(titleMatch.id, typeMatch.id, categoryMatch.id), ids) + assertEquals( + setOf( + "id", + "title", + "contentType", + "category", + "isAdult", + "description", + "originalWork", + "originalLink", + "writer", + "studio", + "originalLinks", + "tags", + "imageUrl" + ), + data.first().fieldNames().asSequence().toSet() + ) + val titleItem = data.first { it.path("id").asLong() == titleMatch.id } + assertEquals(listOf("https://example.com/p2-r9-moon-title"), titleItem.path("originalLinks").map { it.asText() }) + assertEquals(listOf("p2-r9-moon-title-tag"), titleItem.path("tags").map { it.asText() }) + assertEquals("original p2-r9 moon title", titleItem.path("originalWork").asText()) + assertEquals("https://source.example.com/p2-r9 moon title", titleItem.path("originalLink").asText()) + assertEquals("writer p2-r9 moon title", titleItem.path("writer").asText()) + assertEquals("studio p2-r9 moon title", titleItem.path("studio").asText()) + } + + @Test + @DisplayName("원작 검색은 결과가 없으면 빈 직접 배열을 반환한다") + fun shouldReturnEmptyArrayWhenNoOriginalWorkMatches() { + saveOriginalWork("unrelated original", "novel", "drama") + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/original-works/search") + .param("searchTerm", "missing-p2-r9") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(0)) + } + + @Test + @DisplayName("원작 검색은 필수 searchTerm 누락을 신규 prefix 오류 envelope로 반환한다") + fun shouldRejectMissingSearchTermWithLocalizedError() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/original-works/search") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + } + + @Test + @DisplayName("원작 검색은 characterId 상세 route와 충돌하지 않고 ADMIN 이중 인가와 CORS를 상속한다") + fun shouldKeepRouteAuthorizationAndCorsContract() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/original-works/search") + .param("searchTerm", "p2-r9") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(nonAdminAuthentication()) + ) + .andExpect(status().isForbidden) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("You do not have permission.")) + + mockMvc.perform( + options("/api/v2/admin/ai-characters/original-works/search") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + } + + private fun saveOriginalWork( + title: String, + contentType: String, + category: String, + isDeleted: Boolean = false + ): OriginalWork { + val slug = title.replace(" ", "-") + val originalWork = OriginalWork( + title = title, + contentType = contentType, + category = category, + isAdult = true, + description = "description $title", + originalWork = "original $title", + originalLink = "https://source.example.com/$title", + writer = "writer $title", + studio = "studio $title" + ) + originalWork.imagePath = "originals/$slug.png" + originalWork.isDeleted = isDeleted + originalWork.originalLinks.add(OriginalWorkLink("https://example.com/$slug", originalWork)) + val tag = OriginalWorkTag("$slug-tag") + entityManager.persist(tag) + originalWork.tagMappings.add(OriginalWorkTagMapping(originalWork, tag)) + entityManager.persist(originalWork) + return originalWork + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun nonAdminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "user@example.com", + password = "password", + nickname = "user", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_USER")) + ) + ) + + private companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt new file mode 100644 index 00000000..1281c151 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest.kt @@ -0,0 +1,164 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import com.amazonaws.services.s3.AmazonS3Client +import com.sun.net.httpserver.HttpServer +import kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterController +import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.net.InetSocketAddress +import java.net.URL +import java.nio.charset.StandardCharsets +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest @Autowired constructor( + private val chatCharacterService: ChatCharacterService, + private val adminChatCharacterService: AdminChatCharacterService, + private val originalWorkService: AdminOriginalWorkService, + private val creatorMemberService: ChatCharacterCreatorMemberService, + private val entityManager: EntityManager +) { + @Test + fun `기존 관리자 캐릭터 등록은 설명 언어 감지 이벤트를 발행한다`() { + val server = startExternalCharacterApi() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val controller = createLegacyController( + s3Uploader = stubS3Uploader("characters/event/profile.png"), + publisher = publisher, + apiUrl = "http://localhost:${server.address.port}" + ) + + try { + controller.registerCharacter( + image = MockMultipartFile("image", "image.png", "image/png", byteArrayOf(1)), + requestString = """ + { + "name":"legacy-character-language-detect", + "systemPrompt":"prompt", + "description":"detect this description", + "age":"21", + "gender":"여성", + "region":"KR", + "tags":[], + "hobbies":[], + "values":[], + "goals":[], + "relationships":[], + "personalities":[], + "backgrounds":[], + "memories":[] + } + """.trimIndent() + ) + entityManager.flush() + + val saved = chatCharacterService.findByName("legacy-character-language-detect")!! + val event = capturePublishedEvent(publisher) as LanguageDetectEvent + + assertEquals(saved.id, event.id) + assertEquals("detect this description", event.query) + assertEquals(LanguageDetectTargetType.CHARACTER, event.targetType) + } finally { + server.stop(0) + } + } + + @Test + fun `기존 관리자 캐릭터 수정은 번역 이벤트를 발행한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-translation", + name = "legacy-character-translation", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + val publisher = Mockito.mock(ApplicationEventPublisher::class.java) + val controller = createLegacyController( + s3Uploader = stubS3Uploader("unused.png"), + publisher = publisher, + apiUrl = "http://localhost" + ) + + controller.updateCharacter( + image = null, + requestString = """ + { + "id":${character.id}, + "originalTitle":"changed original" + } + """.trimIndent() + ) + + val event = capturePublishedEvent(publisher) as LanguageTranslationEvent + + assertEquals(character.id, event.id) + assertEquals(LanguageTranslationTargetType.CHARACTER, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + private fun createLegacyController( + s3Uploader: S3Uploader, + publisher: ApplicationEventPublisher, + apiUrl: String + ): AdminChatCharacterController { + return AdminChatCharacterController( + service = chatCharacterService, + adminService = adminChatCharacterService, + s3Uploader = s3Uploader, + originalWorkService = originalWorkService, + creatorMemberService = creatorMemberService, + applicationEventPublisher = publisher, + apiKey = "test-api-key", + apiUrl = apiUrl, + s3Bucket = "test-bucket", + imageHost = "https://cdn.example.com" + ) + } + + private fun stubS3Uploader(path: String): S3Uploader { + val amazonS3Client = Mockito.mock(AmazonS3Client::class.java) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://cdn.example.com/$path")) + return S3Uploader(amazonS3Client) + } + + private fun startExternalCharacterApi(): HttpServer { + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/api/characters") { exchange -> + val response = """{"success":true,"data":{"id":"external-event-character"}}""" + .toByteArray(StandardCharsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, response.size.toLong()) + exchange.responseBody.use { it.write(response) } + } + server.start() + return server + } + + private fun capturePublishedEvent(publisher: ApplicationEventPublisher): Any { + val captor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(publisher).publishEvent(captor.capture()) + return captor.value + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt new file mode 100644 index 00000000..43045953 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt @@ -0,0 +1,220 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character + +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyChatCharacterAdminCharacterizationTest @Autowired constructor( + private val chatCharacterService: ChatCharacterService, + private val originalWorkService: AdminOriginalWorkService, + private val chatCharacterRepository: ChatCharacterRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager +) { + @Test + fun `기존 관리자 캐릭터 생성은 AI creatorMember와 상세 정보를 함께 저장한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-1", + name = "legacy-character-create", + description = "legacy description", + systemPrompt = "system prompt", + age = 21, + gender = "여성", + mbti = "INFP", + speechPattern = "polite", + speechStyle = "soft", + appearance = "silver hair", + originalTitle = "original title", + originalLink = "https://example.com/original", + characterType = CharacterType.Character, + region = "KR", + tags = listOf("tag-a", "tag-a", "tag-b"), + values = listOf("value-a"), + hobbies = listOf("hobby-a"), + goals = listOf("goal-a"), + memories = listOf(Triple("memory", "content", "happy")), + personalities = listOf(Pair("kind", "kind desc")) + ) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + val creatorMember = saved.creatorMember + + assertNotNull(creatorMember) + assertEquals(MemberRole.CREATOR, creatorMember!!.role) + assertEquals(MemberKind.AI_CHARACTER, creatorMember.memberKind) + assertEquals("legacy-character-create", creatorMember.nickname) + assertEquals("legacy description", creatorMember.introduce) + assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted()) + assertEquals(listOf("value-a"), saved.valueMappings.map { it.value.value }) + assertEquals(listOf("hobby-a"), saved.hobbyMappings.map { it.hobby.hobby }) + assertEquals(listOf("goal-a"), saved.goalMappings.map { it.goal.goal }) + assertEquals(listOf("memory"), saved.memories.map { it.title }) + assertEquals(listOf("kind"), saved.personalities.map { it.trait }) + } + + @Test + fun `기존 관리자 원작 배정은 캐릭터 originalWork 연결을 저장한다`() { + val originalWork = saveOriginalWork("legacy-original-work") + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-original", + name = "legacy-character-original-work", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertEquals(originalWork.id, saved.originalWork!!.id) + assertEquals("legacy-original-work", saved.originalWork!!.title) + } + + @Test + fun `기존 관리자 캐릭터 수정은 변경 필드와 AI creatorMember 표시 정보를 동기화한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-2", + name = "legacy-character-update", + description = "before description", + systemPrompt = "before prompt", + tags = listOf("old-tag"), + memories = listOf(Triple("old-memory", "old content", "sad")) + ) + entityManager.flush() + + chatCharacterService.updateChatCharacterWithDetails( + imagePath = "characters/${character.id}/profile.png", + request = ChatCharacterUpdateRequest( + id = character.id!!, + name = "legacy-character-updated", + description = "after description", + age = "24", + tags = listOf("new-tag"), + memories = listOf(ChatCharacterMemoryRequest("new-memory", "new content", "calm")), + personalities = listOf(ChatCharacterPersonalityRequest("calm", "calm desc")) + ) + ) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow() + + assertEquals("legacy-character-updated", saved.name) + assertEquals("after description", saved.description) + assertEquals(24, saved.age) + assertEquals("characters/${character.id}/profile.png", saved.imagePath) + assertEquals(listOf("new-tag"), saved.tagMappings.map { it.tag.tag }) + assertEquals(listOf("new-memory"), saved.memories.map { it.title }) + assertEquals(listOf("calm"), saved.personalities.map { it.trait }) + assertEquals("legacy-character-updated", creatorMember.nickname) + assertEquals("characters/${character.id}/profile.png", creatorMember.profileImage) + assertEquals("after description", creatorMember.introduce) + } + + @Test + fun `기존 관리자 캐릭터 비활성화는 row와 creatorMember를 남기고 isActive false로 저장한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-3", + name = "legacy-character-disable", + description = "disable description", + systemPrompt = "prompt" + ) + val creatorMemberId = character.creatorMember!!.id!! + val audioContent = saveAudioContent(character.creatorMember!!) + entityManager.flush() + + chatCharacterService.updateChatCharacterWithDetails( + request = ChatCharacterUpdateRequest( + id = character.id!!, + name = character.name, + isActive = false + ) + ) + entityManager.flush() + entityManager.clear() + + val saved = chatCharacterRepository.findById(character.id!!).orElseThrow() + + assertFalse(saved.isActive) + assertTrue(saved.name.startsWith("inactive_legacy-character-disable_")) + assertTrue(memberRepository.existsById(creatorMemberId)) + assertEquals(creatorMemberId, saved.creatorMember!!.id) + assertNotNull(entityManager.find(AudioContent::class.java, audioContent.id!!)) + assertEquals(creatorMemberId, entityManager.find(AudioContent::class.java, audioContent.id!!).member!!.id) + } + + @Test + fun `기존 관리자 중복 이름 검증은 findByName 조회 결과에 의존한다`() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "external-character-4", + name = "legacy-duplicate-name", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + entityManager.clear() + + val found = chatCharacterService.findByName("legacy-duplicate-name") + + assertEquals(character.id, found!!.id) + } + + private fun saveOriginalWork(title: String): OriginalWork { + val originalWork = OriginalWork( + title = title, + contentType = "webtoon", + category = "fantasy", + isAdult = false, + description = "description" + ) + entityManager.persist(originalWork) + return originalWork + } + + private fun saveAudioContent(creator: Member): AudioContent { + val theme = AudioContentTheme(theme = "legacy-theme-${creator.id}", image = "theme.png", isActive = true) + entityManager.persist(theme) + val content = AudioContent( + title = "legacy-audio-${creator.id}", + detail = "detail", + languageCode = "ko" + ) + content.member = creator + content.theme = theme + content.isActive = true + content.duration = "00:10:00" + entityManager.persist(content) + return content + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt new file mode 100644 index 00000000..8c612174 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCommentTest.kt @@ -0,0 +1,421 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito.verifyNoInteractions +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCommunityPostCommentTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @Test + @DisplayName("원댓글과 답글 목록은 timezone 없이 UTC Z date와 기존 totalCount 및 items를 반환한다") + fun shouldListRootCommentsAndRepliesWithUtcDates() { + registerMysqlDateFunctions() + val character = createCharacter("community-comment-list") + val post = savePost(character.creatorMember!!, "comment list post") + val root = saveComment(post, character.creatorMember!!, "root") + val reply = saveComment(post, character.creatorMember!!, "reply", root) + root.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 1) + reply.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 2) + entityManager.flush() + + mockMvc.perform( + get(commentsPath(character.id!!, post.id!!)) + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(root.id)) + .andExpect(jsonPath("$.data.items[0].comment").value("root")) + .andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:01Z")) + + mockMvc.perform( + get("${commentsPath(character.id!!, post.id!!)}/${root.id}/replies") + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].comment").value("reply")) + .andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:02Z")) + } + + @Test + @DisplayName("추가 timezone query는 원댓글과 답글 목록 결과에 영향을 주지 않는다") + fun shouldIgnoreTimezoneQueryForRootCommentsAndReplies() { + registerMysqlDateFunctions() + val character = createCharacter("community-comment-timezone") + val post = savePost(character.creatorMember!!, "comment timezone post") + val root = saveComment(post, character.creatorMember!!, "root") + saveComment(post, character.creatorMember!!, "reply", root) + entityManager.flush() + + val rootWithoutTimezone = mockMvc.perform( + get(commentsPath(character.id!!, post.id!!)) + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn().response.contentAsString + val rootWithTimezone = mockMvc.perform( + get(commentsPath(character.id!!, post.id!!)) + .param("timezone", "America/Los_Angeles") + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn().response.contentAsString + assertEquals(rootWithoutTimezone, rootWithTimezone) + + val repliesPath = "${commentsPath(character.id!!, post.id!!)}/${root.id}/replies" + val repliesWithoutTimezone = mockMvc.perform( + get(repliesPath) + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn().response.contentAsString + val repliesWithTimezone = mockMvc.perform( + get(repliesPath) + .param("timezone", "America/Los_Angeles") + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn().response.contentAsString + assertEquals(repliesWithoutTimezone, repliesWithTimezone) + } + + @Test + @DisplayName("작성은 target AI를 writer로 사용하고 root와 답글 모두 data null을 반환한다") + fun shouldCreateRootAndReplyAsTargetAi() { + val character = createCharacter("community-comment-create") + val post = savePost(character.creatorMember!!, "comment create post") + entityManager.flush() + + mockMvc.perform( + post(commentsPath(character.id!!, post.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"root"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val root = commentsFor(post.id!!).single() + assertEquals(character.creatorMember!!.id, root.member!!.id) + + mockMvc.perform( + post(commentsPath(character.id!!, post.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"reply", "parentId":${root.id}, "isSecret":false}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val reply = commentsFor(post.id!!).single { it.parent?.id == root.id } + assertEquals(character.creatorMember!!.id, reply.member!!.id) + } + + @Test + @DisplayName("다른 게시글 또는 비활성 parent 답글 작성은 insert 없이 거부한다") + fun shouldRejectInvalidReplyParentWithoutInsert() { + val character = createCharacter("community-comment-parent") + val post = savePost(character.creatorMember!!, "comment parent post") + val otherPost = savePost(character.creatorMember!!, "comment other post") + val foreignParent = saveComment(otherPost, character.creatorMember!!, "other root") + val inactiveParent = saveComment(post, character.creatorMember!!, "inactive root").apply { isActive = false } + entityManager.flush() + + val beforeCount = commentsFor(post.id!!).size + listOf(foreignParent.id!!, inactiveParent.id!!).forEach { parentId -> + mockMvc.perform( + post(commentsPath(character.id!!, post.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"must not persist", "parentId":$parentId}""") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + assertEquals(beforeCount, commentsFor(post.id!!).size) + } + + @Test + @DisplayName("target AI가 작성하지 않은 활성 댓글 수정은 거부하고 원문을 유지한다") + fun shouldRejectFanAuthoredCommentUpdate() { + val character = createCharacter("community-comment-update") + val post = savePost(character.creatorMember!!, "comment update post") + val fanComment = saveComment(post, saveMember("community-comment-update-fan"), "fan comment") + entityManager.flush() + + mockMvc.perform( + put("${commentsPath(character.id!!, post.id!!)}/${fanComment.id}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"changed"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals("fan comment", entityManager.find(CreatorCommunityComment::class.java, fanComment.id).comment) + } + + @Test + @DisplayName("target AI가 작성한 활성 댓글 수정은 data null로 완료한다") + fun shouldUpdateTargetAiAuthoredComment() { + val character = createCharacter("community-comment-update-target") + val post = savePost(character.creatorMember!!, "comment update target post") + val comment = saveComment(post, character.creatorMember!!, "before") + entityManager.flush() + + mockMvc.perform( + put("${commentsPath(character.id!!, post.id!!)}/${comment.id}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"after"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + assertEquals("after", entityManager.find(CreatorCommunityComment::class.java, comment.id).comment) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("text/plain 댓글 작성과 수정은 415로 거부하고 부작용이 없다") + fun shouldRejectPlainTextCommentCreateAndUpdateBeforeSideEffects(language: String, message: String) { + val character = createCharacter("community-comment-media-type-$language") + val post = savePost(character.creatorMember!!, "comment media type post") + val comment = saveComment(post, character.creatorMember!!, "before") + entityManager.flush() + + val commentsPath = commentsPath(character.id!!, post.id!!) + val beforeCount = commentsFor(post.id!!).size + mockMvc.perform( + post(commentsPath) + .contentType(MediaType.TEXT_PLAIN) + .content("{\"comment\":\"created\"}") + .header("Accept-Language", language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.errorProperty").doesNotExist()) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + mockMvc.perform( + put("$commentsPath/${comment.id}") + .contentType(MediaType.TEXT_PLAIN) + .content("{\"comment\":\"updated\"}") + .header("Accept-Language", language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.errorProperty").doesNotExist()) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertEquals(beforeCount, commentsFor(post.id!!).size) + assertEquals("before", entityManager.find(CreatorCommunityComment::class.java, comment.id).comment) + verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("삭제는 대상 게시글의 한 row만 비활성화하고 이미 비활성이면 성공 no-op이다") + fun shouldSoftDeleteOnlyTargetRowAndIgnoreInactiveComment() { + val character = createCharacter("community-comment-delete") + val post = savePost(character.creatorMember!!, "comment delete post") + val fan = saveMember("community-comment-delete-fan") + val root = saveComment(post, fan, "root") + val reply = saveComment(post, fan, "reply", root) + entityManager.flush() + + val path = "${commentsPath(character.id!!, post.id!!)}/${root.id}" + mockMvc.perform(delete(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertFalse(entityManager.find(CreatorCommunityComment::class.java, root.id).isActive) + assertTrue(entityManager.find(CreatorCommunityComment::class.java, reply.id).isActive) + + mockMvc.perform(delete(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(entityManager.find(CreatorCommunityComment::class.java, reply.id).isActive) + } + + @Test + @DisplayName("누락 또는 잘못된 요청은 공통 400 envelope으로 변환한다") + fun shouldRejectMissingOrInvalidCommentRequests() { + val character = createCharacter("community-comment-request") + val post = savePost(character.creatorMember!!, "comment request post") + entityManager.flush() + + listOf("{}", "{\"comment\":", "{\"comment\":\"comment\",\"unknown\":true}").forEach { body -> + mockMvc.perform( + post(commentsPath(character.id!!, post.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + mockMvc.perform( + get(commentsPath(character.id!!, post.id!!)) + .param("page", "-1") + .param("size", "0") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-$name-character", + name = name, + description = "description", + systemPrompt = "prompt" + ).also { + it.creatorMember!!.profileImage = "profile/$name.png" + } + + private fun savePost(owner: Member, content: String): CreatorCommunity { + return CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = true, + isAdult = false + ).apply { + member = owner + entityManager.persist(this) + } + } + + private fun saveComment( + post: CreatorCommunity, + writer: Member, + comment: String, + parent: CreatorCommunityComment? = null + ): CreatorCommunityComment { + return CreatorCommunityComment(comment = comment).apply { + creatorCommunity = post + member = writer + this.parent = parent + entityManager.persist(this) + } + } + + private fun saveMember(nickname: String): Member { + return Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.USER + ).apply(entityManager::persist) + } + + private fun commentsFor(postId: Long): List { + entityManager.flush() + return entityManager.createQuery( + "select comment from CreatorCommunityComment comment where comment.creatorCommunity.id = :postId", + CreatorCommunityComment::class.java + ).setParameter("postId", postId).resultList + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + + private fun commentsPath(characterId: Long, postId: Long): String { + return "/api/v2/admin/ai-characters/$characterId/community-posts/$postId/comments" + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt new file mode 100644 index 00000000..77d1574c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt @@ -0,0 +1,286 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCommunityPostConcurrencyTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val transactionManager: PlatformTransactionManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @SpyBean + private lateinit var creatorCommunityRepository: CreatorCommunityRepository + + @Test + @DisplayName("순차 고정 요청에서 세 번째는 허용되고 네 번째는 최대 고정 수로 거부된다") + fun shouldAllowThirdFixedPostAndRejectFourthWhenRequestsAreSerialized() { + val character = createCharacter("community-fixed-count") + val owner = character.creatorMember!! + repeat(2) { index -> savePost(owner, "already fixed $index").fix() } + val thirdPost = savePost(owner, "third fixed") + val fourthPost = savePost(owner, "fourth fixed") + entityManager.flush() + Mockito.clearInvocations(amazonS3Client, applicationEventPublisher) + + mockMvc.perform(fixedRequest(character.id!!, thirdPost.id!!)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + assertTrue(reload(thirdPost.id!!).isFixed) + + mockMvc.perform(fixedRequest(character.id!!, fourthPost.id!!)) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("최대 3개까지 고정 가능합니다.")) + + assertFalse(reload(fourthPost.id!!).isFixed) + assertEquals(3L, fixedPostCount(owner.id!!)) + Mockito.verifyNoInteractions(amazonS3Client, applicationEventPublisher) + } + + @Test + @DisplayName("이미 고정된 세 번째 게시글의 재고정은 최대 고정 수를 다시 적용하지 않는다") + fun shouldAllowRefixingAlreadyFixedPostAtMaximumCount() { + val character = createCharacter("community-refix") + val owner = character.creatorMember!! + repeat(2) { index -> savePost(owner, "other fixed $index").fix() } + val fixedPost = savePost(owner, "already fixed").fix() + entityManager.flush() + Mockito.clearInvocations(amazonS3Client, applicationEventPublisher) + + mockMvc.perform(fixedRequest(character.id!!, fixedPost.id!!)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + + val reloadedPost = reload(fixedPost.id!!) + assertTrue(reloadedPost.isFixed) + assertNotNull(reloadedPost.fixedAt) + assertEquals(3L, fixedPostCount(owner.id!!)) + Mockito.verifyNoInteractions(amazonS3Client, applicationEventPublisher) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("병렬 고정 요청에서도 최대 3개 불변식을 유지한다") + fun shouldKeepMaximumThreeFixedPostsWhenRequestsRace() { + val transactionTemplate = TransactionTemplate(transactionManager) + val fixture = transactionTemplate.execute { + val character = createCharacter("community-fixed-race") + val owner = character.creatorMember!! + repeat(2) { index -> savePost(owner, "race fixed $index").fix() } + val firstPost = savePost(owner, "race first") + val secondPost = savePost(owner, "race second") + entityManager.flush() + FixedRaceFixture(character.id!!, owner.id!!, firstPost.id!!, secondPost.id!!) + } ?: throw IllegalStateException("fixture creation failed") + + val firstCountIntercepted = AtomicBoolean(false) + val firstCountRead = CountDownLatch(1) + val releaseFirstCount = CountDownLatch(1) + Mockito.doAnswer { invocation -> + if (invocation.arguments[0] == fixture.ownerId && firstCountIntercepted.compareAndSet(false, true)) { + firstCountRead.countDown() + releaseFirstCount.await(300, TimeUnit.MILLISECONDS) + } + fixedPostCount(fixture.ownerId) + }.`when`(creatorCommunityRepository).countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(fixture.ownerId) + + val executor = Executors.newFixedThreadPool(2) + try { + val first = executor.submit { + mockMvc.perform(fixedRequest(fixture.characterId, fixture.firstPostId)) + .andReturn() + .response + .status + } + assertTrue(firstCountRead.await(5, TimeUnit.SECONDS)) + + val second = executor.submit { + mockMvc.perform(fixedRequest(fixture.characterId, fixture.secondPostId)) + .andReturn() + .response + .status + } + + releaseFirstCount.countDown() + val statuses = listOf(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)) + assertEquals(listOf(200, 400), statuses.sorted()) + } finally { + releaseFirstCount.countDown() + executor.shutdownNow() + } + + assertEquals(3L, transactionTemplate.execute { fixedPostCount(fixture.ownerId) }) + } + + @Test + @DisplayName("invalid target와 cross-owner 고정 요청은 DB, S3, event 변경 없이 거부된다") + fun shouldRejectInvalidTargetAndCrossOwnerFixedRequestsWithoutSideEffects() { + val character = createCharacter("community-fixed-owner") + val otherCharacter = createCharacter("community-fixed-other-owner") + val ownPost = savePost(character.creatorMember!!, "own", imagePath = "creator_community/own.png") + val foreignPost = savePost(otherCharacter.creatorMember!!, "foreign", imagePath = "creator_community/foreign.png") + entityManager.flush() + val beforeCount = postCount() + Mockito.clearInvocations(amazonS3Client, applicationEventPublisher) + + mockMvc.perform(fixedRequest(999_999L, ownPost.id!!, pngFile())) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + + mockMvc.perform(fixedRequest(character.id!!, foreignPost.id!!, pngFile())) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + + assertEquals(beforeCount, postCount()) + assertFalse(reload(ownPost.id!!).isFixed) + assertFalse(reload(foreignPost.id!!).isFixed) + assertEquals("creator_community/own.png", reload(ownPost.id!!).imagePath) + assertEquals("creator_community/foreign.png", reload(foreignPost.id!!).imagePath) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun fixedRequest( + characterId: Long, + postId: Long, + postImage: MockMultipartFile? = null + ) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply { + file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(mapOf("isFixed" to true)) + ) + ) + postImage?.let(::file) + }.with(adminAuthentication()) + + private fun pngFile() = MockMultipartFile( + "postImage", + "post.png", + MediaType.IMAGE_PNG_VALUE, + byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + ) + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun savePost(owner: Member, content: String, imagePath: String? = null): CreatorCommunity { + return CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = true, + isAdult = false, + imagePath = imagePath + ).apply { + member = owner + entityManager.persist(this) + } + } + + private fun CreatorCommunity.fix(): CreatorCommunity { + isFixed = true + fixedAt = LocalDateTime.now() + return this + } + + private fun reload(postId: Long): CreatorCommunity { + entityManager.flush() + entityManager.clear() + return entityManager.find(CreatorCommunity::class.java, postId) + } + + private fun fixedPostCount(ownerId: Long): Long = entityManager.createQuery( + "select count(p) from CreatorCommunity p where p.member.id = :ownerId and p.isActive = true and p.isFixed = true", + java.lang.Long::class.java + ).setParameter("ownerId", ownerId).singleResult.toLong() + + private fun postCount(): Long = entityManager.createQuery( + "select count(p) from CreatorCommunity p", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private data class FixedRaceFixture( + val characterId: Long, + val ownerId: Long, + val firstPostId: Long, + val secondPostId: Long + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt new file mode 100644 index 00000000..101d1795 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostContractTest.kt @@ -0,0 +1,290 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.support.MissingServletRequestPartException +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCommunityPostContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("잘못된 target과 post는 공통 400 ApiResponse를 요청 언어로 반환한다") + fun shouldReturnLocalizedInvalidRequestForInvalidTargetAndPost(language: String, message: String) { + val character = createCharacter("community-contract-invalid") + val post = savePost(character.creatorMember!!, "unchanged") + entityManager.flush() + val beforeCount = countPosts() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/999999/community-posts") + .header("Accept-Language", language) + .with(adminAuthentication()) + ).andExpectApiError(400, message) + + mockMvc.perform( + updateRequest(character.id!!, 999998L, mapOf("content" to "unexpected")) + .header("Accept-Language", language) + ).andExpectApiError(400, message) + + assertEquals(beforeCount, countPosts()) + assertEquals("unchanged", reload(post.id!!).content) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,유료 게시글 등록을 위해서는 이미지가 필요합니다.,최대 3개까지 고정 가능합니다.", + "en,An image is required to post paid content.,You can pin up to 3 posts.", + "ja,有料投稿を登録するには画像が必要です。,固定できる投稿は最大3件までです。" + ) + @DisplayName("media 검증과 최대 고정 수 오류는 legacy message를 요청 언어로 반환한다") + fun shouldReturnLocalizedLegacyMediaAndFixedCountErrors( + language: String, + mediaMessage: String, + fixedCountMessage: String + ) { + val character = createCharacter("community-contract-legacy-errors") + val owner = character.creatorMember!! + repeat(3) { index -> + savePost(owner, "fixed $index").apply { + isFixed = true + fixedAt = LocalDateTime.now() + } + } + val candidate = savePost(owner, "candidate", imagePath = "creator_community/original.png") + entityManager.flush() + val beforeCount = countPosts() + + mockMvc.perform( + createRequest(character.id!!, price = 10) + .header("Accept-Language", language) + ).andExpectApiError(400, mediaMessage) + + mockMvc.perform( + updateRequest( + character.id!!, + candidate.id!!, + mapOf("isFixed" to true), + pngFile() + ).header("Accept-Language", language) + ).andExpectApiError(400, fixedCountMessage) + + assertEquals(beforeCount, countPosts()) + assertEquals("creator_community/original.png", reload(candidate.id!!).imagePath) + assertEquals(false, reload(candidate.id!!).isFixed) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("필수 multipart request part 누락은 공통 400 ApiResponse를 요청 언어로 반환한다") + fun shouldReturnLocalizedInvalidRequestForMissingMultipartRequestPart(language: String, message: String) { + val createResult = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/1/community-posts") + .header("Accept-Language", language) + .with(adminAuthentication()) + ) + createResult.andExpectApiError(400, message) + assertEquals(MissingServletRequestPartException::class.java, createResult.andReturn().resolvedException?.javaClass) + + val updateResult = mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/community-posts/1") + .header("Accept-Language", language) + .with(adminAuthentication()) + ) + updateResult.andExpectApiError(400, message) + assertEquals(MissingServletRequestPartException::class.java, updateResult.andReturn().resolvedException?.javaClass) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("HUMAN target과 다른 AI 캐릭터 게시글 mutation은 DB와 S3 변경 없이 거부한다") + fun shouldRejectHumanTargetAndCrossCharacterMutationWithoutDatabaseOrS3SideEffects() { + val humanCharacter = createCharacter("community-contract-human") + humanCharacter.creatorMember!!.memberKind = MemberKind.HUMAN + val character = createCharacter("community-contract-owner") + val otherCharacter = createCharacter("community-contract-other") + val foreignPost = savePost( + otherCharacter.creatorMember!!, + "foreign", + imagePath = "creator_community/foreign.png" + ) + entityManager.flush() + val beforeCount = countPosts() + + mockMvc.perform( + createRequest(humanCharacter.id!!, postImage = pngFile()) + .header("Accept-Language", "en") + ).andExpectApiError(400, "Invalid request.") + + mockMvc.perform( + updateRequest( + character.id!!, + foreignPost.id!!, + mapOf("content" to "unexpected"), + pngFile() + ).header("Accept-Language", "en") + ).andExpectApiError(400, "Invalid request.") + + assertEquals(beforeCount, countPosts()) + assertEquals("foreign", reload(foreignPost.id!!).content) + assertEquals("creator_community/foreign.png", reload(foreignPost.id!!).imagePath) + Mockito.verifyNoInteractions(amazonS3Client) + } + + private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) { + andExpect(status().`is`(httpStatus)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createRequest( + characterId: Long, + price: Int = 0, + postImage: MockMultipartFile? = null + ) = multipart("/api/v2/admin/ai-characters/$characterId/community-posts").apply { + file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes( + mapOf( + "content" to "community post", + "isCommentAvailable" to true, + "isAdult" to false, + "price" to price + ) + ) + ) + ) + postImage?.let(::file) + }.with(adminAuthentication()) + + private fun updateRequest( + characterId: Long, + postId: Long, + request: Map, + postImage: MockMultipartFile? = null + ) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply { + file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ) + ) + postImage?.let(::file) + }.with(adminAuthentication()) + + private fun pngFile() = MockMultipartFile( + "postImage", + "post.png", + MediaType.IMAGE_PNG_VALUE, + byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + ) + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun savePost(owner: Member, content: String, imagePath: String? = null): CreatorCommunity { + return CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = true, + isAdult = false, + imagePath = imagePath + ).apply { + member = owner + entityManager.persist(this) + } + } + + private fun reload(postId: Long): CreatorCommunity { + entityManager.flush() + entityManager.clear() + return entityManager.find(CreatorCommunity::class.java, postId) + } + + private fun countPosts(): Long = entityManager.createQuery( + "select count(p) from CreatorCommunity p", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt new file mode 100644 index 00000000..cb200f9f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt @@ -0,0 +1,426 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCommunityPostCreateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @Test + @DisplayName("정상 무료 게시글 생성은 target 소유자를 작성자로 사용하고 data null을 반환한다") + fun shouldCreateFreePostForTargetOwnerAndReturnNullData() { + val character = createCharacter("community-create-free") + val otherCharacter = createCharacter("community-create-other") + val ownerId = character.creatorMember!!.id + val content = "free post for target owner" + + mockMvc.perform( + createRequest(character.id!!, content = content) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(null as String?)) + .andExpect(jsonPath("$.data").value(null as String?)) + + val post = findPost(content) + assertNotNull(post) + assertEquals(ownerId, post!!.member!!.id) + assertTrue(post.member!!.id != otherCharacter.creatorMember!!.id) + } + + @Test + @DisplayName("생성 price 생략은 기본값 0으로 게시글을 생성한다") + fun shouldCreatePostWithDefaultPriceWhenPriceIsOmitted() { + val character = createCharacter("community-create-default-price") + val content = "default price post" + + mockMvc.perform( + createRawRequest( + character.id!!, + """{"content":"$content","isCommentAvailable":true,"isAdult":false}""" + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(0, findPost(content)!!.price) + } + + @Test + @DisplayName("이미지와 오디오가 있는 게시글 생성은 legacy media 업로드와 side effect를 사용한다") + fun shouldCreatePostWithImageAndAudioThroughLegacyMediaPath() { + val character = createCharacter("community-create-media") + val content = "media post" + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + createRequest( + character.id!!, + content = content, + postImage = MockMultipartFile( + "postImage", + "post.png", + "image/png", + byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + ), + audioFile = MockMultipartFile("audioFile", "post.m4a", "audio/mp4", byteArrayOf(4, 5, 6)) + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(null as String?)) + + val post = findPost(content)!! + assertNotNull(post.imagePath) + assertNotNull(post.audioPath) + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("유료 게시글의 이미지 누락은 legacy 오류 메시지로 400을 반환한다") + fun shouldRejectPaidPostWithoutImageWithLegacyErrorMessage() { + val character = createCharacter("community-create-paid-without-image") + val beforeCount = countPosts() + + mockMvc.perform( + createRequest(character.id!!, price = 10) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An image is required to post paid content.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("오디오 게시글의 이미지 누락은 legacy 오류 메시지로 400을 반환한다") + fun shouldRejectAudioPostWithoutImageWithLegacyErrorMessage() { + val character = createCharacter("community-create-audio-without-image") + val beforeCount = countPosts() + + mockMvc.perform( + createRequest( + character.id!!, + audioFile = MockMultipartFile("audioFile", "post.m4a", "audio/mp4", byteArrayOf(1)) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An image is required to upload audio.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("비활성 target은 게시글 생성과 모든 side effect 전에 400으로 거부한다") + fun shouldRejectInactiveTargetBeforePersistenceAndSideEffects() { + val character = createCharacter("community-create-inactive") + character.isActive = false + entityManager.flush() + val beforeCount = countPosts() + + mockMvc.perform(createRequest(character.id!!)) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("필수 request part 누락은 common.error.invalid_request 메시지로 400을 반환한다") + fun shouldRejectMissingRequestPart() { + val character = createCharacter("community-create-missing-request") + val beforeCount = countPosts() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/community-posts") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("생성 multipart의 정의되지 않은 part는 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects() { + val character = createCharacter("community-create-undefined-part") + val beforeCount = countPosts() + + mockMvc.perform( + createRequest( + character.id!!, + content = "undefined create part", + extraFile = MockMultipartFile("unexpected", "unexpected.txt", MediaType.TEXT_PLAIN_VALUE, byteArrayOf(1)) + ).header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성 multipart의 filename 없는 정의되지 않은 part는 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("community-create-filename-less-part-$language") + val beforeCount = countPosts() + + mockMvc.perform( + createRequest( + character.id!!, + content = "filename less create part $language", + extraPart = MockPart("unexpected", "unexpected".toByteArray()) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("malformed, 필수 field 누락, 미지 field 생성 request는 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectInvalidCreateJsonWithoutSideEffects() { + val character = createCharacter("community-create-invalid-json") + + listOf( + "{", + """{"isCommentAvailable":true,"isAdult":false,"price":0}""", + """{"content":"invalid json","isCommentAvailable":true,"isAdult":false,"price":0,"unexpected":true}""" + ).forEach { requestJson -> + val beforeCount = countPosts() + + mockMvc.perform( + createRawRequest(character.id!!, requestJson) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = createCharacter("community-create-request-media-type-$language") + val beforeCount = countPosts() + + mockMvc.perform( + createRawRequest( + character.id!!, + """{"content":"rejected media type","isCommentAvailable":true,"isAdult":false,"price":0}""", + requestContentType + ).header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "missing-isCommentAvailable", + "null-isCommentAvailable", + "missing-isAdult", + "null-isAdult", + "null-price" + ] + ) + @DisplayName("생성 primitive required/null 위반은 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectMissingOrNullPrimitiveCreateFieldsWithoutSideEffects(case: String) { + val character = createCharacter("community-create-primitive-$case") + val requestJson = when (case) { + "missing-isCommentAvailable" -> """{"content":"$case","isAdult":false,"price":0}""" + "null-isCommentAvailable" -> """{"content":"$case","isCommentAvailable":null,"isAdult":false,"price":0}""" + "missing-isAdult" -> """{"content":"$case","isCommentAvailable":true,"price":0}""" + "null-isAdult" -> """{"content":"$case","isCommentAvailable":true,"isAdult":null,"price":0}""" + "null-price" -> """{"content":"$case","isCommentAvailable":true,"isAdult":false,"price":null}""" + else -> error("unknown case") + } + val beforeCount = countPosts() + + mockMvc.perform( + createRawRequest(character.id!!, requestJson) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals(beforeCount, countPosts()) + Mockito.verifyNoInteractions(amazonS3Client) + } + + private fun createRequest( + characterId: Long, + content: String = "community post", + price: Int = 0, + postImage: MockMultipartFile? = null, + audioFile: MockMultipartFile? = null, + extraFile: MockMultipartFile? = null, + extraPart: MockPart? = null + ) = multipart("/api/v2/admin/ai-characters/$characterId/community-posts").apply { + file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes( + mapOf( + "content" to content, + "isCommentAvailable" to true, + "isAdult" to false, + "price" to price + ) + ) + ) + ) + postImage?.let(::file) + audioFile?.let(::file) + extraFile?.let(::file) + extraPart?.let { part(it) } + }.with(adminAuthentication()) + + private fun createRawRequest( + characterId: Long, + requestJson: String, + requestContentType: String? = MediaType.APPLICATION_JSON_VALUE + ) = multipart("/api/v2/admin/ai-characters/$characterId/community-posts") + .file( + MockMultipartFile( + "request", + "request.json", + requestContentType, + requestJson.toByteArray() + ) + ) + .with(adminAuthentication()) + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun findPost(content: String): CreatorCommunity? = entityManager.createQuery( + "select p from CreatorCommunity p where p.content = :content", + CreatorCommunity::class.java + ).setParameter("content", content).resultList.firstOrNull() + + private fun countPosts(): Long = entityManager.createQuery( + "select count(p) from CreatorCommunity p", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + kr.co.vividnext.sodalive.member.Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt new file mode 100644 index 00000000..81c80599 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostQueryTest.kt @@ -0,0 +1,290 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCommunityPostQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @Test + @DisplayName("게시글 목록은 timezone 없이 target 소유 활성 게시글을 pagination wrapper로 반환한다") + fun shouldReturnOnlyActiveOwnerPostsWithLegacyShapeAndOwnerAccess() { + val character = createCharacter("community-list-character") + val owner = character.creatorMember!! + owner.profileImage = "profiles/community-list.png" + val otherCharacter = createCharacter("community-list-other-character") + val normal = savePost(owner = owner, content = "normal post") + val fixedOld = savePost(owner = owner, content = "fixed old") + val fixedNew = savePost( + owner = owner, + content = "full paid owner content", + price = 100, + isAdult = true, + audioPath = "private/community-owner.m4a", + imagePath = "community/owner.png" + ) + savePost(owner = owner, content = "inactive post", isActive = false) + savePost(owner = otherCharacter.creatorMember!!, content = "foreign post") + fixedOld.isFixed = true + fixedOld.fixedAt = LocalDateTime.of(2026, 7, 27, 9, 0) + fixedNew.isFixed = true + fixedNew.fixedAt = LocalDateTime.of(2026, 7, 28, 9, 0) + entityManager.persist( + CreatorCommunityLike().apply { + member = owner + creatorCommunity = fixedNew + } + ) + entityManager.persist( + CreatorCommunityComment("owner comment").apply { + member = owner + creatorCommunity = fixedNew + } + ) + entityManager.flush() + entityManager.clear() + Mockito.`when`(audioContentCloudFront.generateSignedURL("private/community-owner.m4a", 1_800_000L)) + .thenReturn("https://signed.example.com/community-owner.m4a?Expires=1") + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .param("page", "0") + .param("size", "2") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + val items = data.path("items") + val item = items.path(0) + assertEquals(3, data.path("totalCount").asLong()) + assertEquals(0, data.path("page").asInt()) + assertEquals(2, data.path("size").asInt()) + assertTrue(data.path("hasNext").asBoolean()) + assertTrue(items.isArray) + assertEquals(listOf(fixedNew.id, fixedOld.id), items.map { it.path("postId").asLong() }) + assertEquals( + setOf( + "postId", + "creatorId", + "creatorNickname", + "creatorProfileUrl", + "imageUrl", + "audioUrl", + "content", + "price", + "date", + "dateUtc", + "isCommentAvailable", + "isAdult", + "isFixed", + "isLike", + "existOrdered", + "likeCount", + "commentCount", + "firstComment" + ), + item.fieldNames().asSequence().toSet() + ) + assertEquals("full paid owner content", item.path("content").asText()) + assertEquals("https://test.cloudfront.net/profiles/community-list.png", item.path("creatorProfileUrl").asText()) + assertEquals("https://test.cloudfront.net/community/owner.png", item.path("imageUrl").asText()) + assertEquals("https://signed.example.com/community-owner.m4a?Expires=1", item.path("audioUrl").asText()) + assertTrue(item.path("isAdult").asBoolean()) + assertTrue(item.path("existOrdered").asBoolean()) + assertTrue(!item.path("isLike").asBoolean()) + assertEquals(1, item.path("likeCount").asInt()) + assertEquals(1, item.path("commentCount").asInt()) + assertTrue(item.path("firstComment").isNull) + Mockito.verify(audioContentCloudFront).generateSignedURL("private/community-owner.m4a", 1_800_000L) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .param("page", "1") + .param("size", "2") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(2)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].postId").value(normal.id)) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .param("page", "2") + .param("size", "2") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.page").value(2)) + .andExpect(jsonPath("$.data.size").value(2)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.items.length()").value(0)) + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 게시글 목록을 400으로 거부한다") + fun shouldRejectInactiveTarget() { + val character = createCharacter("inactive-community-target") + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("게시글 목록은 음수 page와 1 미만 size만 400으로 거부하고 size 51은 허용한다") + fun shouldRejectInvalidPagination() { + val character = createCharacter("invalid-community-pagination") + savePost(character.creatorMember!!, "size 51 allowed") + entityManager.flush() + entityManager.clear() + + listOf("-1" to "20", "0" to "0").forEach { (page, size) -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .param("page", page) + .param("size", size) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .param("page", "0") + .param("size", "51") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(51)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + } + + @Test + @DisplayName("게시글 목록은 page와 size 기본값으로 timezone 없이 조회된다") + fun shouldUseDefaultPaginationWithoutTimezone() { + val character = createCharacter("required-community-timezone") + savePost(character.creatorMember!!, "default pagination") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/community-posts") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun savePost( + owner: Member, + content: String, + price: Int = 0, + isAdult: Boolean = false, + isActive: Boolean = true, + audioPath: String? = null, + imagePath: String? = null + ): CreatorCommunity { + return CreatorCommunity( + content = content, + price = price, + isCommentAvailable = true, + isAdult = isAdult, + audioPath = audioPath, + imagePath = imagePath, + isActive = isActive + ).apply { + member = owner + entityManager.persist(this) + } + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt new file mode 100644 index 00000000..571e02a2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt @@ -0,0 +1,498 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminCommunityPostUpdateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @Test + @DisplayName("게시글 수정은 content, 댓글 허용, 성인 여부를 legacy 수정 경로로 반영한다") + fun shouldUpdateContentCommentAvailabilityAndAdultStatus() { + val character = createCharacter("community-update-fields") + val post = savePost(character.creatorMember!!, "before", isCommentAvailable = true, isAdult = false).apply { + isFixed = true + fixedAt = LocalDateTime.now() + } + entityManager.flush() + + mockMvc.perform( + updateRequest( + character.id!!, + post.id!!, + mapOf( + "content" to "after", + "isCommentAvailable" to false, + "isAdult" to true + ) + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(null as String?)) + + val updated = reload(post.id!!) + assertEquals("after", updated.content) + assertFalse(updated.isCommentAvailable) + assertTrue(updated.isAdult) + assertTrue(updated.isFixed) + assertNotNull(updated.fixedAt) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("게시글 수정은 새 postImage를 legacy 업로드 경로로 교체한다") + fun shouldReplacePostImageThroughLegacyUploadPath() { + val character = createCharacter("community-update-image") + val post = savePost(character.creatorMember!!, "image", imagePath = "creator_community/old.png") + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/updated")) + + mockMvc.perform( + updateRequest( + character.id!!, + post.id!!, + mapOf(), + MockMultipartFile( + "postImage", + "updated.png", + MediaType.IMAGE_PNG_VALUE, + byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + ) + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertTrue(reload(post.id!!).imagePath!!.startsWith("creator_community/${post.id}/")) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("최대 고정 수 초과는 postImage 업로드와 imagePath 변경 전에 거부한다") + fun shouldRejectFixedPostLimitBeforeUploadingPostImage() { + val character = createCharacter("community-update-fixed-limit") + val owner = character.creatorMember!! + repeat(3) { index -> + savePost(owner, "already fixed $index").apply { + isFixed = true + fixedAt = LocalDateTime.now() + } + } + val post = savePost(owner, "unfixed", imagePath = "creator_community/original.png") + val originalImagePath = post.imagePath + entityManager.flush() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/should-not-upload")) + + mockMvc.perform( + updateRequest( + character.id!!, + post.id!!, + mapOf("isFixed" to true), + MockMultipartFile( + "postImage", + "updated.png", + MediaType.IMAGE_PNG_VALUE, + byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + ) + ) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("최대 3개까지 고정 가능합니다.")) + + assertEquals(originalImagePath, reload(post.id!!).imagePath) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("게시글 수정은 isFixed true와 false를 legacy 고정 경로로 반영한다") + fun shouldFixAndUnfixPost() { + val character = createCharacter("community-update-fixed") + val post = savePost(character.creatorMember!!, "fixed") + + mockMvc.perform(updateRequest(character.id!!, post.id!!, mapOf("isFixed" to true))) + .andExpect(status().isOk) + + val fixed = reload(post.id!!) + assertTrue(fixed.isFixed) + assertNotNull(fixed.fixedAt) + + mockMvc.perform(updateRequest(character.id!!, post.id!!, mapOf("isFixed" to false))) + .andExpect(status().isOk) + + val unfixed = reload(post.id!!) + assertFalse(unfixed.isFixed) + assertNull(unfixed.fixedAt) + } + + @Test + @DisplayName("soft delete는 같은 transaction에서 고정 상태와 fixedAt을 함께 해제한다") + fun shouldClearFixedStateAndFixedAtWhenSoftDeletingPost() { + val character = createCharacter("community-update-soft-delete") + val post = savePost(character.creatorMember!!, "delete").apply { + isFixed = true + fixedAt = LocalDateTime.now() + } + entityManager.flush() + + mockMvc.perform( + updateRequest(character.id!!, post.id!!, mapOf("isActive" to false, "isFixed" to true)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(null as String?)) + + val deleted = reload(post.id!!) + assertFalse(deleted.isActive) + assertFalse(deleted.isFixed) + assertNull(deleted.fixedAt) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("missing target와 post, cross-owner, inactive target와 post는 DB와 S3 변경 없이 거부한다") + fun shouldRejectInvalidMutationTargetsWithoutDatabaseOrS3SideEffects() { + val character = createCharacter("community-update-invalid") + val otherCharacter = createCharacter("community-update-other") + val ownPost = savePost(character.creatorMember!!, "own post") + val foreignPost = savePost(otherCharacter.creatorMember!!, "foreign post") + val inactivePost = savePost(character.creatorMember!!, "inactive post", isActive = false) + val inactiveCharacter = createCharacter("community-update-inactive-target").apply { isActive = false } + entityManager.flush() + val beforeCount = countPosts() + + listOf( + updateRequest(999_999L, ownPost.id!!, mapOf("content" to "missing target")), + updateRequest(inactiveCharacter.id!!, ownPost.id!!, mapOf("content" to "inactive target")), + updateRequest(character.id!!, 999_998L, mapOf("content" to "missing post")), + updateRequest(character.id!!, foreignPost.id!!, mapOf("content" to "cross owner")), + updateRequest(character.id!!, inactivePost.id!!, mapOf("content" to "inactive post")) + ).forEach { request -> + mockMvc.perform(request) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect(jsonPath("$.data").value(null as String?)) + } + + assertEquals(beforeCount, countPosts()) + assertEquals("own post", reload(ownPost.id!!).content) + assertEquals("foreign post", reload(foreignPost.id!!).content) + assertEquals("inactive post", reload(inactivePost.id!!).content) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("필수 request part 누락은 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectMissingRequestPartWithoutSideEffects() { + val character = createCharacter("community-update-missing-request") + val post = savePost(character.creatorMember!!, "unchanged") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/community-posts/${post.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals("unchanged", reload(post.id!!).content) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource("unexpected,unexpected.txt", "audioFile,post.m4a") + @DisplayName("수정 multipart의 정의되지 않은 part와 audioFile은 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects(partName: String, fileName: String) { + val character = createCharacter("community-update-undefined-part-$partName") + val post = savePost(character.creatorMember!!, "unchanged", imagePath = "creator_community/original.png") + val originalImagePath = post.imagePath + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + updateRequest( + character.id!!, + post.id!!, + mapOf("content" to "changed"), + extraFile = MockMultipartFile(partName, fileName, MediaType.TEXT_PLAIN_VALUE, byteArrayOf(1)) + ).header(HttpHeaders.ACCEPT_LANGUAGE, "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + val unchanged = reload(post.id!!) + assertEquals("unchanged", unchanged.content) + assertEquals(originalImagePath, unchanged.imagePath) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정 multipart의 filename 없는 정의되지 않은 part는 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("community-update-filename-less-part-$language") + val post = savePost(character.creatorMember!!, "unchanged", imagePath = "creator_community/original.png") + val originalImagePath = post.imagePath + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + updateRequest( + character.id!!, + post.id!!, + mapOf("content" to "changed"), + extraPart = MockPart("unexpected", "unexpected".toByteArray()) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(null as String?)) + + val unchanged = reload(post.id!!) + assertEquals("unchanged", unchanged.content) + assertEquals(originalImagePath, unchanged.imagePath) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("malformed, 미지 field 수정 request는 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectInvalidUpdateJsonWithoutSideEffects() { + val character = createCharacter("community-update-invalid-json") + val post = savePost(character.creatorMember!!, "unchanged") + entityManager.flush() + entityManager.clear() + + listOf( + "{", + """{"content":"changed","isCommentAvailable":false,"isAdult":true,"unexpected":true}""" + ).forEach { requestJson -> + mockMvc.perform( + updateRawRequest(character.id!!, post.id!!, requestJson) + .header("Accept-Language", "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + assertEquals("unchanged", reload(post.id!!).content) + Mockito.verifyNoInteractions(amazonS3Client) + } + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = createCharacter("community-update-request-media-type-$language") + val post = savePost(character.creatorMember!!, "unchanged") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + updateRawRequest( + character.id!!, + post.id!!, + """{"content":"rejected media type"}""", + requestContentType + ).header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertEquals("unchanged", reload(post.id!!).content) + Mockito.verifyNoInteractions(amazonS3Client) + } + + @Test + @DisplayName("수정 isFixed null은 DB와 S3 변경 없이 400으로 거부한다") + fun shouldRejectNullIsFixedUpdateWithoutSideEffects() { + val character = createCharacter("community-update-null-fixed") + val post = savePost(character.creatorMember!!, "unchanged", imagePath = "creator_community/original.png") + val originalImagePath = post.imagePath + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + updateRawRequest(character.id!!, post.id!!, """{"isFixed":null}""") + .header("Accept-Language", "en") + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(null as String?)) + + val unchanged = reload(post.id!!) + assertEquals("unchanged", unchanged.content) + assertEquals(originalImagePath, unchanged.imagePath) + assertFalse(unchanged.isFixed) + Mockito.verifyNoInteractions(amazonS3Client) + } + + private fun updateRequest( + characterId: Long, + postId: Long, + request: Map, + postImage: MockMultipartFile? = null, + extraFile: MockMultipartFile? = null, + extraPart: MockPart? = null + ) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply { + file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(request) + ) + ) + postImage?.let(::file) + extraFile?.let(::file) + extraPart?.let { part(it) } + }.with(adminAuthentication()) + + private fun updateRawRequest( + characterId: Long, + postId: Long, + requestJson: String, + requestContentType: String? = MediaType.APPLICATION_JSON_VALUE + ) = multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/$characterId/community-posts/$postId").apply { + file( + MockMultipartFile( + "request", + "request.json", + requestContentType, + requestJson.toByteArray() + ) + ) + }.with(adminAuthentication()) + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun savePost( + owner: Member, + content: String, + isCommentAvailable: Boolean = true, + isAdult: Boolean = false, + isActive: Boolean = true, + imagePath: String? = null + ): CreatorCommunity { + return CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = isCommentAvailable, + isAdult = isAdult, + imagePath = imagePath, + isActive = isActive + ).apply { + member = owner + entityManager.persist(this) + } + } + + private fun reload(postId: Long): CreatorCommunity { + entityManager.flush() + entityManager.clear() + return entityManager.find(CreatorCommunity::class.java, postId) + } + + private fun countPosts(): Long = entityManager.createQuery( + "select count(p) from CreatorCommunity p", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt new file mode 100644 index 00000000..7eaaad48 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt @@ -0,0 +1,242 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.UpdateCommunityPostFixedRequest +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository +import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import org.springframework.web.multipart.MultipartFile +import java.io.InputStream +import java.time.LocalDateTime + +class LegacyCommunityPostCharacterizationTest { + private lateinit var repository: CreatorCommunityRepository + private lateinit var s3Uploader: S3Uploader + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService + private lateinit var service: CreatorCommunityService + + @BeforeEach + fun setUp() { + repository = Mockito.mock(CreatorCommunityRepository::class.java) + s3Uploader = Mockito.mock(S3Uploader::class.java) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) + service = CreatorCommunityService( + canPaymentService = Mockito.mock(CanPaymentService::class.java), + repository = repository, + blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java), + likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java), + commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java), + useCanRepository = Mockito.mock(UseCanRepository::class.java), + s3Uploader = s3Uploader, + objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()), + audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), + applicationEventPublisher = applicationEventPublisher, + messageSource = SodaMessageSource(), + langContext = LangContext(), + homeFollowingNewsPublishService = homeFollowingNewsPublishService, + imageBucket = "image-bucket", + contentBucket = "content-bucket", + imageHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("기존 커뮤니티 생성은 유료 또는 오디오 게시글에 이미지를 요구한다") + fun shouldRequireImageForPaidOrAudioCommunityPost() { + val creator = createMember(1L, "community-validation-owner") + + val paidException = assertThrows(SodaException::class.java) { + service.createCommunityPost( + audioFile = null, + postImage = null, + requestString = createRequest(price = 10), + member = creator + ) + } + val audioException = assertThrows(SodaException::class.java) { + service.createCommunityPost( + audioFile = audioFile(), + postImage = null, + requestString = createRequest(price = 0), + member = creator + ) + } + + assertEquals("creator.community.paid_post_image_required", paidException.messageKey) + assertEquals("creator.community.audio_post_image_required", audioException.messageKey) + Mockito.verify(repository, Mockito.never()).save(Mockito.any(CreatorCommunity::class.java)) + Mockito.verifyNoInteractions(s3Uploader, applicationEventPublisher, homeFollowingNewsPublishService) + } + + @Test + @DisplayName("기존 커뮤니티 생성은 이미지와 오디오를 업로드하고 알림과 무료 최근 소식을 발행한다") + fun shouldUploadMediaAndPublishNotificationAndFreeRecentNews() { + val creator = createMember(2L, "community-create-owner") + creator.profileImage = "profile/community-create-owner.png" + val createdAt = LocalDateTime.of(2026, 7, 28, 9, 0) + Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { post -> + post.id = 1001L + post.createdAt = createdAt + } + } + Mockito.`when`( + s3Uploader.upload(anyInputStream(), anyStringValue(), anyStringValue(), anyObjectMetadata()) + ).thenReturn("creator_community/1001/image.png", "creator_community/1001/audio.m4a") + + service.createCommunityPost( + audioFile = audioFile(), + postImage = imageFile(), + requestString = createRequest(content = "free community post", price = 0, isAdult = true), + member = creator + ) + + val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value + assertEquals(FcmEventType.CHANGE_NOTICE, event.type) + assertEquals(PushNotificationCategory.COMMUNITY, event.category) + assertEquals("creator.community.fcm.new_post", event.messageKey) + assertEquals(creator.id, event.creatorId) + assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue) + assertEquals(creator.id, event.deepLinkId) + assertEquals(1001L, event.deepLinkCommentPostId) + Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated( + postId = 1001L, + creatorId = creator.id!!, + creatorNickname = creator.nickname, + creatorProfileImagePath = creator.profileImage, + title = "free community post", + body = "free community post", + thumbnailImagePath = "creator_community/1001/image.png", + occurredAtUtc = createdAt, + isAdult = true + ) + } + + @Test + @DisplayName("기존 커뮤니티 생성은 유료 게시글의 최근 소식을 발행하지 않는다") + fun shouldSkipRecentNewsForPaidCommunityPost() { + val creator = createMember(3L, "community-paid-owner") + Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { post -> post.id = 1002L } + } + Mockito.`when`( + s3Uploader.upload(anyInputStream(), anyStringValue(), anyStringValue(), anyObjectMetadata()) + ).thenReturn("creator_community/1002/image.png") + + service.createCommunityPost( + audioFile = null, + postImage = imageFile(), + requestString = createRequest(content = "paid community post", price = 10), + member = creator + ) + + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(FcmEvent::class.java)) + Mockito.verifyNoInteractions(homeFollowingNewsPublishService) + } + + @Test + @DisplayName("기존 커뮤니티 고정은 최대 3개를 유지하고 soft delete는 고정 상태를 해제한다") + fun shouldKeepFixedLimitAndClearFixedStateOnSoftDelete() { + val creator = createMember(4L, "community-fixed-owner") + val post = CreatorCommunity("fixed post", 0, true, false) + post.id = 1003L + post.member = creator + post.isFixed = true + post.fixedAt = LocalDateTime.of(2026, 7, 28, 10, 0) + Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post) + Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L) + + service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(post.id!!, isFixed = true), creator) + service.modifyCommunityPost( + postImage = null, + requestString = """{"creatorCommunityId":${post.id},"isActive":false}""", + member = creator + ) + + assertFalse(post.isActive) + assertFalse(post.isFixed) + assertNull(post.fixedAt) + + val otherPost = CreatorCommunity("other fixed post", 0, true, false) + otherPost.id = 1004L + otherPost.member = creator + Mockito.`when`(repository.findByIdAndMemberId(otherPost.id!!, creator.id!!)).thenReturn(otherPost) + val exception = assertThrows(SodaException::class.java) { + service.updateCommunityPostFixed(UpdateCommunityPostFixedRequest(otherPost.id!!, isFixed = true), creator) + } + assertEquals("creator.community.max_fixed_post_count", exception.messageKey) + } + + private fun createRequest( + content: String = "community post", + price: Int, + isAdult: Boolean = false + ): String { + return """{"content":"$content","price":$price,"isCommentAvailable":true,"isAdult":$isAdult}""" + } + + private fun imageFile(): MultipartFile { + val pngBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + return Mockito.mock(MultipartFile::class.java).also { file -> + Mockito.`when`(file.bytes).thenReturn(pngBytes) + Mockito.`when`(file.size).thenReturn(pngBytes.size.toLong()) + Mockito.`when`(file.inputStream).thenReturn(pngBytes.inputStream()) + } + } + + private fun audioFile(): MultipartFile { + val bytes = byteArrayOf(1, 2, 3) + return Mockito.mock(MultipartFile::class.java).also { file -> + Mockito.`when`(file.size).thenReturn(bytes.size.toLong()) + Mockito.`when`(file.inputStream).thenReturn(bytes.inputStream()) + } + } + + private fun createMember(id: Long, nickname: String): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = MemberRole.CREATOR + ).also { it.id = id } + } + + private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream() + + private fun anyObjectMetadata(): ObjectMetadata = Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata() + + private fun anyStringValue(): String = Mockito.anyString() ?: "" +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt new file mode 100644 index 00000000..e3fe921b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCommentTest.kt @@ -0,0 +1,387 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentCommentTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @Test + @DisplayName("원댓글과 답글 목록은 timezone 없이 UTC Z date와 기존 totalCount 및 items를 반환한다") + fun shouldListRootCommentsAndRepliesWithUtcDates() { + registerMysqlDateFunctions() + val character = createCharacter("comment-list") + val content = saveAudioContent(character.creatorMember!!, "comment-list-content") + val root = saveComment(content, character.creatorMember!!, "root") + val reply = saveComment(content, character.creatorMember!!, "reply", root) + root.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 1) + reply.createdAt = LocalDateTime.of(2027, 7, 30, 10, 0, 2) + entityManager.flush() + + mockMvc.perform( + get(commentsPath(character.id!!, content.id!!)) + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(root.id)) + .andExpect(jsonPath("$.data.items[0].comment").value("root")) + .andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:01Z")) + + mockMvc.perform( + get("${commentsPath(character.id!!, content.id!!)}/${root.id}/replies") + .param("page", "0") + .param("size", "10") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].comment").value("reply")) + .andExpect(jsonPath("$.data.items[0].date").value("2027-07-30T10:00:02Z")) + } + + @Test + @DisplayName("댓글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다") + fun shouldDefaultMissingCommentListPaginationParameters() { + registerMysqlDateFunctions() + val character = createCharacter("comment-default-page") + val content = saveAudioContent(character.creatorMember!!, "comment-default-page-content") + repeat(21) { index -> + saveComment(content, character.creatorMember!!, "root-$index") + } + entityManager.flush() + + val path = commentsPath(character.id!!, content.id!!) + mockMvc.perform(get(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(21)) + .andExpect(jsonPath("$.data.items.length()").value(20)) + + mockMvc.perform(get(path).param("page", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + + mockMvc.perform(get(path).param("size", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + } + + @Test + @DisplayName("답글 목록은 누락한 page 또는 size에 OpenAPI 기본값을 적용한다") + fun shouldDefaultMissingReplyListPaginationParameters() { + registerMysqlDateFunctions() + val character = createCharacter("reply-default-page") + val content = saveAudioContent(character.creatorMember!!, "reply-default-page-content") + val root = saveComment(content, character.creatorMember!!, "root") + repeat(21) { index -> + saveComment(content, character.creatorMember!!, "reply-$index", root) + } + entityManager.flush() + + val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}/replies" + mockMvc.perform(get(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(21)) + .andExpect(jsonPath("$.data.items.length()").value(20)) + + mockMvc.perform(get(path).param("page", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + + mockMvc.perform(get(path).param("size", "1").with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + } + + @Test + @DisplayName("작성은 target AI를 writer로 사용하고 root와 답글 모두 data null을 반환한다") + fun shouldCreateRootAndReplyAsTargetAi() { + val character = createCharacter("comment-create") + val content = saveAudioContent(character.creatorMember!!, "comment-create-content") + entityManager.flush() + + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"root", "languageCode":"ko"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val root = commentsFor(content.id!!).single() + assertEquals(character.creatorMember!!.id, root.member!!.id) + + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"reply", "parentId":${root.id}, "isSecret":false, "languageCode":"ko"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val reply = commentsFor(content.id!!).single { it.parent?.id == root.id } + assertEquals(character.creatorMember!!.id, reply.member!!.id) + } + + @Test + @DisplayName("다른 콘텐츠 또는 비활성 parent 답글 작성은 insert 없이 거부한다") + fun shouldRejectInvalidReplyParentWithoutInsert() { + val character = createCharacter("comment-parent") + val content = saveAudioContent(character.creatorMember!!, "comment-parent-content") + val otherContent = saveAudioContent(character.creatorMember!!, "comment-other-content") + val foreignParent = saveComment(otherContent, character.creatorMember!!, "other root") + val inactiveParent = saveComment(content, character.creatorMember!!, "inactive root").apply { isActive = false } + entityManager.flush() + + val beforeCount = commentsFor(content.id!!).size + listOf(foreignParent.id!!, inactiveParent.id!!).forEach { parentId -> + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"must not persist", "parentId":$parentId}""") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + assertEquals(beforeCount, commentsFor(content.id!!).size) + } + + @Test + @DisplayName("target AI가 작성하지 않은 활성 댓글 수정은 거부하고 원문을 유지한다") + fun shouldRejectFanAuthoredCommentUpdate() { + val character = createCharacter("comment-update") + val content = saveAudioContent(character.creatorMember!!, "comment-update-content") + val fanComment = saveComment(content, saveMember("comment-update-fan"), "fan comment") + entityManager.flush() + + mockMvc.perform( + put("${commentsPath(character.id!!, content.id!!)}/${fanComment.id}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"changed"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals("fan comment", entityManager.find(AudioContentComment::class.java, fanComment.id).comment) + } + + @Test + @DisplayName("target AI가 작성한 활성 댓글 수정은 data null로 완료한다") + fun shouldUpdateTargetAiAuthoredComment() { + val character = createCharacter("comment-update-target") + val content = saveAudioContent(character.creatorMember!!, "comment-update-target-content") + val comment = saveComment(content, character.creatorMember!!, "before") + entityManager.flush() + + mockMvc.perform( + put("${commentsPath(character.id!!, content.id!!)}/${comment.id}") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"comment":"after"}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + assertEquals("after", entityManager.find(AudioContentComment::class.java, comment.id).comment) + } + + @Test + @DisplayName("삭제는 대상 콘텐츠의 한 row만 비활성화하고 이미 비활성이면 성공 no-op이다") + fun shouldSoftDeleteOnlyTargetRowAndIgnoreInactiveComment() { + val character = createCharacter("comment-delete") + val content = saveAudioContent(character.creatorMember!!, "comment-delete-content") + val fan = saveMember("comment-delete-fan") + val root = saveComment(content, fan, "root") + val reply = saveComment(content, fan, "reply", root) + entityManager.flush() + + val path = "${commentsPath(character.id!!, content.id!!)}/${root.id}" + mockMvc.perform(delete(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertFalse(entityManager.find(AudioContentComment::class.java, root.id).isActive) + assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive) + + mockMvc.perform(delete(path).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(entityManager.find(AudioContentComment::class.java, reply.id).isActive) + } + + @Test + @DisplayName("잘못된 요청은 공통 400 envelope으로 변환한다") + fun shouldRejectInvalidCommentRequests() { + val character = createCharacter("comment-request") + val content = saveAudioContent(character.creatorMember!!, "comment-request-content") + entityManager.flush() + + listOf("{}", "{\"comment\":", "{\"comment\":\"comment\",\"unknown\":true}").forEach { body -> + mockMvc.perform( + post(commentsPath(character.id!!, content.id!!)) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + mockMvc.perform( + get(commentsPath(character.id!!, content.id!!)) + .param("page", "-1") + .param("size", "0") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + mockMvc.perform( + get(commentsPath(character.id!!, content.id!!)) + .param("timezone", "UTC") + .param("page", "0") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-$name-character", + name = name, + description = "description", + systemPrompt = "prompt" + ).also { + it.creatorMember!!.profileImage = "profile/$name.png" + } + + private fun saveAudioContent(owner: Member, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = 100, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + entityManager.persist(this) + } + } + + private fun saveComment( + content: AudioContent, + writer: Member, + comment: String, + parent: AudioContentComment? = null + ): AudioContentComment { + return AudioContentComment(comment = comment, languageCode = "ko").apply { + audioContent = content + member = writer + this.parent = parent + entityManager.persist(this) + } + } + + private fun saveMember(nickname: String): Member { + return Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.USER + ).apply(entityManager::persist) + } + + private fun commentsFor(contentId: Long): List { + entityManager.flush() + return entityManager.createQuery( + "select comment from AudioContentComment comment where comment.audioContent.id = :contentId", + AudioContentComment::class.java + ).setParameter("contentId", contentId).resultList + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + + private fun commentsPath(characterId: Long, contentId: Long): String { + return "/api/v2/admin/ai-characters/$characterId/audio-contents/$contentId/comments" + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt new file mode 100644 index 00000000..7b146d31 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt @@ -0,0 +1,1031 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val audioContentService: AudioContentService, + private val creatorAdminContentService: CreatorAdminContentService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublishers() { + registerMysqlDateFunctions() + originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher) + originalCreatorAdminContentServicePublisher = replacePublisher( + creatorAdminContentService, + applicationEventPublisher + ) + } + + @AfterEach + fun restoreActualServicePublishers() { + replacePublisher(audioContentService, originalAudioContentServicePublisher) + replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher) + } + + @Test + @DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다") + fun shouldCreateOwnedContentWithUtcMultipartRequest() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-character", + name = "v2-audio-create-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/test")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"created audio","detail":"created detail","tags":"calm #night","price":100,"purchaseOption":"RENT_ONLY","limited":7,"releaseDate":"2027-07-30T10:00:00Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":true,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"en"} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.contentId").isNumber) + .andExpect(jsonPath("$.data.title").doesNotExist()) + } + + @Test + @DisplayName("수정은 text/plain request part를 415와 부작용 없음으로 거부한다") + fun shouldRejectTextPlainRequestPartBeforeSideEffects() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-series-update-character", + name = "v2-audio-series-update-character", + description = "description", + systemPrompt = "prompt" + ) + val oldSeries = saveSeries("old-series", character.creatorMember!!) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "series update title", + contentPath = "private/series-update.mp3" + ) + saveSeriesContent(oldSeries, content) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.TEXT_PLAIN_VALUE, + """{"title":"series update title","detail":"updated detail"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("series update title", entityManager.find(AudioContent::class.java, content.id).title) + assertEquals(oldSeries.id, linkedSeriesId(content.id!!)) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 계약에 없는 다른 캐릭터 소유 seriesIds를 S3 업로드 전 거부한다") + fun shouldRejectCreateWithUnsupportedSeriesIdsBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-series-target", + name = "v2-audio-create-series-target", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-series-other", + name = "v2-audio-create-series-other", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-invalid-series-theme", image = "theme.png") + entityManager.persist(theme) + val otherSeries = saveSeries("other-series", otherCharacter.creatorMember!!) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"invalid series audio","detail":"created detail","tags":"","price":100,"themeId":${theme.id},"seriesIds":[${otherSeries.id}]} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @Test + @DisplayName("생성은 레거시 계약에 없는 isActive 필드를 거부한다") + fun shouldRejectCreateWithUnsupportedIsActiveField() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-inactive-character", + name = "v2-audio-create-inactive-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-inactive-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"inactive audio","detail":"created detail","tags":"","price":100,"isActive":false,"themeId":${theme.id}} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("생성의 기존 콘텐츠 검증 오류는 invalid request envelope으로 반환한다") + fun shouldMapCreateValidationErrorToBadRequestEnvelope() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-validation-character", + name = "v2-audio-create-validation-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = AudioContentTheme(theme = "create-validation-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"invalid price audio","detail":"created detail","tags":"","price":1,"themeId":${theme.id}} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("수정은 soft delete와 예약일 재설정을 같은 요청에서 거부한다") + fun shouldRejectSoftDeleteWithReleaseDateUtc() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-soft-delete-release-character", + name = "v2-audio-soft-delete-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "soft delete release title", + contentPath = "private/soft-delete-release.mp3" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"isActive":false,"releaseDateUtc":"2026-07-25T00:00:00Z"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(true, entityManager.find(AudioContent::class.java, content.id).isActive) + } + + @Test + @DisplayName("AI 캐릭터 콘텐츠 목록은 search_word와 레거시 signed URL item을 반환한다") + fun shouldListTargetContentsWithLegacySearchAndSignedUrl() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-character", + name = "v2-audio-list-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "target night walk", + contentPath = "private/target-night-walk.mp3", + price = 0 + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-other-character", + name = "v2-audio-other-character", + description = "description", + systemPrompt = "prompt" + ) + saveAudioContent( + owner = otherCharacter.creatorMember!!, + title = "other night walk", + contentPath = "private/other-night-walk.mp3" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "scheduled night walk", + contentPath = "private/scheduled-night-walk.mp3", + releaseDate = Instant.now().plusSeconds(86_400).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("search_word", "target night") + .param("page", "0") + .param("size", "100") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id)) + .andExpect(jsonPath("$.data.items[0].title").value("target night walk")) + .andExpect(jsonPath("$.data.items[0].isPointAvailable").value(true)) + .andExpect( + jsonPath("$.data.items[0].contentUrl").value( + "https://signed.example.com/private/target-night-walk.mp3?Expires=1" + ) + ) + .andExpect(jsonPath("$.data.page").doesNotExist()) + + Mockito.verify(audioContentCloudFront).generateSignedURL( + "private/target-night-walk.mp3", + 10_800_000L + ) + } + + @Test + @DisplayName("status 미지정 목록은 target 소유 공개와 예약 콘텐츠를 함께 반환한다") + fun shouldListOpenAndScheduledContentsWhenStatusIsMissing() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-default-status-character", + name = "v2-audio-list-default-status-character", + description = "description", + systemPrompt = "prompt" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "open default status", + contentPath = "private/open-default-status.mp3" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "scheduled default status", + contentPath = "private/scheduled-default-status.mp3", + releaseDate = Instant.now().plusSeconds(86_400).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + } + + @Test + @DisplayName("search_word 목록은 target 소유 예약 콘텐츠도 검색한다") + fun shouldSearchScheduledOwnedContents() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-scheduled-status-character", + name = "v2-audio-list-scheduled-status-character", + description = "description", + systemPrompt = "prompt" + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "open explicit status", + contentPath = "private/open-explicit-status.mp3" + ) + val scheduled = saveAudioContent( + owner = character.creatorMember!!, + title = "scheduled explicit status", + contentPath = "private/scheduled-explicit-status.mp3", + releaseDate = Instant.now().plusSeconds(86_400).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("search_word", "scheduled explicit") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id)) + } + + @Test + @DisplayName("목록은 UTC 미래 예약 콘텐츠의 레거시 releaseDate를 반환한다") + fun shouldReturnLegacyReleaseDateForUtcFutureScheduledContent() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-list-utc-scheduled-character", + name = "v2-audio-list-utc-scheduled-character", + description = "description", + systemPrompt = "prompt" + ) + val scheduled = saveAudioContent( + owner = character.creatorMember!!, + title = "utc future scheduled status", + contentPath = "private/utc-future-scheduled-status.mp3", + releaseDate = Instant.now().plusSeconds(3600).atOffset(ZoneOffset.UTC).toLocalDateTime() + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("search_word", "utc future") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].audioContentId").value(scheduled.id)) + .andExpect(jsonPath("$.data.items[0].releaseDate").isString) + } + + @Test + @DisplayName("AI 캐릭터 콘텐츠 상세는 signed URL만 반환하고 private object path 필드는 노출하지 않는다") + fun shouldReturnOwnedContentDetailWithSignedUrlWithoutPrivatePath() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-detail-character", + name = "v2-audio-detail-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "detail night walk", + contentPath = "private/detail-night-walk.mp3", + price = 0 + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/private/detail-night-walk.mp3?Expires=1") + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.contentId").value(content.id)) + .andExpect(jsonPath("$.data.detail").value("detail")) + .andExpect(jsonPath("$.data.languageCode").value("ko")) + .andExpect(jsonPath("$.data.themeStr").value("theme-detail night walk")) + .andExpect(jsonPath("$.data.tag").value("")) + .andExpect(jsonPath("$.data.duration").value("01:10:00")) + .andExpect(jsonPath("$.data.releaseDate").value(nullValue())) + .andExpect(jsonPath("$.data.totalContentCount").doesNotExist()) + .andExpect(jsonPath("$.data.remainingContentCount").doesNotExist()) + .andExpect(jsonPath("$.data.orderSequence").doesNotExist()) + .andExpect(jsonPath("$.data.isActivePreview").value(true)) + .andExpect(jsonPath("$.data.isMosaic").value(false)) + .andExpect(jsonPath("$.data.isOnlyRental").value(false)) + .andExpect(jsonPath("$.data.existOrdered").value(false)) + .andExpect(jsonPath("$.data.purchaseOption").value("BOTH")) + .andExpect(jsonPath("$.data.orderType").doesNotExist()) + .andExpect(jsonPath("$.data.remainingTime").doesNotExist()) + .andExpect(jsonPath("$.data.creatorOtherContentList").isEmpty) + .andExpect(jsonPath("$.data.sameThemeOtherContentList").isEmpty) + .andExpect(jsonPath("$.data.isCommentAvailable").value(true)) + .andExpect(jsonPath("$.data.isLike").value(false)) + .andExpect(jsonPath("$.data.likeCount").value(0)) + .andExpect(jsonPath("$.data.commentList").isEmpty) + .andExpect(jsonPath("$.data.commentCount").value(0)) + .andExpect(jsonPath("$.data.isPin").value(false)) + .andExpect(jsonPath("$.data.isAvailablePin").value(false)) + .andExpect(jsonPath("$.data.creator.creatorId").value(character.creatorMember!!.id)) + .andExpect(jsonPath("$.data.previousContent").doesNotExist()) + .andExpect(jsonPath("$.data.nextContent").doesNotExist()) + .andExpect(jsonPath("$.data.buyerList").isEmpty) + .andExpect(jsonPath("$.data.isAvailableUsePoint").value(true)) + .andExpect(jsonPath("$.data.translated").doesNotExist()) + .andExpect( + jsonPath("$.data.contentUrl").value( + "https://signed.example.com/private/detail-night-walk.mp3?Expires=1" + ) + ) + .andExpect(jsonPath("$.data.audioSignedUrl").doesNotExist()) + .andExpect(jsonPath("$.data.content").doesNotExist()) + + Mockito.verify(audioContentCloudFront).generateSignedURL( + "private/detail-night-walk.mp3", + 10_800_000L + ) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("다른 AI 캐릭터 소유 콘텐츠 상세 접근은 invalid request envelope으로 거부한다") + fun shouldRejectCrossCharacterContentDetail(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-target-character", + name = "v2-audio-target-character", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-cross-character", + name = "v2-audio-cross-character", + description = "description", + systemPrompt = "prompt" + ) + val otherContent = saveAudioContent( + owner = otherCharacter.creatorMember!!, + title = "other content", + contentPath = "private/other-content.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${otherContent.id}") + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("other content", entityManager.find(AudioContent::class.java, otherContent.id).title) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("다른 AI 캐릭터 소유 콘텐츠 수정은 DB 변경 없이 invalid request envelope으로 거부한다") + fun shouldRejectCrossCharacterContentUpdateWithoutMutation(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-target-character", + name = "v2-audio-update-target-character", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-cross-character", + name = "v2-audio-update-cross-character", + description = "description", + systemPrompt = "prompt" + ) + val otherContent = saveAudioContent( + owner = otherCharacter.creatorMember!!, + title = "other before title", + contentPath = "private/other-before-title.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${otherContent.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"other after title"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("other before title", entityManager.find(AudioContent::class.java, otherContent.id).title) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 다른 캐릭터 소유 seriesIds를 표지 업로드 전 거부한다") + fun shouldRejectUpdateWithOtherCharacterSeriesIdsBeforeCoverUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-series-target", + name = "v2-audio-update-series-target", + description = "description", + systemPrompt = "prompt" + ) + val otherCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-series-other", + name = "v2-audio-update-series-other", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "before invalid series", + contentPath = "private/before-invalid-series.mp3" + ) + val ownedSeries = saveSeries("update-owned-series", character.creatorMember!!) + saveSeriesContent(ownedSeries, content) + val otherSeries = saveSeries("update-other-series", otherCharacter.creatorMember!!) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"seriesIds":[${otherSeries.id}]}""".toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("before invalid series", entityManager.find(AudioContent::class.java, content.id).title) + assertEquals(ownedSeries.id, linkedSeriesId(content.id!!)) + } + } + + @ParameterizedTest + @ValueSource(strings = ["audioFile", "contentFile"]) + @DisplayName("수정은 audioFile과 contentFile 교체를 거부한다") + fun shouldRejectUnsupportedContentFileUpdate(filePart: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-$filePart-character", + name = "v2-audio-update-$filePart-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "$filePart update title", + contentPath = "private/$filePart-update.mp3" + ) + entityManager.flush() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile(filePart, "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + "{}".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + assertEquals("$filePart update title", entityManager.find(AudioContent::class.java, content.id).title) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 잘못된 releaseDateUtc를 표지 업로드 전 거부한다") + fun shouldRejectInvalidReleaseDateUtcBeforeCoverUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-invalid-release-character", + name = "v2-audio-update-invalid-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "invalid release title", + contentPath = "private/invalid-release.mp3" + ) + val contentId = content.id!! + entityManager.flush() + entityManager.clear() + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + val beforeReleaseDate = entityManager.find(AudioContent::class.java, contentId).releaseDate + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/$contentId") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"releaseDateUtc":"not-a-date"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + ) + .andExpectApiError(message) + + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) { + val persistedContent = entityManager.find(AudioContent::class.java, contentId) + assertEquals("invalid release title", persistedContent.title) + assertEquals(beforeReleaseDate, persistedContent.releaseDate) + } + } + + @Test + @DisplayName("수정은 레거시 계약에 없는 releaseDateUtc를 거부한다") + fun shouldRejectUnsupportedFutureReleaseDateUtc() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-future-release-character", + name = "v2-audio-update-future-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "future release title", + contentPath = "private/future-release.mp3" + ) + entityManager.flush() + val releaseDateUtc = Instant.now().plusSeconds(3600).atOffset(ZoneOffset.UTC).format( + java.time.format.DateTimeFormatter.ISO_INSTANT + ) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"releaseDateUtc":"$releaseDateUtc"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(true, entityManager.find(AudioContent::class.java, content.id).isActive) + } + + @Test + @DisplayName("수정의 isActive false는 target 소유 콘텐츠를 soft delete하고 data null을 반환한다") + fun shouldSoftDeleteOwnedContentAndReturnNull() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-character", + name = "v2-audio-update-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "before title", + contentPath = "private/before-title.mp3" + ) + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"after title","isActive":false}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(nullValue())) + + val updated = entityManager.find(AudioContent::class.java, content.id) + assertEquals("after title", updated.title) + assertEquals(false, updated.isActive) + assertEquals(null, updated.releaseDate) + } + + @Test + @DisplayName("콘텐츠 목록은 ADMIN 인증을 요구하고 기본 페이지 값을 사용한다") + fun shouldRequireAdminAuthenticationAndUseDefaultPagination() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-auth-character", + name = "v2-audio-auth-character", + description = "description", + systemPrompt = "prompt" + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").doesNotExist()) + .andExpect(jsonPath("$.data.size").doesNotExist()) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.size").doesNotExist()) + } + + private fun saveAudioContent( + owner: Member, + title: String, + contentPath: String, + releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime(), + price: Int = 100 + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = price, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + this.releaseDate = releaseDate + duration = "01:10:00" + content = contentPath + coverImage = "cover/$title.png" + entityManager.persist(this) + } + } + + private fun saveSeries(title: String, owner: Member): Series { + val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true) + entityManager.persist(genre) + val series = Series(title = title, introduction = "introduction", languageCode = "ko") + series.member = owner + series.genre = genre + series.coverImage = "$title.png" + entityManager.persist(series) + return series + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + + private fun ResultActions.andExpectApiError(message: String) { + andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun assertNoSideEffects( + beforeAudioContents: Long, + beforeSeriesContents: Long, + stateAssertion: () -> Unit = {} + ) { + assertActualPublisher(applicationEventPublisher, audioContentService) + assertActualPublisher(applicationEventPublisher, creatorAdminContentService) + entityManager.flush() + entityManager.clear() + assertEquals(beforeAudioContents, countAudioContents()) + assertEquals(beforeSeriesContents, countSeriesContents()) + stateAssertion() + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun replacePublisher(service: Any, publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(service) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualPublisher(publisher: ApplicationEventPublisher, service: Any) { + val target = AopTestUtils.getTargetObject(service) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun countAudioContents(): Long { + return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun countSeriesContents(): Long { + return entityManager.createQuery("select count(sc) from SeriesContent sc", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun linkedSeriesId(contentId: Long): Long { + return entityManager.createQuery( + "select sc.series.id from SeriesContent sc where sc.content.id = :contentId", + java.lang.Long::class.java + ) + .setParameter("contentId", contentId) + .singleResult + .toLong() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt new file mode 100644 index 00000000..ee40bf3e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt @@ -0,0 +1,797 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import com.amazonaws.services.s3.model.PutObjectResult +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.RequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.multipart.support.MissingServletRequestPartException +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentCreateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val audioContentService: AudioContentService, + private val objectMapper: ObjectMapper, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublisher() { + originalAudioContentServicePublisher = replaceAudioContentServicePublisher(applicationEventPublisher) + } + + @AfterEach + fun restoreActualServicePublisher() { + replaceAudioContentServicePublisher(originalAudioContentServicePublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 coverImage part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingCoverImageBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-cover-character", + name = "v2-audio-create-missing-cover-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertMissingPartRequest( + requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()), + message = message + ) + } + + @ParameterizedTest + @CsvSource( + "coverImage,ko,잘못된 요청입니다.", + "coverImage,en,Invalid request.", + "coverImage,ja,無効なリクエストです。", + "contentFile,ko,잘못된 요청입니다.", + "contentFile,en,Invalid request.", + "contentFile,ja,無効なリクエストです。" + ) + @DisplayName("생성은 빈 파일 part를 업로드 전 invalid request로 거부한다") + fun shouldRejectEmptyCreateFilesBeforeUpload(filePart: String, language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-empty-file-$filePart-$language", + name = "v2-audio-create-empty-file-$filePart-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val coverBytes = if (filePart == "coverImage") byteArrayOf() else byteArrayOf(1) + val contentBytes = if (filePart == "contentFile") byteArrayOf() else byteArrayOf(1) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", coverBytes)) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", contentBytes)) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 contentFile part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingContentFileBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-audio-character", + name = "v2-audio-create-missing-audio-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertMissingPartRequest( + requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()), + message = message + ) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 request part 누락을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingRequestBeforeUpload(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-request-character", + name = "v2-audio-create-missing-request-character", + description = "description", + systemPrompt = "prompt" + ) + + assertMissingPartRequest( + requestBuilder = multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()), + message = message + ) + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language", + name = "v2-audio-create-non-json-${requestContentType ?: "missing"}-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!, requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 미정의 multipart part를 업로드 전 invalid request로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-undefined-part-$language", + name = "v2-audio-create-undefined-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 filename 없는 미정의 multipart part를 업로드 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-filename-less-part-$language", + name = "v2-audio-create-filename-less-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .part(MockPart("unexpected", "unexpected".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @Test + @DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingThemeBeforeUpload() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-missing-theme-character", + name = "v2-audio-create-missing-theme-character", + description = "description", + systemPrompt = "prompt" + ) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(999999L)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid theme. Please select again.")) + + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @ParameterizedTest + @CsvSource( + value = [ + "onlyStart|ko|미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.", + "onlyStart|en|You must enter both preview start and end times, or neither.", + "onlyStart|ja|プレビューの開始時間と終了時間は両方入力するか、両方入力しないでください。", + "malformed|ko|미리 듣기 시간 형식은 00:30:00 과 같아야 합니다", + "malformed|en|Preview time format must be like 00:30:00.", + "malformed|ja|プレビュー時間の形式は00:30:00のようにする必要があります。", + "tooShort|ko|미리 듣기의 최소 시간은 15초 입니다.", + "tooShort|en|The minimum preview time is 15 seconds.", + "tooShort|ja|プレビューの最小時間は15秒です。" + ], + delimiter = '|' + ) + @DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다") + fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-preview-$previewCase-$language", + name = "v2-audio-create-preview-$previewCase-$language", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val previewFields = when (previewCase) { + "onlyStart" -> ",\"previewStartTime\":\"00:00:01\"" + "malformed" -> ",\"previewStartTime\":\"00:1:01\",\"previewEndTime\":\"00:00:20\"" + else -> ",\"previewStartTime\":\"00:00:01\",\"previewEndTime\":\"00:00:10\"" + } + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id}$previewFields} + """.trimIndent().toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + @Test + @DisplayName("생성은 timezone request field를 업로드 전 invalid request로 거부한다") + fun shouldRejectTimezoneRequestFieldBeforeUpload() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-timezone-field-character", + name = "v2-audio-create-timezone-field-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"2027-07-30 10:00","timezone":"UTC"} + """ + ) + } + + @ParameterizedTest + @ValueSource(strings = ["2027-07-30 10:00", "2027-07-30T10:00:00+09:00", "2027-07-30T10:00:00+00:00"]) + @DisplayName("생성은 local 또는 offset releaseDate를 업로드 전 invalid request로 거부한다") + fun shouldRejectNonUtcReleaseDateBeforeUpload(releaseDate: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}", + name = "v2-audio-create-non-utc-release-character-${releaseDate.hashCode()}", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":${theme.id},"releaseDate":"$releaseDate"} + """ + ) + } + + @ParameterizedTest + @ValueSource(strings = ["missing", "null"]) + @DisplayName("생성은 price 누락과 null을 업로드 전 invalid request로 거부한다") + fun shouldRejectMissingOrNullPriceBeforeUpload(priceCase: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-$priceCase-price-character", + name = "v2-audio-create-$priceCase-price-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val priceJson = if (priceCase == "null") ",\"price\":null" else "" + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"","themeId":${theme.id}$priceJson} + """ + ) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "themeId", + "isAdult", + "isGeneratePreview", + "isOnlyRental", + "isPointAvailable", + "isCommentAvailable", + "isFullDetailVisible" + ] + ) + @DisplayName("생성은 non-null primitive null을 업로드 전 invalid request로 거부한다") + fun shouldRejectNullPrimitiveFieldsBeforeUpload(field: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-null-$field-character", + name = "v2-audio-create-null-$field-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + val primitiveFields = listOf( + "price" to "100", + "themeId" to theme.id.toString(), + "isAdult" to "false", + "isGeneratePreview" to "false", + "isOnlyRental" to "false", + "isPointAvailable" to "false", + "isCommentAvailable" to "false", + "isFullDetailVisible" to "true" + ).joinToString(",") { (key, value) -> + val fieldValue = if (key == field) "null" else value + "\"$key\":$fieldValue" + } + + assertInvalidCreateRequestBeforeSideEffects( + characterId = character.id!!, + request = """ + {"title":"audio","detail":"detail","tags":"",$primitiveFields} + """ + ) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 cover 업로드 실패 시 DB와 이벤트를 남기지 않는다") + fun shouldRollBackDatabaseAndEventWhenCoverUploadFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-cover-failure-character", + name = "v2-audio-create-cover-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = transactionTemplate.execute { saveTheme() }!! + Mockito.doThrow(IllegalStateException("cover upload failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An unknown error occurred. try again.")) + + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertNull(findAudioContentByTitle("audio")) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 audio 업로드 실패 시 DB와 이벤트를 남기지 않고 cover 업로드 호출만 남긴다") + fun shouldRollBackDatabaseAndEventWhenAudioUploadFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-audio-failure-character", + name = "v2-audio-create-audio-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = transactionTemplate.execute { saveTheme() }!! + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + Mockito.`when`(amazonS3Client.putObject(Mockito.any(PutObjectRequest::class.java))) + .thenReturn(PutObjectResult()) + .thenThrow(IllegalStateException("audio upload failure")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An unknown error occurred. try again.")) + + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertNull(findAudioContentByTitle("audio")) + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("생성은 이벤트 발행 실패 시 DB를 롤백하고 두 S3 업로드 호출만 남긴다") + fun shouldRollBackDatabaseWhenCreateEventPublishFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-event-failure-character", + name = "v2-audio-create-event-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = transactionTemplate.execute { saveTheme() }!! + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + val originalPublisher = replaceAudioContentServicePublisher(applicationEventPublisher) + Mockito.doThrow(IllegalStateException("event failure")) + .`when`(applicationEventPublisher) + .publishEvent(Mockito.any(Any::class.java)) + + try { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file(requestPart(theme.id!!)) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("An unknown error occurred. try again.")) + } finally { + replaceAudioContentServicePublisher(originalPublisher) + } + + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) + assertNull(findAudioContentByTitle("audio")) + } + + @Test + @DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다") + fun shouldCreateWithUtcReleaseDateAndReturnContentId() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-minimal-character", + name = "v2-audio-create-minimal-character", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + val response = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"utc audio","detail":"utc detail","tags":"#utc","price":100,"purchaseOption":"BOTH","limited":7,"releaseDate":"2027-07-30T10:00:01Z","themeId":${theme.id},"isAdult":false,"isGeneratePreview":true,"isOnlyRental":false,"isPointAvailable":true,"isCommentAvailable":false,"isFullDetailVisible":false,"previewStartTime":"00:00:05","previewEndTime":"00:00:25","languageCode":"ko"} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentId").isNumber) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + assertEquals(setOf("contentId"), data.fieldNames().asSequence().toSet()) + val content = findAudioContentByTitle("utc audio")!! + assertEquals(data.path("contentId").asLong(), content.id) + assertEquals("utc detail", content.detail) + assertEquals(7, content.limited) + assertEquals("2027-07-30T10:00:01", content.releaseDate.toString()) + + val uploadCaptor = ArgumentCaptor.forClass(PutObjectRequest::class.java) + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(uploadCaptor.capture()) + val audioUpload = uploadCaptor.allValues.single { it.key.startsWith("input/") } + assertEquals("00:00:05", audioUpload.metadata.userMetadata["preview_start_time"]) + assertEquals("00:00:25", audioUpload.metadata.userMetadata["preview_end_time"]) + assertTrue(audioUpload.metadata.userMetadata["generate_preview"].toBoolean()) + } + + @ParameterizedTest + @ValueSource(strings = ["", ",\"releaseDate\":null"]) + @DisplayName("생성은 생략 또는 null releaseDate를 허용한다") + fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}", + name = "v2-audio-create-nullable-release-${releaseDateField.hashCode()}", + description = "description", + systemPrompt = "prompt" + ) + val theme = saveTheme() + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/uploaded")) + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + {"title":"nullable release","detail":"detail","tags":"","price":100,"themeId":${theme.id}$releaseDateField} + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentId").isNumber) + } + + private fun saveTheme(): AudioContentTheme { + val theme = AudioContentTheme(theme = "create-test-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + return theme + } + + private fun requestPart( + themeId: Long, + contentType: String? = MediaType.APPLICATION_JSON_VALUE + ): MockMultipartFile { + return MockMultipartFile( + "request", + "request.json", + contentType, + """ + {"title":"audio","detail":"detail","tags":"","price":100,"themeId":$themeId} + """.trimIndent().toByteArray() + ) + } + + private fun assertMissingPartRequest( + requestBuilder: RequestBuilder, + message: String + ) { + val result = mockMvc.perform(requestBuilder) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + private fun assertInvalidCreateRequestBeforeSideEffects(characterId: Long, request: String) { + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/$characterId/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + request.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertActualAudioContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + assertEquals(0L, countAudioContents()) + } + + private fun countAudioContents(): Long { + return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun findAudioContentByTitle(title: String): AudioContent? { + return entityManager.createQuery( + "select c from AudioContent c where c.title = :title", + AudioContent::class.java + ).setParameter("title", title).resultList.firstOrNull() + } + + private fun replaceAudioContentServicePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(audioContentService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualAudioContentServicePublisher(publisher: ApplicationEventPublisher) { + val target = AopTestUtils.getTargetObject(audioContentService) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt new file mode 100644 index 00000000..8f47bf2a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentOwnershipTest.kt @@ -0,0 +1,344 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.stream.Stream +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentOwnershipTest @Autowired constructor( + private val mockMvc: MockMvc, + private val audioContentService: AudioContentService, + private val creatorAdminContentService: CreatorAdminContentService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublishers() { + originalAudioContentServicePublisher = replacePublisher(audioContentService, applicationEventPublisher) + originalCreatorAdminContentServicePublisher = replacePublisher( + creatorAdminContentService, + applicationEventPublisher + ) + } + + @AfterEach + fun restoreActualServicePublishers() { + replacePublisher(audioContentService, originalAudioContentServicePublisher) + replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher) + } + + @ParameterizedTest + @MethodSource("malformedIdentifierRequests") + @DisplayName("숫자가 아닌 콘텐츠 식별자는 미매핑 404 i18n envelope으로 처리한다") + fun shouldReturnNotFoundEnvelopeForMalformedContentIdentifiers(method: HttpMethod, path: String) { + val result = mockMvc.perform( + request(method, path) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectApiError(404, "Invalid request.") + } + + @ParameterizedTest + @MethodSource("phase3EndpointRequests") + @DisplayName("실제 Phase 3 endpoint는 JWT ADMIN이 아니면 binding 전에 거부한다") + fun shouldRejectNonAdminJwtRoleBeforePhase3EndpointBinding(method: HttpMethod, path: String) { + val result = mockMvc.perform( + request(method, path) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(authentication(MemberRole.USER, MemberRole.ADMIN)) + ) + + result.andExpectApiError(403, "You do not have permission.") + } + + @ParameterizedTest + @MethodSource("phase3EndpointRequests") + @DisplayName("실제 Phase 3 endpoint는 stale ADMIN claim을 binding 전에 거부한다") + fun shouldRejectStaleAdminClaimBeforePhase3EndpointBinding(method: HttpMethod, path: String) { + val result = mockMvc.perform( + request(method, path) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(authentication(MemberRole.ADMIN, MemberRole.USER)) + ) + + result.andExpectApiError(403, "You do not have permission.") + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 생성은 S3 업로드 전에 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeCreateUpload(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/999999/audio-contents") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + "{}".toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 목록은 DB/S3/event 변경 없이 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeListSideEffects(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + val result = mockMvc.perform( + request(HttpMethod.GET, "/api/v2/admin/ai-characters/999999/audio-contents") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 상세는 DB/S3/event 변경 없이 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeDetailSideEffects(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + val result = mockMvc.perform( + request(HttpMethod.GET, "/api/v2/admin/ai-characters/999999/audio-contents/1") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 characterId 수정은 DB/S3/event 변경 없이 invalid request로 거부한다") + fun shouldRejectUnknownCharacterBeforeUpdateSideEffects(language: String, message: String) { + val beforeAudioContents = countAudioContents() + val beforeSeriesContents = countSeriesContents() + + val result = mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/999999/audio-contents/1") + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + + result.andExpectApiError(400, message) + assertNoSideEffects(beforeAudioContents, beforeSeriesContents) + } + + private fun assertNoSideEffects(beforeAudioContents: Long, beforeSeriesContents: Long) { + assertActualPublisher(applicationEventPublisher, audioContentService) + assertActualPublisher(applicationEventPublisher, creatorAdminContentService) + assertEquals(beforeAudioContents, countAudioContents()) + assertEquals(beforeSeriesContents, countSeriesContents()) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun replacePublisher(service: Any, publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(service) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualPublisher(publisher: ApplicationEventPublisher, service: Any) { + val target = AopTestUtils.getTargetObject(service) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + @ParameterizedTest + @MethodSource("phase3EndpointRequests") + @DisplayName("실제 Phase 3 endpoint preflight는 캐릭터 관리자 Origin만 허용한다") + fun shouldApplyCorsPreflightToEveryPhase3Endpoint(method: HttpMethod, path: String) { + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method.name) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options(path) + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method.name) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + @Test + @DisplayName("콘텐츠 테마 preflight는 캐릭터 관리자 Origin만 허용한다") + fun shouldAllowOnlyCharacterAdminOriginForThemePreflight() { + mockMvc.perform( + options("/api/v2/admin/ai-characters/audio-content-themes") + .header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isOk) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN)) + + mockMvc.perform( + options("/api/v2/admin/ai-characters/audio-content-themes") + .header(HttpHeaders.ORIGIN, CREATOR_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type") + ) + .andExpect(status().isForbidden) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)) + } + + private fun authentication(jwtRole: MemberRole, currentRole: MemberRole) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = currentRole + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}")) + ) + ) + + private fun adminAuthentication() = authentication(MemberRole.ADMIN, MemberRole.ADMIN) + + private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) { + andExpect(status().`is`(httpStatus)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun countAudioContents(): Long { + return entityManager.createQuery("select count(c) from AudioContent c", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun countSeriesContents(): Long { + return entityManager.createQuery("select count(sc) from SeriesContent sc", java.lang.Long::class.java) + .singleResult + .toLong() + } + + companion object { + private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net" + private const val CREATOR_ORIGIN = "https://creator.sodalive.net" + + @JvmStatic + fun malformedIdentifierRequests(): Stream = Stream.of( + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/not-a-number/audio-contents"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/not-a-number/audio-contents/1"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents/not-a-number"), + Arguments.of(HttpMethod.POST, "/api/v2/admin/ai-characters/not-a-number/audio-contents"), + Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/not-a-number/audio-contents/1"), + Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/audio-contents/not-a-number") + ) + + @JvmStatic + fun phase3EndpointRequests(): Stream = Stream.of( + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/audio-content-themes"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents"), + Arguments.of(HttpMethod.GET, "/api/v2/admin/ai-characters/1/audio-contents/1"), + Arguments.of(HttpMethod.POST, "/api/v2/admin/ai-characters/1/audio-contents"), + Arguments.of(HttpMethod.PUT, "/api/v2/admin/ai-characters/1/audio-contents/1") + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt new file mode 100644 index 00000000..35645d33 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt @@ -0,0 +1,332 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse +import kr.co.vividnext.sodalive.content.PurchaseOption +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @Test + @DisplayName("목록은 search_word로 검색하고 레거시 전체 item exact 필드를 반환한다") + fun shouldSearchByLegacyQueryAndReturnFullLegacyListItem() { + registerMysqlDateFunctions() + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-list-character", + name = "v2-audio-query-list-character", + description = "description", + systemPrompt = "prompt" + ) + val matching = saveAudioContent( + owner = character.creatorMember!!, + title = "legacy target audio", + releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0) + ) + saveAudioContent( + owner = character.creatorMember!!, + title = "different audio", + releaseDate = LocalDateTime.of(2026, 7, 24, 0, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents") + .param("search_word", "legacy target") + .param("page", "0") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + val item = data.path("items").path(0) + assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet()) + assertEquals( + setOf( + "audioContentId", + "title", + "detail", + "coverImageUrl", + "creatorNickname", + "theme", + "price", + "totalContentCount", + "remainingContentCount", + "isAdult", + "isPointAvailable", + "isCommentAvailable", + "remainingTime", + "contentUrl", + "date", + "releaseDate", + "tags" + ), + item.fieldNames().asSequence().toSet() + ) + assertEquals(matching.id, item.path("audioContentId").asLong()) + assertEquals("legacy target audio", item.path("title").asText()) + assertEquals("detail", item.path("detail").asText()) + assertEquals("https://test.cloudfront.net/cover/legacy target audio.png", item.path("coverImageUrl").asText()) + assertEquals( + "https://signed.example.com/private/legacy target audio.mp3?Expires=1", + item.path("contentUrl").asText() + ) + } + + @Test + @DisplayName("상세는 timezone 없이 기존 nested DTO exact 필드를 반환한다") + fun shouldReturnFullLegacyDetailContractWithoutTimezone() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-release-character", + name = "v2-audio-query-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "query release audio", + releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/query-release.mp3?Expires=1") + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/query-release.mp3?Expires=1")) + .andExpect(jsonPath("$.data.isOnlyRental").value(true)) + .andExpect(jsonPath("$.data.purchaseOption").value("RENT_ONLY")) + .andReturn() + + val data = objectMapper.readTree(response.response.contentAsString).path("data") + assertEquals( + setOf( + "contentId", + "title", + "detail", + "languageCode", + "coverImageUrl", + "contentUrl", + "themeStr", + "tag", + "price", + "duration", + "releaseDate", + "totalContentCount", + "remainingContentCount", + "orderSequence", + "isActivePreview", + "isAdult", + "isMosaic", + "isOnlyRental", + "existOrdered", + "purchaseOption", + "orderType", + "remainingTime", + "creatorOtherContentList", + "sameThemeOtherContentList", + "isCommentAvailable", + "isLike", + "likeCount", + "commentList", + "commentCount", + "isPin", + "isAvailablePin", + "creator", + "previousContent", + "nextContent", + "buyerList", + "isAvailableUsePoint", + "translated" + ), + data.fieldNames().asSequence().toSet() + ) + assertEquals( + setOf("creatorId", "nickname", "profileImageUrl", "isFollowing", "isFollow", "isNotify"), + data.path("creator").fieldNames().asSequence().toSet() + ) + Mockito.verify(audioContentCloudFront).generateSignedURL("private/query release audio.mp3", 10_800_000L) + } + + @Test + @DisplayName("상세는 미래 예약일을 UTC Z releaseDate로 반환한다") + fun shouldReturnFutureReleaseDateAsUtcIso() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-future-release", + name = "v2-audio-query-future-release", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "future release audio", + releaseDate = LocalDateTime.of(2027, 7, 30, 10, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/future-release.mp3?Expires=1") + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.releaseDate").value("2027-07-30T10:00:00Z")) + .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/future-release.mp3?Expires=1")) + } + + @Test + @DisplayName("상세는 현재 또는 과거 예약일의 releaseDate를 null로 반환한다") + fun shouldReturnNullReleaseDateForPastOrCurrentReleaseDate() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-past-release-character", + name = "v2-audio-query-past-release-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "past release audio", + releaseDate = LocalDateTime.of(2020, 1, 1, 0, 0) + ) + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://signed.example.com/past-release.mp3?Expires=1") + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.contentUrl").value("https://signed.example.com/past-release.mp3?Expires=1")) + .andReturn() + + assertTrue(objectMapper.readTree(response.response.contentAsString).path("data").path("releaseDate").isNull) + } + + @Test + @DisplayName("상세는 timezone query를 허용하지 않는다") + fun shouldRejectTimezoneForDetail() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-query-timezone-character", + name = "v2-audio-query-timezone-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent( + owner = character.creatorMember!!, + title = "timezone required audio", + releaseDate = LocalDateTime.of(2026, 7, 25, 0, 0) + ) + entityManager.flush() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .param("timezone", "UTC") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + } + + @Test + @DisplayName("상세 응답은 레거시 GetAudioContentDetailResponse 타입을 재사용한다") + fun shouldReuseLegacyDetailResponseType() { + assertEquals(GetAudioContentDetailResponse::class.java, AiCharacterAdminAudioContentResponse::class.java) + } + + private fun saveAudioContent(owner: Member, title: String, releaseDate: LocalDateTime): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5, + price = 100, + purchaseOption = PurchaseOption.RENT_ONLY, + isOnlyRental = false, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + this.releaseDate = releaseDate + duration = "01:10:00" + content = "private/$title.mp3" + coverImage = "cover/$title.png" + entityManager.persist(this) + } + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt new file mode 100644 index 00000000..7cfcec31 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentThemeControllerTest.kt @@ -0,0 +1,87 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentThemeControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("콘텐츠 테마 목록은 ADMIN에게 활성 테마만 레거시 exact 필드로 반환한다") + fun shouldReturnActiveThemesWithLegacyFields() { + val later = AudioContentTheme(theme = "later theme", image = "theme/later.png", orders = 2) + val earlier = AudioContentTheme(theme = "earlier theme", image = "theme/earlier.png", orders = 1) + val inactive = AudioContentTheme(theme = "inactive theme", image = "theme/inactive.png", isActive = false) + entityManager.persist(later) + entityManager.persist(earlier) + entityManager.persist(inactive) + entityManager.flush() + + val response = mockMvc.perform( + get("/api/v2/admin/ai-characters/audio-content-themes") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(earlier.id)) + .andExpect(jsonPath("$.data[0].theme").value("earlier theme")) + .andExpect(jsonPath("$.data[0].image").value("https://test.cloudfront.net/theme/earlier.png")) + .andReturn() + + val item = objectMapper.readTree(response.response.contentAsString).path("data").path(0) + assertEquals(setOf("id", "theme", "image"), item.fieldNames().asSequence().toSet()) + } + + @Test + @DisplayName("콘텐츠 테마 목록은 ADMIN 인증을 요구한다") + fun shouldRequireAdminAuthentication() { + mockMvc.perform( + get("/api/v2/admin/ai-characters/audio-content-themes") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt new file mode 100644 index 00000000..05c10248 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentUpdateTest.kt @@ -0,0 +1,554 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.Instant +import java.time.ZoneOffset +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminAudioContentUpdateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val creatorAdminContentService: CreatorAdminContentService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublisher() { + originalCreatorAdminContentServicePublisher = replaceCreatorAdminContentServicePublisher(applicationEventPublisher) + } + + @AfterEach + fun restoreActualServicePublisher() { + replaceCreatorAdminContentServicePublisher(originalCreatorAdminContentServicePublisher) + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language", + name = "v2-audio-update-non-json-${requestContentType ?: "missing"}-$language", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "before update") + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + requestContentType, + "{\"title\":\"after update\"}".toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + entityManager.clear() + assertEquals("before update", entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-undefined-part-$language", + name = "v2-audio-update-undefined-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "undefined-part-before") + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/undefined-part-before.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"should not change"}""".toByteArray() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("undefined-part-before", entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedMultipartPartBeforeSideEffects(language: String, message: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-filename-less-part-$language", + name = "v2-audio-update-filename-less-part-$language", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "filename-less-part-before") + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/filename-less-part-before.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"should not change"}""".toByteArray() + ) + ) + .part(MockPart("unexpected", "unexpected".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("filename-less-part-before", entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("수정은 레거시 전체 request를 적용하고 기존 series metadata와 data null 계약을 보존한다") + fun shouldApplyLegacyRequestAndPreserveExistingSeriesMetadata() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-preserve-series-character", + name = "v2-audio-update-preserve-series-character", + description = "description", + systemPrompt = "prompt" + ) + val series = saveSeries("preserve-series", character.creatorMember!!) + val content = saveAudioContent(character.creatorMember!!, "preserve-series-content") + saveSeriesContent(series, content).apply { orders = 7 } + val contentId = content.id!! + entityManager.flush() + entityManager.clear() + val persistedSeriesContent = findSeriesContents(contentId).single() + val beforeId = persistedSeriesContent.id + val beforeCreatedAt = persistedSeriesContent.createdAt + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/preserve-series-content.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/$contentId") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """ + { + "title":"updated title", + "detail":"updated detail", + "tags":"#updated", + "price":250, + "isAdult":true, + "isActive":false, + "isPointAvailable":false, + "isCommentAvailable":false + } + """.trimIndent().toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val saved = findSeriesContents(contentId).single() + assertEquals(beforeId, saved.id) + assertEquals(7, saved.orders) + assertEquals(beforeCreatedAt, saved.createdAt) + val updated = entityManager.find(AudioContent::class.java, contentId) + assertEquals("updated title", updated.title) + assertEquals("updated detail", updated.detail) + assertEquals(250, updated.price) + assertEquals(true, updated.isAdult) + assertEquals(false, updated.isActive) + assertEquals(false, updated.isPointAvailable) + assertEquals(false, updated.isCommentAvailable) + } + + @Test + @DisplayName("레거시 수정은 기존 series 연결을 추가·제거하지 않는다") + fun shouldLeaveSeriesConnectionsUnchanged() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-diff-series-character", + name = "v2-audio-update-diff-series-character", + description = "description", + systemPrompt = "prompt" + ) + val removedSeries = saveSeries("removed-series", character.creatorMember!!) + val keptSeries = saveSeries("kept-series", character.creatorMember!!) + val addedSeries = saveSeries("added-series", character.creatorMember!!) + val content = saveAudioContent(character.creatorMember!!, "diff-series-content") + saveSeriesContent(removedSeries, content) + saveSeriesContent(keptSeries, content).apply { orders = 5 } + val contentId = content.id!! + entityManager.flush() + + entityManager.clear() + val persistedKept = findSeriesContents(contentId).single { it.series!!.id == keptSeries.id } + val keptId = persistedKept.id + val keptCreatedAt = persistedKept.createdAt + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/diff-series-content.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/$contentId") + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"diff updated title"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + val saved = findSeriesContents(contentId) + assertEquals(setOf(removedSeries.id, keptSeries.id), saved.map { it.series!!.id }.toSet()) + val kept = saved.single { it.series!!.id == keptSeries.id } + assertEquals(keptId, kept.id) + assertEquals(5, kept.orders) + assertEquals(keptCreatedAt, kept.createdAt) + assertEquals(false, saved.any { it.series!!.id == addedSeries.id }) + } + + @Test + @DisplayName("수정은 cover가 없으면 유지하고 성공 교체 시 새 cover path를 저장한다") + fun shouldKeepOrReplaceCoverImage() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-cover-character", + name = "v2-audio-update-cover-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "cover-update-content") + val oldCover = content.coverImage + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/cover-update-content.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + assertEquals(oldCover, entityManager.find(AudioContent::class.java, content.id).coverImage) + + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://test.cloudfront.net/cover-updated")) + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + val updatedCover = entityManager.find(AudioContent::class.java, content.id).coverImage!! + assertTrue(updatedCover.startsWith("audio_content_cover/${content.id}/")) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("수정은 빈 coverImage를 생략과 동일하게 처리한다") + fun shouldTreatEmptyCoverImageAsAbsent() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-empty-cover-character", + name = "v2-audio-update-empty-cover-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "empty-cover-update-content") + val oldCover = content.coverImage + entityManager.flush() + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenReturn("https://test.cloudfront.net/private/empty-cover-update-content.mp3") + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("coverImage", "empty.png", "image/png", byteArrayOf())) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{}".toByteArray())) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + + assertEquals(oldCover, entityManager.find(AudioContent::class.java, content.id).coverImage) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @ParameterizedTest + @ValueSource(strings = ["audioFile", "contentFile"]) + @DisplayName("수정은 파일 교체 part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFileReplacementPartBeforeSideEffects(filePart: String) { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-file-replacement-$filePart", + name = "v2-audio-update-file-replacement-$filePart", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "file-replacement-$filePart") + val oldTitle = content.title + entityManager.flush() + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile(filePart, "empty.mp3", "audio/mpeg", byteArrayOf())) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"should not change"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + + assertEquals(oldTitle, entityManager.find(AudioContent::class.java, content.id).title) + assertActualCreatorAdminContentServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("수정은 cover 업로드 실패 시 DB를 변경하지 않는다") + fun shouldKeepDatabaseStateWhenCoverUploadFails() { + val character = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "v2-audio-update-cover-failure-character", + name = "v2-audio-update-cover-failure-character", + description = "description", + systemPrompt = "prompt" + ) + val content = saveAudioContent(character.creatorMember!!, "cover-failure-content") + val oldCover = content.coverImage + entityManager.flush() + Mockito.doThrow(IllegalStateException("cover upload failure")) + .`when`(amazonS3Client) + .putObject(Mockito.any(PutObjectRequest::class.java)) + + mockMvc.perform( + multipart(HttpMethod.PUT, "/api/v2/admin/ai-characters/${character.id}/audio-contents/${content.id}") + .file(MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1))) + .file( + MockMultipartFile( + "request", + "request.json", + MediaType.APPLICATION_JSON_VALUE, + """{"title":"cover failure after title"}""".toByteArray() + ) + ) + .with(adminAuthentication()) + ) + .andExpect(status().isInternalServerError) + + val saved = entityManager.find(AudioContent::class.java, content.id) + assertEquals("cover-failure-content", saved.title) + assertEquals(oldCover, saved.coverImage) + } + + private fun saveAudioContent(owner: Member, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + price = 100, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true + ).apply { + member = owner + this.theme = theme + isActive = true + releaseDate = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime() + duration = "01:10:00" + content = "private/$title.mp3" + coverImage = "cover/$title.png" + entityManager.persist(this) + } + } + + private fun saveSeries(title: String, owner: Member): Series { + val genre = SeriesGenre(genre = "genre-$title", isAdult = false, isActive = true) + entityManager.persist(genre) + return Series(title = title, introduction = "introduction", languageCode = "ko").apply { + member = owner + this.genre = genre + coverImage = "$title.png" + entityManager.persist(this) + } + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + return SeriesContent().apply { + this.series = series + this.content = content + entityManager.persist(this) + } + } + + private fun findSeriesContents(contentId: Long): List { + return entityManager.createQuery( + "select sc from SeriesContent sc where sc.content.id = :contentId", + SeriesContent::class.java + ).setParameter("contentId", contentId).resultList + } + + private fun replaceCreatorAdminContentServicePublisher( + publisher: ApplicationEventPublisher + ): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(creatorAdminContentService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualCreatorAdminContentServicePublisher(publisher: ApplicationEventPublisher) { + val target = AopTestUtils.getTargetObject(creatorAdminContentService) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt new file mode 100644 index 00000000..1ff7deea --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt @@ -0,0 +1,70 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.net.URI +import java.nio.file.Files +import java.security.KeyPairGenerator +import java.util.Base64 + +class AudioContentCloudFrontCharacterizationTest { + @Test + @DisplayName("기존 AudioContentCloudFront는 요청한 밀리초 TTL과 중첩 경로로 signed URL을 만든다") + fun shouldUseRequestedTtlAndNestedResourcePath() { + withCloudFront { cloudFront, privateKeyMaterial -> + val before = System.currentTimeMillis() / 1_000 + + val signedUrl = cloudFront.generateSignedURL("audio/nested/clip.mp3", 3_600_000L) + + val after = System.currentTimeMillis() / 1_000 + val uri = URI(signedUrl) + val expires = queryParameter(uri, "Expires").toLong() + assertTrue(expires in (before + 3_599)..(after + 3_601)) + assertEquals("/audio/nested/clip.mp3", uri.path) + assertFalse(signedUrl.contains(privateKeyMaterial)) + assertFalse(signedUrl.contains("BEGIN PRIVATE KEY")) + } + } + + @Test + @DisplayName("기존 AudioContentCloudFront는 선행 slash를 정규화하지 않고 즉시 만료 URL도 서명한다") + fun shouldPreserveLeadingSlashAndAllowZeroTtl() { + withCloudFront { cloudFront, _ -> + val before = System.currentTimeMillis() / 1_000 + + val signedUrl = cloudFront.generateSignedURL("/audio/edge.mp3", 0L) + + val after = System.currentTimeMillis() / 1_000 + val uri = URI(signedUrl) + val expires = queryParameter(uri, "Expires").toLong() + assertEquals("//audio/edge.mp3", uri.path) + assertTrue(expires in (before - 1)..(after + 1)) + } + } + + private fun withCloudFront(block: (AudioContentCloudFront, String) -> Unit) { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1_024) }.generateKeyPair() + val keyFile = Files.createTempFile("audio-content-cloudfront", ".der") + Files.write(keyFile, keyPair.private.encoded) + + try { + block( + AudioContentCloudFront("https://cdn.example.com", keyFile.toString(), "test-key-pair"), + Base64.getEncoder().encodeToString(keyPair.private.encoded) + ) + } finally { + Files.deleteIfExists(keyFile) + } + } + + private fun queryParameter(uri: URI, name: String): String { + return uri.rawQuery + .split("&") + .map { it.split("=", limit = 2) } + .single { it[0] == name }[1] + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt new file mode 100644 index 00000000..69b2af34 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt @@ -0,0 +1,251 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content + +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.ContentPriceChangeLogRepository +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentRepository +import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor( + private val audioContentService: AudioContentService, + private val repository: CreatorAdminContentRepository, + private val hashTagRepository: HashTagRepository, + private val contentPriceChangeLogRepository: ContentPriceChangeLogRepository, + private val entityManager: EntityManager +) { + private lateinit var audioContentCloudFront: AudioContentCloudFront + + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + private lateinit var s3Uploader: S3Uploader + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var service: CreatorAdminContentService + + @BeforeEach + fun setUp() { + registerMysqlDateFunctions() + audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/test")) + s3Uploader = S3Uploader(amazonS3Client) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + service = CreatorAdminContentService( + repository = repository, + hashTagRepository = hashTagRepository, + contentPriceChangeLogRepository = contentPriceChangeLogRepository, + audioContentCloudFront = audioContentCloudFront, + objectMapper = jacksonObjectMapper(), + s3Uploader = s3Uploader, + applicationEventPublisher = applicationEventPublisher, + bucket = "test-bucket", + coverImageHost = "https://cover.example.com" + ) + } + + @Test + @DisplayName("기존 콘텐츠 생성은 표지와 오디오를 업로드하고 처리 대기 콘텐츠를 저장한다") + fun shouldCreateContentThroughLegacyUploadPipeline() { + val owner = saveMember("legacy-create-owner") + val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + + val response = audioContentService.createAudioContent( + contentFile = MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1, 2, 3)), + coverImage = MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(4, 5, 6)), + requestString = """ + {"title":"legacy created audio","detail":"created detail","tags":"#legacy","price":100,"themeId":${theme.id},"isAdult":false,"languageCode":"ko"} + """.trimIndent(), + member = owner + ) + entityManager.flush() + + val content = entityManager.find(AudioContent::class.java, response.contentId) + assertEquals("legacy created audio", content.title) + assertEquals(owner.id, content.member!!.id) + assertEquals(theme.id, content.theme!!.id) + assertFalse(content.isActive) + assertTrue(content.coverImage!!.startsWith("audio_content_cover/${response.contentId}/")) + assertTrue(content.content!!.startsWith("input/${response.contentId}/")) + Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any()) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 목록과 검색은 소유자, 처리 완료, 예약 상태를 구분하고 signed URL로 교체한다") + fun shouldListAndSearchOnlyOwnersProcessedOrReservedContentWithSignedUrl() { + val owner = saveMember("legacy-content-owner") + val otherOwner = saveMember("legacy-content-other-owner") + val published = saveAudioContent(owner, "published legacy audio", "01:10:00") + val reserved = saveAudioContent(owner, "reserved legacy audio", "00:10:00").apply { + isActive = false + releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0) + } + saveAudioContent(owner, "processing legacy audio", "00:10:00").duration = null + saveAudioContent(otherOwner, "published legacy audio", "01:10:00") + entityManager.flush() + entityManager.clear() + + Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) + .thenAnswer { invocation -> "https://signed.example.com/${invocation.arguments[0]}?Expires=1" } + + val list = service.getAudioContentList(PageRequest.of(0, 10), owner) + val search = service.searchAudioContent("published", owner, PageRequest.of(0, 10)) + + assertEquals(2, list.totalCount) + assertEquals(setOf(published.id, reserved.id), list.items.map { it.audioContentId }.toSet()) + assertEquals(1, search.totalCount) + assertEquals(listOf(published.id), search.items.map { it.audioContentId }) + assertEquals( + "https://signed.example.com/${published.content}?Expires=1", + list.items.single { it.audioContentId == published.id }.contentUrl + ) + assertNotEquals(published.content, list.items.single { it.audioContentId == published.id }.contentUrl) + Mockito.verify(audioContentCloudFront, Mockito.times(2)) + .generateSignedURL(published.content!!, 10_800_000L) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 검색은 한 글자 검색어를 거부한다") + fun shouldRejectOneCharacterSearchWord() { + val owner = saveMember("legacy-search-owner") + + val exception = assertThrows(SodaException::class.java) { + service.searchAudioContent("a", owner, PageRequest.of(0, 10)) + } + + assertEquals("creator.admin.content.search_word_min_length", exception.messageKey) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 수정은 가격과 플래그를 저장하고 비활성화 시 예약을 해제하며 번역 이벤트를 발행한다") + fun shouldUpdatePriceFlagsSoftDeleteAndTranslationEvent() { + val owner = saveMember("legacy-update-owner") + val content = saveAudioContent(owner, "before title", "00:10:00").apply { + price = 100 + releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0) + } + entityManager.flush() + + service.updateAudioContent( + coverImage = null, + requestString = """ + {"id":${content.id},"title":"after title","detail":"after detail","tags":"#updated updated","price":250,"isAdult":true,"isPointAvailable":true,"isCommentAvailable":false,"isActive":false} + """.trimIndent(), + member = owner + ) + entityManager.flush() + + assertEquals("after title", content.title) + assertEquals("after detail", content.detail) + assertEquals(250, content.price) + assertTrue(content.isAdult) + assertTrue(content.isPointAvailable) + assertFalse(content.isCommentAvailable) + assertFalse(content.isActive) + assertNull(content.releaseDate) + assertEquals(100, contentPriceChangeLogRepository.findAll().single().prevPrice) + assertEquals(listOf("#updated"), content.audioContentHashTags.map { it.hashTag!!.tag }) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageTranslationEvent + assertEquals(content.id, event.id) + assertEquals(LanguageTranslationTargetType.CONTENT, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("기존 크리에이터 콘텐츠 표지 수정은 생성된 uploader 객체 경로를 저장한다") + fun shouldStoreGeneratedUploaderCoverPath() { + val owner = saveMember("legacy-cover-owner") + val content = saveAudioContent(owner, "cover legacy audio", "00:10:00") + + service.updateAudioContent( + coverImage = MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(1)), + requestString = """{"id":${content.id}}""", + member = owner + ) + + assertTrue(content.coverImage!!.startsWith("audio_content_cover/${content.id}/")) + } + + private fun saveMember(nickname: String): Member { + val member = Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.CREATOR + ) + entityManager.persist(member) + entityManager.flush() + return member + } + + private fun saveAudioContent(owner: Member, title: String, duration: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5 + ).apply { + member = owner + this.theme = theme + isActive = true + releaseDate = LocalDateTime.of(2026, 7, 24, 10, 0) + this.duration = duration + content = "audio/${title.replace(" ", "-")}.mp3" + coverImage = "audio/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt new file mode 100644 index 00000000..8d7561fd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkDeleteTest.kt @@ -0,0 +1,259 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkDeleteTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("관리자 FanTalk 삭제는 target 채널의 팬 작성 root만 soft delete하고 creator reply row를 유지한다") + fun shouldSoftDeleteFanRootOnlyAndKeepCreatorReplyRows() { + val character = createCharacter("fantalk-delete-character") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-admin", MemberRole.ADMIN) + val fan = saveMember("fantalk-delete-fan") + val root = saveCheers(fan, creator, "fan root") + val creatorReply = saveCheers(creator, creator, "creator reply", parent = root) + entityManager.flush() + entityManager.clear() + + deleteFanTalk(character.id!!, root.id!!, admin) + .andExpect(status().isOk) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(nullValue())) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(!findCheers(root.id!!).isActive) + assertTrue(findCheers(creatorReply.id!!).isActive) + + val data = getFanTalks(character.id!!, admin) + assertEquals(0, data.path("fanTalkCount").asInt()) + assertTrue(data.path("fanTalks").isEmpty) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 같은 target의 이미 비활성인 팬 root를 성공 no-op 처리한다") + fun shouldNoopInactiveFanRootInSameTarget() { + val character = createCharacter("fantalk-delete-inactive-character") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-inactive-admin", MemberRole.ADMIN) + val inactiveRoot = saveCheers( + member = saveMember("fantalk-delete-inactive-fan"), + creator = creator, + content = "inactive fan root", + isActive = false + ) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + + deleteFanTalk(character.id!!, inactiveRoot.id!!, admin) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(beforeCount, countCheers()) + assertTrue(!findCheers(inactiveRoot.id!!).isActive) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 target AI 작성 root를 저장 변경 없이 400으로 거부한다") + fun shouldRejectCreatorAuthoredRootWithoutMutation() { + val character = createCharacter("fantalk-delete-creator-root") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-creator-root-admin", MemberRole.ADMIN) + val creatorRoot = saveCheers(creator, creator, "creator root") + + assertRejectedDelete(character.id!!, creatorRoot.id!!, admin, creatorRoot) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 reply row를 저장 변경 없이 400으로 거부한다") + fun shouldRejectReplyRowWithoutMutation() { + val character = createCharacter("fantalk-delete-reply") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-reply-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-delete-reply-fan"), creator, "root") + val reply = saveCheers(creator, creator, "reply", parent = root) + + assertRejectedDelete(character.id!!, reply.id!!, admin, reply) + assertTrue(findCheers(root.id!!).isActive) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 다른 채널 root를 저장 변경 없이 400으로 거부한다") + fun shouldRejectCrossCharacterRootWithoutMutation() { + val targetCharacter = createCharacter("fantalk-delete-cross-target") + val otherCharacter = createCharacter("fantalk-delete-cross-other") + val admin = saveMember("fantalk-delete-cross-admin", MemberRole.ADMIN) + val otherRoot = saveCheers( + member = saveMember("fantalk-delete-cross-fan"), + creator = otherCharacter.creatorMember!!, + content = "other root" + ) + + assertRejectedDelete(targetCharacter.id!!, otherRoot.id!!, admin, otherRoot) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 존재하지 않는 FanTalk ID를 공통 오류 envelope로 거부한다") + fun shouldRejectMissingFanTalkIdWithoutMutation() { + val character = createCharacter("fantalk-delete-missing") + val admin = saveMember("fantalk-delete-missing-admin", MemberRole.ADMIN) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + + deleteFanTalk(character.id!!, Long.MAX_VALUE, admin, language = "en") + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + assertEquals(beforeCount, countCheers()) + } + + @Test + @DisplayName("관리자 FanTalk 삭제는 비활성 AI 캐릭터 target을 공통 오류 envelope로 거부한다") + fun shouldRejectInactiveTargetWithoutMutation() { + val character = createCharacter("fantalk-delete-inactive-target").apply { isActive = false } + val creator = character.creatorMember!! + val admin = saveMember("fantalk-delete-inactive-target-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-delete-inactive-target-fan"), creator, "root") + + assertRejectedDelete(character.id!!, root.id!!, admin, root) + } + + private fun assertRejectedDelete(characterId: Long, fanTalkId: Long, admin: Member, targetRow: CreatorCheers) { + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val beforeActive = findCheers(targetRow.id!!).isActive + + deleteFanTalk(characterId, fanTalkId, admin, language = "en") + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(beforeCount, countCheers()) + assertEquals(beforeActive, findCheers(targetRow.id!!).isActive) + } + + private fun deleteFanTalk( + characterId: Long, + fanTalkId: Long, + admin: Member, + language: String = "ko" + ): ResultActions { + return mockMvc.perform( + delete("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ) + } + + private fun getFanTalks(characterId: Long, admin: Member): JsonNode { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/$characterId/fan-talks") + .with(adminAuthentication(admin)) + ) + .andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + parent: CreatorCheers? = null, + isActive: Boolean = true + ): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko", isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun findCheers(id: Long): CreatorCheers = entityManager.find(CreatorCheers::class.java, id) + + private fun countCheers(): Long = entityManager.createQuery( + "select count(c) from CreatorCheers c", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt new file mode 100644 index 00000000..8e84dc6b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkQueryTest.kt @@ -0,0 +1,263 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("관리자 FanTalk 목록은 target 소유 활성 root와 creator reply만 공개 v2 필드 형태로 반환한다") + fun shouldReturnOwnerScopedActiveRootsAndCreatorRepliesWithExactPublicFields() { + val character = createCharacter("fantalk-query-character") + val owner = character.creatorMember!! + val admin = saveMember("fantalk-query-admin", MemberRole.ADMIN) + val blockedWriter = saveMember("fantalk-query-blocked-writer") + val writer = saveMember("fantalk-query-writer") + val otherCharacter = createCharacter("fantalk-query-other-character") + val createdAt = LocalDateTime.of(2026, 7, 28, 12, 0) + val firstRoot = saveCheers(blockedWriter, owner, "first root", true, createdAt) + val secondRoot = saveCheers(writer, owner, "second root", true, createdAt) + saveCheers(writer, owner, "inactive root", false, createdAt.plusMinutes(1)) + saveCheers(writer, otherCharacter.creatorMember!!, "foreign root", true, createdAt.plusMinutes(2)) + val firstCreatorReply = saveCheers(owner, owner, "first creator reply", true, createdAt.plusMinutes(3), secondRoot) + val secondCreatorReply = saveCheers(owner, owner, "second creator reply", true, createdAt.plusMinutes(3), secondRoot) + saveCheers(writer, owner, "fan reply", true, createdAt.plusMinutes(4), secondRoot) + saveCheers(owner, owner, "inactive creator reply", false, createdAt.plusMinutes(5), secondRoot) + saveCheers(owner, owner, "nested creator reply", true, createdAt.plusMinutes(6), firstCreatorReply) + saveBlock(admin, blockedWriter) + entityManager.flush() + entityManager.clear() + + val data = getFanTalks(character.id!!, admin) + + assertEquals(setOf("fanTalkCount", "fanTalks", "page", "size", "hasNext"), data.fieldNames().asSequence().toSet()) + assertEquals(2, data.path("fanTalkCount").asInt()) + assertEquals(listOf(secondRoot.id, firstRoot.id), data.path("fanTalks").map { it.path("fanTalkId").asLong() }) + assertEquals(0, data.path("page").asInt()) + assertEquals(20, data.path("size").asInt()) + assertTrue(!data.path("hasNext").asBoolean()) + + val root = data.path("fanTalks").first() + assertEquals( + setOf( + "fanTalkId", + "writerId", + "writerNickname", + "writerProfileImageUrl", + "content", + "createdAtUtc", + "creatorReplies" + ), + root.fieldNames().asSequence().toSet() + ) + assertEquals(secondRoot.id, root.path("fanTalkId").asLong()) + assertEquals(writer.id, root.path("writerId").asLong()) + assertEquals(writer.nickname, root.path("writerNickname").asText()) + assertEquals(profileUrl(writer), root.path("writerProfileImageUrl").asText()) + assertEquals("second root", root.path("content").asText()) + assertEquals("2026-07-28T12:00:00Z", root.path("createdAtUtc").asText()) + assertEquals( + listOf(firstCreatorReply.id, secondCreatorReply.id), + root.path("creatorReplies").map { it.path("fanTalkId").asLong() } + ) + + val reply = root.path("creatorReplies").first() + assertEquals( + setOf( + "fanTalkId", + "writerId", + "writerNickname", + "writerProfileImageUrl", + "content", + "createdAtUtc" + ), + reply.fieldNames().asSequence().toSet() + ) + assertEquals(firstCreatorReply.id, reply.path("fanTalkId").asLong()) + assertEquals(owner.id, reply.path("writerId").asLong()) + assertEquals(owner.nickname, reply.path("writerNickname").asText()) + assertEquals(profileUrl(owner), reply.path("writerProfileImageUrl").asText()) + assertEquals("first creator reply", reply.path("content").asText()) + assertEquals("2026-07-28T12:03:00Z", reply.path("createdAtUtc").asText()) + } + + @Test + @DisplayName("관리자 FanTalk 목록은 page size hasNext와 전체 count를 유지한다") + fun shouldApplyPaginationAndKeepTotalCount() { + val character = createCharacter("fantalk-pagination-character") + val owner = character.creatorMember!! + val admin = saveMember("fantalk-pagination-admin", MemberRole.ADMIN) + val writer = saveMember("fantalk-pagination-writer") + val createdAt = LocalDateTime.of(2026, 7, 28, 13, 0) + saveCheers(writer, owner, "oldest", true, createdAt) + saveCheers(writer, owner, "middle", true, createdAt.plusMinutes(1)) + saveCheers(writer, owner, "newest", true, createdAt.plusMinutes(2)) + entityManager.flush() + entityManager.clear() + + val middlePage = getFanTalks(character.id!!, admin, page = 1, size = 20) + val lastPage = getFanTalks(character.id!!, admin, page = 2, size = 20) + + assertEquals(3, middlePage.path("fanTalkCount").asInt()) + assertTrue(middlePage.path("fanTalks").isEmpty) + assertEquals(1, middlePage.path("page").asInt()) + assertEquals(20, middlePage.path("size").asInt()) + assertTrue(!middlePage.path("hasNext").asBoolean()) + assertEquals(3, lastPage.path("fanTalkCount").asInt()) + assertTrue(lastPage.path("fanTalks").isEmpty) + assertEquals(2, lastPage.path("page").asInt()) + assertEquals(20, lastPage.path("size").asInt()) + assertTrue(!lastPage.path("hasNext").asBoolean()) + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 FanTalk 목록을 400으로 거부한다") + fun shouldRejectInactiveTarget() { + val character = createCharacter("fantalk-inactive-character") + val admin = saveMember("fantalk-inactive-admin", MemberRole.ADMIN) + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/fan-talks") + .header("Accept-Language", "en") + .with(adminAuthentication(admin)) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("관리자 FanTalk 목록은 공개 v2와 같이 pagination 범위를 보정한다") + fun shouldNormalizeInvalidPaginationLikePublicV2() { + val character = createCharacter("fantalk-invalid-page-character") + val admin = saveMember("fantalk-invalid-page-admin", MemberRole.ADMIN) + val writer = saveMember("fantalk-invalid-page-writer") + saveCheers(writer, character.creatorMember!!, "pagination root", true, LocalDateTime.of(2026, 7, 28, 14, 0)) + entityManager.flush() + entityManager.clear() + + listOf( + Triple(-1, 20, 0 to 20), + Triple(0, 0, 0 to 20), + Triple(0, 1, 0 to 20), + Triple(0, 51, 0 to 50) + ).forEach { (page, size, expected) -> + val data = getFanTalks(character.id!!, admin, page = page, size = size) + + assertEquals(expected.first, data.path("page").asInt()) + assertEquals(expected.second, data.path("size").asInt()) + } + } + + private fun getFanTalks(characterId: Long, admin: Member, page: Int = 0, size: Int = 20): JsonNode { + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/$characterId/fan-talks") + .param("page", page.toString()) + .param("size", size.toString()) + .with(adminAuthentication(admin)) + ) + .andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + profileImage = "$prefix.png", + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + isActive: Boolean, + createdAt: LocalDateTime, + parent: CreatorCheers? = null + ): CreatorCheers { + val cheers = CreatorCheers(cheers = content, languageCode = "ko", isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + } + entityManager.persist(cheers) + entityManager.flush() + entityManager.createQuery("update CreatorCheers e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", cheers.id!!) + .executeUpdate() + return cheers + } + + private fun saveBlock(member: Member, blockedMember: Member) { + entityManager.persist( + BlockMember().apply { + this.member = member + this.blockedMember = blockedMember + } + ) + } + + private fun profileUrl(member: Member): String { + return member.profileImage?.let { "https://test.cloudfront.net/$it" } + ?: "https://test.cloudfront.net/profile/default-profile.png" + } + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt new file mode 100644 index 00000000..b594f3bb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyContractTest.kt @@ -0,0 +1,208 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.util.stream.Stream +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 빈 문자열과 공백 content를 저장이나 언어 감지 전에 거부한다") + fun shouldRejectEmptyOrBlankContentBeforeSavingOrPublishing(language: String, message: String) { + assertInvalidReply(language, message, """{"content":""}""") + assertInvalidReply(language, message, """{"content":" "}""") + } + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 malformed, missing body와 content binding 오류를 공통 오류 envelope로 반환한다") + fun shouldReturnInvalidRequestEnvelopeForMalformedOrMissingRequestBody(language: String, message: String) { + assertInvalidReply(language, message, """{"content":"unterminated""", includeBody = true) + assertInvalidReply(language, message, "{}") + assertInvalidReply(language, message, requestBody = null) + } + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 계약 밖 필드를 저장이나 언어 감지 전에 거부한다") + fun shouldRejectUnknownFieldsBeforeSavingOrPublishing(language: String, message: String) { + assertInvalidReply(language, message, """{"content":"reply","extra":"ignored"}""") + } + + @ParameterizedTest + @MethodSource("localizedMessages") + @DisplayName("FanTalk reply는 application/json이 아닌 요청을 저장이나 언어 감지 전에 415로 거부한다") + fun shouldRejectUnsupportedMediaTypeBeforeSavingOrPublishing(language: String, message: String) { + val character = createCharacter("fantalk-reply-media-type-$language") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-reply-media-type-admin-$language", MemberRole.ADMIN) + val root = saveRoot(saveMember("fantalk-reply-media-type-writer-$language"), creator) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + mockMvc.perform( + replyRequest(character.id!!, root.id!!, admin, language, """{"content":"reply"}""") + .contentType(MediaType.TEXT_PLAIN) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(beforeCount, countCheers()) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun assertInvalidReply( + language: String, + message: String, + requestBody: String, + includeBody: Boolean = true + ) { + assertInvalidReply(language, message, requestBody.takeIf { includeBody }) + } + + private fun assertInvalidReply(language: String, message: String, requestBody: String?) { + val character = createCharacter("fantalk-reply-contract-$language-${requestBody.hashCode()}") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-reply-contract-admin-$language-${requestBody.hashCode()}", MemberRole.ADMIN) + val root = saveRoot(saveMember("fantalk-reply-contract-writer-$language-${requestBody.hashCode()}"), creator) + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + mockMvc.perform(replyRequest(character.id!!, root.id!!, admin, language, requestBody)) + .andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(beforeCount, countCheers()) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun replyRequest( + characterId: Long, + fanTalkId: Long, + admin: Member, + language: String, + requestBody: String? + ): MockHttpServletRequestBuilder { + return post("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies") + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + .also { request -> requestBody?.let(request::content) } + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveRoot(member: Member, creator: Member): CreatorCheers { + return CreatorCheers(cheers = "root", languageCode = "ko").apply { + this.member = member + this.creator = creator + }.also(entityManager::persist) + } + + private fun countCheers(): Long = entityManager.createQuery( + "select count(c) from CreatorCheers c", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private companion object { + @JvmStatic + fun localizedMessages(): Stream = Stream.of( + Arguments.of("ko", "잘못된 요청입니다."), + Arguments.of("en", "Invalid request."), + Arguments.of("ja", "無効なリクエストです。") + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt new file mode 100644 index 00000000..a7e9d6c6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyCreateTest.kt @@ -0,0 +1,146 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyCreateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @Test + @DisplayName("관리자 FanTalk root 답변은 target creator로 저장하고 언어 감지 이벤트를 발행한다") + fun shouldCreateReplyForActiveRootWithTargetOwnerAndPublishLanguageDetection() { + val character = createCharacter("fantalk-reply-character") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-reply-admin", MemberRole.ADMIN) + val writer = saveMember("fantalk-reply-writer") + val root = saveRoot(writer, creator, "root fan talk") + val content = "thank you for your support" + entityManager.flush() + entityManager.clear() + + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + val data = try { + createReply(character.id!!, root.id!!, content, admin) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals( + setOf("fanTalkId", "replyId", "creatorMemberId", "content", "createdAtUtc"), + data.fieldNames().asSequence().toSet() + ) + assertEquals(root.id, data.path("fanTalkId").asLong()) + val replyId = data.path("replyId").asLong() + assertTrue(replyId > 0) + assertEquals(creator.id, data.path("creatorMemberId").asLong()) + assertEquals(content, data.path("content").asText()) + assertNotNull(OffsetDateTime.parse(data.path("createdAtUtc").asText())) + + val reply = entityManager.find(CreatorCheers::class.java, replyId) + assertNotNull(reply) + assertEquals(root.id, reply!!.parent!!.id) + assertEquals(creator.id, reply.member!!.id) + assertEquals(creator.id, reply.creator!!.id) + assertTrue(reply.member!!.id != admin.id) + assertNull(reply.languageCode) + + val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(replyId, eventCaptor.value.id) + assertEquals(content, eventCaptor.value.query) + assertEquals(LanguageDetectTargetType.CREATOR_CHEERS, eventCaptor.value.targetType) + } + + private fun createReply(characterId: Long, fanTalkId: Long, content: String, admin: Member): JsonNode { + val result = mockMvc.perform( + post("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(mapOf("content" to content))) + .with(adminAuthentication(admin)) + ) + .andExpect(status().isOk) + .andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveRoot(member: Member, creator: Member, content: String): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko").apply { + this.member = member + this.creator = creator + }.also(entityManager::persist) + } + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt new file mode 100644 index 00000000..1a4c2819 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyOwnershipTest.kt @@ -0,0 +1,224 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyOwnershipTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("다른 AI 캐릭터의 root FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectCrossCharacterRootBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val targetCharacter = createCharacter("fantalk-ownership-target") + val otherCharacter = createCharacter("fantalk-ownership-other") + val admin = saveMember("fantalk-ownership-admin", MemberRole.ADMIN) + val root = saveCheers( + member = saveMember("fantalk-ownership-writer"), + creator = otherCharacter.creatorMember!!, + content = "other character root" + ) + + assertRejectedReply(targetCharacter.id!!, root.id!!, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("nested FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectNestedFanTalkBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-nested") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-ownership-nested-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-ownership-nested-writer"), creator, "root") + val nested = saveCheers(creator, creator, "nested", parent = root) + + assertRejectedReply(character.id!!, nested.id!!, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("비활성 root FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectInactiveRootBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-inactive-root") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-ownership-inactive-root-admin", MemberRole.ADMIN) + val root = saveCheers( + member = saveMember("fantalk-ownership-inactive-root-writer"), + creator = creator, + content = "inactive root", + isActive = false + ) + + assertRejectedReply(character.id!!, root.id!!, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("존재하지 않는 FanTalk 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectMissingFanTalkBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-missing") + val admin = saveMember("fantalk-ownership-missing-admin", MemberRole.ADMIN) + + assertRejectedReply(character.id!!, Long.MAX_VALUE, admin, language, message) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("비활성 AI 캐릭터 target 답변은 현지화된 invalid request로 거부한다") + fun shouldRejectInactiveTargetBeforeReplySaveAndLanguageDetection(language: String, message: String) { + val character = createCharacter("fantalk-ownership-inactive-target").apply { isActive = false } + val creator = character.creatorMember!! + val admin = saveMember("fantalk-ownership-inactive-target-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-ownership-inactive-target-writer"), creator, "root") + + assertRejectedReply(character.id!!, root.id!!, admin, language, message) + } + + private fun assertRejectedReply( + characterId: Long, + fanTalkId: Long, + admin: Member, + language: String, + message: String + ) { + entityManager.flush() + entityManager.clear() + val beforeCount = countCheers() + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + mockMvc.perform( + post("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(mapOf("content" to "should not be saved"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ).andExpectInvalidRequest(message) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(beforeCount, countCheers()) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun ResultActions.andExpectInvalidRequest(message: String) { + andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + parent: CreatorCheers? = null, + isActive: Boolean = true + ): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko", isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun countCheers(): Long = entityManager.createQuery( + "select count(c) from CreatorCheers c", + java.lang.Long::class.java + ).singleResult.toLong() + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt new file mode 100644 index 00000000..8c33a1cf --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateContractTest.kt @@ -0,0 +1,261 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyUpdateContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @ParameterizedTest + @CsvSource("inactive-root", "cross-character-root", "cross-root-reply", "fan-authored-reply", "root-row", "nested-reply") + @DisplayName("관리자 FanTalk 답변 수정은 target/root/direct-reply 경계를 벗어나면 변경 없이 400으로 거부한다") + fun shouldRejectInvalidReplyOwnershipWithoutMutation(caseName: String) { + val fixture = invalidOwnershipFixture(caseName) + entityManager.flush() + entityManager.clear() + val beforeContent = findCheers(fixture.targetRow.id!!).cheers + val beforeActive = findCheers(fixture.targetRow.id!!).isActive + + updateReply( + fixture.characterId, + fixture.fanTalkId, + fixture.replyId, + fixture.admin, + """{"content":"mutated","isActive":false}""" + ) + .andExpectInvalidRequest("Invalid request.") + + entityManager.flush() + entityManager.clear() + assertEquals(beforeContent, findCheers(fixture.targetRow.id!!).cheers) + assertEquals(beforeActive, findCheers(fixture.targetRow.id!!).isActive) + } + + @ParameterizedTest + @CsvSource("ko,잘못된 요청입니다.", "en,Invalid request.", "ja,無効なリクエストです。") + @DisplayName("관리자 FanTalk 답변 수정은 malformed JSON과 미지 필드를 공통 오류 envelope로 거부한다") + fun shouldRejectMalformedJsonAndUnknownFields(language: String, message: String) { + val fixture = validFixture("fantalk-update-contract-$language") + assertInvalidUpdate(fixture, language, message, """{"content":"unterminated""") + assertInvalidUpdate(fixture, language, message, """{"content":"ok","extra":"nope"}""") + } + + @ParameterizedTest + @CsvSource("ko,잘못된 요청입니다.", "en,Invalid request.", "ja,無効なリクエストです。") + @DisplayName("관리자 FanTalk 답변 수정은 JSON이 아닌 요청을 변경이나 이벤트 없이 415로 거부한다") + fun shouldRejectUnsupportedMediaTypeBeforeMutationOrEvent(language: String, message: String) { + val fixture = validFixture("fantalk-update-media-$language") + entityManager.flush() + entityManager.clear() + val beforeContent = findCheers(fixture.reply.id!!).cheers + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + + try { + updateReply(fixture, """{"content":"new"}""", language, MediaType.TEXT_PLAIN) + .andExpect(status().isUnsupportedMediaType) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + entityManager.flush() + entityManager.clear() + assertEquals(beforeContent, findCheers(fixture.reply.id!!).cheers) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun assertInvalidUpdate(fixture: ValidFixture, language: String, message: String, requestBody: String) { + entityManager.flush() + entityManager.clear() + val beforeContent = findCheers(fixture.reply.id!!).cheers + val beforeActive = findCheers(fixture.reply.id!!).isActive + + updateReply(fixture, requestBody, language).andExpectInvalidRequest(message) + + entityManager.flush() + entityManager.clear() + assertEquals(beforeContent, findCheers(fixture.reply.id!!).cheers) + assertEquals(beforeActive, findCheers(fixture.reply.id!!).isActive) + } + + private fun updateReply( + fixture: ValidFixture, + requestBody: String, + language: String, + contentType: MediaType = MediaType.APPLICATION_JSON + ): ResultActions = updateReply( + fixture.character.id!!, + fixture.root.id!!, + fixture.reply.id!!, + fixture.admin, + requestBody, + language, + contentType + ) + + private fun updateReply( + characterId: Long, + fanTalkId: Long, + replyId: Long, + admin: Member, + requestBody: String, + language: String = "en", + contentType: MediaType = MediaType.APPLICATION_JSON + ): ResultActions { + return mockMvc.perform( + put("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies/$replyId") + .contentType(contentType) + .content(requestBody) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ) + } + + private fun ResultActions.andExpectInvalidRequest(message: String) { + andExpect(status().isBadRequest) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun validFixture(prefix: String): ValidFixture { + val character = createCharacter(prefix) + val creator = character.creatorMember!! + val admin = saveMember("$prefix-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("$prefix-fan"), creator, "root") + val reply = saveCheers(creator, creator, "reply", parent = root) + return ValidFixture(character, admin, root, reply) + } + + private fun invalidOwnershipFixture(caseName: String): InvalidOwnershipFixture { + val character = createCharacter("fantalk-update-$caseName-target") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-$caseName-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-$caseName-fan"), creator, "root") + return when (caseName) { + "inactive-root" -> { + root.isActive = false + val reply = saveCheers(creator, creator, "reply", parent = root) + InvalidOwnershipFixture(character.id!!, root.id!!, reply.id!!, admin, reply) + } + "cross-character-root" -> { + val other = createCharacter("fantalk-update-cross-character-other") + val otherRoot = saveCheers(saveMember("fantalk-update-cross-character-fan"), other.creatorMember!!, "other") + val reply = saveCheers(other.creatorMember!!, other.creatorMember!!, "reply", parent = otherRoot) + InvalidOwnershipFixture(character.id!!, otherRoot.id!!, reply.id!!, admin, reply) + } + "cross-root-reply" -> { + val otherRoot = saveCheers(saveMember("fantalk-update-cross-root-fan"), creator, "other root") + val reply = saveCheers(creator, creator, "reply", parent = otherRoot) + InvalidOwnershipFixture(character.id!!, root.id!!, reply.id!!, admin, reply) + } + "fan-authored-reply" -> { + val fanReply = saveCheers(saveMember("fantalk-update-fan-reply"), creator, "fan reply", parent = root) + InvalidOwnershipFixture(character.id!!, root.id!!, fanReply.id!!, admin, fanReply) + } + "root-row" -> InvalidOwnershipFixture(character.id!!, root.id!!, root.id!!, admin, root) + "nested-reply" -> { + val reply = saveCheers(creator, creator, "reply", parent = root) + val nested = saveCheers(creator, creator, "nested", parent = reply) + InvalidOwnershipFixture(character.id!!, root.id!!, nested.id!!, admin, nested) + } + else -> error(caseName) + } + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member(email = "$prefix@test.com", password = "password", nickname = prefix, role = role) + .also(entityManager::persist) + } + + private fun saveCheers(member: Member, creator: Member, content: String, parent: CreatorCheers? = null): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko").apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun findCheers(id: Long): CreatorCheers = entityManager.find(CreatorCheers::class.java, id) + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken(MemberAdapter(admin), "token", listOf(SimpleGrantedAuthority("ROLE_ADMIN"))) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private data class ValidFixture( + val character: ChatCharacter, + val admin: Member, + val root: CreatorCheers, + val reply: CreatorCheers + ) + + private data class InvalidOwnershipFixture( + val characterId: Long, + val fanTalkId: Long, + val replyId: Long, + val admin: Member, + val targetRow: CreatorCheers + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt new file mode 100644 index 00000000..0e9fab56 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/AiCharacterAdminFanTalkReplyUpdateTest.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.OffsetDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminFanTalkReplyUpdateTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val fanTalkFacade: AiCharacterAdminFanTalkFacade +) { + private val applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + + @Test + @DisplayName("관리자 FanTalk 답변 수정은 content와 isActive를 선택 반영하고 reply 응답을 반환한다") + fun shouldUpdateReplyContentAndActiveState() { + val character = createCharacter("fantalk-update-success") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-success-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-success-fan"), creator, "root") + val reply = saveCheers(creator, creator, "old reply", parent = root, languageCode = "en") + entityManager.flush() + entityManager.clear() + + val originalPublisher = replaceFanTalkFacadePublisher(applicationEventPublisher) + val data = try { + updateReply(character.id!!, root.id!!, reply.id!!, admin, """{"content":"new reply","isActive":false}""") + .andExpect(status().isOk) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andReturnData() + } finally { + replaceFanTalkFacadePublisher(originalPublisher) + } + + assertEquals(reply.id, data.path("fanTalkId").asLong()) + assertEquals(creator.id, data.path("writerId").asLong()) + assertEquals("new reply", data.path("content").asText()) + assertTrue(data.path("creatorReplies").isEmpty) + OffsetDateTime.parse(data.path("createdAtUtc").asText()) + + entityManager.flush() + entityManager.clear() + val updated = findCheers(reply.id!!) + assertEquals("new reply", updated.cheers) + assertTrue(!updated.isActive) + assertEquals("en", updated.languageCode) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("관리자 FanTalk 답변 수정은 빈 객체와 explicit null을 성공 no-op 처리한다") + fun shouldNoopEmptyAndExplicitNullRequest() { + val character = createCharacter("fantalk-update-noop") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-noop-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-noop-fan"), creator, "root") + val reply = saveCheers(creator, creator, "keep me", parent = root, isActive = false) + entityManager.flush() + entityManager.clear() + + updateReply(character.id!!, root.id!!, reply.id!!, admin, "{}") + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("keep me")) + updateReply(character.id!!, root.id!!, reply.id!!, admin, """{"content":null,"isActive":null}""") + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("keep me")) + + entityManager.flush() + entityManager.clear() + val updated = findCheers(reply.id!!) + assertEquals("keep me", updated.cheers) + assertTrue(!updated.isActive) + } + + @Test + @DisplayName("관리자 FanTalk 답변 수정은 비활성 reply 재활성화와 공백 content 반영을 허용한다") + fun shouldReactivateInactiveReplyAndKeepLegacyBlankContentSemantics() { + val character = createCharacter("fantalk-update-reactivate") + val creator = character.creatorMember!! + val admin = saveMember("fantalk-update-reactivate-admin", MemberRole.ADMIN) + val root = saveCheers(saveMember("fantalk-update-reactivate-fan"), creator, "root") + val reply = saveCheers(creator, creator, "inactive reply", parent = root, isActive = false) + entityManager.flush() + entityManager.clear() + + updateReply(character.id!!, root.id!!, reply.id!!, admin, """{"content":" ","isActive":true}""") + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value(" ")) + + entityManager.flush() + entityManager.clear() + val updated = findCheers(reply.id!!) + assertEquals(" ", updated.cheers) + assertTrue(updated.isActive) + } + + private fun updateReply( + characterId: Long, + fanTalkId: Long, + replyId: Long, + admin: Member, + requestBody: String, + language: String = "en", + contentType: MediaType = MediaType.APPLICATION_JSON + ): ResultActions { + return mockMvc.perform( + put("/api/v2/admin/ai-characters/$characterId/fan-talks/$fanTalkId/replies/$replyId") + .contentType(contentType) + .content(requestBody) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication(admin)) + ) + } + + private fun ResultActions.andReturnData(): JsonNode { + val result = andReturn() + return objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveMember(prefix: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$prefix@test.com", + password = "password", + nickname = prefix, + role = role + ).also(entityManager::persist) + } + + private fun saveCheers( + member: Member, + creator: Member, + content: String, + parent: CreatorCheers? = null, + isActive: Boolean = true, + languageCode: String? = "ko" + ): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = languageCode, isActive = isActive).apply { + this.member = member + this.creator = creator + this.parent = parent + }.also(entityManager::persist) + } + + private fun findCheers(id: Long): CreatorCheers = entityManager.find(CreatorCheers::class.java, id) + + private fun adminAuthentication(admin: Member) = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(admin), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replaceFanTalkFacadePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(fanTalkFacade) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt new file mode 100644 index 00000000..8e3755f6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt @@ -0,0 +1,260 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContentService +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.content.series.ContentSeriesService +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.explorer.ExplorerService +import kr.co.vividnext.sodalive.explorer.profile.ChannelNoticeRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService +import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.MemberService +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import java.time.LocalDateTime + +class LegacyFanTalkReplyCharacterizationTest { + private lateinit var memberService: MemberService + private lateinit var queryRepository: ExplorerQueryRepository + private lateinit var cheersRepository: CreatorCheersRepository + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var messageSource: SodaMessageSource + private lateinit var service: ExplorerService + + @BeforeEach + fun setUp() { + memberService = Mockito.mock(MemberService::class.java) + queryRepository = Mockito.mock(ExplorerQueryRepository::class.java) + cheersRepository = Mockito.mock(CreatorCheersRepository::class.java) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + messageSource = Mockito.mock(SodaMessageSource::class.java) + service = ExplorerService( + memberService = memberService, + memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), + audioContentService = Mockito.mock(AudioContentService::class.java), + donationRankingService = Mockito.mock(CreatorDonationRankingService::class.java), + queryRepository = queryRepository, + cheersRepository = cheersRepository, + noticeRepository = Mockito.mock(ChannelNoticeRepository::class.java), + channelDonationService = Mockito.mock(ChannelDonationService::class.java), + communityService = Mockito.mock(CreatorCommunityService::class.java), + seriesService = Mockito.mock(ContentSeriesService::class.java), + applicationEventPublisher = applicationEventPublisher, + contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), + messageSource = messageSource, + langContext = LangContext().apply { setLang(Lang.KO) }, + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("기존 FanTalk 답변은 root parent와 작성자/크리에이터를 저장하고 빈 언어 코드에만 감지 이벤트를 발행한다") + fun shouldStoreRootReplyAndPublishLanguageDetectionWhenLanguageCodeIsBlank() { + val creator = createMember(10L, "reply-creator", MemberRole.CREATOR) + val writer = createMember(20L, "reply-writer") + val root = createCheers(id = 100L, content = "root fan talk", member = writer, creator = creator) + val createdAt = LocalDateTime.of(2026, 7, 28, 11, 30) + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(root.id!!)).thenReturn(root) + stubSave(id = 101L, createdAt = createdAt) + + val response = service.writeCheers( + PostWriteCheersRequest( + parentId = root.id, + creatorId = creator.id!!, + content = "thank you for your support", + languageCode = " " + ), + writer + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository).save(cheersCaptor.capture()) + val savedCheers = cheersCaptor.value + assertSame(root, savedCheers.parent) + assertSame(writer, savedCheers.member) + assertSame(creator, savedCheers.creator) + assertEquals("thank you for your support", savedCheers.cheers) + assertEquals(" ", savedCheers.languageCode) + assertEquals(101L, response.fanTalkId) + assertEquals(writer.id, response.writerId) + assertEquals(writer.nickname, response.writerNickname) + assertEquals("https://cdn.test/profile/default-profile.png", response.writerProfileImageUrl) + assertEquals("thank you for your support", response.content) + assertEquals("2026-07-28T11:30:00Z", response.createdAtUtc) + assertTrue(response.creatorReplies.isEmpty()) + + val eventCaptor = ArgumentCaptor.forClass(LanguageDetectEvent::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + assertEquals(101L, eventCaptor.value.id) + assertEquals("thank you for your support", eventCaptor.value.query) + assertEquals(LanguageDetectTargetType.CREATOR_CHEERS, eventCaptor.value.targetType) + } + + @Test + @DisplayName("기존 FanTalk 작성은 존재하지 않는 parent를 root 글처럼 저장하고 언어 코드가 있으면 감지 이벤트를 발행하지 않는다") + fun shouldCreateRootFanTalkWhenRequestedParentDoesNotExist() { + val creator = createMember(30L, "root-creator", MemberRole.CREATOR) + val writer = createMember(40L, "root-writer") + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(999L)).thenReturn(null) + stubSave(id = 102L, createdAt = LocalDateTime.of(2026, 7, 28, 11, 31)) + + service.writeCheers( + PostWriteCheersRequest( + parentId = 999L, + creatorId = creator.id!!, + content = "new root fan talk", + languageCode = "ko" + ), + writer + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository).save(cheersCaptor.capture()) + assertNull(cheersCaptor.value.parent) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + @Test + @DisplayName("기존 FanTalk 작성은 조회된 비활성 nested parent에도 답변을 연결한다") + fun shouldAllowReplyToInactiveNestedParentReturnedByQueryRepository() { + val creator = createMember(50L, "nested-creator", MemberRole.CREATOR) + val writer = createMember(60L, "nested-writer") + val root = createCheers(id = 200L, content = "root", member = writer, creator = creator) + val nested = createCheers(id = 201L, content = "nested", member = creator, creator = creator).apply { + parent = root + isActive = false + } + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(nested.id!!)).thenReturn(nested) + stubSave(id = 202L, createdAt = LocalDateTime.of(2026, 7, 28, 11, 32)) + + service.writeCheers( + PostWriteCheersRequest( + parentId = nested.id, + creatorId = creator.id!!, + content = "nested reply", + languageCode = "ko" + ), + writer + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository).save(cheersCaptor.capture()) + assertSame(nested, cheersCaptor.value.parent) + assertSame(root, nested.parent) + assertFalse(nested.isActive) + } + + @Test + @DisplayName("기존 FanTalk 작성은 같은 root에 대한 크리에이터 답변을 중복 허용한다") + fun shouldAllowMultipleCreatorRepliesToSameRoot() { + val creator = createMember(70L, "duplicate-creator", MemberRole.CREATOR) + val root = createCheers(id = 300L, content = "root", member = creator, creator = creator) + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(queryRepository.getCreatorCheers(root.id!!)).thenReturn(root) + var nextId = 301L + Mockito.`when`(cheersRepository.save(Mockito.any(CreatorCheers::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { cheers -> + cheers.id = nextId++ + cheers.createdAt = LocalDateTime.of(2026, 7, 28, 11, 33) + } + } + + service.writeCheers( + PostWriteCheersRequest(root.id, creator.id!!, "first reply", "ko"), + creator + ) + service.writeCheers( + PostWriteCheersRequest(root.id, creator.id!!, "second reply", "ko"), + creator + ) + + val cheersCaptor = ArgumentCaptor.forClass(CreatorCheers::class.java) + Mockito.verify(cheersRepository, Mockito.times(2)).save(cheersCaptor.capture()) + assertEquals(listOf("first reply", "second reply"), cheersCaptor.allValues.map { it.cheers }) + assertTrue(cheersCaptor.allValues.all { it.parent === root }) + } + + @Test + @DisplayName("기존 FanTalk 작성은 크리에이터가 없으면 user_not_found key를 반환한다") + fun shouldRejectMissingCreatorWithUserNotFoundMessageKey() { + val writer = createMember(80L, "missing-creator-writer") + Mockito.`when`(queryRepository.getMember(404L)).thenReturn(null) + + val exception = assertThrows(SodaException::class.java) { + service.writeCheers(PostWriteCheersRequest(creatorId = 404L, content = "reply"), writer) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + Mockito.verifyNoInteractions(cheersRepository, applicationEventPublisher) + } + + @Test + @DisplayName("기존 FanTalk 작성은 차단된 크리에이터에 explorer.creator.blocked_cheers 기반 메시지 예외를 반환한다") + fun shouldRejectBlockedCreatorWithLocalizedMessage() { + val creator = createMember(90L, "blocked-creator", MemberRole.CREATOR) + val writer = createMember(91L, "blocked-writer") + Mockito.`when`(queryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`(memberService.isBlocked(writer.id!!, creator.id!!)).thenReturn(true) + Mockito.`when`(messageSource.getMessage("explorer.creator.blocked_cheers", Lang.KO)).thenReturn("blocked %s") + + val exception = assertThrows(SodaException::class.java) { + service.writeCheers(PostWriteCheersRequest(creatorId = creator.id!!, content = "reply"), writer) + } + + assertNull(exception.messageKey) + assertEquals("blocked blocked-creator", exception.message) + Mockito.verifyNoInteractions(cheersRepository, applicationEventPublisher) + } + + private fun stubSave(id: Long, createdAt: LocalDateTime) { + Mockito.`when`(cheersRepository.save(Mockito.any(CreatorCheers::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { cheers -> + cheers.id = id + cheers.createdAt = createdAt + } + } + } + + private fun createCheers(id: Long, content: String, member: Member, creator: Member): CreatorCheers { + return CreatorCheers(cheers = content, languageCode = "ko").apply { + this.id = id + this.member = member + this.creator = creator + } + } + + private fun createMember(id: Long, nickname: String, role: MemberRole = MemberRole.USER): Member { + return Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ).apply { this.id = id } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt new file mode 100644 index 00000000..2df32ecb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContentTest.kt @@ -0,0 +1,424 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesContentTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("연결 콘텐츠 목록은 legacy 응답 형태와 owner 전체 link 수를 반환한다") + fun shouldReturnLinkedContentsWithLegacyResponseShape() { + val character = createCharacter("series-content-list-character") + val owner = character.creatorMember!! + val genre = saveGenre("series-content-list-genre") + val targetSeries = saveSeries(owner, genre, "target series") + val anotherSeries = saveSeries(owner, genre, "another series") + val targetContent = saveAudioContent(owner, "target night") + val anotherContent = saveAudioContent(owner, "another night") + saveSeriesContent(targetSeries, targetContent) + saveSeriesContent(anotherSeries, anotherContent) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${targetSeries.id}/contents") + .param("page", "0") + .param("size", "20") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + val item = data.path("items").single() + assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet()) + assertEquals(2, data.path("totalCount").asInt()) + assertEquals(targetContent.id, item.path("contentId").asLong()) + assertEquals("target night", item.path("title").asText()) + assertEquals("https://test.cloudfront.net/audio/target-night.png", item.path("coverImage").asText()) + assertEquals(setOf("contentId", "coverImage", "title", "isAdult"), item.fieldNames().asSequence().toSet()) + } + + @Test + @DisplayName("미연결 콘텐츠 검색은 eligible owner 콘텐츠만 별도 배열 응답으로 반환한다") + fun shouldSearchEligibleUnlinkedOwnerContentsWithSeparateResponse() { + val character = createCharacter("series-content-search-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-content-search-other-character") + val genre = saveGenre("series-content-search-genre") + val series = saveSeries(owner, genre, "search series") + val linked = saveAudioContent(owner, "night linked") + val eligible = saveAudioContent(owner, "night eligible") + val reserved = saveAudioContent(owner, "night reserved", isActive = false) + saveAudioContent(owner, "night processing", duration = null) + saveAudioContent(owner, "night inactive", isActive = false, releaseDate = null) + saveAudioContent(otherCharacter.creatorMember!!, "night foreign") + saveSeriesContent(series, linked) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/search") + .param("search_word", "night") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val items = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + assertEquals(setOf(eligible.id, reserved.id), items.map { it.path("contentId").asLong() }.toSet()) + assertTrue(items.all { it.fieldNames().asSequence().toSet() == setOf("contentId", "title", "coverImage") }) + assertTrue(items.all { it.path("coverImage").asText().startsWith("https://test.cloudfront.net/audio/") }) + } + + @Test + @DisplayName("연결 목록은 pagination 경계를 적용하고 잘못된 pagination을 거부한다") + fun shouldApplyPaginationAndRejectInvalidPagination() { + val character = createCharacter("series-content-pagination-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-pagination-genre"), "pagination series") + saveSeriesContent(series, saveAudioContent(owner, "pagination first")) + saveSeriesContent(series, saveAudioContent(owner, "pagination second")) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .param("page", "1") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + + listOf("-1" to "20", "0" to "0").forEach { (page, size) -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .param("page", page) + .param("size", size) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + } + + @Test + @DisplayName("소유한 eligible 콘텐츠는 모두 연결하고 성공 응답은 data null이다") + fun shouldLinkAllOwnedEligibleContentsAtomically() { + val character = createCharacter("series-content-link-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-link-genre"), "link series") + val first = saveAudioContent(owner, "link first") + val second = saveAudioContent(owner, "link second") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[${first.id},${second.id}]}""") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(setOf(first.id, second.id), contentIdsInSeries(series.id!!).toSet()) + } + + @Test + @DisplayName("콘텐츠 연결 body에 계약 밖 field가 있으면 400으로 거부하고 연결하지 않는다") + fun shouldRejectContentLinkUnknownFieldWithoutMutation() { + val character = createCharacter("series-content-unknown-field-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-unknown-field-genre"), "unknown field series") + val content = saveAudioContent(owner, "unknown field content") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[${content.id}],"unexpected":true}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertTrue(contentIdsInSeries(series.id!!).isEmpty()) + } + + @Test + @DisplayName("잘못된 콘텐츠 ID와 이미 연결된 콘텐츠는 일부 연결 없이 거부하고 빈 목록은 legacy 오류를 유지한다") + fun shouldRejectInvalidOrLinkedContentBeforeAnyLinkMutation() { + val character = createCharacter("series-content-invalid-link-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-content-invalid-link-other-character") + val series = saveSeries(owner, saveGenre("series-content-invalid-link-genre"), "invalid link series") + val valid = saveAudioContent(owner, "valid link") + val foreign = saveAudioContent(otherCharacter.creatorMember!!, "foreign link") + val inactive = saveAudioContent(owner, "inactive link", isActive = false, releaseDate = null) + val linked = saveAudioContent(owner, "already linked") + saveSeriesContent(series, linked) + entityManager.flush() + entityManager.clear() + + listOf( + listOf(valid.id, foreign.id), + listOf(valid.id, Long.MAX_VALUE), + listOf(valid.id, inactive.id), + listOf(valid.id, valid.id), + listOf(linked.id) + ).forEach { contentIdList -> + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":$contentIdList}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertEquals(setOf(linked.id), contentIdsInSeries(series.id!!).toSet()) + } + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"contentIdList":[]}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.message").value("No content was added.")) + } + + @Test + @DisplayName("연결 해제는 기존 소유 link만 제거하고 성공 응답은 data null이다") + fun shouldUnlinkExistingOwnerContentAndRejectMissingLink() { + val character = createCharacter("series-content-unlink-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-unlink-genre"), "unlink series") + val content = saveAudioContent(owner, "unlink content") + saveSeriesContent(series, content) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(contentIdsInSeries(series.id!!).isEmpty()) + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + + @Test + @DisplayName("연결 해제는 soft delete된 기존 소유 link도 제거한다") + fun shouldUnlinkSoftDeletedOwnerContent() { + val character = createCharacter("series-content-unlink-soft-deleted-character") + val owner = character.creatorMember!! + val series = saveSeries(owner, saveGenre("series-content-unlink-soft-deleted-genre"), "soft deleted unlink") + val content = saveAudioContent(owner, "soft deleted unlink content") + saveSeriesContent(series, content) + content.isActive = false + content.releaseDate = null + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}/contents/${content.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertTrue(contentIdsInSeries(series.id!!).isEmpty()) + } + + @Test + @DisplayName("비활성 또는 타 소유 시리즈와 malformed 연결 요청은 400으로 거부한다") + fun shouldRejectInvalidSeriesAndMalformedContentRequests() { + val character = createCharacter("series-content-invalid-series-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-content-invalid-series-other-character") + val genre = saveGenre("series-content-invalid-series-genre") + val inactive = saveSeries(owner, genre, "inactive series", isActive = false) + val foreign = saveSeries(otherCharacter.creatorMember!!, genre, "foreign series") + val content = saveAudioContent(owner, "malformed content") + entityManager.flush() + entityManager.clear() + + listOf(inactive.id!!, foreign.id!!, Long.MAX_VALUE).forEach { seriesId -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/$seriesId/contents/search") + .param("search_word", "content") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + + mockMvc.perform( + post("/api/v2/admin/ai-characters/${character.id}/series/${foreign.id}/contents") + .contentType(MediaType.APPLICATION_JSON) + .content("{") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${foreign.id}/contents/${content.id}") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + } + + private fun ResultActions.andExpectInvalidRequest(): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun saveSeries(owner: Member, genre: SeriesGenre, title: String, isActive: Boolean = true): Series { + return Series( + title = title, + introduction = "introduction for $title", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun saveAudioContent( + owner: Member, + title: String, + isActive: Boolean = true, + releaseDate: LocalDateTime? = LocalDateTime.of(2026, 7, 24, 10, 0), + duration: String? = "00:10:00" + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + return AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5, + releaseDate = releaseDate + ).apply { + member = owner + this.theme = theme + this.isActive = isActive + this.duration = duration + content = "audio/${title.replace(" ", "-")}.mp3" + coverImage = "audio/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + return SeriesContent().apply { + this.series = series + this.content = content + entityManager.persist(this) + } + } + + private fun contentIdsInSeries(seriesId: Long): List { + return entityManager.createQuery( + "select seriesContent.content.id from SeriesContent seriesContent where seriesContent.series.id = :seriesId", + java.lang.Long::class.java + ) + .setParameter("seriesId", seriesId) + .resultList + .map(java.lang.Long::toLong) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt new file mode 100644 index 00000000..d4184bd0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesContractTest.kt @@ -0,0 +1,284 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.amazonaws.services.s3.AmazonS3Client +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesContractTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + @Autowired + private lateinit var legacySeriesService: CreatorAdminContentSeriesService + + private lateinit var originalPublisher: ApplicationEventPublisher + + @BeforeEach + fun setUp() { + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/series-cover")) + originalPublisher = replacePublisher(applicationEventPublisher) + } + + @AfterEach + fun tearDown() { + replacePublisher(originalPublisher) + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("target invalid request는 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForTarget(language: String, message: String) { + val target = createCharacter("series-contract-target-$language") + target.isActive = false + entityManager.flush() + entityManager.clear() + + listOf( + get("/api/v2/admin/ai-characters/${target.id}/series"), + get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("series invalid request는 active target에서도 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForSeries(language: String, message: String) { + val target = createCharacter("series-contract-series-target-$language") + val otherTarget = createCharacter("series-contract-series-other-$language") + val genre = saveGenre("series-contract-series-genre-$language") + val inactiveSeries = saveSeries(target.creatorMember!!, genre, "inactive detail", isActive = false) + val foreignSeries = saveSeries(otherTarget.creatorMember!!, genre, "foreign detail") + entityManager.flush() + entityManager.clear() + + listOf( + get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}"), + get("/api/v2/admin/ai-characters/${target.id}/series/${inactiveSeries.id}"), + get("/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("content/pagination invalid request는 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForContentAndPagination(language: String, message: String) { + val target = createCharacter("series-contract-content-target-$language") + val otherTarget = createCharacter("series-contract-content-other-$language") + val genre = saveGenre("series-contract-content-genre-$language") + val series = saveSeries(target.creatorMember!!, genre, "content series") + val inactiveSeries = saveSeries(target.creatorMember!!, genre, "inactive content series", isActive = false) + val foreignSeries = saveSeries(otherTarget.creatorMember!!, genre, "foreign content series") + entityManager.flush() + entityManager.clear() + + listOf( + get("/api/v2/admin/ai-characters/${target.id}/series/${Long.MAX_VALUE}/contents/search") + .param("search_word", "night"), + get("/api/v2/admin/ai-characters/${target.id}/series/${series.id}/contents") + .param("page", "-1") + .param("size", "20"), + get("/api/v2/admin/ai-characters/${target.id}/series/${series.id}/contents") + .param("page", "0") + .param("size", "0"), + get("/api/v2/admin/ai-characters/${target.id}/series/${inactiveSeries.id}/contents/search") + .param("search_word", "night"), + get("/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}/contents/search") + .param("search_word", "night") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("order/binding invalid request는 400 invalid_request envelope을 유지한다") + fun shouldReturnInvalidRequestEnvelopeForOrderAndBinding(language: String, message: String) { + val target = createCharacter("series-contract-order-target-$language") + val otherTarget = createCharacter("series-contract-order-other-$language") + val genre = saveGenre("series-contract-order-genre-$language") + val owned = saveSeries(target.creatorMember!!, genre, "owned order") + val inactive = saveSeries(target.creatorMember!!, genre, "inactive order", isActive = false) + val foreign = saveSeries(otherTarget.creatorMember!!, genre, "foreign order") + entityManager.flush() + entityManager.clear() + + listOf( + put("/api/v2/admin/ai-characters/${target.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("{"), + put("/api/v2/admin/ai-characters/${target.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${owned.id},${foreign.id},${inactive.id}]}""") + ).forEach { request -> + mockMvc.perform(request.header(HttpHeaders.ACCEPT_LANGUAGE, language).with(adminAuthentication())) + .andExpectInvalidRequest(message) + } + + mockMvc.perform( + delete("/api/v2/admin/ai-characters/${target.id}/series/${foreign.id}/contents/${owned.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ).andExpectInvalidRequest(message) + } + + @Test + @DisplayName("타 owner 수정은 400 invalid_request로 막고 DB와 event side effect를 남기지 않는다") + fun shouldRejectCrossOwnerMutationWithoutSideEffects() { + val target = createCharacter("series-contract-mutation-target") + val foreign = createCharacter("series-contract-mutation-foreign") + val foreignSeries = saveSeries(foreign.creatorMember!!, saveGenre("series-contract-mutation-genre"), "foreign mutation") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${target.id}/series/${foreignSeries.id}" + ) + .file(MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1))) + .file(MockMultipartFile("request", "request.json", MediaType.APPLICATION_JSON_VALUE, "{".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest("Invalid request.") + + entityManager.clear() + assertEquals("foreign mutation", entityManager.find(Series::class.java, foreignSeries.id).title) + Mockito.verifyNoInteractions(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any()) + } + + private fun ResultActions.andExpectInvalidRequest(message: String): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre = SeriesGenre(genre = name).also(entityManager::persist) + + private fun saveSeries(owner: Member, genre: SeriesGenre, title: String, isActive: Boolean = true): Series { + return Series( + title = title, + introduction = "introduction for $title", + state = SeriesState.PROCEEDING, + writer = "writer", + studio = "studio", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive, + orders = 1 + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private fun replacePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(legacySeriesService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt new file mode 100644 index 00000000..a7c472cd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesGenreTest.kt @@ -0,0 +1,103 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesGenreTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager +) { + @Test + @DisplayName("시리즈 장르 목록은 활성 장르만 orders 오름차순 직접 배열로 반환한다") + fun shouldReturnActiveGenresOrderedByOrders() { + val second = saveGenre("second", isAdult = true, orders = 20) + val first = saveGenre("first", isAdult = false, orders = 10) + saveGenre("inactive", isAdult = false, isActive = false, orders = 1) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get(PATH).with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(first.id)) + .andExpect(jsonPath("$.data[0].genre").value("first")) + .andExpect(jsonPath("$.data[0].isAdult").value(false)) + .andExpect(jsonPath("$.data[1].id").value(second.id)) + .andExpect(jsonPath("$.data[1].genre").value("second")) + .andExpect(jsonPath("$.data[1].isAdult").value(true)) + .andExpect(jsonPath("$.data[2]").doesNotExist()) + } + + @Test + @DisplayName("시리즈 장르 목록은 빈 목록과 ADMIN 공통 경계를 유지한다") + fun shouldReturnEmptyListAndKeepAdminBoundary() { + mockMvc.perform(get(PATH).with(adminAuthentication())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.length()").value(0)) + + mockMvc.perform(get(PATH)) + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun saveGenre( + name: String, + isAdult: Boolean, + isActive: Boolean = true, + orders: Int + ): SeriesGenre { + return SeriesGenre( + genre = name, + isAdult = isAdult, + isActive = isActive, + orders = orders + ).also(entityManager::persist) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + + private companion object { + const val PATH = "/api/v2/admin/ai-characters/series-genres" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt new file mode 100644 index 00000000..06f738bd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesMutationTest.kt @@ -0,0 +1,1156 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.model.PutObjectRequest +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.mock.web.MockPart +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.util.AopTestUtils +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.support.MissingServletRequestPartException +import java.net.URL +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesMutationTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val legacyService: CreatorAdminContentSeriesService, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + @MockBean + private lateinit var applicationEventPublisher: ApplicationEventPublisher + + private lateinit var originalSeriesServicePublisher: ApplicationEventPublisher + + @BeforeEach + fun replaceActualServicePublisher() { + originalSeriesServicePublisher = replaceSeriesServicePublisher(applicationEventPublisher) + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/series-cover")) + } + + @AfterEach + fun restoreActualServicePublisher() { + replaceSeriesServicePublisher(originalSeriesServicePublisher) + } + + @Test + @DisplayName("생성은 target owner로 레거시 키워드·표지·이벤트 동작을 적용하고 v2 null 성공 envelope을 반환한다") + fun shouldCreateOwnedSeriesThroughLegacyMutationPath() { + val character = createCharacter("series-mutation-create-character") + val genre = saveGenre("series-mutation-create-genre") + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file( + requestPart( + """ + { + "title":"created series", + "introduction":"created introduction", + "publishedDaysOfWeek":["MON","WED"], + "keyword":"#night night #walk", + "genreId":${genre.id}, + "isAdult":true, + "writer":"created writer", + "studio":"created studio" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + + val created = findSeriesByTitle("created series")!! + assertEquals(character.creatorMember!!.id, created.member!!.id) + assertEquals(genre.id, created.genre!!.id) + assertEquals( + setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), + created.publishedDaysOfWeek + ) + assertEquals(setOf("#night", "#walk"), created.keywordList.map { it.keyword!!.tag }.toSet()) + assertTrue(created.coverImage!!.startsWith("series_cover/${created.id}/${created.id}-cover")) + assertTrue(created.isAdult) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageDetectEvent + assertEquals(created.id, event.id) + assertEquals("created series created introduction #night night #walk", event.query) + assertEquals(LanguageDetectTargetType.SERIES, event.targetType) + } + + @Test + @DisplayName("수정은 활성 owner 시리즈에 이미지와 레거시 전체 field를 반영한다") + fun shouldUpdateOwnedActiveSeriesThroughLegacyMutationPath() { + val character = createCharacter("series-mutation-update-character") + val oldGenre = saveGenre("series-mutation-old-genre") + val newGenre = saveGenre("series-mutation-new-genre") + val series = saveSeries(character.creatorMember!!, oldGenre, "before update") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file( + requestPart( + """ + { + "title":"after update", + "introduction":"after introduction", + "publishedDaysOfWeek":["FRI"], + "genreId":${newGenre.id}, + "isAdult":true, + "state":"COMPLETE", + "isActive":true, + "writer":"after writer", + "studio":"after studio" + } + """.trimIndent() + ) + ) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + + val updated = entityManager.find(Series::class.java, series.id) + assertEquals("after update", updated.title) + assertEquals("after introduction", updated.introduction) + assertEquals(setOf(SeriesPublishedDaysOfWeek.FRI), updated.publishedDaysOfWeek) + assertEquals(newGenre.id, updated.genre!!.id) + assertEquals(SeriesState.COMPLETE, updated.state) + assertTrue(updated.isAdult) + assertTrue(updated.isActive) + assertEquals("after writer", updated.writer) + assertEquals("after studio", updated.studio) + assertTrue(updated.coverImage!!.startsWith("series_cover/${series.id}/${series.id}-cover")) + Mockito.verify(amazonS3Client).putObject(Mockito.any(PutObjectRequest::class.java)) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(applicationEventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageTranslationEvent + assertEquals(series.id, event.id) + assertEquals(LanguageTranslationTargetType.SERIES, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("DELETE 시리즈는 계약 밖 endpoint라 405로 거부하고 시리즈를 변경하지 않는다") + fun shouldRejectSeriesDeleteEndpointWithoutMutation() { + val character = createCharacter("series-mutation-delete-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-mutation-delete-genre"), + "delete target" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .with(adminAuthentication()) + ) + + result.andExpect(status().isMethodNotAllowed) + entityManager.flush() + entityManager.clear() + assertTrue(entityManager.find(Series::class.java, series.id).isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정 isActive=false는 활성 owner 시리즈를 soft delete하고 v2 목록에서 제외한다") + fun shouldSoftDeleteOwnedActiveSeriesThroughUpdateIsActiveFalse() { + val character = createCharacter("series-mutation-soft-delete-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-mutation-soft-delete-genre"), + "soft delete target" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(requestPart("""{"isActive":false}""")) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + assertFalse(entityManager.find(Series::class.java, series.id).isActive) + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.items.length()").value(0)) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("타 owner 시리즈 수정은 owner 검증에서 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectCrossOwnerUpdateBeforeSideEffects() { + val targetCharacter = createCharacter("series-cross-owner-update-target") + val foreignCharacter = createCharacter("series-cross-owner-update-foreign") + val foreignSeries = saveSeries( + foreignCharacter.creatorMember!!, + saveGenre("series-cross-owner-update-genre"), + "foreign update" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${targetCharacter.id}/series/${foreignSeries.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"rejected update"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + entityManager.clear() + assertEquals("foreign update", entityManager.find(Series::class.java, foreignSeries.id).title) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("타 owner 시리즈 DELETE도 계약 밖 endpoint라 owner 검증 없이 405로 거부한다") + fun shouldRejectCrossOwnerDeleteEndpointWithoutMutation() { + val targetCharacter = createCharacter("series-cross-owner-delete-target") + val foreignCharacter = createCharacter("series-cross-owner-delete-foreign") + val foreignSeries = saveSeries( + foreignCharacter.creatorMember!!, + saveGenre("series-cross-owner-delete-genre"), + "foreign delete" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${targetCharacter.id}/series/${foreignSeries.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpect(status().isMethodNotAllowed) + entityManager.clear() + assertTrue(entityManager.find(Series::class.java, foreignSeries.id).isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("비활성 AI 캐릭터 생성은 target 검증에서 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectInactiveTargetCreateBeforeSideEffects() { + val character = createCharacter("series-inactive-target-create-character") + val genre = saveGenre("series-inactive-target-create-genre") + character.isActive = false + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, "inactive target create"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("미존재 양수 장르 생성은 legacy 호출 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectMissingGenreCreateBeforeSideEffects() { + val character = createCharacter("series-missing-genre-create-character") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(Long.MAX_VALUE, "missing genre create"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "0,ko,잘못된 요청입니다.", + "0,en,Invalid request.", + "0,ja,無効なリクエストです。", + "-1,ko,잘못된 요청입니다.", + "-1,en,Invalid request.", + "-1,ja,無効なリクエストです。" + ) + @DisplayName("0 이하 장르 생성은 legacy 호출 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectNonPositiveGenreCreateBeforeSideEffects(genreId: Long, language: String, message: String) { + val character = createCharacter("series-non-positive-genre-create-$genreId-$language") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genreId, "non positive genre create $genreId $language"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @ValueSource(strings = ["genreId", "isAdult"]) + @DisplayName("생성은 primitive null을 레거시 호출 전에 400으로 거부한다") + fun shouldRejectNullPrimitiveCreateFieldsBeforeSideEffects(field: String) { + val character = createCharacter("series-null-$field-create-character") + val genre = saveGenre("series-null-$field-create-genre") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + val genreIdValue = if (field == "genreId") "null" else genre.id.toString() + val isAdultValue = if (field == "isAdult") "null" else "false" + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file( + requestPart( + """ + { + "title":"null $field create", + "introduction":"introduction", + "publishedDaysOfWeek":["MON"], + "keyword":"#keyword", + "genreId":$genreIdValue, + "isAdult":$isAdultValue + } + """.trimIndent() + ) + ) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("비활성 owner 시리즈 수정은 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectInactiveSeriesUpdateBeforeSideEffects() { + val character = createCharacter("series-inactive-update-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-inactive-update-genre"), + "inactive update", + isActive = false + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"rejected inactive update"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + entityManager.clear() + val unchanged = entityManager.find(Series::class.java, series.id) + assertEquals("inactive update", unchanged.title) + assertFalse(unchanged.isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("미존재 양수 장르 수정은 이미지 업로드 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectMissingGenreUpdateBeforeSideEffects() { + val character = createCharacter("series-missing-genre-update-character") + val genre = saveGenre("series-missing-genre-update-genre") + val series = saveSeries(character.creatorMember!!, genre, "missing genre update") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"genreId":${Long.MAX_VALUE}}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + entityManager.clear() + val unchanged = entityManager.find(Series::class.java, series.id) + assertEquals(genre.id, unchanged.genre!!.id) + assertTrue(unchanged.coverImage!!.startsWith("series/missing-genre-update")) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "0,ko,잘못된 요청입니다.", + "0,en,Invalid request.", + "0,ja,無効なリクエストです。", + "-1,ko,잘못된 요청입니다.", + "-1,en,Invalid request.", + "-1,ja,無効なリクエストです。" + ) + @DisplayName("0 이하 장르 수정은 이미지 업로드 전에 400으로 실패하고 DB·S3·event를 변경하지 않는다") + fun shouldRejectNonPositiveGenreUpdateBeforeSideEffects(genreId: Long, language: String, message: String) { + val character = createCharacter("series-non-positive-genre-update-$genreId-$language") + val genre = saveGenre("series-non-positive-genre-update-genre-$genreId-$language") + val series = saveSeries(character.creatorMember!!, genre, "non positive genre update $genreId $language") + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"genreId":$genreId}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + val unchanged = entityManager.find(Series::class.java, series.id) + assertEquals(genre.id, unchanged.genre!!.id) + assertTrue(unchanged.coverImage!!.startsWith("series/non-positive-genre-update")) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("비활성 owner 시리즈 DELETE도 계약 밖 endpoint라 405로 거부한다") + fun shouldRejectInactiveSeriesDeleteEndpointWithoutMutation() { + val character = createCharacter("series-inactive-delete-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-inactive-delete-genre"), + "inactive delete", + isActive = false + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpect(status().isMethodNotAllowed) + entityManager.clear() + assertFalse(entityManager.find(Series::class.java, series.id).isActive) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("미존재 시리즈 수정은 400이고 DELETE는 계약 밖 endpoint라 405로 실패한다") + fun shouldRejectMissingSeriesMutationsBeforeSideEffects() { + val character = createCharacter("series-missing-mutation-character") + entityManager.flush() + entityManager.clear() + + val updateResult = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${Long.MAX_VALUE}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"missing"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + updateResult.andExpectInvalidRequest() + + val deleteResult = mockMvc.perform( + delete("/api/v2/admin/ai-characters/${character.id}/series/${Long.MAX_VALUE}") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + deleteResult.andExpect(status().isMethodNotAllowed) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("생성 validation SodaException key는 v2 400 envelope으로 보존한다") + fun shouldPreserveLegacyCreateValidationKeyAsBadRequest() { + val character = createCharacter("series-create-validation-character") + val genre = saveGenre("series-create-validation-genre") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, " "))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Please enter a series title.")) + .andReturn() + + val exception = result.resolvedException as SodaException + assertEquals("creator.admin.series.title_required", exception.messageKey) + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성 image part 누락은 MVC binding에서 invalid request로 거부한다") + fun shouldRejectMissingCreateImagePartBeforeLegacyService(language: String, message: String) { + val character = createCharacter("series-create-cover-required-character") + val genre = saveGenre("series-create-cover-required-genre") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(requestPart(validCreateRequest(genre.id!!, "cover required"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("생성은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonCreateRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = createCharacter("series-create-request-media-type-character") + val genre = saveGenre("series-create-request-media-type-genre") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, "rejected media type"), requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + value = [ + "text/plain,ko,잘못된 요청입니다.", + "text/plain,en,Invalid request.", + "text/plain,ja,無効なリクエストです。", + ",ko,잘못된 요청입니다.", + ",en,Invalid request.", + ",ja,無効なリクエストです。" + ], + nullValues = [""] + ) + @DisplayName("수정은 JSON이 아닌 request part를 지역화된 415와 부작용 없음으로 거부한다") + fun shouldRejectNonJsonUpdateRequestPartBeforeSideEffects( + requestContentType: String?, + language: String, + message: String + ) { + val character = createCharacter("series-update-request-media-type-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-update-request-media-type-genre"), + "before media type update" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"rejected media type update"}""", requestContentType)) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isUnsupportedMediaType) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + .andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)) + + entityManager.clear() + assertEquals("before media type update", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectUndefinedCreateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-create-undefined-part-$language") + val genre = saveGenre("series-create-undefined-part-genre-$language") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file(requestPart(validCreateRequest(genre.id!!, "undefined create part $language"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("생성은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedCreateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-create-filename-less-part-$language") + val genre = saveGenre("series-create-filename-less-part-genre-$language") + entityManager.flush() + entityManager.clear() + val seriesCount = countSeries() + + mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .file(requestPart(validCreateRequest(genre.id!!, "filename less create part $language"))) + .part(MockPart("unexpected", "unexpected".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + assertEquals(seriesCount, countSeries()) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectUndefinedUpdateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-update-undefined-part-$language") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-update-undefined-part-genre-$language"), + "before undefined update $language" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(MockMultipartFile("unexpected", "unexpected.txt", "text/plain", byteArrayOf(1))) + .file(requestPart("""{"title":"after undefined update"}""")) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("before undefined update $language", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @ParameterizedTest + @CsvSource( + "ko,잘못된 요청입니다.", + "en,Invalid request.", + "ja,無効なリクエストです。" + ) + @DisplayName("수정은 filename 없는 미정의 multipart part를 mutation 전 invalid request로 거부한다") + fun shouldRejectFilenameLessUndefinedUpdateMultipartPartBeforeSideEffects(language: String, message: String) { + val character = createCharacter("series-update-filename-less-part-$language") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-update-filename-less-part-genre-$language"), + "before filename less update $language" + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .file(requestPart("""{"title":"after filename less update"}""")) + .part(MockPart("unexpected", "unexpected".toByteArray())) + .header(HttpHeaders.ACCEPT_LANGUAGE, language) + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(message)) + + entityManager.clear() + assertEquals("before filename less update $language", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("생성 request part 누락은 기존 400 binding 오류를 유지한다") + fun shouldPreserveMissingCreateRequestPartAsBadRequest() { + val character = createCharacter("series-missing-create-request-character") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(imagePart()) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정 request part 누락은 기존 400 binding 오류를 유지한다") + fun shouldPreserveMissingUpdateRequestPartAsBadRequest() { + val character = createCharacter("series-missing-update-request-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-missing-update-request-genre"), + "before missing request update" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(imagePart()) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andReturn() + + assertEquals(MissingServletRequestPartException::class.java, result.resolvedException?.javaClass) + entityManager.clear() + assertEquals("before missing request update", entityManager.find(Series::class.java, series.id).title) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("생성은 빈 필수 image를 레거시 호출 전에 400으로 거부한다") + fun shouldRejectEmptyCreateImageBeforeLegacyService() { + val character = createCharacter("series-empty-create-image-character") + val genre = saveGenre("series-empty-create-image-genre") + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart("/api/v2/admin/ai-characters/${character.id}/series") + .file(MockMultipartFile("image", "empty.png", "image/png", byteArrayOf())) + .file(requestPart(validCreateRequest(genre.id!!, "empty create image"))) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + + result.andExpectInvalidRequest() + assertEquals(0L, countSeries()) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정은 빈 optional image를 생략으로 처리해 기존 cover를 유지한다") + fun shouldTreatEmptyUpdateImageAsOmittedImage() { + val character = createCharacter("series-empty-update-image-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-empty-update-image-genre"), + "empty update image" + ) + val beforeCoverImage = series.coverImage + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(MockMultipartFile("image", "empty.png", "image/png", byteArrayOf())) + .file(requestPart("""{"title":"empty update image changed"}""")) + .with(adminAuthentication()) + ) + + result.andExpectNullSuccess() + entityManager.flush() + entityManager.clear() + val updated = entityManager.find(Series::class.java, series.id) + assertEquals("empty update image changed", updated.title) + assertEquals(beforeCoverImage, updated.coverImage) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + } + + @Test + @DisplayName("수정은 빈 optional image만 있으면 기존 no changes 400을 유지한다") + fun shouldPreserveNoChangesWhenOnlyEmptyUpdateImageExists() { + val character = createCharacter("series-empty-update-image-only-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-empty-update-image-only-genre"), + "empty update image only" + ) + val beforeCoverImage = series.coverImage + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(MockMultipartFile("image", "empty.png", "image/png", byteArrayOf())) + .file(requestPart("{}")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("No changes to update.")) + .andReturn() + + val exception = result.resolvedException as SodaException + assertEquals("creator.admin.series.no_changes", exception.messageKey) + entityManager.clear() + assertEquals(beforeCoverImage, entityManager.find(Series::class.java, series.id).coverImage) + assertNoExternalSideEffects() + } + + @Test + @DisplayName("수정 field와 image가 모두 없으면 레거시 no changes key를 v2 400 envelope으로 보존한다") + fun shouldPreserveLegacyNoChangesKeyAsBadRequest() { + val character = createCharacter("series-no-changes-character") + val series = saveSeries( + character.creatorMember!!, + saveGenre("series-no-changes-genre"), + "no changes" + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + multipart( + HttpMethod.PUT, + "/api/v2/admin/ai-characters/${character.id}/series/${series.id}" + ) + .file(requestPart("{}")) + .header(HttpHeaders.ACCEPT_LANGUAGE, "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("No changes to update.")) + .andReturn() + + val exception = result.resolvedException as SodaException + assertEquals("creator.admin.series.no_changes", exception.messageKey) + entityManager.clear() + assertTrue(entityManager.find(Series::class.java, series.id).isActive) + assertNoExternalSideEffects() + } + + private fun ResultActions.andExpectNullSuccess() { + andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(nullValue())) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun ResultActions.andExpectInvalidRequest() { + andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun assertNoExternalSideEffects() { + assertActualSeriesServicePublisher(applicationEventPublisher) + Mockito.verify(amazonS3Client, Mockito.never()).putObject(Mockito.any(PutObjectRequest::class.java)) + Mockito.verifyNoInteractions(applicationEventPublisher) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun saveSeries( + owner: Member, + genre: SeriesGenre, + title: String, + isActive: Boolean = true + ): Series { + return Series( + title = title, + introduction = "introduction for $title", + state = SeriesState.PROCEEDING, + writer = "writer", + studio = "studio", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive, + orders = 1 + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun imagePart() = MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1, 2, 3)) + + private fun requestPart( + json: String, + contentType: String? = MediaType.APPLICATION_JSON_VALUE + ) = MockMultipartFile( + "request", + "request.json", + contentType, + json.toByteArray() + ) + + private fun validCreateRequest(genreId: Long, title: String): String { + return """ + { + "title":"$title", + "introduction":"introduction", + "publishedDaysOfWeek":["MON"], + "keyword":"#keyword", + "genreId":$genreId + } + """.trimIndent() + } + + private fun countSeries(): Long { + return entityManager.createQuery("select count(s) from Series s", java.lang.Long::class.java) + .singleResult + .toLong() + } + + private fun findSeriesByTitle(title: String): Series? { + return entityManager.createQuery( + "select s from Series s where s.title = :title", + Series::class.java + ).setParameter("title", title).resultList.firstOrNull() + } + + private fun replaceSeriesServicePublisher(publisher: ApplicationEventPublisher): ApplicationEventPublisher { + val target = AopTestUtils.getTargetObject(legacyService) + val original = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher + ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher) + return original + } + + private fun assertActualSeriesServicePublisher(publisher: ApplicationEventPublisher) { + val target = AopTestUtils.getTargetObject(legacyService) + assertSame(publisher, ReflectionTestUtils.getField(target, "applicationEventPublisher")) + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt new file mode 100644 index 00000000..4beac33a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesOrderTest.kt @@ -0,0 +1,254 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesOrderTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager, + private val seriesRepository: CreatorAdminContentSeriesRepository +) { + @Test + @DisplayName("소유한 active series만 요청 순서대로 1..n으로 재정렬하고 성공 응답은 null data이다") + fun shouldReorderOwnedActiveSeriesInRequestOrder() { + val character = createCharacter("series-order-success-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "first series", orders = 3) + val second = saveSeries(owner, "second series", orders = 2) + val third = saveSeries(owner, "third series", orders = 1) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to listOf(third.id, first.id, second.id)))) + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value(nullValue())) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(third.id, first.id, second.id), orderedSeriesIds(owner.id!!)) + assertEquals(listOf(1, 2, 3), orderedSeriesOrders(owner.id!!)) + } + + @Test + @DisplayName("cross-owner, missing, duplicate, inactive series ID는 400 invalid_request로 거부하고 순서는 유지된다") + fun shouldRejectInvalidSeriesIdsWithoutMutation() { + val character = createCharacter("series-order-invalid-character") + val owner = character.creatorMember!! + val otherCharacter = createCharacter("series-order-invalid-other-character") + val first = saveSeries(owner, "order invalid first", orders = 1) + val second = saveSeries(owner, "order invalid second", orders = 2) + val inactive = saveSeries(owner, "order invalid inactive", isActive = false, orders = 3) + val foreign = saveSeries(otherCharacter.creatorMember!!, "order invalid foreign", orders = 1) + entityManager.flush() + entityManager.clear() + + listOf( + listOf(first.id, foreign.id, second.id), + listOf(first.id, Long.MAX_VALUE, second.id), + listOf(first.id, first.id, second.id), + listOf(first.id, inactive.id, second.id), + emptyList() + ).forEach { ids -> + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to ids))) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(first.id, second.id, inactive.id), orderedSeriesIds(owner.id!!)) + assertEquals(listOf(1, 2, 3), orderedSeriesOrders(owner.id!!)) + } + } + + @Test + @DisplayName("순서 변경 body에 계약 밖 field가 있으면 400으로 거부하고 순서를 변경하지 않는다") + fun shouldRejectOrderUnknownFieldWithoutMutation() { + val character = createCharacter("series-order-unknown-field-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "order unknown first", orders = 1) + val second = saveSeries(owner, "order unknown second", orders = 2) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[${second.id},${first.id}],"unexpected":true}""") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ).andExpectInvalidRequest() + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(first.id, second.id), orderedSeriesIds(owner.id!!)) + assertEquals(listOf(1, 2), orderedSeriesOrders(owner.id!!)) + } + + @Test + @DisplayName("동일 owner 순서 변경은 마지막 요청 결과를 남긴다") + fun shouldKeepLastOrderRequestResult() { + val character = createCharacter("series-order-last-request-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "last order first", orders = 1) + val second = saveSeries(owner, "last order second", orders = 2) + val third = saveSeries(owner, "last order third", orders = 3) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to listOf(third.id, second.id, first.id)))) + .with(adminAuthentication()) + ).andExpect(status().isOk) + + mockMvc.perform( + put("/api/v2/admin/ai-characters/${character.id}/series/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(mapOf("ids" to listOf(second.id, first.id, third.id)))) + .with(adminAuthentication()) + ).andExpect(status().isOk) + + entityManager.flush() + entityManager.clear() + assertEquals(listOf(second.id, first.id, third.id), orderedSeriesIds(owner.id!!)) + } + + @Test + @DisplayName("동시 순서 변경용 잠금은 ID 오름차순으로 owner active series를 선점한다") + fun shouldLockOwnedActiveSeriesInIdOrderBeforeReorder() { + val character = createCharacter("series-order-lock-character") + val owner = character.creatorMember!! + val first = saveSeries(owner, "lock first", orders = 1) + val second = saveSeries(owner, "lock second", orders = 2) + val third = saveSeries(owner, "lock third", orders = 3) + entityManager.flush() + entityManager.clear() + + val lockedIds = seriesRepository.findActiveByCreatorIdAndIdInForUpdate( + owner.id!!, + listOf(third.id!!, first.id!!, second.id!!) + ).map { it.id } + + assertEquals(listOf(first.id, second.id, third.id), lockedIds) + } + + private fun ResultActions.andExpectInvalidRequest(): ResultActions { + return andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveSeries( + owner: Member, + title: String, + isActive: Boolean = true, + orders: Int + ): Series { + return Series( + title = title, + introduction = "introduction for $title", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + isActive = isActive, + orders = orders + ).apply { + member = owner + genre = saveGenre(title) + coverImage = "series/${title.replace(" ", "-")}.png" + entityManager.persist(this) + } + } + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun orderedSeriesIds(ownerId: Long): List { + return entityManager.createQuery( + "select s.id from Series s where s.member.id = :ownerId order by s.orders asc, s.id asc", + java.lang.Long::class.java + ) + .setParameter("ownerId", ownerId) + .resultList + .map(java.lang.Long::toLong) + } + + private fun orderedSeriesOrders(ownerId: Long): List { + return entityManager.createQuery( + "select s.orders from Series s where s.member.id = :ownerId order by s.orders asc, s.id asc", + Int::class.javaObjectType + ) + .setParameter("ownerId", ownerId) + .resultList + .map { it as Int } + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt new file mode 100644 index 00000000..1b90e20c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/AiCharacterAdminSeriesQueryTest.kt @@ -0,0 +1,343 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.hamcrest.Matchers.nullValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"]) +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AiCharacterAdminSeriesQueryTest @Autowired constructor( + private val mockMvc: MockMvc, + private val chatCharacterService: ChatCharacterService, + private val objectMapper: ObjectMapper, + private val entityManager: EntityManager +) { + @Test + @DisplayName("시리즈 목록은 활성 소유 시리즈만 순서대로 레거시 전체 필드로 반환한다") + fun shouldReturnExactLegacyListFieldsAndExcludeInactiveSeries() { + val character = createCharacter("series-list-character") + val owner = character.creatorMember!! + val genre = saveGenre("series-list-genre") + val first = saveSeries( + owner = owner, + genre = genre, + title = "first series", + orders = 1, + state = SeriesState.SUSPEND, + days = mutableSetOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), + isAdult = true, + writer = null + ) + val second = saveSeries(owner, genre, "second series", orders = 2) + val inactive = saveSeries(owner, genre, "inactive series", orders = 0, isActive = false) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .param("page", "0") + .param("size", "20") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + val items = data.path("items") + val item = items.path(0) + assertEquals(setOf("totalCount", "items"), data.fieldNames().asSequence().toSet()) + assertEquals(2, data.path("totalCount").asInt()) + assertEquals(listOf(first.id, second.id), items.map { it.path("seriesId").asLong() }) + assertTrue(items.none { it.path("seriesId").asLong() == inactive.id }) + assertEquals( + setOf( + "seriesId", + "title", + "introduction", + "coverImageUrl", + "publishedDaysOfWeek", + "genreId", + "isAdult", + "state", + "isActive", + "writer", + "studio" + ), + item.fieldNames().asSequence().toSet() + ) + assertEquals("first series", item.path("title").asText()) + assertEquals("introduction for first series", item.path("introduction").asText()) + assertEquals("https://test.cloudfront.net/series/first-series.png", item.path("coverImageUrl").asText()) + assertEquals(setOf("MON", "WED"), item.path("publishedDaysOfWeek").map { it.asText() }.toSet()) + assertEquals(genre.id, item.path("genreId").asLong()) + assertTrue(item.path("isAdult").asBoolean()) + assertEquals("SUSPEND", item.path("state").asText()) + assertTrue(item.path("isActive").asBoolean()) + assertTrue(item.path("writer").isNull) + assertEquals("studio", item.path("studio").asText()) + } + + @Test + @DisplayName("시리즈 상세 data는 목록 item과 동일한 필드와 타입으로 반환한다") + fun shouldReturnDetailAsSingleListItemSchema() { + val character = createCharacter("series-detail-character") + val genre = saveGenre("series-detail-genre") + val series = saveSeries( + owner = character.creatorMember!!, + genre = genre, + title = "detail series", + state = SeriesState.COMPLETE, + days = mutableSetOf(SeriesPublishedDaysOfWeek.WED, SeriesPublishedDaysOfWeek.SUN), + keywords = listOf("#night") + ) + entityManager.flush() + entityManager.clear() + + val result = mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andReturn() + + val data = objectMapper.readTree(String(result.response.contentAsByteArray, Charsets.UTF_8)).path("data") + assertEquals( + setOf( + "seriesId", + "title", + "introduction", + "coverImageUrl", + "publishedDaysOfWeek", + "genreId", + "isAdult", + "state", + "isActive", + "writer", + "studio" + ), + data.fieldNames().asSequence().toSet() + ) + assertEquals(series.id, data.path("seriesId").asLong()) + assertEquals("detail series", data.path("title").asText()) + assertEquals("introduction for detail series", data.path("introduction").asText()) + assertEquals("https://test.cloudfront.net/series/detail-series.png", data.path("coverImageUrl").asText()) + assertEquals(setOf("SUN", "WED"), data.path("publishedDaysOfWeek").map { it.asText() }.toSet()) + assertEquals(genre.id, data.path("genreId").asLong()) + assertEquals("COMPLETE", data.path("state").asText()) + assertTrue(data.path("isActive").asBoolean()) + assertEquals("writer", data.path("writer").asText()) + assertEquals("studio", data.path("studio").asText()) + } + + @Test + @DisplayName("시리즈 상세는 미존재 비활성 타 소유자 리소스를 같은 400 오류로 거부한다") + fun shouldRejectMissingInactiveAndCrossOwnerDetail() { + val character = createCharacter("series-detail-owner-character") + val otherCharacter = createCharacter("series-detail-other-character") + val genre = saveGenre("series-invalid-detail-genre") + val inactive = saveSeries(character.creatorMember!!, genre, "inactive detail", isActive = false) + val foreign = saveSeries(otherCharacter.creatorMember!!, genre, "foreign detail") + entityManager.flush() + entityManager.clear() + + listOf(Long.MAX_VALUE, inactive.id!!, foreign.id!!).forEach { seriesId -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/$seriesId") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 시리즈 목록을 400으로 거부한다") + fun shouldRejectInactiveTargetList() { + val character = createCharacter("inactive-target-series-list-character") + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("비활성 AI 캐릭터 target의 시리즈 상세를 400으로 거부한다") + fun shouldRejectInactiveTargetDetail() { + val character = createCharacter("inactive-target-series-detail-character") + val series = saveSeries( + owner = character.creatorMember!!, + genre = saveGenre("inactive-target-series-detail-genre"), + title = "inactive target detail" + ) + character.isActive = false + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series/${series.id}") + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + + @Test + @DisplayName("시리즈 목록은 최소 size 1과 다음 page 경계를 그대로 적용한다") + fun shouldRespectMinimumSizeAndNextPageBoundary() { + val character = createCharacter("series-pagination-character") + val genre = saveGenre("series-pagination-genre") + val first = saveSeries(character.creatorMember!!, genre, "page first", orders = 1) + val second = saveSeries(character.creatorMember!!, genre, "page second", orders = 2) + saveSeries(character.creatorMember!!, genre, "page third", orders = 3) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .param("page", "1") + .param("size", "1") + .with(adminAuthentication()) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].seriesId").value(second.id)) + .andExpect(jsonPath("$.data.items[0].seriesId").value(org.hamcrest.Matchers.not(first.id))) + } + + @Test + @DisplayName("시리즈 목록은 음수 page와 0 size를 400으로 거부한다") + fun shouldRejectInvalidPaginationBoundaries() { + val character = createCharacter("series-invalid-pagination-character") + entityManager.flush() + entityManager.clear() + + listOf("-1" to "20", "0" to "0").forEach { (page, size) -> + mockMvc.perform( + get("/api/v2/admin/ai-characters/${character.id}/series") + .param("page", page) + .param("size", size) + .header("Accept-Language", "en") + .with(adminAuthentication()) + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Invalid request.")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.errorProperty").value(nullValue())) + } + } + + private fun createCharacter(name: String) = chatCharacterService.createChatCharacterWithDetails( + characterUUID = name, + name = name, + description = "description", + systemPrompt = "prompt" + ) + + private fun saveGenre(name: String): SeriesGenre { + return SeriesGenre(genre = name).also(entityManager::persist) + } + + private fun saveSeries( + owner: Member, + genre: SeriesGenre, + title: String, + orders: Int = 1, + isActive: Boolean = true, + state: SeriesState = SeriesState.PROCEEDING, + days: MutableSet = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + keywords: List = emptyList(), + isAdult: Boolean = false, + writer: String? = "writer" + ): Series { + val series = Series( + title = title, + introduction = "introduction for $title", + state = state, + writer = writer, + studio = "studio", + publishedDaysOfWeek = days, + isAdult = isAdult, + isActive = isActive, + orders = orders + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + } + keywords.forEach { tag -> + val hashTag = HashTag(tag).also(entityManager::persist) + series.keywordList.add( + SeriesKeyword().apply { + this.series = series + keyword = hashTag + } + ) + } + entityManager.persist(series) + return series + } + + private fun adminAuthentication() = authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter( + Member( + email = "admin@example.com", + password = "password", + nickname = "admin", + role = MemberRole.ADMIN + ) + ), + "token", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt new file mode 100644 index 00000000..6ca1b11f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt @@ -0,0 +1,490 @@ +package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series + +import com.amazonaws.services.s3.AmazonS3Client +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest +import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository +import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationEventPublisher +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.net.URL +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class LegacyCreatorAdminSeriesCharacterizationTest @Autowired constructor( + private val repository: CreatorAdminContentSeriesRepository, + private val adminRepository: AdminContentSeriesRepository, + private val genreRepository: CreatorAdminContentSeriesGenreRepository, + private val hashTagRepository: HashTagRepository, + private val audioContentRepository: AudioContentRepository, + private val messageSource: SodaMessageSource, + private val entityManager: EntityManager +) { + @MockBean + private lateinit var amazonS3Client: AmazonS3Client + + private lateinit var eventPublisher: ApplicationEventPublisher + private lateinit var service: CreatorAdminContentSeriesService + + @BeforeEach + fun setUp() { + Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) + .thenReturn(URL("https://s3.example.com/series-cover")) + eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + service = CreatorAdminContentSeriesService( + repository = repository, + genreRepository = genreRepository, + hashTagRepository = hashTagRepository, + audioContentRepository = audioContentRepository, + s3Uploader = S3Uploader(amazonS3Client), + objectMapper = jacksonObjectMapper(), + applicationEventPublisher = eventPublisher, + coverImageBucket = "test-bucket", + coverImageHost = "https://cover.example.com" + ) + } + + @Test + @DisplayName("기존 시리즈 생성은 키워드를 정규화하고 표지 업로드와 언어 감지 이벤트를 남긴다") + fun shouldCreateSeriesWithNormalizedKeywordsCoverAndLanguageDetectEvent() { + val owner = saveMember("legacy-series-create-owner") + val genre = saveGenre("legacy-create-genre") + + service.createSeries( + coverImage = MockMultipartFile("image", "cover.png", "image/png", byteArrayOf(1, 2, 3)), + requestString = """ + { + "title":"legacy created series", + "introduction":"legacy introduction", + "publishedDaysOfWeek":["MON","WED"], + "keyword":"#night night #walk", + "genreId":${genre.id}, + "isAdult":true, + "writer":"legacy writer", + "studio":"legacy studio" + } + """.trimIndent(), + member = owner + ) + entityManager.flush() + + val created = repository.findAll().single { it.title == "legacy created series" } + assertEquals(owner.id, created.member!!.id) + assertEquals(genre.id, created.genre!!.id) + assertEquals(setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.WED), created.publishedDaysOfWeek) + assertEquals(setOf("#night", "#walk"), created.keywordList.map { it.keyword!!.tag }.toSet()) + assertTrue(created.coverImage!!.startsWith("series_cover/${created.id}/${created.id}-cover")) + assertTrue(created.isActive) + assertTrue(created.isAdult) + Mockito.verify(amazonS3Client).putObject(Mockito.any()) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as LanguageDetectEvent + assertEquals(created.id, event.id) + assertEquals("legacy created series legacy introduction #night night #walk", event.query) + assertEquals(LanguageDetectTargetType.SERIES, event.targetType) + } + + @Test + @DisplayName("기존 목록은 활성 소유 시리즈만 순서대로 반환하지만 상세는 비활성 시리즈도 반환한다") + fun shouldListOnlyActiveOwnedSeriesButAllowInactiveOwnedDetail() { + val owner = saveMember("legacy-series-list-owner") + val otherOwner = saveMember("legacy-series-list-other-owner") + val genre = saveGenre("legacy-list-genre") + val first = saveSeries(owner, genre, "first active series", orders = 1) + val second = saveSeries(owner, genre, "second active series", orders = 2) + val inactive = saveSeries( + owner = owner, + genre = genre, + title = "inactive series", + isActive = false, + orders = 0, + state = SeriesState.COMPLETE, + days = mutableSetOf(SeriesPublishedDaysOfWeek.WED, SeriesPublishedDaysOfWeek.SUN), + keywords = listOf("#inactive") + ) + saveSeries(otherOwner, genre, "other owner series", orders = 0) + entityManager.flush() + entityManager.clear() + + val response = service.getSeriesList(offset = 0, limit = 20, creatorId = owner.id!!) + + assertEquals(2, response.totalCount) + assertEquals(listOf(first.id, second.id), response.items.map { it.seriesId }) + assertEquals("https://cover.example.com/series/first-active-series.png", response.items.first().coverImageUrl) + assertEquals(genre.id, response.items.first().genreId) + assertTrue(response.items.all { it.isActive }) + + val inactiveDetail = service.getDetail(id = inactive.id!!, memberId = owner.id!!) + assertEquals(inactive.id, inactiveDetail.seriesId) + assertEquals("일, 수", inactiveDetail.publishedDaysOfWeek) + assertEquals("완결", inactiveDetail.state) + assertEquals("#inactive", inactiveDetail.keywords) + assertNull(adminRepository.findByIdAndActiveTrue(inactive.id!!)) + } + + @Test + @DisplayName("기존 수정은 일반 필드와 soft delete를 함께 반영하고 비활성 시리즈를 목록에서 제외한다") + fun shouldModifyFieldsAndSoftDeleteInOneRequest() { + val owner = saveMember("legacy-series-update-owner") + val oldGenre = saveGenre("legacy-old-genre") + val newGenre = saveGenre("legacy-new-genre") + val series = saveSeries(owner, oldGenre, "before title") + val originalCover = series.coverImage + + service.modifySeries( + coverImage = null, + requestString = """ + { + "seriesId":${series.id}, + "title":"after title", + "introduction":"after introduction", + "publishedDaysOfWeek":["FRI"], + "genreId":${newGenre.id}, + "isAdult":true, + "state":"COMPLETE", + "isActive":false, + "writer":"after writer", + "studio":"after studio" + } + """.trimIndent(), + member = owner + ) + entityManager.flush() + + assertEquals("after title", series.title) + assertEquals("after introduction", series.introduction) + assertEquals(setOf(SeriesPublishedDaysOfWeek.FRI), series.publishedDaysOfWeek) + assertEquals(newGenre.id, series.genre!!.id) + assertEquals(SeriesState.COMPLETE, series.state) + assertEquals(originalCover, series.coverImage) + assertTrue(series.isAdult) + assertFalse(series.isActive) + assertEquals("after writer", series.writer) + assertEquals("after studio", series.studio) + assertEquals(0, service.getSeriesList(0, 20, owner.id!!).totalCount) + assertEquals("after title", service.getDetail(series.id!!, owner.id!!).title) + assertNull(adminRepository.findByIdAndActiveTrue(series.id!!)) + + val eventCaptor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) + val event = eventCaptor.value as kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent + assertEquals(series.id, event.id) + assertEquals(kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType.SERIES, event.targetType) + assertTrue(event.waitTransactionCommit) + } + + @Test + @DisplayName("기존 요청 검증과 소유권 실패는 고유 message key를 사용하고 generic 오류 번역은 세 언어로 유지된다") + fun shouldExposeLegacyValidationOwnershipAndLocalizationBaseline() { + val validRequest = CreateSeriesRequest( + title = "title", + introduction = "introduction", + publishedDaysOfWeek = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + keyword = "#keyword", + genreId = 1L + ) + val validationKeys = listOf( + assertThrows(SodaException::class.java) { validRequest.copy(title = " ").toSeries() }.messageKey, + assertThrows(SodaException::class.java) { validRequest.copy(introduction = " ").toSeries() }.messageKey, + assertThrows(SodaException::class.java) { validRequest.copy(keyword = " ").toSeries() }.messageKey, + assertThrows(SodaException::class.java) { validRequest.copy(genreId = 0).toSeries() }.messageKey, + assertThrows(SodaException::class.java) { + validRequest.copy(publishedDaysOfWeek = mutableSetOf()).toSeries() + }.messageKey, + assertThrows(SodaException::class.java) { + validRequest.copy( + publishedDaysOfWeek = mutableSetOf( + SeriesPublishedDaysOfWeek.RANDOM, + SeriesPublishedDaysOfWeek.MON + ) + ).toSeries() + }.messageKey + ) + assertEquals( + listOf( + "creator.admin.series.title_required", + "creator.admin.series.introduction_required", + "creator.admin.series.keyword_required", + "creator.admin.series.genre_required", + "creator.admin.series.published_days_required", + "creator.admin.series.published_days_random_exclusive" + ), + validationKeys + ) + + val owner = saveMember("legacy-series-error-owner") + val otherOwner = saveMember("legacy-series-error-other-owner") + val genre = saveGenre("legacy-error-genre") + val ownerSeries = saveSeries(owner, genre, "owner series") + val otherSeries = saveSeries(otherOwner, genre, "other series") + assertEquals( + "creator.admin.series.cover_image_required", + assertThrows(SodaException::class.java) { service.createSeries(null, "{}", owner) }.messageKey + ) + assertEquals( + "creator.admin.series.no_changes", + assertThrows(SodaException::class.java) { + service.modifySeries(null, """{"seriesId":${ownerSeries.id}}""", owner) + }.messageKey + ) + assertEquals( + "creator.admin.series.invalid_access", + assertThrows(SodaException::class.java) { service.getDetail(otherSeries.id!!, owner.id!!) }.messageKey + ) + assertEquals( + "creator.admin.series.no_content_added", + assertThrows(SodaException::class.java) { + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(ownerSeries.id!!, emptyList()), + owner.id!! + ) + }.messageKey + ) + assertEquals( + listOf("잘못된 요청입니다.", "Invalid request.", "無効なリクエストです。"), + Lang.values().map { messageSource.getMessage("common.error.invalid_request", it) } + ) + } + + @Test + @DisplayName("기존 콘텐츠 연결은 소유한 ID만 부분 반영하고 전체 owner count와 무해한 해제를 유지한다") + fun shouldPartiallyLinkOwnedContentCountAcrossSeriesAndSilentlyUnlink() { + val owner = saveMember("legacy-series-link-owner") + val otherOwner = saveMember("legacy-series-link-other-owner") + val genre = saveGenre("legacy-link-genre") + val targetSeries = saveSeries(owner, genre, "target series") + val secondSeries = saveSeries(owner, genre, "second series") + val targetContent = saveAudioContent(owner, "target audio") + val secondContent = saveAudioContent(owner, "second audio") + val foreignContent = saveAudioContent(otherOwner, "foreign audio") + + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest( + targetSeries.id!!, + listOf(targetContent.id!!, foreignContent.id!!, Long.MAX_VALUE) + ), + owner.id!! + ) + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(secondSeries.id!!, listOf(secondContent.id!!)), + owner.id!! + ) + entityManager.flush() + entityManager.clear() + + val targetResponse = service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!) + assertEquals(2, targetResponse.totalCount) + assertEquals(listOf(targetContent.id), targetResponse.items.map { it.contentId }) + assertEquals("https://cover.example.com/audio/target-audio.png", targetResponse.items.single().coverImage) + + val missingSeriesResponse = service.getSeriesContent(Long.MAX_VALUE, 0, 20, owner.id!!) + assertEquals(2, missingSeriesResponse.totalCount) + assertTrue(missingSeriesResponse.items.isEmpty()) + assertEquals( + "creator.admin.series.no_content_added", + assertThrows(SodaException::class.java) { + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(targetSeries.id!!, listOf(foreignContent.id!!, Long.MAX_VALUE)), + owner.id!! + ) + }.messageKey + ) + assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.size) + + service.removeContentInTheSeries( + RemoveContentToTheSeriesRequest(targetSeries.id!!, Long.MAX_VALUE), + owner.id!! + ) + assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.size) + service.removeContentInTheSeries( + RemoveContentToTheSeriesRequest(targetSeries.id!!, targetContent.id!!), + owner.id!! + ) + entityManager.flush() + assertTrue(service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).items.isEmpty()) + assertEquals(1, service.getSeriesContent(targetSeries.id!!, 0, 20, owner.id!!).totalCount) + } + + @Test + @DisplayName("기존 미연결 검색은 처리 완료 또는 예약된 소유 콘텐츠만 반환하고 시리즈 소유권은 검증하지 않는다") + fun shouldSearchEligibleUnlinkedOwnerContentWithoutSeriesOwnershipValidation() { + val owner = saveMember("legacy-series-search-owner") + val otherOwner = saveMember("legacy-series-search-other-owner") + val genre = saveGenre("legacy-search-genre") + val targetSeries = saveSeries(owner, genre, "search target series") + val linked = saveAudioContent(owner, "night linked") + val eligible = saveAudioContent(owner, "night eligible") + val reserved = saveAudioContent( + owner = owner, + title = "night reserved", + isActive = false, + releaseDate = LocalDateTime.of(2026, 7, 30, 10, 0) + ) + saveAudioContent(owner, "night processing", duration = null) + saveAudioContent(owner, "night inactive", isActive = false, releaseDate = null) + saveAudioContent(otherOwner, "night foreign") + service.addingContentToTheSeries( + AddingContentToTheSeriesRequest(targetSeries.id!!, listOf(linked.id!!)), + owner.id!! + ) + entityManager.flush() + entityManager.clear() + + val result = service.searchContentNotInSeries(targetSeries.id!!, "night", owner.id!!) + assertEquals(setOf(eligible.id, reserved.id), result.map { it.contentId }.toSet()) + assertTrue(result.all { it.coverImage.startsWith("https://cover.example.com/audio/") }) + + val missingSeriesResult = service.searchContentNotInSeries(Long.MAX_VALUE, "night", owner.id!!) + assertEquals(setOf(linked.id, eligible.id, reserved.id), missingSeriesResult.map { it.contentId }.toSet()) + } + + @Test + @DisplayName("기존 순서 변경은 owner와 활성 상태를 검사하지 않고 누락 ID의 순번을 건너뛴다") + fun shouldUpdateOrdersAcrossOwnersAndSkipMissingIds() { + val owner = saveMember("legacy-series-order-owner") + val otherOwner = saveMember("legacy-series-order-other-owner") + val genre = saveGenre("legacy-order-genre") + val ownedSeries = saveSeries(owner, genre, "owned order series", orders = 10) + val foreignInactiveSeries = saveSeries( + owner = otherOwner, + genre = genre, + title = "foreign inactive order series", + isActive = false, + orders = 10 + ) + + service.updateSeriesOrders(listOf(foreignInactiveSeries.id!!, Long.MAX_VALUE, ownedSeries.id!!)) + entityManager.flush() + entityManager.clear() + + assertEquals(1, repository.findById(foreignInactiveSeries.id!!).orElseThrow().orders) + assertFalse(repository.findById(foreignInactiveSeries.id!!).orElseThrow().isActive) + assertEquals(3, repository.findById(ownedSeries.id!!).orElseThrow().orders) + } + + private fun saveMember(nickname: String): Member { + val member = Member( + email = "$nickname@example.com", + password = "password", + nickname = nickname, + role = MemberRole.CREATOR + ) + entityManager.persist(member) + return member + } + + private fun saveGenre(name: String): SeriesGenre { + val genre = SeriesGenre(genre = name) + entityManager.persist(genre) + return genre + } + + private fun saveSeries( + owner: Member, + genre: SeriesGenre, + title: String, + isActive: Boolean = true, + orders: Int = 1, + state: SeriesState = SeriesState.PROCEEDING, + days: MutableSet = mutableSetOf(SeriesPublishedDaysOfWeek.MON), + keywords: List = emptyList() + ): Series { + val series = Series( + title = title, + introduction = "introduction for $title", + state = state, + writer = "writer", + studio = "studio", + publishedDaysOfWeek = days, + isActive = isActive, + orders = orders + ).apply { + member = owner + this.genre = genre + coverImage = "series/${title.replace(" ", "-")}.png" + } + keywords.forEach { tag -> + val hashTag = HashTag(tag) + entityManager.persist(hashTag) + series.keywordList.add( + SeriesKeyword().apply { + this.series = series + keyword = hashTag + } + ) + } + entityManager.persist(series) + return series + } + + private fun saveAudioContent( + owner: Member, + title: String, + isActive: Boolean = true, + releaseDate: LocalDateTime? = LocalDateTime.of(2026, 7, 24, 10, 0), + duration: String? = "00:10:00" + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + limited = 10, + remaining = 5, + releaseDate = releaseDate + ).apply { + member = owner + this.theme = theme + this.isActive = isActive + this.duration = duration + this.content = "audio/${title.replace(" ", "-")}.mp3" + coverImage = "audio/${title.replace(" ", "-")}.png" + } + entityManager.persist(content) + return content + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt index 41ff9136..be6caf15 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt @@ -94,6 +94,27 @@ class MainContentAllEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.audios.length()").value(1)) } + @Test + @DisplayName("전체 탭 POINT API는 무료 포인트 콘텐츠를 목록과 페이징 후보에서 제외한다") + fun shouldExcludeFreePointAudioContentsForPointType() { + val fixture = createAudioFixture("main-all-point-paid-e2e", freeControl = true) + + mockMvc.perform( + get("/api/v2/audio/contents") + .param("type", "POINT") + .param("sort", "LATEST") + .param("page", "0") + .param("size", "1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.type").value("POINT")) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.audios.length()").value(1)) + .andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.audios[0].price").value(100)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + @Test @DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다") fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt index 4ef413df..d240725c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt @@ -67,6 +67,28 @@ class MainContentAllTabResponseTest { isFirstContent = true, isOriginalSeries = false, creatorNickname = "creator" + ), + MainContentAllAudio( + audioContentId = 2L, + title = "free audio", + imageUrl = "https://cdn/free-audio.jpg", + price = 0, + isAdult = false, + isPointAvailable = true, + isFirstContent = false, + isOriginalSeries = false, + creatorNickname = "creator" + ), + MainContentAllAudio( + audioContentId = 3L, + title = "point unavailable audio", + imageUrl = "https://cdn/point-unavailable-audio.jpg", + price = 100, + isAdult = false, + isPointAvailable = false, + isFirstContent = false, + isOriginalSeries = false, + creatorNickname = "creator" ) ), series = emptyList(), @@ -83,6 +105,8 @@ class MainContentAllTabResponseTest { assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean()) assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean()) assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean()) + assertEquals(false, json["audios"][1]["isPointAvailable"].asBoolean()) + assertEquals(false, json["audios"][2]["isPointAvailable"].asBoolean()) assertEquals(true, json["hasNext"].asBoolean()) assertFalse(json["audios"][0].has("duration")) assertFalse(json["series"].any { it.has("publishedDaysOfWeek") }) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt index 14c2ec0c..848ba7bd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.v2.api.content.overview.dto import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -77,4 +80,71 @@ class ContentOverviewPageResponseTest { assertEquals(true, response.isAdult) assertEquals(true, response.isOriginalSeries) } + + @Test + @DisplayName("NEW_AND_HOT 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다") + fun shouldNormalizePointAvailabilityForFreeNewAndHotAudio() { + val freeAudio = audioCard(price = 0, isPointAvailable = true) + val paidAudio = audioCard(price = 100, isPointAvailable = true) + val paidUnavailableAudio = audioCard(price = 100, isPointAvailable = false) + + assertFalse(ContentOverviewItemResponse.fromNewAndHot(freeAudio).isPointAvailable) + assertTrue(ContentOverviewItemResponse.fromNewAndHot(paidAudio).isPointAvailable) + assertFalse(ContentOverviewItemResponse.fromNewAndHot(paidUnavailableAudio).isPointAvailable) + } + + @Test + @DisplayName("첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다") + fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() { + val freeAudio = firstAudio(price = 0, isPointAvailable = true) + val paidAudio = firstAudio(price = 100, isPointAvailable = true) + val paidUnavailableAudio = firstAudio(price = 100, isPointAvailable = false) + + assertFalse( + ContentOverviewItemResponse.fromFirstAudioContent(freeAudio, "https://cdn.test/free.png", false, false) + .isPointAvailable + ) + assertTrue( + ContentOverviewItemResponse.fromFirstAudioContent(paidAudio, "https://cdn.test/paid.png", false, false) + .isPointAvailable + ) + assertFalse( + ContentOverviewItemResponse.fromFirstAudioContent( + paidUnavailableAudio, + "https://cdn.test/paid-unavailable.png", + false, + false + ).isPointAvailable + ) + } + + private fun audioCard(price: Int, isPointAvailable: Boolean): AudioCard { + return AudioCard( + audioContentId = price.toLong() + 1, + title = "audio", + duration = "00:01", + imageUrl = "https://cdn.test/audio.png", + price = price, + isAdult = false, + isPointAvailable = isPointAvailable, + isFirstContent = true, + isOriginalSeries = false, + creatorNickname = "creator" + ) + } + + private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord { + return HomeFirstAudioContentRecord( + contentId = price.toLong() + 1, + creatorId = 10L, + creatorNickname = "creator", + creatorProfileImage = null, + title = "first audio", + price = price, + coverImage = "cover/audio.png", + isPointAvailable = isPointAvailable, + isAdult = false, + isOriginalSeries = false + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index 59de94ab..a2426a31 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -54,6 +54,9 @@ class AudioRecommendationEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true)) .andExpect(jsonPath("$.data.recommendedAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.pointAudios.length()").value(1)) + .andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId)) + .andExpect(jsonPath("$.data.pointAudios[0].price").value(100)) .andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment")) .andExpect( jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl") @@ -68,6 +71,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor( val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png") val theme = saveTheme() val audio = saveAudio(creator, theme, now) + val paidPointAudio = saveAudio(creator, theme, now.minusMinutes(10), "paid-point", 100) val series = saveSeries(creator) saveSeriesContent(series, audio) saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10)) @@ -79,7 +83,8 @@ class AudioRecommendationEndToEndTest @Autowired constructor( Fixture( seriesId = series.id!!, - audioContentId = audio.id!! + audioContentId = audio.id!!, + paidPointAudioContentId = paidPointAudio.id!! ) }!! } @@ -102,14 +107,20 @@ class AudioRecommendationEndToEndTest @Autowired constructor( return theme } - private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent { + private fun saveAudio( + creator: Member, + theme: AudioContentTheme, + releaseDate: LocalDateTime, + title: String = "audio-recommendation-e2e", + price: Int = 0 + ): AudioContent { val audio = AudioContent( - title = "audio-recommendation-e2e", + title = title, detail = "detail", languageCode = "ko", releaseDate = releaseDate, isAdult = false, - price = 0, + price = price, isPointAvailable = true ) audio.member = creator @@ -174,6 +185,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor( private data class Fixture( val seriesId: Long, - val audioContentId: Long + val audioContentId: Long, + val paidPointAudioContentId: Long ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt index 4aa58596..b2d79d53 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt @@ -33,10 +33,13 @@ class AudioRecommendationFacadeTest { assertEquals(1, response.mostCommentedAudios.size) assertEquals(1, response.recommendedAudios.size) assertEquals(false, response.latestAudios[0].isOriginalSeries) + assertEquals(false, response.latestAudios[0].isPointAvailable) + assertEquals(true, response.newAndHotAudios[0].isPointAvailable) + assertEquals(false, response.recommendedAudios[0].isPointAvailable) val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean()) - assertEquals(true, json["latestAudios"][0]["isPointAvailable"].asBoolean()) + assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean()) assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean()) assertEquals(false, json["latestAudios"][0].has("adult")) @@ -61,7 +64,7 @@ class AudioRecommendationFacadeTest { banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")), originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")), latestAudios = listOf(card), - newAndHotAudios = listOf(card.copy(audioContentId = 3L)), + newAndHotAudios = listOf(card.copy(audioContentId = 3L, price = 100)), freeAudios = listOf(card.copy(audioContentId = 4L)), pointAudios = listOf(card.copy(audioContentId = 5L)), mostCommentedAudios = listOf( @@ -73,7 +76,7 @@ class AudioRecommendationFacadeTest { latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png" ) ), - recommendedAudios = listOf(card.copy(audioContentId = 7L)) + recommendedAudios = listOf(card.copy(audioContentId = 7L, price = 100, isPointAvailable = false)) ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt new file mode 100644 index 00000000..d730fc85 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto + +import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class CreatorChannelAudioContentResponseTest { + @Test + @DisplayName("크리에이터 채널 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다") + fun shouldNormalizePointAvailabilityForFreeAudioContent() { + val freeAudio = audioContent(price = 0, isPointAvailable = true) + val paidAudio = audioContent(price = 100, isPointAvailable = true) + val paidUnavailableAudio = audioContent(price = 100, isPointAvailable = false) + + assertFalse(CreatorChannelAudioContentResponse.from(freeAudio).isPointAvailable) + assertTrue(CreatorChannelAudioContentResponse.from(paidAudio).isPointAvailable) + assertFalse(CreatorChannelAudioContentResponse.from(paidUnavailableAudio).isPointAvailable) + } + + private fun audioContent(price: Int, isPointAvailable: Boolean): CreatorChannelAudioContent { + return CreatorChannelAudioContent( + audioContentId = price.toLong() + 1, + title = "audio", + duration = "00:10:00", + imageUrl = "audio.png", + price = price, + isAdult = false, + isPointAvailable = isPointAvailable, + isFirstContent = true, + seriesName = "series", + isOriginalSeries = true, + isOwned = false, + isRented = false + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index 12f21b92..ce9c67b4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -1,12 +1,8 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -23,18 +19,25 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime +import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelCommunityController::class) -@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) +@Import(CreatorChannelCommunityControllerTest.TestSecurityConfig::class) class CreatorChannelCommunityControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -50,8 +53,22 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @MockBean - private lateinit var tokenProvider: TokenProvider + @TestConfiguration + class TestSecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .csrf().disable() + .authorizeRequests() + .anyRequest().authenticated() + .and() + .exceptionHandling() + .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } + .and() + .build() + } + } @Test @DisplayName("크리에이터 채널 커뮤니티 탭 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt index ff5880be..ba73eca9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt @@ -1,12 +1,8 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -19,18 +15,25 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime +import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelFanTalkController::class) -@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) +@Import(CreatorChannelFanTalkControllerTest.TestSecurityConfig::class) class CreatorChannelFanTalkControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -46,8 +49,22 @@ class CreatorChannelFanTalkControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @MockBean - private lateinit var tokenProvider: TokenProvider + @TestConfiguration + class TestSecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .csrf().disable() + .authorizeRequests() + .anyRequest().authenticated() + .and() + .exceptionHandling() + .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } + .and() + .build() + } + } @Test @DisplayName("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt new file mode 100644 index 00000000..9389351d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt @@ -0,0 +1,275 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.home + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.time.ZoneOffset +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:creator-channel-home-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class CreatorChannelHomeEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @MockBean + private lateinit var countryContext: CountryContext + + @Test + @DisplayName("크리에이터 채널 홈 API는 단일 인증 요청으로 전체 홈 섹션을 조립한다") + fun shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest() { + Mockito.doReturn("US").`when`(countryContext).countryCode + val fixture = createFixture() + + mockMvc.perform(get("/api/v2/creator-channels/${fixture.creatorId}/home").with(user(MemberAdapter(fixture.viewer)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creator.creatorId").value(fixture.creatorId)) + .andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId)) + .andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId)) + .andExpect(jsonPath("$.data.channelDonations[0].can").value(500)) + .andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId)) + .andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId)) + .andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId)) + .andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId)) + .andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId)) + .andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId)) + .andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId)) + .andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId)) + .andExpect(jsonPath("$.data.introduce").value("e2e introduce")) + .andExpect(jsonPath("$.data.activity.audioContentCount").value(3)) + .andExpect(jsonPath("$.data.sns.instagramUrl").value("e2e-instagram")) + } + + private fun createFixture(): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now(ZoneOffset.UTC) + val viewer = saveMember("home-e2e-viewer", MemberRole.USER) + val creator = saveMember("home-e2e-creator", MemberRole.CREATOR) + creator.introduce = "e2e introduce" + creator.instagramUrl = "e2e-instagram" + creator.fancimmUrl = "e2e-fancimm" + creator.xUrl = "e2e-x" + creator.youtubeUrl = "e2e-youtube" + creator.websiteUrl = "e2e-kakao" + val donor = saveMember("home-e2e-donor", MemberRole.USER) + val fan = saveMember("home-e2e-fan", MemberRole.USER) + val currentLive = saveLiveRoom(creator, now.minusHours(2), channelName = "e2e-live") + val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null) + val audioSchedule = saveAudioContent(creator, now.plusHours(2), title = "schedule-audio") + val firstAudio = saveAudioContent(creator, now.minusDays(3), title = "first-audio") + val listAudio = saveAudioContent(creator, now.minusDays(2), title = "list-audio") + val latestAudio = saveAudioContent(creator, now.minusDays(1), title = "latest-audio") + val series = saveSeries("home-e2e-series", creator) + saveSeriesContent(series, listAudio) + saveOrder(viewer, creator, latestAudio, OrderType.KEEP) + saveDonation(creator, donor, now.minusHours(3)) + val notice = saveCommunityPost(creator, "e2e notice", isFixed = true, fixedAt = now.minusHours(4)) + val community = saveCommunityPost(creator, "e2e community", isFixed = false, fixedAt = null) + val fanTalk = saveCheers(fan, creator, "e2e fan talk", now.minusMinutes(30)) + saveVisit(currentLive, viewer) + entityManager.flush() + Fixture( + viewer = viewer, + creatorId = creator.id!!, + currentLiveId = currentLive.id!!, + latestAudioId = latestAudio.id!!, + liveScheduleId = liveSchedule.id!!, + audioScheduleId = audioSchedule.id!!, + firstAudioId = firstAudio.id!!, + listAudioId = listAudio.id!!, + seriesId = series.id!!, + noticeId = notice.id!!, + communityId = community.id!!, + fanTalkId = fanTalk.id!! + ) + } ?: throw IllegalStateException("fixture creation failed") + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom { + val liveRoom = LiveRoom( + title = "live-$channelName", + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 0, + coverImage = "live.png", + isAdult = false, + price = 50 + ) + liveRoom.member = creator + liveRoom.channelName = channelName + liveRoom.isActive = true + entityManager.persist(liveRoom) + return liveRoom + } + + private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, title: String): AudioContent { + val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true) + entityManager.persist(theme) + val content = AudioContent( + title = title, + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + price = 100, + isAdult = false + ) + content.member = creator + content.theme = theme + content.isActive = true + content.coverImage = "$title.png" + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun saveSeries(title: String, creator: Member): Series { + val series = Series(title = title, introduction = "intro", languageCode = "ko", isOriginal = true, isActive = true) + series.member = creator + series.genre = saveSeriesGenre(title) + entityManager.persist(series) + return series + } + + private fun saveSeriesGenre(name: String): SeriesGenre { + val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true) + entityManager.persist(genre) + return genre + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + + private fun saveOrder(member: Member, creator: Member, content: AudioContent, type: OrderType): Order { + val order = Order(type = type, isActive = true) + order.member = member + order.creator = creator + order.audioContent = content + entityManager.persist(order) + return order + } + + private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage { + val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks") + donation.creator = creator + donation.member = donor + entityManager.persist(donation) + entityManager.flush() + updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt) + return donation + } + + private fun saveCommunityPost( + creator: Member, + content: String, + isFixed: Boolean, + fixedAt: LocalDateTime? + ): CreatorCommunity { + val post = CreatorCommunity( + content = content, + price = 0, + isCommentAvailable = true, + isAdult = false, + imagePath = "$content.png", + isFixed = isFixed, + fixedAt = fixedAt + ) + post.member = creator + entityManager.persist(post) + return post + } + + private fun saveCheers(member: Member, creator: Member, cheers: String, createdAt: LocalDateTime): CreatorCheers { + val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = true) + creatorCheers.member = member + creatorCheers.creator = creator + entityManager.persist(creatorCheers) + entityManager.flush() + updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt) + return creatorCheers + } + + private fun saveVisit(room: LiveRoom, member: Member): LiveRoomVisit { + val visit = LiveRoomVisit() + visit.room = room + visit.member = member + entityManager.persist(visit) + return visit + } + + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private data class Fixture( + val viewer: Member, + val creatorId: Long, + val currentLiveId: Long, + val latestAudioId: Long, + val liveScheduleId: Long, + val audioScheduleId: Long, + val firstAudioId: Long, + val listAudioId: Long, + val seriesId: Long, + val noticeId: Long, + val communityId: Long, + val fanTalkId: Long + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt index 95b6ca33..0a2785cb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt @@ -147,6 +147,33 @@ class CreatorChannelHomeControllerTest @Autowired constructor( ) } + @Test + @DisplayName("크리에이터 채널 홈 빈 섹션은 단건 null과 목록 빈 배열로 응답한다") + fun shouldReturnNullSinglesAndEmptyArraysForEmptyCreatorChannelHomeSections() { + val viewer = createMember(id = 10L) + Mockito.doReturn(CreatorChannelHomeResponse.from(createEmptyHome())).`when`(facade).getHome( + Mockito.eq(1L), + Mockito.any(Member::class.java) ?: viewer, + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + + mockMvc.perform( + get("/api/v2/creator-channels/1/home") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.currentLive").value(null as String?)) + .andExpect(jsonPath("$.data.latestAudioContent").value(null as String?)) + .andExpect(jsonPath("$.data.fanTalk.latestFanTalk").value(null as String?)) + .andExpect(jsonPath("$.data.channelDonations").isEmpty) + .andExpect(jsonPath("$.data.notices").isEmpty) + .andExpect(jsonPath("$.data.schedules").isEmpty) + .andExpect(jsonPath("$.data.audioContents").isEmpty) + .andExpect(jsonPath("$.data.series").isEmpty) + .andExpect(jsonPath("$.data.communities").isEmpty) + } + private fun createMember(id: Long): Member { return Member( email = "viewer$id@test.com", @@ -288,4 +315,18 @@ class CreatorChannelHomeControllerTest @Autowired constructor( ) ) } + + private fun createEmptyHome(): CreatorChannelHome { + return createHome().copy( + currentLive = null, + latestAudioContent = null, + channelDonations = emptyList(), + notices = emptyList(), + schedules = emptyList(), + audioContents = emptyList(), + series = emptyList(), + communities = emptyList(), + fanTalk = CreatorChannelFanTalkSummary(totalCount = 0, latestFanTalk = null) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt index 3fc712f9..be855561 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt @@ -22,8 +22,11 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor import org.mockito.Mockito import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.TimeZone class CreatorChannelHomeFacadeTest { @Test @@ -77,6 +80,40 @@ class CreatorChannelHomeFacadeTest { assertEquals("instagram", response.sns.instagramUrl) } + @Test + @DisplayName("크리에이터 채널 홈 facade 기본 now는 JVM timezone과 무관하게 UTC 기준으로 전달한다") + fun shouldUseUtcNowWhenNowIsNotProvided() { + val service = Mockito.mock(CreatorChannelHomeQueryService::class.java) + val facade = CreatorChannelHomeFacade(service) + val viewer = createMember(id = 10L) + val previousTimeZone = TimeZone.getDefault() + Mockito.doReturn(createHome()).`when`(service).getHome( + Mockito.eq(1L), + Mockito.eq(viewer) ?: viewer, + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1) + + facade.getHome(creatorId = 1L, viewer = viewer) + + val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1) + val nowCaptor = ArgumentCaptor.forClass(LocalDateTime::class.java) + Mockito.verify(service).getHome( + Mockito.eq(1L), + Mockito.eq(viewer) ?: viewer, + nowCaptor.capture() ?: LocalDateTime.MIN + ) + val capturedNow = nowCaptor.value + assertTrue(!capturedNow.isBefore(beforeUtc), "captured now should be after UTC lower bound") + assertTrue(!capturedNow.isAfter(afterUtc), "captured now should be before UTC upper bound") + } finally { + TimeZone.setDefault(previousTimeZone) + } + } + private fun createMember(id: Long): Member { return Member( email = "viewer$id@test.com", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt index 7aedabed..7ad5dc80 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt @@ -1,12 +1,8 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext -import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler -import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint -import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -19,18 +15,25 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.time.LocalDateTime +import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelSeriesController::class) -@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) +@Import(CreatorChannelSeriesControllerTest.TestSecurityConfig::class) class CreatorChannelSeriesControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -46,8 +49,22 @@ class CreatorChannelSeriesControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @MockBean - private lateinit var tokenProvider: TokenProvider + @TestConfiguration + class TestSecurityConfig { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + return http + .csrf().disable() + .authorizeRequests() + .anyRequest().authenticated() + .and() + .exceptionHandling() + .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } + .and() + .build() + } + } @Test @DisplayName("크리에이터 채널 시리즈 탭 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index e13d1ed3..a32db47b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -38,6 +39,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime +import java.time.ZoneOffset import javax.persistence.EntityManager @SpringBootTest @@ -524,6 +526,50 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id)) } + @Test + @DisplayName("메인 홈 통합 조회는 응원 크리에이터 item의 기존 3개 필드만 노출한다") + fun shouldKeepCheerCreatorItemSchemaOnHomeRecommendations() { + val creator = saveMember("cheer-api-creator", MemberRole.CREATOR).apply { + profileImage = "cheer-api-profile.png" + } + val snapshotAt = RecommendationSnapshotWindowPolicy() + .previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC)) + .snapshotAt + saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt) + saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt.plusDays(1)) + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/recommendations")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.cheerCreators[0].creatorId").value(creator.id)) + .andExpect(jsonPath("$.data.cheerCreators[0].creatorNickname").value("cheer-api-creator")) + .andExpect(jsonPath("$.data.cheerCreators[0].creatorProfileImage").value("/cheer-api-profile.png")) + .andExpect(jsonPath("$.data.cheerCreators[0].length()").value(3)) + } + + @Test + @DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다") + fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() { + val activeCreator = saveMember("active-live-activity", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-live-activity", MemberRole.CREATOR) + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + val activeLive = saveLiveRoom(activeCreator, baseAt.plusMinutes(2), "active-live-channel", isAdult = false) + saveLiveRoom(inactiveCreator, baseAt.plusMinutes(1), "inactive-live-channel", isAdult = false, isActive = false) + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/recommendations")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.recentlyActiveCreators[0].creatorId").value(activeCreator.id)) + .andExpect(jsonPath("$.data.recentlyActiveCreators[0].activityType").value("LIVE")) + .andExpect(jsonPath("$.data.recentlyActiveCreators[0].targetId").value(activeLive.id)) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].creatorId").value(inactiveCreator.id)) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].activityType").value("LIVE")) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").hasJsonPath()) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").doesNotExist()) + } + private fun saveMember(seed: String, role: MemberRole): Member { return memberRepository.saveAndFlush( Member( @@ -548,7 +594,8 @@ class HomeRecommendationControllerTest @Autowired constructor( creator: Member, beginDateTime: LocalDateTime, channelName: String, - isAdult: Boolean + isAdult: Boolean, + isActive: Boolean = true ): LiveRoom { val room = LiveRoom( title = "live-${creator.nickname}-$channelName", @@ -559,6 +606,7 @@ class HomeRecommendationControllerTest @Autowired constructor( ) room.member = creator room.channelName = channelName + room.isActive = isActive entityManager.persist(room) return room } @@ -595,4 +643,16 @@ class HomeRecommendationControllerTest @Autowired constructor( ) ) } + + private fun saveCheerCreatorRecommendationSnapshot(creatorId: Long, snapshotAt: LocalDateTime) { + entityManager.persist( + RecommendationSnapshot( + sectionType = RecommendedSectionType.CHEER_CREATOR, + targetId = creatorId, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.1 + ) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt new file mode 100644 index 00000000..a6bbae1e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -0,0 +1,163 @@ +package kr.co.vividnext.sodalive.v2.api.home.application + +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationGroup +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class HomeRecommendationFacadeTest { + private val queryPort = FakeHomeRecommendationQueryPort() + private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort()) + private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test") + + @Test + @DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다") + fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() { + queryPort.firstAudioContents = listOf( + firstAudio(price = 0, isPointAvailable = true), + firstAudio(price = 100, isPointAvailable = true), + firstAudio(price = 100, isPointAvailable = false) + ) + + val response = facade.getHomeRecommendations(null) + + assertFalse(response.firstAudioContents[0].isPointAvailable) + assertTrue(response.firstAudioContents[1].isPointAvailable) + assertFalse(response.firstAudioContents[2].isPointAvailable) + } + + private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord { + return HomeFirstAudioContentRecord( + contentId = price.toLong() + 1, + creatorId = 10L, + creatorNickname = "creator", + creatorProfileImage = null, + title = "first audio", + price = price, + coverImage = "cover/audio.png", + isPointAvailable = isPointAvailable, + isAdult = false, + isOriginalSeries = false + ) + } + + private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { + var firstAudioContents: List = emptyList() + + override fun findLiveRecommendations( + offset: Long, + limit: Int, + memberId: Long?, + includeAdultLives: Boolean + ): List = emptyList() + + override fun findHomeBanners(limit: Int, memberId: Long?): List = emptyList() + + override fun findRecentlyActiveCreators( + limit: Int, + memberId: Long?, + includeAdultActivities: Boolean + ): List = emptyList() + + override fun findRecentDebutCreators( + now: LocalDateTime, + offset: Long, + limit: Int, + memberId: Long?, + includeAdultContents: Boolean + ): List = emptyList() + + override fun findFirstAudioContents( + now: LocalDateTime, + offset: Long, + limit: Int, + memberId: Long?, + includeAdultContents: Boolean + ): List = firstAudioContents + + override fun findAiCharacterSnapshots( + windowStart: LocalDateTime, + windowEndExclusive: LocalDateTime, + limit: Int + ): List = emptyList() + + override fun findCheerCreatorSnapshots( + windowStart: LocalDateTime, + windowEndExclusive: LocalDateTime, + limit: Int + ): List = emptyList() + + override fun findPopularCommunitySnapshots( + windowStart: LocalDateTime, + windowEndExclusive: LocalDateTime, + limit: Int + ): List = emptyList() + + override fun findAiCharacterRecommendationDetails( + characterIds: List + ): List = emptyList() + + override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List, limit: Int): List = emptyList() + + override fun findCheerCreatorRecommendationDetails( + creatorIds: List, + memberId: Long? + ): List = emptyList() + + override fun findPopularCommunityRecommendationDetails( + communityIds: List, + memberId: Long?, + includeAdultCommunities: Boolean + ): List = emptyList() + + override fun findGenreCreatorRecommendations( + memberId: Long?, + includeAdultGenres: Boolean, + genreLimit: Int, + creatorLimit: Int + ): List = emptyList() + } + + private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort { + override fun findLatestSnapshots( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int + ): List = emptyList() + + override fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List = emptyList() + + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true + + override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) = Unit + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt index 3d218302..ec6fda32 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt @@ -58,7 +58,13 @@ class HomeRecommendationResponseTest { ) ), genreCreators = emptyList(), - cheerCreators = emptyList(), + cheerCreators = listOf( + HomeCreatorItem( + creatorId = 15L, + creatorNickname = "cheer-creator", + creatorProfileImage = "https://cdn.test/profile/cheer.png" + ) + ), popularCommunityPosts = listOf( HomePopularCommunityPostItem( postId = 5L, @@ -108,6 +114,13 @@ class HomeRecommendationResponseTest { assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText()) assertEquals(13L, json["aiCharacters"][0]["creatorId"].asLong()) assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull) + assertEquals(15L, json["cheerCreators"][0]["creatorId"].asLong()) + assertEquals("cheer-creator", json["cheerCreators"][0]["creatorNickname"].asText()) + assertEquals("https://cdn.test/profile/cheer.png", json["cheerCreators"][0]["creatorProfileImage"].asText()) + assertEquals( + setOf("creatorId", "creatorNickname", "creatorProfileImage"), + json["cheerCreators"][0].fieldNames().asSequence().toSet() + ) assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong()) assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText()) assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt index 4324eb60..54090af8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt @@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant @@ -167,7 +169,9 @@ class HomeFollowingEndToEndTest @Autowired constructor( newsType = FollowingNewsType.COMMUNITY_POST, targetId = paidPost.id!! ) - val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7) + val rankedVisibleFromAtUtc = now.minusHours(1) + saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc) + val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7) val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10)) entityManager.flush() entityManager.clear() @@ -275,6 +279,28 @@ class HomeFollowingEndToEndTest @Autowired constructor( return news } + private fun saveCreatorRankingSnapshot( + creator: Member, + rankNo: Int, + visibleFromAtUtc: LocalDateTime + ): CreatorRankingSnapshot { + val snapshot = CreatorRankingSnapshot( + rankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1), + aggregationEndAtUtc = visibleFromAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorId = creator.id!!, + nickname = creator.nickname, + profileImageUrl = creator.profileImage, + rankNo = rankNo, + finalScore = 100.0 - rankNo, + scorePolicyVersion = "v1", + scoreDetailJson = "{}" + ) + entityManager.persist(snapshot) + return snapshot + } + private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity { val post = CreatorCommunity( content = content, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt deleted file mode 100644 index cbf60118..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitEventBoundaryIntegrationTest.kt +++ /dev/null @@ -1,333 +0,0 @@ -package kr.co.vividnext.sodalive.v2.common.application - -import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository -import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository -import kr.co.vividnext.sodalive.configs.QueryDslConfig -import kr.co.vividnext.sodalive.content.AudioContentRepository -import kr.co.vividnext.sodalive.content.LanguageDetectEvent -import kr.co.vividnext.sodalive.content.LanguageDetectListener -import kr.co.vividnext.sodalive.content.LanguageDetectTargetType -import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService -import kr.co.vividnext.sodalive.content.category.CategoryRepository -import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository -import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository -import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository -import kr.co.vividnext.sodalive.fcm.FcmEvent -import kr.co.vividnext.sodalive.fcm.FcmEventType -import kr.co.vividnext.sodalive.fcm.FcmSendListener -import kr.co.vividnext.sodalive.fcm.FcmService -import kr.co.vividnext.sodalive.fcm.PushTokenInfo -import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService -import kr.co.vividnext.sodalive.i18n.SodaMessageSource -import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent -import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationListener -import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType -import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler -import kr.co.vividnext.sodalive.member.MemberRepository -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.mockito.Mockito -import org.mockito.Mockito.verify -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.ApplicationEventPublisher -import org.springframework.context.annotation.Import -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.annotation.Propagation -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.event.TransactionPhase -import org.springframework.transaction.event.TransactionalEventListener -import org.springframework.transaction.support.TransactionTemplate - -@DataJpaTest( - properties = [ - "spring.cache.type=none", - "cloud.naver.papago-client-id=test-client-id", - "cloud.naver.papago-client-secret=test-client-secret" - ] -) -@Import( - AfterCommitExecutor::class, - QueryDslConfig::class, - AfterCommitEventBoundaryIntegrationTest.TestEventListener::class, - FcmSendListener::class, - LanguageDetectListener::class, - LanguageTranslationListener::class -) -@Transactional(propagation = Propagation.NOT_SUPPORTED) -class AfterCommitEventBoundaryIntegrationTest @Autowired constructor( - private val executor: AfterCommitExecutor, - private val eventPublisher: ApplicationEventPublisher, - private val testEventListener: TestEventListener, - transactionManager: PlatformTransactionManager -) { - private val transactionTemplate = TransactionTemplate(transactionManager) - - @MockBean - private lateinit var fcmService: FcmService - - @MockBean - private lateinit var memberRepository: MemberRepository - - @MockBean - private lateinit var contentCommentRepository: AudioContentCommentRepository - - @MockBean - private lateinit var sodaMessageSource: SodaMessageSource - - @MockBean - private lateinit var pushNotificationService: PushNotificationService - - @MockBean - private lateinit var audioContentRepository: AudioContentRepository - - @MockBean - private lateinit var chatCharacterRepository: ChatCharacterRepository - - @MockBean - private lateinit var characterCommentRepository: kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository - - @MockBean - private lateinit var creatorCheersRepository: CreatorCheersRepository - - @MockBean - private lateinit var seriesRepository: ContentSeriesRepository - - @MockBean - private lateinit var originalWorkRepository: OriginalWorkRepository - - @MockBean - private lateinit var categoryRepository: CategoryRepository - - @MockBean - private lateinit var languageDetectionCacheService: LanguageDetectionCacheService - - @MockBean - private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler - - @Test - @DisplayName("direct callback은 실제 transaction commit 후 1회 실행된다") - fun shouldRunCallbackAfterRealTransactionCommit() { - var count = 0 - - transactionTemplate.executeWithoutResult { - executor.executeAfterCommit { count += 1 } - assertEquals(0, count) - } - - assertEquals(1, count) - } - - @Test - @DisplayName("direct callback은 실제 transaction rollback 후 실행되지 않는다") - fun shouldNotRunCallbackAfterRealTransactionRollback() { - var count = 0 - - try { - transactionTemplate.executeWithoutResult { - executor.executeAfterCommit { count += 1 } - throw IllegalStateException("rollback") - } - } catch (e: IllegalStateException) { - assertEquals("rollback", e.message) - } - - assertEquals(0, count) - } - - @Test - @DisplayName("첫 attempt rollback 후 동일 command 재시도 commit 시 callback은 총 1회 실행된다") - fun shouldRunCallbackOnceWhenSameCommandCommitsOnRetryAfterRollback() { - var attemptCount = 0 - var callbackCount = 0 - val command = { - transactionTemplate.executeWithoutResult { - attemptCount += 1 - executor.executeAfterCommit { callbackCount += 1 } - if (attemptCount == 1) { - throw IllegalStateException("rollback") - } - } - } - - assertThrows(IllegalStateException::class.java) { command() } - assertEquals(0, callbackCount) - - command() - - assertEquals(2, attemptCount) - assertEquals(1, callbackCount) - } - - @Test - @DisplayName("기존 FCM과 언어 event listener는 AFTER_COMMIT 경계를 유지한다") - fun shouldKeepExistingEventListenersAfterCommit() { - assertEquals( - TransactionPhase.AFTER_COMMIT, - transactionalEventListener(FcmSendListener::class.java, "send", FcmEvent::class.java).phase - ) - assertEquals( - TransactionPhase.AFTER_COMMIT, - transactionalEventListener( - LanguageDetectListener::class.java, - "detectLanguage", - LanguageDetectEvent::class.java - ).phase - ) - assertEquals( - TransactionPhase.AFTER_COMMIT, - transactionalEventListener( - LanguageTranslationListener::class.java, - "translationAfterCommit", - LanguageTranslationEvent::class.java - ).phase - ) - } - - @Test - @DisplayName("일반 event publish는 rollback되면 transactional listener를 실행하지 않는다") - fun shouldNotRunTransactionalEventListenerAfterRollback() { - testEventListener.messages.clear() - - try { - transactionTemplate.executeWithoutResult { - eventPublisher.publishEvent(TestEvent("rollback")) - throw IllegalStateException("rollback") - } - } catch (e: IllegalStateException) { - assertEquals("rollback", e.message) - } - - assertEquals(emptyList(), testEventListener.messages) - } - - @Test - @DisplayName("일반 event publish는 commit 이후 transactional listener를 실행한다") - fun shouldRunTransactionalEventListenerAfterCommit() { - testEventListener.messages.clear() - - transactionTemplate.executeWithoutResult { - eventPublisher.publishEvent(TestEvent("commit")) - assertEquals(emptyList(), testEventListener.messages) - } - - assertEquals(listOf("commit"), testEventListener.messages) - } - - @Test - @DisplayName("실제 FCM event listener는 transaction commit 이후 실행된다") - fun shouldRunRealFcmEventListenerAfterCommit() { - val event = FcmEvent( - type = FcmEventType.CANCEL_LIVE, - title = "title", - message = "message", - pushTokens = listOf(PushTokenInfo(token = "token", deviceType = "ios", languageCode = "ko")) - ) - - transactionTemplate.executeWithoutResult { - eventPublisher.publishEvent(event) - Mockito.verifyNoInteractions(fcmService) - } - - Mockito.verify(fcmService, Mockito.timeout(1000)).send( - tokens = listOf("token"), - title = "title", - message = "message", - container = "ios", - roomId = null, - messageId = null, - contentId = null, - creatorId = null, - auditionId = null, - deepLinkValue = null, - deepLinkId = null, - deepLinkCommentPostId = null, - chatType = null - ) - } - - @Test - @DisplayName("실제 언어 감지 event listener는 rollback되면 실행되지 않는다") - fun shouldNotRunRealLanguageDetectListenerAfterRollback() { - try { - transactionTemplate.executeWithoutResult { - eventPublisher.publishEvent( - LanguageDetectEvent(id = 9L, query = "hello", targetType = LanguageDetectTargetType.CHARACTER) - ) - throw IllegalStateException("rollback") - } - } catch (e: IllegalStateException) { - assertEquals("rollback", e.message) - } - - Mockito.verifyNoInteractions(chatCharacterRepository) - } - - @Test - @DisplayName("실제 언어 감지 event listener는 commit 이후 실행된다") - fun shouldRunRealLanguageDetectListenerAfterCommit() { - Mockito.doReturn("ko").`when`(languageDetectionCacheService) - .detectWithCache(eqValue("hello"), eqValue("papago"), anyValue()) - - transactionTemplate.executeWithoutResult { - eventPublisher.publishEvent( - LanguageDetectEvent( - id = 9L, - query = "hello", - targetType = LanguageDetectTargetType.CHARACTER - ) - ) - Mockito.verifyNoInteractions(chatCharacterRepository) - } - - verify(chatCharacterRepository, Mockito.timeout(1000)).findById(eqValue(9L)) - } - - @Test - @DisplayName("waitTransactionCommit=true 언어 번역 event는 commit 이후 scheduler를 호출한다") - fun shouldRunRealLanguageTranslationListenerAfterCommitWhenWaitingTransactionCommit() { - transactionTemplate.executeWithoutResult { - eventPublisher.publishEvent( - LanguageTranslationEvent( - id = 11L, - targetType = LanguageTranslationTargetType.CHARACTER, - waitTransactionCommit = true - ) - ) - Mockito.verifyNoInteractions(resourceTranslationJobScheduler) - } - - Mockito.verify(resourceTranslationJobScheduler, Mockito.timeout(1000)) - .scheduleResourceTranslations(LanguageTranslationTargetType.CHARACTER, 11L) - } - - private fun transactionalEventListener( - type: Class<*>, - methodName: String, - eventType: Class<*> - ): TransactionalEventListener { - return type.getDeclaredMethod(methodName, eventType).getAnnotation(TransactionalEventListener::class.java) - } - - private fun eqValue(value: T): T { - return Mockito.eq(value) ?: value - } - - private fun anyValue(): T { - return Mockito.any() - } - - data class TestEvent(val message: String) - - class TestEventListener { - val messages = mutableListOf() - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - fun handle(event: TestEvent) { - messages += event.message - } - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt deleted file mode 100644 index f386b8a3..00000000 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/common/application/AfterCommitExecutorTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package kr.co.vividnext.sodalive.v2.common.application - -import org.junit.jupiter.api.Assertions.assertDoesNotThrow -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test -import org.springframework.transaction.support.TransactionSynchronizationManager - -class AfterCommitExecutorTest { - @Test - @DisplayName("트랜잭션이 없으면 direct callback을 즉시 1회 실행한다") - fun shouldRunCallbackImmediatelyWithoutTransaction() { - val executor = AfterCommitExecutor() - var count = 0 - - executor.executeAfterCommit { count += 1 } - - assertEquals(1, count) - } - - @Test - @DisplayName("트랜잭션이 없으면 direct callback 예외를 호출자에게 전파하지 않는다") - fun shouldNotPropagateCallbackExceptionWithoutTransaction() { - val executor = AfterCommitExecutor() - - assertDoesNotThrow { - executor.executeAfterCommit { throw IllegalStateException("sensitive-body") } - } - } - - @Test - @DisplayName("트랜잭션 commit 이후 direct callback을 1회 실행한다") - fun shouldRunCallbackOnceAfterCommit() { - val executor = AfterCommitExecutor() - var count = 0 - TransactionSynchronizationManager.initSynchronization() - - try { - executor.executeAfterCommit { count += 1 } - TransactionSynchronizationManager.getSynchronizations().forEach { synchronization -> - synchronization.afterCommit() - } - } finally { - TransactionSynchronizationManager.clearSynchronization() - } - - assertEquals(1, count) - } - - @Test - @DisplayName("afterCommit callback 예외는 격리되어 이후 callback 실행을 막지 않는다") - fun shouldIsolateCallbackExceptionAfterCommit() { - val executor = AfterCommitExecutor() - var count = 0 - TransactionSynchronizationManager.initSynchronization() - - try { - executor.executeAfterCommit { throw IllegalStateException("sensitive-body") } - executor.executeAfterCommit { count += 1 } - - assertDoesNotThrow { - TransactionSynchronizationManager.getSynchronizations().forEach { synchronization -> - synchronization.afterCommit() - } - } - } finally { - TransactionSynchronizationManager.clearSynchronization() - } - - assertEquals(1, count) - } - - @Test - @DisplayName("트랜잭션 rollback이면 direct callback을 실행하지 않는다") - fun shouldNotRunCallbackBeforeCommit() { - val executor = AfterCommitExecutor() - var count = 0 - TransactionSynchronizationManager.initSynchronization() - - try { - executor.executeAfterCommit { count += 1 } - } finally { - TransactionSynchronizationManager.clearSynchronization() - } - - assertEquals(0, count) - } -} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt index 7b993f70..f6f95220 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt @@ -52,6 +52,7 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor( val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false) val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0) val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true) + val freePoint = saveAudioContent(creator, theme, now.minusHours(12), isAdult = false, price = 0, isPointAvailable = true) saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200) saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100) saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100) @@ -66,11 +67,12 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor( val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true) val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true) - assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now)) + assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now)) assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true)) - assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId }) + assertEquals(1, repository.countAudios(null, false, now, onlyPointAvailable = true)) + assertEquals(listOf(freePoint.id, point.id, free.id), visible.map { it.audioContentId }) assertEquals(listOf(point.id), paidAudios.map { it.audioContentId }) - assertEquals(listOf(free.id), freeAudios.map { it.audioContentId }) + assertEquals(listOf(freePoint.id, free.id), freeAudios.map { it.audioContentId }) assertEquals(listOf(point.id), pointAudios.map { it.audioContentId }) assertEquals("https://cdn.test/audio.png", visible.first().imageUrl) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index 504971f6..58a12c13 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -90,7 +90,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( val now = LocalDateTime.of(2026, 6, 23, 12, 0) val creator = saveMember("audio-creator", MemberRole.CREATOR) val theme = saveTheme() - val first = saveAudio( + saveAudio( creator = creator, theme = theme, title = "first", @@ -108,6 +108,15 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( isPointAvailable = false, coverImage = "latest.png" ) + val paidPoint = saveAudio( + creator = creator, + theme = theme, + title = "paid-point", + releaseDate = now.minusDays(2), + price = 20, + isPointAvailable = true, + coverImage = "paid-point.png" + ) saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true) saveAudio(creator, theme, "future", now.plusDays(1)) saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false @@ -139,11 +148,11 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now) assertEquals(12, latestAudios.size) - assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId }) + assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId }) assertEquals(10, freeAudios.size) assertEquals(true, freeAudios.all { it.price == 0 }) - assertEquals(10, pointAudios.size) - assertEquals(true, pointAudios.all { it.isPointAvailable }) + assertEquals(listOf(paidPoint.id), pointAudios.map { it.audioContentId }) + assertEquals(true, pointAudios.all { it.isPointAvailable && it.price > 0 }) val latestCard = latestAudios.first() assertEquals("latest", latestCard.title) assertEquals("00:01", latestCard.duration) @@ -154,8 +163,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(false, latestCard.isFirstContent) assertEquals(true, latestCard.isOriginalSeries) assertEquals(creator.nickname, latestCard.creatorNickname) - assertEquals(true, latestAudios[1].isFirstContent) - assertEquals(false, latestAudios[1].isOriginalSeries) + assertEquals(true, latestAudios[2].isFirstContent) + assertEquals(false, latestAudios[2].isOriginalSeries) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index db6599db..65b8dec6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.pin.PinContent import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent @@ -513,6 +514,66 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( assertTrue(records.last().isPointAvailable) } + @Test + @DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다") + fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("home-audio-pin-creator", MemberRole.CREATOR) + val pinnedRecent = saveAudioContent(creator, now.minusDays(5), isAdult = false) + val pinnedOlder = saveAudioContent(creator, now.minusDays(4), isAdult = false) + val normal = saveAudioContent(creator, now.minusDays(3), isAdult = false) + val inactivePinned = saveAudioContent(creator, now.minusDays(2), isAdult = false) + val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false) + savePinContent(creator, latest, isActive = true, updatedAt = now.minusMinutes(5)) + savePinContent(creator, pinnedRecent, isActive = true, updatedAt = now.minusMinutes(10)) + savePinContent(creator, pinnedOlder, isActive = true, updatedAt = now.minusMinutes(20)) + savePinContent(creator, inactivePinned, isActive = false, updatedAt = now.minusMinutes(1)) + flushAndClear() + + val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false) + val records = repository.findAudioContents( + creator.id!!, + now, + latestAudioContentId = latestRecord!!.audioContentId, + canViewAdultContent = false, + limit = 9 + ) + + assertEquals(latest.id, latestRecord.audioContentId) + assertEquals( + listOf(pinnedRecent.id, pinnedOlder.id, inactivePinned.id, normal.id), + records.map { it.audioContentId } + ) + } + + @Test + @DisplayName("홈 오디오 목록은 활성 고정 후보가 9개를 넘어도 최대 9개만 조회한다") + fun shouldLimitHomeAudioContentsToNineWhenActivePinnedCandidatesExceedLimit() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("home-audio-pin-limit-creator", MemberRole.CREATOR) + val pinnedContents = (1..10).map { index -> + saveAudioContent(creator, now.minusDays(index.toLong() + 1), isAdult = false) + } + val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false) + pinnedContents.forEachIndexed { index, content -> + savePinContent(creator, content, isActive = true, updatedAt = now.minusMinutes(index.toLong())) + } + flushAndClear() + + val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false) + val records = repository.findAudioContents( + creator.id!!, + now, + latestAudioContentId = latestRecord!!.audioContentId, + canViewAdultContent = false, + limit = 9 + ) + + assertEquals(latest.id, latestRecord.audioContentId) + assertEquals(9, records.size) + assertEquals(pinnedContents.take(9).map { it.id }, records.map { it.audioContentId }) + } + @Test @DisplayName("최신 오디오와 오디오 목록은 조회자의 유효한 소장/대여 주문 상태를 함께 반환한다") fun shouldFindAudioContentOwnershipFlagsByViewerOrders() { @@ -1154,6 +1215,21 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( return seriesContent } + private fun savePinContent( + creator: Member, + content: AudioContent, + isActive: Boolean, + updatedAt: LocalDateTime + ): PinContent { + val pinContent = PinContent(isActive = isActive) + pinContent.member = creator + pinContent.content = content + entityManager.persist(pinContent) + entityManager.flush() + updateUpdatedAt("PinContent", pinContent.id!!, updatedAt) + return pinContent + } + private fun saveDonation( creator: Member, donor: Member, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 6e234918..25f783a0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -108,7 +108,7 @@ class CreatorChannelHomeQueryServiceTest { fun shouldPassViewerCreatorFlagToLivePolicyQueries() { val port = FakeCreatorChannelHomeQueryPort() val service = createService(port) - val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null) + val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null, role = MemberRole.CREATOR) service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) @@ -118,6 +118,64 @@ class CreatorChannelHomeQueryServiceTest { assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender) } + @Test + @DisplayName("조회자가 다른 크리에이터여도 라이브 조회 정책 컨텍스트에 크리에이터 여부를 전달한다") + fun shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator() { + val port = FakeCreatorChannelHomeQueryPort() + val service = createService(port) + val viewer = createMember(id = 10L, role = MemberRole.CREATOR) + + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + + assertTrue(port.currentLiveIsViewerCreator == true) + assertTrue(port.schedulesIsViewerCreator == true) + } + + @Test + @DisplayName("일반 회원 조회자는 라이브 조회 정책 컨텍스트에 크리에이터가 아닌 것으로 전달한다") + fun shouldPassNonCreatorFlagWhenViewerIsUser() { + val port = FakeCreatorChannelHomeQueryPort() + val service = createService(port) + val viewer = createMember(id = 10L, role = MemberRole.USER) + + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + + assertFalse(port.currentLiveIsViewerCreator == true) + assertFalse(port.schedulesIsViewerCreator == true) + } + + @Test + @DisplayName("홈 조회는 저장된 콘텐츠 선호를 한 번만 읽어 성인 필터에 재사용한다") + fun shouldReuseStoredPreferenceAdultFlagForAdultFilters() { + val port = FakeCreatorChannelHomeQueryPort() + val communityPort = FakeCreatorChannelCommunityQueryPort() + val preferenceService = createPreferenceService(canViewAdultContent = false) + val service = createService(port, communityPort, preferenceService = preferenceService) + val viewer = createMember(id = 10L) + + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + + assertEquals(false, port.currentLiveCanViewAdultContent) + assertEquals(false, port.schedulesCanViewAdultContent) + assertEquals(listOf(false, false), communityPort.homeCanViewAdultContentValues) + Mockito.verify(preferenceService).getStoredPreference(viewer) + Mockito.verify(preferenceService, Mockito.never()).canViewAdultContent(viewer) + } + + @Test + @DisplayName("홈 채널 후원자는 삭제 회원 닉네임 prefix를 제거해 조립한다") + fun shouldRemoveDeletedNicknamePrefixFromChannelDonation() { + val port = FakeCreatorChannelHomeQueryPort().apply { + channelDonationNickname = "deleted_donor" + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val home = service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + + assertEquals("donor", home.channelDonations.first().nickname) + } + @Test @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { @@ -409,22 +467,9 @@ class CreatorChannelHomeQueryServiceTest { private fun createService( port: FakeCreatorChannelHomeQueryPort, communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(), - canViewAdultContent: Boolean = true + canViewAdultContent: Boolean = true, + preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent) ): CreatorChannelHomeQueryService { - val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - Mockito.`when`( - preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L)) - ).thenReturn( - ViewerContentPreference( - countryCode = "US", - isAdultContentVisible = canViewAdultContent, - contentType = ContentType.ALL, - isAdult = canViewAdultContent - ) - ) - Mockito.`when`( - preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L)) - ).thenReturn(canViewAdultContent) val messageSource = SodaMessageSource() val langContext = LangContext() langContext.setLang(Lang.KO) @@ -450,17 +495,37 @@ class CreatorChannelHomeQueryServiceTest { ) } + private fun createPreferenceService(canViewAdultContent: Boolean): MemberContentPreferenceService { + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + Mockito.`when`( + preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L)) + ).thenReturn( + ViewerContentPreference( + countryCode = "US", + isAdultContentVisible = canViewAdultContent, + contentType = ContentType.ALL, + isAdult = canViewAdultContent + ) + ) + Mockito.`when`( + preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L)) + ).thenReturn(canViewAdultContent) + return preferenceService + } + private fun createMember( id: Long, gender: Gender = Gender.NONE, - authGender: Int? = null + authGender: Int? = null, + role: MemberRole = MemberRole.USER ): Member { val member = Member( email = "member$id@test.com", password = "password", nickname = "member$id", provider = MemberProvider.EMAIL, - gender = gender + gender = gender, + role = role ) member.id = id authGender?.let { @@ -500,6 +565,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { var schedulesEffectiveViewerGender: Gender? = null var schedulesCanViewAdultContent: Boolean? = null var audioContentsLatestAudioContentId: Long? = null + var channelDonationNickname: String = "fan" override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator @@ -541,7 +607,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { limit: Int ): List = listOf( CreatorChannelDonationRecord( - nickname = "fan", + nickname = channelDonationNickname, profileImagePath = "profile/fan.png", can = 30, message = "thanks", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt index e624688b..461212b7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt @@ -7,13 +7,20 @@ import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshotJob +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -98,12 +105,50 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator") flushAndClear() - val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10) + val lives = repository.findOnAirLives( + memberId = viewer.id!!, + canViewAdultContent = false, + effectiveViewerGender = Gender.NONE, + isViewerCreator = false, + limit = 10 + ) assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId }) assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl) } + @Test + @DisplayName("On Air는 성별 제한과 크리에이터 입장 제한 정책을 반영한다") + fun shouldFindFollowingOnAirLivesWithViewerGenderAndCreatorJoinPolicy() { + val viewer = saveMember("live-policy-viewer", MemberRole.CREATOR) + val creator = saveMember("live-policy-creator", MemberRole.CREATOR) + saveFollowing(viewer, creator) + saveLiveRoom( + creator, + LocalDateTime.of(2026, 6, 25, 10, 0), + channelName = "male-only", + genderRestriction = GenderRestriction.MALE_ONLY + ) + saveLiveRoom( + creator, + LocalDateTime.of(2026, 6, 25, 11, 0), + channelName = "creator-hidden", + isAvailableJoinCreator = false + ) + val visible = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "visible") + flushAndClear() + + val lives = repository.findOnAirLives( + memberId = viewer.id!!, + canViewAdultContent = true, + effectiveViewerGender = Gender.FEMALE, + isViewerCreator = true, + limit = 10 + ) + + assertEquals(listOf(visible.id!!), lives.map { it.liveId }) + } + @Test @DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다") fun shouldFindMonthlySchedulesInKstWindow() { @@ -127,6 +172,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( val schedules = repository.findMonthlySchedules( memberId = viewer.id!!, canViewAdultContent = false, + effectiveViewerGender = Gender.NONE, + isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 12, 0), limit = 3 ) @@ -160,6 +207,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( val schedules = repository.findMonthlySchedules( memberId = viewer.id!!, canViewAdultContent = false, + effectiveViewerGender = Gender.NONE, + isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 0, 0), limit = 3 ) @@ -181,6 +230,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( val schedules = repository.findMonthlySchedules( memberId = viewer.id!!, canViewAdultContent = false, + effectiveViewerGender = Gender.NONE, + isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 14, 30), limit = 3 ) @@ -205,6 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( val schedules = repository.findMonthlySchedules( memberId = viewer.id!!, canViewAdultContent = false, + effectiveViewerGender = Gender.NONE, + isViewerCreator = false, now = LocalDateTime.of(2026, 6, 25, 0, 0), limit = 10 ) @@ -220,6 +273,41 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( ) } + @Test + @DisplayName("라이브 스케줄은 성별 제한과 크리에이터 입장 제한 정책을 반영한다") + fun shouldFindLiveMonthlySchedulesWithViewerGenderAndCreatorJoinPolicy() { + val viewer = saveMember("schedule-policy-viewer", MemberRole.CREATOR) + val creator = saveMember("schedule-policy-creator", MemberRole.CREATOR) + val theme = saveTheme("schedule-policy-theme") + saveFollowing(viewer, creator) + saveLiveRoom( + creator, + LocalDateTime.of(2026, 6, 25, 1, 0), + channelName = null, + genderRestriction = GenderRestriction.MALE_ONLY + ) + saveLiveRoom( + creator, + LocalDateTime.of(2026, 6, 25, 2, 0), + channelName = null, + isAvailableJoinCreator = false + ) + val visibleLive = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 3, 0), channelName = null) + val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 4, 0)) + flushAndClear() + + val schedules = repository.findMonthlySchedules( + memberId = viewer.id!!, + canViewAdultContent = true, + effectiveViewerGender = Gender.FEMALE, + isViewerCreator = true, + now = LocalDateTime.of(2026, 6, 25, 0, 0), + limit = 10 + ) + + assertEquals(listOf("LIVE:${visibleLive.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId }) + } + @Test @DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다") fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() { @@ -230,6 +318,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( saveFollowing(viewer, creator) saveFollowing(viewer, blockedCreator) saveFollowing(viewer, nonCreator) + saveCreatorRankingSnapshot(creator, rankNo = 3, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0)) saveNews(viewer.id!!, creator.id!!, "old-without-rank", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null) val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3) saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1) @@ -250,12 +339,236 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( assertEquals(listOf(3), news.map { it.creatorRanking?.rank }) } + @Test + @DisplayName("최근 소식의 크리에이터 랭킹은 최신 공개 배치만 조회한다") + fun shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews() { + val viewer = saveMember("news-ranking-viewer", MemberRole.USER) + val firstCreator = saveMember("news-ranking-first", MemberRole.CREATOR) + val secondCreator = saveMember("news-ranking-second", MemberRole.CREATOR) + saveFollowing(viewer, firstCreator) + saveFollowing(viewer, secondCreator) + saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)) + saveCreatorRankingSnapshot(secondCreator, rankNo = 2, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)) + saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 15, 0, 0)) + val oldFirstNews = saveNews( + viewer.id!!, + firstCreator.id!!, + "old-ranking-first", + LocalDateTime.of(2026, 6, 8, 0, 0), + rank = 1 + ) + val oldSecondNews = saveNews( + viewer.id!!, + secondCreator.id!!, + "old-ranking-second", + LocalDateTime.of(2026, 6, 8, 0, 0), + rank = 2 + ) + val latestFirstNews = saveNews( + viewer.id!!, + firstCreator.id!!, + "latest-ranking-first", + LocalDateTime.of(2026, 6, 15, 0, 0), + rank = 1 + ) + flushAndClear() + + val beforeLatestVisible = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 14, 23, 59), + limit = 30 + ) + val afterLatestVisible = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0), + limit = 30 + ) + + assertEquals(listOf(oldSecondNews.id!!.toString(), oldFirstNews.id!!.toString()), beforeLatestVisible.map { it.newsId }) + assertEquals(listOf(latestFirstNews.id!!.toString()), afterLatestVisible.map { it.newsId }) + } + + @Test + @DisplayName("최근 소식의 크리에이터 랭킹은 최신 완료 배치가 비어 있어도 과거 배치로 보충하지 않는다") + fun shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty() { + val viewer = saveMember("news-empty-ranking-viewer", MemberRole.USER) + val creator = saveMember("news-empty-ranking-creator", MemberRole.CREATOR) + saveFollowing(viewer, creator) + saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)) + saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 8, 0, 0)) + saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 15, 0, 0)) + saveNews( + viewer.id!!, + creator.id!!, + "old-ranking-empty-latest", + LocalDateTime.of(2026, 6, 8, 0, 0), + rank = 1 + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0), + limit = 30 + ) + + assertTrue(news.none { it.type == FollowingNewsType.CREATOR_RANKING }) + } + + @Test + @DisplayName("최근 소식의 콘텐츠 랭킹은 콘텐츠별 최신 row만 limit 전에 남긴다") + fun shouldFindLatestContentRankingNewsPerContentBeforeLimit() { + val viewer = saveMember("news-content-ranking-viewer", MemberRole.USER) + val creator = saveMember("news-content-ranking-creator", MemberRole.CREATOR) + val theme = saveTheme("news-content-ranking-theme") + saveFollowing(viewer, creator) + val firstContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0)).apply { + title = "first ranked content" + coverImage = "content-ranking/first.png" + } + val secondContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10)).apply { + title = "second ranked content" + coverImage = "content-ranking/second.png" + } + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-first-older", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + rank = 3, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = firstContent.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-first-same-time-older-id", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1), + rank = 2, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = firstContent.id!! + ) + val latestFirst = saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-first-same-time-latest-id", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1), + rank = 1, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = firstContent.id!! + ) + val latestSecond = saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-second", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 8, 59), + rank = 4, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = secondContent.id!! + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + limit = 2 + ) + + assertEquals(listOf(latestFirst.id!!.toString(), latestSecond.id!!.toString()), news.map { it.newsId }) + assertEquals(listOf(firstContent.id!!, secondContent.id!!), news.map { it.contentRanking?.contentId }) + assertEquals(listOf(1, 4), news.map { it.contentRanking?.rank }) + assertTrue(news.all { it.creatorRanking == null && it.audioContent == null && it.communityPost == null }) + } + + @Test + @DisplayName("최근 소식의 콘텐츠 랭킹은 rank가 없는 최신 row가 이전 노출 가능 row를 가리지 않는다") + fun shouldKeepOlderContentRankingNewsWhenNewerRowHasNoRank() { + val viewer = saveMember("news-content-null-rank-viewer", MemberRole.USER) + val creator = saveMember("news-content-null-rank-creator", MemberRole.CREATOR) + val theme = saveTheme("news-content-null-rank-theme") + saveFollowing(viewer, creator) + val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0)) + val visible = saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-visible-before-null-rank", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + rank = 2, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = content.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-null-rank-newer", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + rank = null, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = content.id!! + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0), + limit = 30 + ) + + assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId }) + } + + @Test + @DisplayName("최근 소식의 콘텐츠 랭킹은 성인 최신 row가 비성인 회원의 이전 노출 가능 row를 가리지 않는다") + fun shouldKeepOlderContentRankingNewsWhenNewerRowIsAdultForNonAdultViewer() { + val viewer = saveMember("news-content-adult-row-viewer", MemberRole.USER) + val creator = saveMember("news-content-adult-row-creator", MemberRole.CREATOR) + val theme = saveTheme("news-content-adult-row-theme") + saveFollowing(viewer, creator) + val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isAdult = false) + val visible = saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-visible-before-adult-row", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + rank = 2, + isAdult = false, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = content.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "content-ranking-adult-newer", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + rank = 1, + isAdult = true, + newsType = FollowingNewsType.CONTENT_RANKING, + targetId = content.id!! + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = false, + nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0), + limit = 30 + ) + + assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId }) + } + @Test @DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다") fun shouldNotExposeNewsVisibleAfterUtcNow() { val viewer = saveMember("news-utc-viewer", MemberRole.USER) val creator = saveMember("news-utc-creator", MemberRole.CREATOR) saveFollowing(viewer, creator) + saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 14, 30)) val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30), rank = 1) saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31), rank = 2) flushAndClear() @@ -555,14 +868,18 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( creator: Member, beginDateTime: LocalDateTime, channelName: String?, - isAdult: Boolean = false + isAdult: Boolean = false, + genderRestriction: GenderRestriction = GenderRestriction.ALL, + isAvailableJoinCreator: Boolean = true ): LiveRoom { val liveRoom = LiveRoom( title = "live-${creator.nickname}-$beginDateTime", notice = "notice", beginDateTime = beginDateTime, numberOfPeople = 0, - isAdult = isAdult + isAdult = isAdult, + isAvailableJoinCreator = isAvailableJoinCreator, + genderRestriction = genderRestriction ).apply { member = creator this.channelName = channelName @@ -667,6 +984,43 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( return news } + private fun saveCreatorRankingSnapshot( + creator: Member, + rankNo: Int, + visibleFromAtUtc: LocalDateTime, + rankingType: CreatorRankingType = CreatorRankingType.WEEKLY + ): CreatorRankingSnapshot { + val snapshot = CreatorRankingSnapshot( + rankingType = rankingType, + aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1), + aggregationEndAtUtc = visibleFromAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorId = creator.id!!, + nickname = creator.nickname, + profileImageUrl = creator.profileImage, + rankNo = rankNo, + finalScore = 100.0 - rankNo, + scorePolicyVersion = "v1", + scoreDetailJson = "{}" + ) + entityManager.persist(snapshot) + return snapshot + } + + private fun saveCreatorRankingSnapshotJob(visibleFromAtUtc: LocalDateTime): CreatorRankingSnapshotJob { + val job = CreatorRankingSnapshotJob( + rankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1), + aggregationEndAtUtc = visibleFromAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + trigger = CreatorRankingSnapshotJobTrigger.SCHEDULED, + status = CreatorRankingSnapshotJobStatus.DONE, + processedAt = visibleFromAtUtc.minusHours(1) + ) + entityManager.persist(job) + return job + } + private fun flushAndClear() { entityManager.flush() entityManager.clear() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt index d4f79f2e..380bd0f1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord import org.junit.jupiter.api.Assertions.assertDoesNotThrow @@ -17,7 +18,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.transaction.TestTransaction +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import javax.persistence.EntityManager @DataJpaTest( @@ -29,9 +38,13 @@ import javax.persistence.EntityManager @Import(QueryDslConfig::class) class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor( private val repository: HomeFollowingNewsInboxJpaRepository, - private val entityManager: EntityManager + private val creatorFollowingRepository: CreatorFollowingRepository, + private val entityManager: EntityManager, + transactionManager: PlatformTransactionManager ) { private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager) + private val transactionalAdapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager, transactionManager) + private val transactionTemplate = TransactionTemplate(transactionManager) @Test @DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다") @@ -115,6 +128,75 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor( assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds) } + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @DisplayName("publish가 follower 조회 후 insert 전 멈춘 사이 언팔로우가 완료돼도 재팔로우 후 과거 소식이 활성화되지 않는다") + fun shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed() { + val (memberId, creatorId) = transactionTemplate.execute { + val creator = saveMember("concurrent-creator", MemberRole.CREATOR) + val follower = saveMember("concurrent-follower", MemberRole.USER) + saveFollowing(follower, creator, isActive = true) + entityManager.flush() + follower.id!! to creator.id!! + }!! + val followerRead = CountDownLatch(1) + val allowPublishInsert = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + + try { + val publish = executor.submit { + transactionTemplate.execute { + val followerIds = transactionalAdapter.findActiveFollowerIds(creatorId) + followerRead.countDown() + assertTrue(allowPublishInsert.await(5, TimeUnit.SECONDS)) + transactionalAdapter.insertIgnoreAll( + followerIds.map { followerId -> + record(memberId = followerId, creatorId = creatorId, sourceKey = "COMMUNITY_POST:delayed") + } + ) + }!! + } + assertTrue(followerRead.await(5, TimeUnit.SECONDS)) + val unfollow = executor.submit { + transactionTemplate.executeWithoutResult { + val following = creatorFollowingRepository.findByCreatorIdAndMemberId( + creatorId = creatorId, + memberId = memberId + )!! + following.isActive = false + transactionalAdapter.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId) + } + } + + Thread.sleep(200) + val unfollowCompletedBeforeInsert = try { + unfollow.get(1, TimeUnit.SECONDS) + true + } catch (_: TimeoutException) { + false + } + allowPublishInsert.countDown() + assertEquals(1, publish.get(5, TimeUnit.SECONDS)) + if (!unfollowCompletedBeforeInsert) { + unfollow.get(5, TimeUnit.SECONDS) + } + transactionTemplate.executeWithoutResult { + creatorFollowingRepository.findByCreatorIdAndMemberId( + creatorId = creatorId, + memberId = memberId + )!!.isActive = true + } + + val activeRows = transactionTemplate.execute { + repository.findAll().filter { it.memberId == memberId && it.creatorId == creatorId && it.isActive } + }!! + assertEquals(emptyList(), activeRows) + } finally { + executor.shutdownNow() + } + } + private fun saveMember(seed: String, role: MemberRole): Member { val member = Member( email = "$seed@test.com", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt index ecaf237e..db834f1d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.home.following.application +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive @@ -58,6 +61,24 @@ class HomeFollowingQueryServiceTest { assertEquals(11L, queryPort.memberId) } + @Test + @DisplayName("본인인증 성별 우선 effective gender와 크리에이터 여부는 라이브 조회 port에 전달된다") + fun shouldPassEffectiveGenderAndCreatorRoleToLiveQueryPort() { + val member = member(13L).apply { + role = MemberRole.CREATOR + gender = Gender.MALE + auth = Auth(name = "auth", birth = "20000101", uniqueCi = "ci", di = "di", gender = 0) + } + Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) + + service.findHomeFollowing(member) + + assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender) + assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender) + assertTrue(queryPort.onAirIsViewerCreator!!) + assertTrue(queryPort.monthlySchedulesIsViewerCreator!!) + } + @Test @DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다") fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() { @@ -91,9 +112,13 @@ class HomeFollowingQueryServiceTest { var followingCreatorsLimit: Int? = null var onAirLivesLimit: Int? = null var onAirCanViewAdultContent: Boolean? = null + var onAirEffectiveViewerGender: Gender? = null + var onAirIsViewerCreator: Boolean? = null var monthlySchedulesLimit: Int? = null var monthlySchedulesNow: LocalDateTime? = null var monthlySchedulesCanViewAdultContent: Boolean? = null + var monthlySchedulesEffectiveViewerGender: Gender? = null + var monthlySchedulesIsViewerCreator: Boolean? = null var recentNewsLimit: Int? = null var recentNewsNow: LocalDateTime? = null var recentNewsCanViewAdultContent: Boolean? = null @@ -104,8 +129,16 @@ class HomeFollowingQueryServiceTest { return listOf(HomeFollowingCreator(1L, "creator", "profile")) } - override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List { + override fun findOnAirLives( + memberId: Long, + canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, + limit: Int + ): List { onAirCanViewAdultContent = canViewAdultContent + onAirEffectiveViewerGender = effectiveViewerGender + onAirIsViewerCreator = isViewerCreator onAirLivesLimit = limit return emptyList() } @@ -113,10 +146,14 @@ class HomeFollowingQueryServiceTest { override fun findMonthlySchedules( memberId: Long, canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, now: LocalDateTime, limit: Int ): List { monthlySchedulesCanViewAdultContent = canViewAdultContent + monthlySchedulesEffectiveViewerGender = effectiveViewerGender + monthlySchedulesIsViewerCreator = isViewerCreator monthlySchedulesNow = now monthlySchedulesLimit = limit return emptyList() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index ef606ecd..fc49c9ca 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -411,7 +411,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val replayCreator = saveMember("activity-replay", MemberRole.CREATOR) val communityCreator = saveMember("activity-community", MemberRole.CREATOR) saveAudioContent(liveCreator, baseAt.minusDays(2), isActive = true) - saveLiveRoom(liveCreator, baseAt, channelName = "activity-live-channel") + val live = saveLiveRoom(liveCreator, baseAt, channelName = "activity-live-channel") val audio = saveAudioContent(audioCreator, baseAt.minusHours(1), isActive = true) val replay = saveAudioContent(replayCreator, baseAt.minusHours(2), isActive = true, themeName = "다시듣기") val community = saveCommunity(communityCreator, isCommentAvailable = true) @@ -426,8 +426,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname), creators.map { it.creatorNickname } ) + assertEquals(liveCreator.id, byCreatorNickname[liveCreator.nickname]!!.creatorId) + assertEquals(audioCreator.id, byCreatorNickname[audioCreator.nickname]!!.creatorId) + assertEquals(replayCreator.id, byCreatorNickname[replayCreator.nickname]!!.creatorId) + assertEquals(communityCreator.id, byCreatorNickname[communityCreator.nickname]!!.creatorId) assertEquals(CreatorActivityType.LIVE, byCreatorNickname[liveCreator.nickname]!!.activityType) - assertEquals(null, byCreatorNickname[liveCreator.nickname]!!.targetId) + assertEquals(live.id, byCreatorNickname[liveCreator.nickname]!!.targetId) assertEquals(baseAt, byCreatorNickname[liveCreator.nickname]!!.activityAt) assertEquals(CreatorActivityType.AUDIO, byCreatorNickname[audioCreator.nickname]!!.activityType) assertEquals(audio.id, byCreatorNickname[audioCreator.nickname]!!.targetId) @@ -457,6 +461,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val creators = repository.findRecentlyActiveCreators(limit = 10) assertEquals(listOf(inactiveLiveCreator.nickname), creators.map { it.creatorNickname }) + assertEquals(inactiveLiveCreator.id, creators.single().creatorId) assertEquals(CreatorActivityType.LIVE, creators.single().activityType) assertEquals(baseAt.plusMinutes(2), creators.single().activityAt) assertEquals(null, creators.single().targetId) @@ -470,8 +475,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val adultLiveCreator = saveMember("activity-adult-live", MemberRole.CREATOR) val adultAudioCreator = saveMember("activity-adult-audio", MemberRole.CREATOR) val adultCommunityCreator = saveMember("activity-adult-community", MemberRole.CREATOR) - saveLiveRoom(normalLiveCreator, baseAt.plusMinutes(3), channelName = "normal-live", isAdult = false) - saveLiveRoom(adultLiveCreator, baseAt.plusMinutes(2), channelName = "adult-live", isAdult = true) + val normalLive = saveLiveRoom(normalLiveCreator, baseAt.plusMinutes(3), channelName = "normal-live", isAdult = false) + val adultLive = saveLiveRoom(adultLiveCreator, baseAt.plusMinutes(2), channelName = "adult-live", isAdult = true) val adultAudio = saveAudioContent(adultAudioCreator, baseAt.plusMinutes(1), isActive = true, isAdult = true) val adultCommunity = saveCommunity(adultCommunityCreator, isCommentAvailable = true, isAdult = true) updateCreatedAt("CreatorCommunity", adultCommunity.id!!, baseAt) @@ -490,8 +495,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ), visibleCreators.map { it.creatorNickname } ) - assertEquals(null, visibleCreators[0].targetId) - assertEquals(null, visibleCreators[1].targetId) + assertEquals(normalLive.id, visibleCreators[0].targetId) + assertEquals(adultLive.id, visibleCreators[1].targetId) assertEquals(adultAudio.id, visibleCreators[2].targetId) assertEquals(adultCommunity.id, visibleCreators[3].targetId) assertEquals(CreatorActivityType.LIVE, visibleCreators[0].activityType) @@ -522,6 +527,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType) } + @Test + @DisplayName("최근 활동 크리에이터는 현재 크리에이터 역할이 아닌 회원(전 크리에이터)의 활동을 제외한다") + fun shouldExcludeNonCreatorRoleMembersFromRecentlyActiveCreators() { + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + val formerCreator = saveMember("former-creator", MemberRole.USER) + val visibleCreator = saveMember("visible-role-creator", MemberRole.CREATOR) + saveLiveRoom(formerCreator, baseAt.plusMinutes(2), channelName = "former-creator-channel") + val community = saveCommunity(visibleCreator, isCommentAvailable = true) + updateCreatedAt("CreatorCommunity", community.id!!, baseAt.plusMinutes(1)) + flushAndClear() + + val creators = repository.findRecentlyActiveCreators(limit = 10) + + assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname }) + assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType) + } + @Test @DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다") fun shouldFindAiCharacterSnapshotsWithFollowIncrease() { @@ -935,6 +957,100 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(expectedScore, snapshots.single().score, 0.0001) } + @Test + @DisplayName("최근 응원 스냅샷은 활동 점수가 없는 후보와 미래 데뷔 또는 비활성 크리에이터를 제외한다") + fun shouldExcludeIneligibleCheerCreatorSnapshotCandidates() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val donor = saveMember("cheer-boundary-donor", MemberRole.USER) + val zeroActivityCreator = saveMember("zero-activity-cheer", MemberRole.CREATOR) + val futureDebutCreator = saveMember("future-debut-cheer", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-cheer-candidate", MemberRole.CREATOR, isActive = false) + val visibleCreator = saveMember("visible-cheer-candidate", MemberRole.CREATOR) + saveLiveRoom(zeroActivityCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "zero-activity") + saveLiveRoom(futureDebutCreator, snapshotAt.plusSeconds(1), channelName = "future-debut") + saveLiveRoom(inactiveCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "inactive-cheer") + saveLiveRoom(visibleCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "visible-cheer") + listOf(futureDebutCreator, inactiveCreator, visibleCreator).forEach { creator -> + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + 10, + UseCanCalculateStatus.RECEIVED, + false, + windowStart.plusDays(1) + ) + } + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16) + + assertEquals(listOf(visibleCreator.id), snapshots.map { it.targetId }) + } + + @Test + @DisplayName("최근 응원 스냅샷은 채널명이 있는 종료 라이브를 데뷔 이력으로 인정한다") + fun shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val donor = saveMember("ended-live-cheer-donor", MemberRole.USER) + val endedLiveCreator = saveMember("ended-live-cheer", MemberRole.CREATOR) + val blankChannelCreator = saveMember("blank-ended-live-cheer", MemberRole.CREATOR) + saveLiveRoom( + endedLiveCreator, + LocalDateTime.of(2026, 5, 1, 0, 0), + channelName = "ended-live-cheer", + isActive = false + ) + saveLiveRoom( + blankChannelCreator, + LocalDateTime.of(2026, 5, 1, 0, 0), + channelName = "", + isActive = false + ) + val endedLiveCheer = saveCreatorCheers(donor, endedLiveCreator, isActive = true) + val blankChannelCheer = saveCreatorCheers(donor, blankChannelCreator, isActive = true) + updateCreatedAt("CreatorCheers", endedLiveCheer.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCheers", blankChannelCheer.id!!, windowStart.plusDays(1)) + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16) + + assertEquals(listOf(endedLiveCreator.id), snapshots.map { it.targetId }) + } + + @Test + @DisplayName("최근 응원 스냅샷은 점수 내림차순 상위 16개만 반환한다") + fun shouldReturnTopSixteenCheerCreatorSnapshotsByScore() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val donor = saveMember("cheer-top-donor", MemberRole.USER) + val creators = (1..17).map { index -> + val creator = saveMember("cheer-top-$index", MemberRole.CREATOR) + saveLiveRoom(creator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "cheer-top-$index") + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + index, + UseCanCalculateStatus.RECEIVED, + false, + windowStart.plusDays(1) + ) + creator + } + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16) + + assertEquals(16, snapshots.size) + assertEquals(creators.drop(1).reversed().map { it.id }, snapshots.map { it.targetId }) + } + @Test @DisplayName("인기 커뮤니티 스냅샷은 좋아요와 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다") fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { @@ -1681,6 +1797,42 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(visibleCreator.id), details.map { it.creatorId }) } + @Test + @DisplayName("최근 응원 크리에이터 상세는 조회자 본인과 활성 팔로우 크리에이터를 제외한다") + fun shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails() { + val viewer = saveMember("self-follow-cheer-viewer", MemberRole.CREATOR) + val activeFollowedCreator = saveMember("active-followed-cheer", MemberRole.CREATOR) + val inactiveFollowedCreator = saveMember("inactive-followed-cheer", MemberRole.CREATOR) + val visibleCreator = saveMember("unrelated-cheer", MemberRole.CREATOR) + saveFollowing(viewer, activeFollowedCreator, isActive = true) + saveFollowing(viewer, inactiveFollowedCreator, isActive = false) + flushAndClear() + + val details = repository.findCheerCreatorRecommendationDetails( + listOf(viewer.id!!, activeFollowedCreator.id!!, inactiveFollowedCreator.id!!, visibleCreator.id!!), + memberId = viewer.id + ) + + assertEquals(listOf(inactiveFollowedCreator.id, visibleCreator.id), details.map { it.creatorId }) + } + + @Test + @DisplayName("최근 응원 크리에이터 상세는 비회원 조회에서 회원별 필터를 적용하지 않는다") + fun shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters() { + val viewer = saveMember("anonymous-filter-source", MemberRole.CREATOR) + val activeFollowedCreator = saveMember("anonymous-active-followed-cheer", MemberRole.CREATOR) + val visibleCreator = saveMember("anonymous-unrelated-cheer", MemberRole.CREATOR) + saveFollowing(viewer, activeFollowedCreator, isActive = true) + flushAndClear() + + val details = repository.findCheerCreatorRecommendationDetails( + listOf(viewer.id!!, activeFollowedCreator.id!!, visibleCreator.id!!), + memberId = null + ) + + assertEquals(listOf(viewer.id, activeFollowedCreator.id, visibleCreator.id), details.map { it.creatorId }) + } + @Test @DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다") fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt index 041f5ec2..c7f9e9aa 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt @@ -108,6 +108,40 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt }) } + @Test + fun shouldFindCheerCreatorSnapshotsWithScoreDescendingAndTieBreakerAscending() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + repository.saveAll( + listOf( + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 1L, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.9 + ), + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 2L, + score = 200.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.8 + ), + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 3L, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.1 + ) + ) + ) + + val snapshots = adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, offset = 0, limit = 10) + + assertEquals(listOf(2L, 3L, 1L), snapshots.map { it.targetId }) + } + @Test fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() { val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index a6efc425..2da88b6e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -1012,6 +1012,7 @@ class HomeRecommendationQueryServiceTest { ) val activeCreators = listOf( RecentlyActiveCreatorRecord( + creatorId = 301L, creatorNickname = "creator", creatorProfileImage = "profile.png", activityType = CreatorActivityType.LIVE, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt index 925ae2e2..1619ef09 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt @@ -13,6 +13,8 @@ import java.time.LocalDateTime import java.util.concurrent.CountDownLatch import java.util.concurrent.Executor import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit class RecommendationSnapshotFallbackServiceTest { @@ -278,6 +280,80 @@ class RecommendationSnapshotFallbackServiceTest { assertEquals(listOf(99L), second.map { it.targetId }) } + @Test + @DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다") + fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() { + val existsSnapshotEntered = CountDownLatch(2) + val existsSnapshotReturned = CountDownLatch(2) + val snapshotPort = FakeRecommendationFallbackSnapshotPort( + existsSnapshotEntered = existsSnapshotEntered, + existsSnapshotReturned = existsSnapshotReturned + ) + val refreshService = FastCheerRefreshService(snapshotPort) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val workerExecutor = CapturingExecutor() + val requestExecutor = Executors.newFixedThreadPool(2) + val service = RecommendationSnapshotFallbackService( + snapshotPort, + refreshService, + redissonClient, + workerExecutor, + homeWaitMillis = 1_000 + ) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + + try { + val first = requestExecutor.submitCheerRefresh(service, nowUtc) + val second = requestExecutor.submitCheerRefresh(service, nowUtc) + assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS)) + assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS)) + assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS)) + assertEquals(false, first.isDone) + assertEquals(false, second.isDone) + assertEquals(1, workerExecutor.taskCount) + + workerExecutor.runNext() + + assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId }) + assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId }) + assertEquals(1, refreshService.cheerRefreshCount) + } finally { + requestExecutor.shutdownNow() + } + } + + @Test + @DisplayName("응원 크리에이터 fallback은 lock 내부 double-check에서 최신 상태가 생기면 refresh를 생략한다") + fun shouldSkipCheerCreatorRefreshWhenSnapshotAppearsBeforeLockDoubleCheck() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + snapshotAt, + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 88L)) + ) + true + } + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(listOf(88L), snapshots.map { it.targetId }) + Mockito.verify(refreshService, Mockito.never()).refreshCheerCreatorSnapshots(nowUtc) + Mockito.verify(lock).unlock() + } + @Test @DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다") fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() { @@ -321,6 +397,32 @@ class RecommendationSnapshotFallbackServiceTest { } private fun directExecutor(): Executor = Executor { command -> command.run() } + + private fun java.util.concurrent.ExecutorService.submitCheerRefresh( + service: RecommendationSnapshotFallbackService, + nowUtc: LocalDateTime + ): Future> { + return submit> { + service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + } + } +} + +private class CapturingExecutor : Executor { + val taskSubmitted = CountDownLatch(1) + private val tasks = LinkedBlockingQueue() + + val taskCount: Int + get() = tasks.size + + override fun execute(command: Runnable) { + tasks.add(command) + taskSubmitted.countDown() + } + + fun runNext() { + tasks.poll(1, TimeUnit.SECONDS)!!.run() + } } private class BlockingCheerRefreshService( @@ -331,7 +433,10 @@ private class BlockingCheerRefreshService( snapshotPort, Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) ) { + var cheerRefreshCount: Int = 0 + override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { + cheerRefreshCount += 1 refreshStarted.countDown() allowRefreshComplete.await(1, TimeUnit.SECONDS) snapshotPort.replaceSnapshots( @@ -343,6 +448,25 @@ private class BlockingCheerRefreshService( } } +private class FastCheerRefreshService( + private val snapshotPort: RecommendationSnapshotPort +) : RecommendationSnapshotRefreshService( + snapshotPort, + Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) +) { + var cheerRefreshCount: Int = 0 + + override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { + cheerRefreshCount += 1 + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L)) + ) + return 1 + } +} + private class BlockingAiAndFastCheerRefreshService( private val snapshotPort: RecommendationSnapshotPort, private val aiRefreshStarted: CountDownLatch, @@ -372,7 +496,10 @@ private class BlockingAiAndFastCheerRefreshService( } } -private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort { +private class FakeRecommendationFallbackSnapshotPort( + private val existsSnapshotEntered: CountDownLatch? = null, + private val existsSnapshotReturned: CountDownLatch? = null +) : RecommendationSnapshotPort { private val snapshots = mutableListOf() override fun findLatestSnapshots( @@ -404,7 +531,12 @@ private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPor } override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { - return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + existsSnapshotEntered?.countDown() + existsSnapshotEntered?.await(1, TimeUnit.SECONDS) + val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + existsSnapshotReturned?.countDown() + existsSnapshotReturned?.await(1, TimeUnit.SECONDS) + return exists } override fun replaceSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt index 9fad1533..e2b6c680 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt @@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -197,6 +199,29 @@ class RecommendationSnapshotRefreshServiceTest { assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) } + @Test + @DisplayName("응원 크리에이터 스냅샷 갱신 실패는 섹션 event와 window를 로그로 남기고 예외를 전파한다") + fun shouldLogCheerCreatorRefreshFailureWithWindow(output: CapturedOutput) { + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val service = service(queryPort = queryPort) + val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0) + val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16)) + .thenThrow(IllegalStateException("cheer refresh failed")) + + val exception = assertThrows(IllegalStateException::class.java) { + service.refreshCheerCreatorSnapshots(now) + } + + assertEquals("cheer refresh failed", exception.message) + assertTrue(output.out.contains("event=cheer_creator_recommendation_snapshot_refresh_failure")) + assertTrue(output.out.contains("snapshotAt=2026-05-29T14:59:59")) + assertTrue(output.out.contains("windowStartUtc=2026-05-22T15:00")) + assertTrue(output.out.contains("windowEndExclusiveUtc=2026-05-29T15:00")) + assertTrue(output.out.contains("error=cheer refresh failed")) + } + @Test @DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다") fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {