Compare commits
4 Commits
2c44cb90ee
...
f27074167a
| Author | SHA1 | Date | |
|---|---|---|---|
| f27074167a | |||
| 5d1290e114 | |||
| a7b2ecc983 | |||
| 074c035c34 |
@@ -543,6 +543,58 @@
|
|||||||
- GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR`의 `bannerCreator.id`, `SERIES`의 `seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다.
|
- GREEN: 홈 통합 조회에서 배너 조회에도 `memberId`를 전달하고, 배너 조회 포트/서비스/repository가 `CREATOR`의 `bannerCreator.id`, `SERIES`의 `seriesOwner.id` 기준으로 양방향 `block_member` 제외 조건을 적용한다.
|
||||||
- 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다.
|
- 기대 결과: 홈 배너 섹션에서도 차단 관계 크리에이터 또는 시리즈 소유자의 추천 데이터가 노출되지 않는다.
|
||||||
|
|
||||||
|
### Phase 8: AI 캐릭터 추천 item creator id 추가
|
||||||
|
|
||||||
|
- [x] **Task 8.1: AI 캐릭터 추천 record에 creator id 추가**
|
||||||
|
- 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`
|
||||||
|
- Test: `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`
|
||||||
|
- RED: `HomeAiCharacterRecommendationRecord`에 `creatorId`가 없어서 컴파일이 실패하는 service 테스트를 먼저 작성한다. repository 테스트에는 활성 `ChatCharacter.creatorMember.id`가 record의 `creatorId`로 내려오는 케이스와 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 상세 응답에서 제외되는 케이스를 추가한다.
|
||||||
|
- 실패 확인:
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
|
||||||
|
```
|
||||||
|
- GREEN: `HomeAiCharacterRecommendationRecord`에 non-null `creatorId: Long`을 추가하고, `findAiCharacterRecommendationDetails(...)`에서 `chatCharacter.creatorMember`를 inner join해 `creatorMember.id`를 select한다. 상세 조회 조건은 기존 `chatCharacter.isActive = true`와 `characterIds` 조건을 유지하면서 `creatorMember.isActive = true`, `creatorMember.role = CREATOR`, `creatorMember.memberKind = AI_CHARACTER`를 함께 적용한다.
|
||||||
|
- REFACTOR: 스냅샷 target id는 계속 `characterId`로 유지하고, `creatorId`는 상세 조립 단계에서만 추가한다. AI 캐릭터 추천 점수 산식, 스냅샷 생성, 정렬 순서는 변경하지 않는다.
|
||||||
|
- 기대 결과: 내부 추천 record가 `characterId`와 `creatorId`를 모두 가지며, AI 캐릭터 전체보기와 홈 통합 조회가 같은 상세 조회 결과를 재사용할 수 있다.
|
||||||
|
|
||||||
|
- [x] **Task 8.2: 홈 추천 AI 캐릭터 API 응답에 creatorId 노출**
|
||||||
|
- 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/dto/recommendation/HomeRecommendationResponseTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||||
|
- RED: `HomeAiCharacterItem` 생성자와 JSON 검증에 `creatorId`를 추가해 기존 구현이 컴파일 또는 JSON assertion에서 실패하도록 한다. 홈 통합 조회의 `$.data.aiCharacters[0].creatorId`와 AI 캐릭터 전체보기의 `$.data.items[0].creatorId`가 내려오는 controller 테스트를 추가한다.
|
||||||
|
- 실패 확인:
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
||||||
|
```
|
||||||
|
- GREEN: `HomeAiCharacterItem`에 non-null `creatorId: Long`을 추가하고, `HomeRecommendationFacade.HomeAiCharacterRecommendationRecord.toItem()` 변환에서 `creatorId = creatorId`를 매핑한다.
|
||||||
|
- REFACTOR: 기존 `characterId` 필드명과 의미는 변경하지 않는다. 신규 `creatorId`는 additive schema 변경으로만 처리하고, 다른 추천 item DTO나 endpoint URL은 변경하지 않는다.
|
||||||
|
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters[]`와 `GET /api/v2/home/recommendations/ai-characters`의 `items[]` 모두 `characterId`와 `creatorId`를 함께 반환한다.
|
||||||
|
|
||||||
|
- [x] **Task 8.3: Phase 8 회귀 검증과 문서 기록**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||||
|
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task라 제품 코드 테스트를 새로 작성하지 않는다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--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
|
||||||
|
```
|
||||||
|
- 기대 결과: Phase 8 관련 테스트, ktlint, Gradle task 목록 조회가 모두 성공하고, 이 문서 하단 Verification Log에 실행 명령/목적/결과를 누적한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PRD Coverage Check
|
## PRD Coverage Check
|
||||||
@@ -553,7 +605,7 @@
|
|||||||
- Feature D: Task 1.3, Task 3.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable을 검증한다.
|
- Feature D: Task 1.3, Task 3.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable을 검증한다.
|
||||||
- Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다.
|
- 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에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외를 검증한다.
|
- Feature F: Task 1.1, Task 3.2, Task 6.3에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외를 검증한다.
|
||||||
- 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에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기를 검증한다.
|
- 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` 노출을 검증한다.
|
||||||
- Feature H: Task 4.1, Task 4.2, Task 4.3, Task 4.4에서 장르 조회 이력, 조회 이력 없을 때 랜덤 장르, 부족분 랜덤 보충, 한 응답 내 크리에이터 중복 제거, 조회 시점별 재노출 허용, 팔로우 제외, 조회자 본인 크리에이터 제외, 성인 장르 조건, 크리에이터 프로필 이미지/닉네임/id 노출을 검증한다.
|
- Feature H: Task 4.1, Task 4.2, Task 4.3, Task 4.4에서 장르 조회 이력, 조회 이력 없을 때 랜덤 장르, 부족분 랜덤 보충, 한 응답 내 크리에이터 중복 제거, 조회 시점별 재노출 허용, 팔로우 제외, 조회자 본인 크리에이터 제외, 성인 장르 조건, 크리에이터 프로필 이미지/닉네임/id 노출을 검증한다.
|
||||||
- Feature I: Task 5.1, Task 5.2에서 장르의 크리에이터와 최근 응원이 많은 크리에이터가 공통 동시 팔로우 use case를 재사용하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며, 비활성 팔로우 이력은 재활성화하고, 존재하지 않는 id/크리에이터가 아닌 id는 전체 실패로 처리하는지 검증한다.
|
- Feature I: Task 5.1, Task 5.2에서 장르의 크리에이터와 최근 응원이 많은 크리에이터가 공통 동시 팔로우 use case를 재사용하고, 이미 팔로우 중인 id와 본인 id는 서버 내부에서 제외하며, 비활성 팔로우 이력은 재활성화하고, 존재하지 않는 id/크리에이터가 아닌 id는 전체 실패로 처리하는지 검증한다.
|
||||||
- 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 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, 해당 섹션의 동시 팔로우를 검증한다.
|
||||||
@@ -565,6 +617,9 @@
|
|||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
|
||||||
|
- 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 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
|
||||||
|
- 2026-06-23: Phase 8 구현을 진행했다. RED에서 `HomeAiCharacterRecommendationRecord`와 `HomeAiCharacterItem`의 `creatorId` 미구현으로 `compileTestKotlin`이 실패하는 것을 확인했고, GREEN에서 `HomeAiCharacterRecommendationRecord.creatorId`, AI 캐릭터 상세 조회의 `ChatCharacter.creatorMember` inner join 및 활성/CREATOR/AI_CHARACTER 조건, `HomeAiCharacterItem.creatorId`, facade 매핑을 추가했다. 회귀 검증으로 `./gradlew test --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`를 실행해 모두 성공을 확인했다. `ktlintCheck`는 최초 실행에서 import 정렬 오류로 실패했고 import 순서 보정 후 `BUILD SUCCESSFUL`로 통과했다. 리뷰어 지적에 따라 `creatorMember` 누락 row 제외 테스트를 추가했고, 해당 단일 테스트와 Phase 8 대상 테스트 묶음, `ktlintCheck`를 재실행해 모두 성공한 뒤 리뷰어 재검토에서 승인받았다.
|
||||||
|
- 2026-06-23: 사용자 피드백에 따라 AI 캐릭터 추천 item에 `creatorId`를 추가하는 요구사항을 기존 홈 추천 API PRD와 plan-task에 후속 Phase 8로 보강했다. `creatorId`는 `ChatCharacter.creatorMember.id`로 확정하고, 기존 `characterId`는 AI 채팅 이동 대상 id로 유지하는 additive schema 변경으로 문서화했다. 검증으로 `rg -n "creatorId|Phase 8|Task 8\\.|ChatCharacter.creatorMember|Feature G" docs/20260529_메인_홈_추천_API/prd.md docs/20260529_메인_홈_추천_API/plan-task.md`, `git diff --check`, `./gradlew tasks --all`을 실행했다. `./gradlew tasks --all`은 최초 샌드박스 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했으나, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
- 2026-05-30: plan-task 문서 작성 전 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, `docs/20260529_메인_홈_추천_API/prd.md`를 확인했다.
|
- 2026-05-30: plan-task 문서 작성 전 `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`, `docs/20260529_메인_홈_추천_API/prd.md`를 확인했다.
|
||||||
- 2026-05-30: 기존 v2 패키지 구조, 테스트 스타일, QueryDSL/스케줄러 사용 패턴, 관련 엔티티/리포지토리 후보를 `find`, `rg`, `sed`로 확인해 계획의 파일 경로와 검증 명령에 반영했다.
|
- 2026-05-30: 기존 v2 패키지 구조, 테스트 스타일, QueryDSL/스케줄러 사용 패턴, 관련 엔티티/리포지토리 후보를 `find`, `rg`, `sed`로 확인해 계획의 파일 경로와 검증 명령에 반영했다.
|
||||||
- 2026-05-30: `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck` 생성 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 13s`를 확인했다.
|
- 2026-05-30: `./gradlew tasks --all`을 실행했다. sandbox 기본 권한에서는 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck` 생성 권한 문제로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 13s`를 확인했다.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
- 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다.
|
- 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다.
|
||||||
- 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다.
|
- 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다.
|
||||||
- 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
|
- 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
|
||||||
|
- 사용자는 추천 AI 캐릭터의 채팅 화면뿐 아니라 크리에이터 채널로도 이동할 수 있도록 AI 캐릭터에 대응하는 creator id를 받고 싶다.
|
||||||
- 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다.
|
- 사용자는 내가 봤던 콘텐츠 장르 또는 랜덤 장르 기준으로 팔로우하지 않은 크리에이터를 추천받고 싶다.
|
||||||
- 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다.
|
- 사용자는 장르 추천에서 여러 크리에이터를 한 번에 팔로우하고 싶다.
|
||||||
- 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다.
|
- 사용자는 최근 응원이 많은 크리에이터를 순위로 보고 싶다.
|
||||||
@@ -163,7 +164,9 @@
|
|||||||
- AI 캐릭터 리스트를 조회한다.
|
- AI 캐릭터 리스트를 조회한다.
|
||||||
- 홈 첫 화면은 10개를 조회한다.
|
- 홈 첫 화면은 10개를 조회한다.
|
||||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||||
- 노출 정보는 캐릭터 이름, 캐릭터 소개, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
||||||
|
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
||||||
|
- 기존 `characterId`는 AI 채팅 이동 대상 id로 유지하고, 신규 `creatorId`는 크리에이터 채널/Member 기반 기능 이동 대상 id로 별도 제공한다.
|
||||||
- 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다.
|
- 작품명은 오리지널 작품 캐릭터인 경우에만 내려준다.
|
||||||
- 1차 정렬은 AI 채팅 추천 점수 내림차순이다.
|
- 1차 정렬은 AI 채팅 추천 점수 내림차순이다.
|
||||||
- 2차 정렬은 동일 점수인 경우 랜덤이다.
|
- 2차 정렬은 동일 점수인 경우 랜덤이다.
|
||||||
@@ -177,6 +180,7 @@
|
|||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||||
|
- 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
|
||||||
|
|
||||||
### Feature H. 장르의 크리에이터
|
### Feature H. 장르의 크리에이터
|
||||||
|
|
||||||
@@ -258,6 +262,7 @@
|
|||||||
- `v2.api.home`과 `v2.recommendation` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
|
- `v2.api.home`과 `v2.recommendation` 모두 필요한 범위에서 경량 헥사고날 아키텍처를 적용하고, 기본 하위 패키지는 `application`, `domain`, `port`, `adapter`, `dto`를 사용한다.
|
||||||
- Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다.
|
- Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다.
|
||||||
- `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
|
- `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
|
||||||
|
- 홈 추천 AI 캐릭터 응답의 `creatorId` 추가는 기존 `characterId` 의미를 변경하지 않는 additive schema 변경으로만 처리한다.
|
||||||
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다.
|
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다.
|
||||||
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다.
|
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다.
|
||||||
- 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다.
|
- 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다.
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ class HomeRecommendationFacade(
|
|||||||
|
|
||||||
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(
|
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(
|
||||||
characterId = characterId,
|
characterId = characterId,
|
||||||
|
creatorId = creatorId,
|
||||||
name = name,
|
name = name,
|
||||||
description = description,
|
description = description,
|
||||||
profileImage = imageUrl(cloudFrontHost, profileImage),
|
profileImage = imageUrl(cloudFrontHost, profileImage),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ data class HomeFirstAudioContentItem(
|
|||||||
|
|
||||||
data class HomeAiCharacterItem(
|
data class HomeAiCharacterItem(
|
||||||
val characterId: Long,
|
val characterId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val profileImage: String?,
|
val profileImage: String?,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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.comment.QCreatorCommunityComment.creatorCommunityComment
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
||||||
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.QMember
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
@@ -688,12 +690,14 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
): List<HomeAiCharacterRecommendationRecord> {
|
): List<HomeAiCharacterRecommendationRecord> {
|
||||||
if (characterIds.isEmpty()) return emptyList()
|
if (characterIds.isEmpty()) return emptyList()
|
||||||
val linkedOriginalWork = QOriginalWork("linkedOriginalWork")
|
val linkedOriginalWork = QOriginalWork("linkedOriginalWork")
|
||||||
|
val creatorMember = QMember("creatorMember")
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
Projections.constructor(
|
Projections.constructor(
|
||||||
HomeAiCharacterRecommendationRecord::class.java,
|
HomeAiCharacterRecommendationRecord::class.java,
|
||||||
chatCharacter.id,
|
chatCharacter.id,
|
||||||
|
creatorMember.id,
|
||||||
chatCharacter.name,
|
chatCharacter.name,
|
||||||
chatCharacter.description,
|
chatCharacter.description,
|
||||||
chatCharacter.imagePath,
|
chatCharacter.imagePath,
|
||||||
@@ -702,6 +706,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(chatCharacter)
|
.from(chatCharacter)
|
||||||
|
.join(chatCharacter.creatorMember, creatorMember)
|
||||||
.leftJoin(chatCharacter.originalWork, linkedOriginalWork).on(linkedOriginalWork.isDeleted.isFalse)
|
.leftJoin(chatCharacter.originalWork, linkedOriginalWork).on(linkedOriginalWork.isDeleted.isFalse)
|
||||||
.leftJoin(chatParticipant).on(
|
.leftJoin(chatParticipant).on(
|
||||||
chatParticipant.character.id.eq(chatCharacter.id),
|
chatParticipant.character.id.eq(chatCharacter.id),
|
||||||
@@ -712,9 +717,16 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
chatMessage.participant.id.eq(chatParticipant.id),
|
chatMessage.participant.id.eq(chatParticipant.id),
|
||||||
chatMessage.isActive.isTrue
|
chatMessage.isActive.isTrue
|
||||||
)
|
)
|
||||||
.where(chatCharacter.isActive.isTrue, chatCharacter.id.`in`(characterIds))
|
.where(
|
||||||
|
chatCharacter.isActive.isTrue,
|
||||||
|
chatCharacter.id.`in`(characterIds),
|
||||||
|
creatorMember.isActive.isTrue,
|
||||||
|
creatorMember.role.eq(MemberRole.CREATOR),
|
||||||
|
creatorMember.memberKind.eq(MemberKind.AI_CHARACTER)
|
||||||
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
chatCharacter.id,
|
chatCharacter.id,
|
||||||
|
creatorMember.id,
|
||||||
chatCharacter.name,
|
chatCharacter.name,
|
||||||
chatCharacter.description,
|
chatCharacter.description,
|
||||||
chatCharacter.imagePath,
|
chatCharacter.imagePath,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ data class HomeFirstAudioContentRecord(
|
|||||||
|
|
||||||
data class HomeAiCharacterRecommendationRecord(
|
data class HomeAiCharacterRecommendationRecord(
|
||||||
val characterId: Long,
|
val characterId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val profileImage: String?,
|
val profileImage: String?,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home
|
package kr.co.vividnext.sodalive.v2.api.home
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
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.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||||
@@ -11,7 +13,9 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
|||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
|
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.application.HomeRecommendationQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
@@ -481,6 +485,29 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.size").value(20))
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 추천은 홈 통합과 전체보기 응답에 characterId와 creatorId를 함께 노출한다")
|
||||||
|
fun shouldExposeCreatorIdOnAiCharacterRecommendations() {
|
||||||
|
val member = saveMember("ai-character-page-viewer", MemberRole.USER)
|
||||||
|
val character = saveAiCharacter("ai-character-api")
|
||||||
|
saveRecommendationSnapshot(character.id!!)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v2/home/recommendations"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters[0].characterId").value(character.id))
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters[0].creatorId").value(character.creatorMember!!.id))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/home/recommendations/ai-characters")
|
||||||
|
.with(user(MemberAdapter(member)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.items[0].characterId").value(character.id))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id))
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||||
return memberRepository.saveAndFlush(
|
return memberRepository.saveAndFlush(
|
||||||
Member(
|
Member(
|
||||||
@@ -519,4 +546,37 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
entityManager.persist(room)
|
entityManager.persist(room)
|
||||||
return room
|
return room
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveAiCharacter(name: String): ChatCharacter {
|
||||||
|
val creatorMember = Member(
|
||||||
|
email = null,
|
||||||
|
password = "",
|
||||||
|
nickname = name,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
entityManager.persist(creatorMember)
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "$name-uuid",
|
||||||
|
name = name,
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "system",
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
character.creatorMember = creatorMember
|
||||||
|
entityManager.persist(character)
|
||||||
|
return character
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveRecommendationSnapshot(characterId: Long) {
|
||||||
|
entityManager.persist(
|
||||||
|
RecommendationSnapshot(
|
||||||
|
sectionType = RecommendedSectionType.AI_CHARACTER,
|
||||||
|
targetId = characterId,
|
||||||
|
score = 100.0,
|
||||||
|
snapshotAt = LocalDateTime.of(2026, 6, 1, 23, 59, 59),
|
||||||
|
randomTieBreaker = 0.1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class HomeRecommendationResponseTest {
|
|||||||
aiCharacters = listOf(
|
aiCharacters = listOf(
|
||||||
HomeAiCharacterItem(
|
HomeAiCharacterItem(
|
||||||
characterId = 3L,
|
characterId = 3L,
|
||||||
|
creatorId = 13L,
|
||||||
name = "character",
|
name = "character",
|
||||||
description = "description",
|
description = "description",
|
||||||
profileImage = "https://cdn.test/profile/character.png",
|
profileImage = "https://cdn.test/profile/character.png",
|
||||||
@@ -38,6 +39,7 @@ class HomeRecommendationResponseTest {
|
|||||||
),
|
),
|
||||||
HomeAiCharacterItem(
|
HomeAiCharacterItem(
|
||||||
characterId = 4L,
|
characterId = 4L,
|
||||||
|
creatorId = 14L,
|
||||||
name = "character-without-image",
|
name = "character-without-image",
|
||||||
description = "description",
|
description = "description",
|
||||||
profileImage = null,
|
profileImage = null,
|
||||||
@@ -86,6 +88,7 @@ class HomeRecommendationResponseTest {
|
|||||||
assertFalse(json["firstAudioContents"][0].has("pointAvailable"))
|
assertFalse(json["firstAudioContents"][0].has("pointAvailable"))
|
||||||
assertFalse(json["firstAudioContents"][0].has("releaseDate"))
|
assertFalse(json["firstAudioContents"][0].has("releaseDate"))
|
||||||
assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText())
|
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(true, json["aiCharacters"][1]["profileImage"].isNull)
|
||||||
assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong())
|
assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong())
|
||||||
assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText())
|
assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText())
|
||||||
|
|||||||
@@ -1169,6 +1169,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
.associateBy { it.characterId }
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
assertEquals(setOf(characterWithWork.id, characterWithoutWork.id), details.keys)
|
assertEquals(setOf(characterWithWork.id, characterWithoutWork.id), details.keys)
|
||||||
|
assertEquals(characterWithWork.creatorMember!!.id, details[characterWithWork.id]!!.creatorId)
|
||||||
|
assertEquals(characterWithoutWork.creatorMember!!.id, details[characterWithoutWork.id]!!.creatorId)
|
||||||
assertEquals("ai-detail-work", details[characterWithWork.id]!!.name)
|
assertEquals("ai-detail-work", details[characterWithWork.id]!!.name)
|
||||||
assertEquals("description", details[characterWithWork.id]!!.description)
|
assertEquals("description", details[characterWithWork.id]!!.description)
|
||||||
assertEquals(2L, details[characterWithWork.id]!!.totalChatCount)
|
assertEquals(2L, details[characterWithWork.id]!!.totalChatCount)
|
||||||
@@ -1177,6 +1179,37 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(null, details[characterWithoutWork.id]!!.originalWorkTitle)
|
assertEquals(null, details[characterWithoutWork.id]!!.originalWorkTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 상세는 활성 AI 캐릭터 크리에이터 회원인 경우만 조회한다")
|
||||||
|
fun shouldFindAiCharacterRecommendationDetailsForActiveAiCreatorMembersOnly() {
|
||||||
|
val activeCharacter = saveCharacter("ai-detail-active-creator-member", isActive = true)
|
||||||
|
val missingCreatorCharacter = saveCharacter("ai-detail-missing-creator-member", isActive = true)
|
||||||
|
val inactiveCreatorCharacter = saveCharacter("ai-detail-inactive-creator-member", isActive = true).apply {
|
||||||
|
creatorMember!!.isActive = false
|
||||||
|
}
|
||||||
|
val userCreatorCharacter = saveCharacter("ai-detail-user-creator-member", isActive = true).apply {
|
||||||
|
creatorMember!!.role = MemberRole.USER
|
||||||
|
}
|
||||||
|
val humanCreatorCharacter = saveCharacter("ai-detail-human-creator-member", isActive = true).apply {
|
||||||
|
creatorMember!!.memberKind = MemberKind.HUMAN
|
||||||
|
}
|
||||||
|
detachCreatorMember(missingCreatorCharacter)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findAiCharacterRecommendationDetails(
|
||||||
|
listOf(
|
||||||
|
activeCharacter.id!!,
|
||||||
|
missingCreatorCharacter.id!!,
|
||||||
|
inactiveCreatorCharacter.id!!,
|
||||||
|
userCreatorCharacter.id!!,
|
||||||
|
humanCreatorCharacter.id!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(activeCharacter.id), details.map { it.characterId })
|
||||||
|
assertEquals(activeCharacter.creatorMember!!.id, details.single().creatorId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
@DisplayName("AI 캐릭터 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
||||||
fun shouldReturnEmptyAiCharacterRecommendationDetailsWhenIdsAreEmpty() {
|
fun shouldReturnEmptyAiCharacterRecommendationDetailsWhenIdsAreEmpty() {
|
||||||
@@ -2107,4 +2140,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun detachCreatorMember(character: ChatCharacter) {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.createNativeQuery("alter table chat_character alter column creator_member_id drop not null")
|
||||||
|
.executeUpdate()
|
||||||
|
entityManager.createNativeQuery("update chat_character set creator_member_id = null where id = :id")
|
||||||
|
.setParameter("id", character.id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
port.aiCharacterDetails = listOf(
|
port.aiCharacterDetails = listOf(
|
||||||
HomeAiCharacterRecommendationRecord(
|
HomeAiCharacterRecommendationRecord(
|
||||||
characterId = 1L,
|
characterId = 1L,
|
||||||
|
creatorId = 101L,
|
||||||
name = "character-1",
|
name = "character-1",
|
||||||
description = "description-1",
|
description = "description-1",
|
||||||
profileImage = "profile/character-1.png",
|
profileImage = "profile/character-1.png",
|
||||||
@@ -151,6 +152,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
),
|
),
|
||||||
HomeAiCharacterRecommendationRecord(
|
HomeAiCharacterRecommendationRecord(
|
||||||
characterId = 2L,
|
characterId = 2L,
|
||||||
|
creatorId = 102L,
|
||||||
name = "character-2",
|
name = "character-2",
|
||||||
description = "description-2",
|
description = "description-2",
|
||||||
profileImage = null,
|
profileImage = null,
|
||||||
@@ -163,6 +165,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
|
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
|
||||||
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||||
|
assertEquals(listOf(101L, 102L), characters.map { it.creatorId })
|
||||||
assertEquals("profile/character-1.png", characters.first().profileImage)
|
assertEquals("profile/character-1.png", characters.first().profileImage)
|
||||||
assertEquals(null, characters.last().profileImage)
|
assertEquals(null, characters.last().profileImage)
|
||||||
assertEquals("original-work", characters.first().originalWorkTitle)
|
assertEquals("original-work", characters.first().originalWorkTitle)
|
||||||
|
|||||||
Reference in New Issue
Block a user