From 5e641c36f17dd523d4bab79aeb474bb13c730dd5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:34:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C=EC=8B=9D=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 277 +++++++++++++++--- docs/20260625_메인_홈_팔로잉_탭_API/prd.md | 83 ++++-- 2 files changed, 300 insertions(+), 60 deletions(-) diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md index 97c43e0d..759ee98b 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md @@ -26,8 +26,13 @@ - 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`와 `ChatRoomListItemResponse`를 재사용한다. - 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다. - 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다. -- 최근 소식 응답에는 별도 `creatorId`를 내려주지 않는다. 크리에이터 채널 이동이 필요한 `CREATOR_RANKING`은 `targetId`가 크리에이터 회원 id다. -- 최근 소식 랭킹 값은 `rank: Int?`만 사용한다. `rankChange`, `isNew`, nested `ranking` object는 사용하지 않는다. +- 최근 소식 응답 최상위에는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 내려준다. +- 최근 소식 상세 값은 타입별 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`을 사용한다. +- `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를 생성하지 않는다. - 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다. - inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다. - 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다. @@ -206,34 +211,94 @@ data class FollowingScheduleResponse( data class FollowingNewsResponse( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: FollowingCreatorRankingNewsResponse?, + val audioContent: FollowingContentNewsResponse?, + val photoContent: FollowingContentNewsResponse?, + val contentRanking: FollowingContentRankingNewsResponse?, + val communityPost: FollowingCommunityPostNewsResponse? ) { companion object { fun from(news: HomeFollowingNews): FollowingNewsResponse { return FollowingNewsResponse( newsId = news.newsId, type = news.type, - creatorProfileImageUrl = news.creatorProfileImageUrl, - creatorNickname = news.creatorNickname, - title = news.title, - body = news.body, - thumbnailImageUrl = news.thumbnailImageUrl, - targetId = news.targetId, - occurredAtUtc = news.occurredAtUtc, visibleFromAtUtc = news.visibleFromAtUtc, - rank = news.rank + creatorRanking = news.creatorRanking?.toResponse(), + audioContent = news.audioContent?.toContentResponse(), + photoContent = news.photoContent?.toContentResponse(), + contentRanking = news.contentRanking?.toResponse(), + communityPost = news.communityPost?.toResponse() ) } } } + +data class FollowingCreatorRankingNewsResponse( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class FollowingContentNewsResponse( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class FollowingContentRankingNewsResponse( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class FollowingCommunityPostNewsResponse( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int +) + +private fun HomeFollowingCreatorRankingNews.toResponse() = FollowingCreatorRankingNewsResponse( + rank = rank, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl +) + +private fun HomeFollowingContentNews.toContentResponse() = FollowingContentNewsResponse( + contentId = contentId, + contentImageUrl = contentImageUrl, + title = title, + creatorProfileImageUrl = creatorProfileImageUrl, + creatorNickname = creatorNickname +) + +private fun HomeFollowingContentRankingNews.toResponse() = FollowingContentRankingNewsResponse( + rank = rank, + contentId = contentId, + contentImageUrl = contentImageUrl, + title = title +) + +private fun HomeFollowingCommunityPostNews.toResponse() = FollowingCommunityPostNewsResponse( + postId = postId, + creatorProfileImage = creatorProfileImage, + creatorNickname = creatorNickname, + imageUrl = imageUrl, + content = content, + createdAt = createdAt, + likeCount = likeCount, + commentCount = commentCount +) ``` --- @@ -283,15 +348,45 @@ data class HomeFollowingSchedule( data class HomeFollowingNews( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: HomeFollowingCreatorRankingNews? = null, + val audioContent: HomeFollowingContentNews? = null, + val photoContent: HomeFollowingContentNews? = null, + val contentRanking: HomeFollowingContentRankingNews? = null, + val communityPost: HomeFollowingCommunityPostNews? = null +) + +data class HomeFollowingCreatorRankingNews( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class HomeFollowingContentNews( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class HomeFollowingContentRankingNews( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class HomeFollowingCommunityPostNews( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int ) enum class FollowingNewsType { @@ -356,7 +451,7 @@ data class HomeFollowingNewsInboxRecord( - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` - RED: `HomeFollowingTabResponse.loginRequired()`가 `isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다. - - RED: `FollowingNewsResponse` 변환 결과가 `creatorId` 없이 `rank: Int?`만 포함하는 테스트를 작성한다. + - RED: `FollowingNewsResponse` 변환 결과의 최상위 필드가 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable nested DTO만 포함하는지 테스트를 작성한다. `CREATOR_RANKING`은 `creatorRanking`만 non-null이고 다른 nested DTO는 null이어야 한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인. - GREEN: DTO/domain enum/model을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. @@ -444,7 +539,7 @@ data class HomeFollowingNewsInboxRecord( - 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` - RED: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다. - - RED: `creatorId`가 응답 domain에 노출되지 않고 `rank`만 nullable로 내려가는 테스트를 작성한다. + - RED: 최근 소식 domain이 타입별 nested DTO를 채우고, `COMMUNITY_POST`/`AUDIO_CONTENT` target 비활성 필터를 유지하는 테스트를 작성한다. - 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인. - GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. @@ -487,7 +582,7 @@ data class HomeFollowingNewsInboxRecord( - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` - - RED: `publishCommunityPostCreated(...)`가 현재 active follower에게만 inbox record를 생성하는 테스트를 작성한다. + - RED: `publishFreeCommunityPostCreated(...)`가 무료 커뮤니티 게시글에 대해서만 현재 active follower에게 inbox record를 생성하고, 유료 커뮤니티 게시글 생성 흐름은 publish service를 호출하지 않는 테스트를 작성한다. - RED: `publishContentUploaded(...)`가 `visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다. - RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인. @@ -524,7 +619,7 @@ data class HomeFollowingNewsInboxRecord( - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` - - RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 `publishCommunityPostCreated(...)`가 post id, creator id, 본문 요약, 생성 시각으로 호출되는 테스트를 작성한다. + - RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 무료 게시글이면 `publishFreeCommunityPostCreated(...)`가 post id, creator id, 본문, 생성 시각으로 호출되고, 유료 게시글이면 호출되지 않는 테스트를 작성한다. - RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다. - RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다. - RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다. @@ -554,12 +649,107 @@ data class HomeFollowingNewsInboxRecord( - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` - RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다. - RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다. - - RED: `FollowingNewsResponse`에 `creatorId`와 nested `ranking`이 없고 `rank`만 있는지 JSON path 테스트를 작성한다. + - RED: `FollowingNewsResponse` 최상위에 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 없고, 타입과 일치하는 nested DTO만 non-null인지 JSON path 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인. - GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다. +### Phase 5.5: 최근 소식 응답 계약 nested DTO 변경 후속 구현 + +> 이 Phase는 Phase 1-5 구현 완료 후 변경된 공개 응답 계약을 실제 코드에 반영하기 위한 후속 작업이다. 기존 flat `FollowingNewsResponse`를 제거하고, 타입별 nullable nested DTO 계약과 무료 커뮤니티 게시글 전용 발행 계약을 구현한다. + +#### 작업 의존성 + +1. `Task 5.5.1` DTO/domain 계약 변경과 `Task 5.5.3` 무료 커뮤니티 발행 제한은 서로 독립적으로 시작할 수 있다. +2. `Task 5.5.2` repository enrichment는 `Task 5.5.1`의 domain model 변경 이후 진행한다. +3. `Task 5.5.4` E2E 검증은 `Task 5.5.1`, `Task 5.5.2`, `Task 5.5.3` 완료 후 진행한다. +4. `Task 5.5.5` 회귀 검증은 모든 구현 task 완료 후 진행한다. + +- [x] **Task 5.5.1: FollowingNewsResponse / HomeFollowingNews nested DTO 모델 전환** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` + - RED: `FollowingNewsResponse` 최상위 JSON에 `newsId`, `type`, `visibleFromAtUtc`, `creatorRanking`, `contentRanking`, `audioContent`, `photoContent`, `communityPost`만 존재하는 테스트를 작성한다. + - RED: 최상위 JSON에 기존 flat 필드인 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 테스트를 작성한다. + - RED: `CREATOR_RANKING`이면 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는 테스트를 작성한다. + - RED: `CONTENT_RANKING`이면 `contentRanking`만 non-null이고 `rank`, `contentId`, `contentImageUrl`, `title`을 포함하는 테스트를 작성한다. + - RED: `AUDIO_CONTENT`이면 `audioContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다. + - RED: `PHOTO_CONTENT`이면 `photoContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다. + - RED: `COMMUNITY_POST`이면 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는 테스트를 작성한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, 기존 flat DTO/domain 구조로 실패 확인. + - GREEN: `HomeFollowingNews`에 공통 필드 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable domain payload를 추가한다. + - GREEN: `FollowingNewsResponse`에 공통 필드와 타입별 nullable response payload를 추가하고, domain payload를 response payload로 1:1 변환한다. + - REFACTOR: 타입별 DTO 변환 helper는 `HomeFollowingTabResponse.kt` 내부 private function 또는 companion object로만 유지하고, sealed class/상속 구조는 도입하지 않는다. + - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.2: 최근 소식 조회 repository 타입별 nested payload enrichment 구현** + - 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` + - RED: `CREATOR_RANKING` row가 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 채우고 다른 nested payload는 null인 테스트를 작성한다. + - RED: `AUDIO_CONTENT` row가 `AudioContent` 원천에서 `contentId`, `contentImageUrl`, `title`, creator profile/nickname을 채우고 공개 시각은 최상위 `visibleFromAtUtc`를 사용하는 테스트를 작성한다. + - RED: `COMMUNITY_POST` row가 무료 `CreatorCommunity` 원천에서 `postId`, `creatorProfileImage`, `creatorNickname`, CDN `imageUrl`, 전체 `content`, UTC `createdAt`, active `likeCount`, active top-level `commentCount`를 채우는 테스트를 작성한다. + - RED: `COMMUNITY_POST` 원천 게시글이 `price > 0`이면 inbox row가 있더라도 최근 소식에서 제외되는 테스트를 작성한다. + - RED: 기존 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target 비활성 제외 테스트가 nested domain 구조에서도 유지되도록 수정한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, flat `HomeFollowingNews` 매핑과 enrichment 미구현으로 실패 확인. + - GREEN: 기존 inbox 후보 조회는 유지하되, 조회된 `targetId`를 타입별로 모아 batch 조회한다. + - GREEN: `AUDIO_CONTENT`는 `audio_content` 원천에서 active row만 조회하고 이미지 URL을 변환해 `audioContent` payload를 만든다. + - GREEN: `COMMUNITY_POST`는 active이고 `price <= 0`인 원천 게시글만 조회하며, active like count와 active top-level comment count를 postIds 기준으로 batch 집계해 `communityPost` payload를 만든다. + - GREEN: `CREATOR_RANKING`은 inbox/creator 정보와 `rank_no`로 `creatorRanking` payload를 만든다. + - GREEN: 아직 생성하지 않는 `CONTENT_RANKING`, `PHOTO_CONTENT`는 domain/DTO 타입만 유지하고, 원천 조회 경로가 없으면 API에 노출하지 않는다. + - REFACTOR: enrichment helper는 `DefaultHomeFollowingQueryRepository.kt` private method로 둔다. 새 port/repository interface는 이 task에서 만들지 않는다. + - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.3: 무료 커뮤니티 게시글만 COMMUNITY_POST 최근 소식 발행** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` + - RED: 무료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 post id, creator id, creator nickname/profile, 본문, 이미지 path, 생성 시각으로 호출되는 테스트를 작성한다. + - RED: 유료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 호출되지 않는 테스트를 작성한다. + - RED: 무료 커뮤니티 게시글 최근 소식 발행 실패가 원 게시글 생성 성공을 실패로 전파하지 않는 기존 after-commit 격리 테스트를 유지한다. + - RED: `HomeFollowingNewsPublishServiceTest`의 커뮤니티 발행 테스트명/설명을 무료 게시글 발행 계약에 맞게 갱신한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, 유료 게시글도 발행하는 기존 구현으로 실패 확인. + - GREEN: `CreatorCommunityService`에서 `post.price <= 0`인 경우에만 최근 소식 publish를 예약한다. + - GREEN: publish service 메서드명은 구현 시 선택한다. 이름을 `publishFreeCommunityPostCreated(...)`로 바꾸면 모든 호출부/테스트를 함께 갱신하고, 기존 이름을 유지하면 호출 조건으로 무료 게시글 전용 계약을 보장한다. + - REFACTOR: 유료 게시글 미리보기 마스킹 로직이 최근 소식 발행에서 더 이상 쓰이지 않으면 제거하되, 커뮤니티 탭/상세의 유료 콘텐츠 정책은 건드리지 않는다. + - 통과 확인: 위 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.4: 팔로잉 탭 API E2E nested response 계약 검증** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + - RED: 로그인 회원 API 응답에 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST` 최근 소식 fixture를 포함한다. + - RED: 각 recentNews item의 최상위 flat 필드 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 JSON path 테스트를 작성한다. + - RED: `CREATOR_RANKING` item은 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는지 검증한다. + - RED: `AUDIO_CONTENT` item은 `audioContent`만 non-null이고 `releaseDate`가 없으며 최상위 `visibleFromAtUtc`가 존재하는지 검증한다. + - RED: `COMMUNITY_POST` item은 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는지 검증한다. + - RED: 유료 커뮤니티 게시글은 최근 소식 응답에 노출되지 않는지 검증한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 기존 flat API 응답으로 실패 확인. + - GREEN: Task 5.5.1-5.5.3 구현으로 대부분 통과해야 한다. wiring 누락이 있으면 관련 production file만 최소 수정한다. + - REFACTOR: E2E fixture helper는 테스트 파일 내부 private helper로만 분리한다. + - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.5: Phase 5.5 문서/회귀 검증 기록** + - Files: + - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` + - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` + - Verify: `docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md` + - Verify: Phase 5.5에서 변경한 Kotlin source/test + - TDD 예외 사유: 문서/회귀 검증 작업이며 신규 production behavior를 만들지 않는다. + - 대체 검증 방법: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` + - `./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.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` + - `./gradlew --no-daemon test` + - `./gradlew --no-daemon ktlintCheck` + - 문서 검증 grep: + - `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API` + - 검색 결과가 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼/과거 검증 기록 맥락인지 확인하고, stale flat `FollowingNewsResponse` 공개 계약이면 수정한다. + - 검증 결과 기록: Phase 5.5 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 `## 6. 검증 기록`에 한국어로 누적 기록한다. + ### Phase 6: 문서/회귀 검증 - [x] **Task 6.1: 문서 동기화 확인** @@ -568,7 +758,7 @@ data class HomeFollowingNewsInboxRecord( - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql` - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` - TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다. - - 대체 검증 방법: `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`로 삭제된 공개 응답 필드가 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄의 `creatorId`와 DDL 내부 컬럼 `creator_id`는 허용한다. + - 대체 검증 방법: `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API`로 최근 소식 flat 공개 응답 필드와 유료 커뮤니티 발행 계약이 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼 맥락은 허용한다. - 실행 명령: `./gradlew tasks --all` - 기대 결과: `BUILD SUCCESSFUL` @@ -591,7 +781,8 @@ data class HomeFollowingNewsInboxRecord( 3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다. 4. query service와 facade에서 섹션을 조립한다. 5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다. -6. End-to-End 테스트와 전체 회귀 검증을 수행한다. +6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다. +7. End-to-End 테스트와 전체 회귀 검증을 수행한다. --- @@ -622,7 +813,7 @@ data class HomeFollowingNewsInboxRecord( - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-26 Phase 3-5 리뷰 보완 검증: - - 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 유료 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다. + - 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다. 이후 계약 변경으로 `COMMUNITY_POST` 최근 소식은 무료 게시글만 발행하도록 재정의했다. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 reviewer 보완 전 7개 regression 테스트 실패를 확인했다. - 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. @@ -650,3 +841,21 @@ data class HomeFollowingNewsInboxRecord( - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. + +- 2026-06-30 Phase 5.5 구현 검증: + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 nested domain/DTO 미구현 컴파일 오류로 `BUILD FAILED`. + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 원천 payload enrichment와 유료 커뮤니티 제외 미구현으로 `BUILD FAILED`. + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 유료 커뮤니티 게시글도 최근 소식을 발행해 `BUILD FAILED`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldPublishNewsAfterCommunityPostCreated" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember"` 실행 결과 `BUILD SUCCESSFUL`. + - Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon ktlintCheck` 실행 결과 import 정렬 위반 2건을 수정한 뒤 재실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완: `CREATOR_RANKING`은 `rank`가 있는 row만 노출하도록 수정했고, `AUDIO_CONTENT`/`COMMUNITY_POST` 최근 소식은 현재 원천 target의 성인 상태를 함께 반영하도록 보강했다. + - 리뷰 보완: `HomeFollowingEndToEndTest`가 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST`, 유료 커뮤니티 미노출 JSON surface를 모두 검증하도록 fixture/assertion을 확장했다. + - 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완 후 Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. + - 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse`의 `releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md index 4a9d99a7..98539465 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md @@ -175,22 +175,22 @@ - API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다. - 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다. - 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다. -- 응답 필드는 `newsId`, `type`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `visibleFromAtUtc`, `rank`를 포함한다. -- 응답에는 `creatorId`를 별도 필드로 내려주지 않는다. -- `CREATOR_RANKING` 터치 액션은 해당 크리에이터 채널 이동이므로 `targetId`는 크리에이터 회원 id다. -- `CONTENT_RANKING` 터치 액션은 향후 콘텐츠 상세 이동이므로 `targetId`는 콘텐츠 id로 정의한다. -- `COMMUNITY_POST` 터치 액션은 게시글 상세 이동이므로 `targetId`는 커뮤니티 게시글 id다. -- `AUDIO_CONTENT` 터치 액션은 오디오 상세 이동이므로 `targetId`는 오디오 콘텐츠 id다. -- `PHOTO_CONTENT` 터치 액션은 향후 화보 상세 이동이므로 `targetId`는 화보 콘텐츠 id로 정의한다. -- 화면의 상대 시간 표시는 `visibleFromAtUtc` 기준을 기본으로 한다. -- 커뮤니티 게시글 업로드 소식의 `occurredAtUtc`와 `visibleFromAtUtc`는 게시글 생성 시각을 기본값으로 한다. -- 오디오 콘텐츠 업로드 소식의 `occurredAtUtc`는 콘텐츠 업로드 또는 공개 예약 생성 시각, `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다. -- 즉시 공개 콘텐츠는 `visibleFromAtUtc = occurredAtUtc`로 저장할 수 있다. +- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다. +- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다. +- `CREATOR_RANKING`은 `creatorRanking`에 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함한다. +- `CONTENT_RANKING`은 `contentRanking`에 `rank`, `contentId`, `contentImageUrl`, `title`을 포함한다. +- `AUDIO_CONTENT`는 `audioContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. +- `PHOTO_CONTENT`는 `photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. +- `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 포함한다. `imageUrl`은 nullable이고 `createdAt` timezone은 UTC다. +- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 팔로잉 최근 소식 inbox row를 생성하지 않는다. +- 타입별 터치 액션 target은 각 nested DTO의 id를 사용한다. `creatorRanking.creatorId`, `contentRanking.contentId`, `audioContent.contentId`, `photoContent.contentId`, `communityPost.postId`가 이동 대상 id다. +- 화면의 상대 시간 표시는 최상위 `visibleFromAtUtc` 기준을 기본으로 한다. +- 커뮤니티 게시글 업로드 소식의 `visibleFromAtUtc`와 `communityPost.createdAt`은 게시글 생성 시각을 기본값으로 한다. +- 오디오/화보 콘텐츠 업로드 소식의 `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다. +- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다. - 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다. - 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다. -- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. -- 랭킹 소식은 이번에 몇 위에 올랐는지를 나타내는 `rank`를 내려준다. -- `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`의 `rank`는 `null`로 내려준다. +- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다. #### Edge Cases - inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다. @@ -198,7 +198,8 @@ - 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다. - 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다. - 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다. -- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl`은 `null`로 내려준다. +- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다. +- 무료 커뮤니티 게시글이 생성되더라도 조회 시 원천 게시글이 비활성화되었거나 성인/차단 정책에 의해 제외되면 `COMMUNITY_POST` 최근 소식은 노출하지 않는다. ### Feature G. Response 재사용 정책 @@ -275,15 +276,45 @@ data class FollowingScheduleResponse( data class FollowingNewsResponse( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: FollowingCreatorRankingNewsResponse?, + val audioContent: FollowingContentNewsResponse?, + val photoContent: FollowingContentNewsResponse?, + val contentRanking: FollowingContentRankingNewsResponse?, + val communityPost: FollowingCommunityPostNewsResponse? +) + +data class FollowingCreatorRankingNewsResponse( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class FollowingContentNewsResponse( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class FollowingContentRankingNewsResponse( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class FollowingCommunityPostNewsResponse( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int ) enum class FollowingNewsType { @@ -297,7 +328,7 @@ enum class FollowingNewsType { ``` - `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다. -- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. +- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다. --- @@ -332,11 +363,11 @@ enum class FollowingNewsType { - MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다. - inbox는 사용자별 소식 저장소다. - inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다. -- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다. +- 콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다. 커뮤니티 업로드 소식은 무료 커뮤니티 게시글 생성 이벤트에서만 현재 follower 회원별로 적재한다. - 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다. - 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다. - 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다. -- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. +- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다. - 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다. - `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다. - `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다. From 941dd3c3a87f76b2463017afc7e37dc809c2f4af Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:34:48 +0900 Subject: [PATCH 2/6] =?UTF-8?q?docs(community):=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9E=89=20=EB=89=B4=EC=8A=A4=20=EC=A0=9C=EC=99=B8=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md b/docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md index 5c789db3..f624f1dc 100644 --- a/docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md +++ b/docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md @@ -27,7 +27,7 @@ v2 API에서 커뮤니티 게시글 item을 내려줄 때 인증 조회자가 - 커뮤니티 게시글 좋아요 생성/취소 API 동작은 변경하지 않는다. - `likeCount` 집계 기준은 변경하지 않는다. - legacy 커뮤니티 API 응답 스키마는 변경하지 않는다. -- 팔로잉 뉴스의 `COMMUNITY_POST` news item에는 게시글 카드 필드가 없으므로 이번 범위에서 제외한다. +- 팔로잉 뉴스의 `COMMUNITY_POST` news item은 이 문서 작성 당시 게시글 카드 필드가 없어 이번 범위에서 제외했다. 이후 `FollowingNewsResponse.communityPost` nested DTO 계약으로 변경하며 무료 커뮤니티 게시글에 한해 `likeCount`, `commentCount`를 포함하도록 별도 후속 범위에서 다룬다. - DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다. - 추천 스냅샷 산정 로직과 인기 커뮤니티 정렬 정책은 변경하지 않는다. @@ -76,7 +76,7 @@ v2 API에서 커뮤니티 게시글 item을 내려줄 때 인증 조회자가 ### 제외 대상 - `HomeFollowingNews` / `FollowingNewsResponse` - - `FollowingNewsType.COMMUNITY_POST`와 `targetId`는 있지만 게시글 카드 DTO가 아니며 `likeCount`도 제공하지 않는다. + - 이 문서 작성 당시에는 `FollowingNewsType.COMMUNITY_POST`와 `targetId`만 있었고 게시글 카드 DTO가 아니어서 제외했다. 이후 팔로잉 뉴스 응답 계약 변경으로 `communityPost` nested DTO가 무료 커뮤니티 게시글의 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 제공하도록 별도 후속 작업에서 갱신한다. --- From 17b1305a953c2607e21f9ff3631663314f359425 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:34:59 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C=EC=8B=9D=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EC=A4=91=EC=B2=A9=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../following/dto/HomeFollowingTabResponse.kt | 96 ++++++++++--- .../v2/home/following/domain/HomeFollowing.kt | 46 ++++-- .../application/HomeFollowingFacadeTest.kt | 15 +- .../dto/HomeFollowingTabResponseTest.kt | 131 +++++++++++++++--- 4 files changed, 240 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt index 8bd65e92..b5b1c557 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt @@ -5,7 +5,11 @@ import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse 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.home.following.domain.HomeFollowing +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 @@ -112,31 +116,91 @@ data class FollowingScheduleResponse( data class FollowingNewsResponse( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: FollowingCreatorRankingNewsResponse?, + val audioContent: FollowingContentNewsResponse?, + val photoContent: FollowingContentNewsResponse?, + val contentRanking: FollowingContentRankingNewsResponse?, + val communityPost: FollowingCommunityPostNewsResponse? ) { companion object { fun from(news: HomeFollowingNews): FollowingNewsResponse { return FollowingNewsResponse( newsId = news.newsId, type = news.type, - creatorProfileImageUrl = news.creatorProfileImageUrl, - creatorNickname = news.creatorNickname, - title = news.title, - body = news.body, - thumbnailImageUrl = news.thumbnailImageUrl, - targetId = news.targetId, - occurredAtUtc = news.occurredAtUtc, visibleFromAtUtc = news.visibleFromAtUtc, - rank = news.rank + creatorRanking = news.creatorRanking?.toResponse(), + audioContent = news.audioContent?.toContentResponse(), + photoContent = news.photoContent?.toContentResponse(), + contentRanking = news.contentRanking?.toResponse(), + communityPost = news.communityPost?.toResponse() ) } } } + +data class FollowingCreatorRankingNewsResponse( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class FollowingContentNewsResponse( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class FollowingContentRankingNewsResponse( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class FollowingCommunityPostNewsResponse( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int +) + +private fun HomeFollowingCreatorRankingNews.toResponse() = FollowingCreatorRankingNewsResponse( + rank = rank, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl +) + +private fun HomeFollowingContentNews.toContentResponse() = FollowingContentNewsResponse( + contentId = contentId, + contentImageUrl = contentImageUrl, + title = title, + creatorProfileImageUrl = creatorProfileImageUrl, + creatorNickname = creatorNickname +) + +private fun HomeFollowingContentRankingNews.toResponse() = FollowingContentRankingNewsResponse( + rank = rank, + contentId = contentId, + contentImageUrl = contentImageUrl, + title = title +) + +private fun HomeFollowingCommunityPostNews.toResponse() = FollowingCommunityPostNewsResponse( + postId = postId, + creatorProfileImage = creatorProfileImage, + creatorNickname = creatorNickname, + imageUrl = imageUrl, + content = content, + createdAt = createdAt, + likeCount = likeCount, + commentCount = commentCount +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt index eefa5a95..d9ecb27f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt @@ -40,13 +40,43 @@ data class HomeFollowingSchedule( data class HomeFollowingNews( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: HomeFollowingCreatorRankingNews? = null, + val audioContent: HomeFollowingContentNews? = null, + val photoContent: HomeFollowingContentNews? = null, + val contentRanking: HomeFollowingContentRankingNews? = null, + val communityPost: HomeFollowingCommunityPostNews? = null +) + +data class HomeFollowingCreatorRankingNews( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class HomeFollowingContentNews( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class HomeFollowingContentRankingNews( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class HomeFollowingCommunityPostNews( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt index 64d97406..753e03ef 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQuery import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing 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 @@ -109,15 +110,13 @@ class HomeFollowingFacadeTest { HomeFollowingNews( newsId = "news-5", type = FollowingNewsType.CREATOR_RANKING, - creatorProfileImageUrl = "https://cdn.test/news.png", - creatorNickname = "creator", - title = "news", - body = "body", - thumbnailImageUrl = null, - targetId = 1L, - occurredAtUtc = "2026-06-25T03:00:00Z", visibleFromAtUtc = "2026-06-25T04:00:00Z", - rank = 7 + creatorRanking = HomeFollowingCreatorRankingNews( + rank = 7, + creatorId = 1L, + nickname = "creator", + profileImageUrl = "https://cdn.test/news.png" + ) ) ) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt index 10b7a5b0..93e5cd5c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt @@ -5,7 +5,11 @@ import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse 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.home.following.domain.HomeFollowing +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 @@ -39,24 +43,71 @@ class HomeFollowingTabResponseTest { } @Test - @DisplayName("팔로잉 탭 도메인은 creatorId 없는 최근 소식과 nullable rank 응답으로 변환한다") - fun shouldMapDomainToResponseWithoutCreatorIdInRecentNews() { + @DisplayName("팔로잉 탭 도메인은 타입별 nested 최근 소식 응답으로 변환한다") + fun shouldMapDomainToNestedRecentNewsResponseByType() { val response = HomeFollowingTabResponse.from(createHomeFollowing()) val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) + val recentNews = json["recentNews"] assertFalse(response.isLoginRequired) assertEquals(1L, response.followingCreators.first().creatorId) assertEquals(10L, response.onAirLives.first().liveId) assertEquals(100L, response.recentChats.first().roomId) assertEquals("LIVE:20", response.monthlySchedules.first().scheduleId) - assertEquals(3, response.recentNews.first().rank) assertEquals(false, json["isLoginRequired"].asBoolean()) - assertFalse(json["recentNews"][0].has("creatorId")) - assertFalse(json["recentNews"][0].has("ranking")) - assertFalse(json["recentNews"][0].has("rankChange")) - assertFalse(json["recentNews"][0].has("isNew")) - assertEquals(3, json["recentNews"][0]["rank"].asInt()) assertEquals(true, json["monthlySchedules"][0]["isOnAir"].asBoolean()) + + val removedTopLevelFields = listOf( + "creatorProfileImageUrl", + "creatorNickname", + "title", + "body", + "thumbnailImageUrl", + "targetId", + "occurredAtUtc", + "rank" + ) + removedTopLevelFields.forEach { field -> + assertFalse(recentNews[0].has(field), "recentNews top-level must not expose $field") + } + + assertEquals("30", recentNews[0]["newsId"].asText()) + assertEquals("CREATOR_RANKING", recentNews[0]["type"].asText()) + assertEquals("2026-06-25T09:00:00Z", recentNews[0]["visibleFromAtUtc"].asText()) + assertEquals(3, recentNews[0]["creatorRanking"]["rank"].asInt()) + assertEquals(1L, recentNews[0]["creatorRanking"]["creatorId"].asLong()) + assertEquals("news-creator", recentNews[0]["creatorRanking"]["nickname"].asText()) + assertTrue(recentNews[0]["audioContent"].isNull) + assertTrue(recentNews[0]["photoContent"].isNull) + assertTrue(recentNews[0]["contentRanking"].isNull) + assertTrue(recentNews[0]["communityPost"].isNull) + + assertEquals("AUDIO_CONTENT", recentNews[1]["type"].asText()) + assertEquals(200L, recentNews[1]["audioContent"]["contentId"].asLong()) + assertEquals("audio title", recentNews[1]["audioContent"]["title"].asText()) + assertFalse(recentNews[1]["audioContent"].has("releaseDate")) + assertTrue(recentNews[1]["creatorRanking"].isNull) + assertTrue(recentNews[1]["communityPost"].isNull) + + assertEquals("PHOTO_CONTENT", recentNews[2]["type"].asText()) + assertEquals(300L, recentNews[2]["photoContent"]["contentId"].asLong()) + assertEquals("photo title", recentNews[2]["photoContent"]["title"].asText()) + assertTrue(recentNews[2]["audioContent"].isNull) + + assertEquals("CONTENT_RANKING", recentNews[3]["type"].asText()) + assertEquals(5, recentNews[3]["contentRanking"]["rank"].asInt()) + assertEquals(400L, recentNews[3]["contentRanking"]["contentId"].asLong()) + assertTrue(recentNews[3]["creatorRanking"].isNull) + + assertEquals("COMMUNITY_POST", recentNews[4]["type"].asText()) + assertEquals(500L, recentNews[4]["communityPost"]["postId"].asLong()) + assertEquals("https://cdn/community-profile.jpg", recentNews[4]["communityPost"]["creatorProfileImage"].asText()) + assertEquals("community creator", recentNews[4]["communityPost"]["creatorNickname"].asText()) + assertTrue(recentNews[4]["communityPost"]["imageUrl"].isNull) + assertEquals("community body", recentNews[4]["communityPost"]["content"].asText()) + assertEquals("2026-06-25T03:00:00Z", recentNews[4]["communityPost"]["createdAt"].asText()) + assertEquals(11, recentNews[4]["communityPost"]["likeCount"].asInt()) + assertEquals(2, recentNews[4]["communityPost"]["commentCount"].asInt()) } private fun createHomeFollowing(): HomeFollowing { @@ -98,15 +149,63 @@ class HomeFollowingTabResponseTest { HomeFollowingNews( newsId = "30", type = FollowingNewsType.CREATOR_RANKING, - creatorProfileImageUrl = "https://cdn/news-profile.jpg", - creatorNickname = "news-creator", - title = "ranking", - body = "ranked", - thumbnailImageUrl = null, - targetId = 1L, - occurredAtUtc = "2026-06-25T00:00:00Z", visibleFromAtUtc = "2026-06-25T09:00:00Z", - rank = 3 + creatorRanking = HomeFollowingCreatorRankingNews( + rank = 3, + creatorId = 1L, + nickname = "news-creator", + profileImageUrl = "https://cdn/news-profile.jpg" + ) + ), + HomeFollowingNews( + newsId = "31", + type = FollowingNewsType.AUDIO_CONTENT, + visibleFromAtUtc = "2026-06-26T00:00:00Z", + audioContent = HomeFollowingContentNews( + contentId = 200L, + contentImageUrl = "https://cdn/audio.jpg", + title = "audio title", + creatorProfileImageUrl = "https://cdn/audio-profile.jpg", + creatorNickname = "audio creator" + ) + ), + HomeFollowingNews( + newsId = "32", + type = FollowingNewsType.PHOTO_CONTENT, + visibleFromAtUtc = "2026-06-27T00:00:00Z", + photoContent = HomeFollowingContentNews( + contentId = 300L, + contentImageUrl = "https://cdn/photo.jpg", + title = "photo title", + creatorProfileImageUrl = "https://cdn/photo-profile.jpg", + creatorNickname = "photo creator" + ) + ), + HomeFollowingNews( + newsId = "33", + type = FollowingNewsType.CONTENT_RANKING, + visibleFromAtUtc = "2026-06-28T00:00:00Z", + contentRanking = HomeFollowingContentRankingNews( + rank = 5, + contentId = 400L, + contentImageUrl = "https://cdn/ranking.jpg", + title = "content ranking" + ) + ), + HomeFollowingNews( + newsId = "34", + type = FollowingNewsType.COMMUNITY_POST, + visibleFromAtUtc = "2026-06-25T03:00:00Z", + communityPost = HomeFollowingCommunityPostNews( + postId = 500L, + creatorProfileImage = "https://cdn/community-profile.jpg", + creatorNickname = "community creator", + imageUrl = null, + content = "community body", + createdAt = "2026-06-25T03:00:00Z", + likeCount = 11, + commentCount = 2 + ) ) ) ) From 5d5547361c54addbdc21d498b6dc6bfeb3beb08a Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:35:13 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(home):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EC=86=8C=EC=8B=9D=20=EC=9B=90=EC=B2=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultHomeFollowingQueryRepository.kt | 181 +++++++++++-- ...DefaultHomeFollowingQueryRepositoryTest.kt | 241 +++++++++++++++++- 2 files changed, 386 insertions(+), 36 deletions(-) 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 43fc2c48..91220210 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 @@ -8,6 +8,8 @@ import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.QAudioContent import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity +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.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.MemberRole @@ -18,7 +20,10 @@ import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox 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.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 @@ -116,50 +121,109 @@ class DefaultHomeFollowingQueryRepository( limit: Int ): List { val creator = QMember("newsCreator") - return queryFactory + val newsAudioContent = QAudioContent("recentNewsAudioContent") + val newsCommunity = QCreatorCommunity("recentNewsCommunity") + val rows = queryFactory .select( homeFollowingNewsInbox.id, homeFollowingNewsInbox.newsType, - homeFollowingNewsInbox.creatorProfileImagePath, - homeFollowingNewsInbox.creatorNickname, - homeFollowingNewsInbox.title, - homeFollowingNewsInbox.body, - homeFollowingNewsInbox.thumbnailImagePath, + creator.profileImage, + creator.nickname, homeFollowingNewsInbox.targetId, - homeFollowingNewsInbox.occurredAtUtc, homeFollowingNewsInbox.visibleFromAtUtc, - homeFollowingNewsInbox.rank + homeFollowingNewsInbox.rank, + newsAudioContent.title, + newsAudioContent.coverImage, + newsCommunity.content, + newsCommunity.imagePath, + newsCommunity.createdAt ) .from(homeFollowingNewsInbox) .join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId)) + .leftJoin(newsAudioContent).on( + homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT), + newsAudioContent.id.eq(homeFollowingNewsInbox.targetId) + ) + .leftJoin(newsCommunity).on( + homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST), + newsCommunity.id.eq(homeFollowingNewsInbox.targetId) + ) .where( homeFollowingNewsInbox.memberId.eq(memberId), homeFollowingNewsInbox.isActive.isTrue, homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc), creator.isActive.isTrue, creator.role.eq(MemberRole.CREATOR), + activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId), adultNewsCondition(canViewAdultContent), notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId), - activeNewsTargetCondition() + activeNewsTargetCondition(canViewAdultContent) ) .orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc()) .limit(limit.toLong()) .fetch() - .map { row -> - HomeFollowingNews( - newsId = row.get(homeFollowingNewsInbox.id)!!.toString(), - type = row.get(homeFollowingNewsInbox.newsType)!!, - creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)), - creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!, - title = row.get(homeFollowingNewsInbox.title)!!, - body = row.get(homeFollowingNewsInbox.body)!!, - thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost), - targetId = row.get(homeFollowingNewsInbox.targetId)!!, - occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(), - visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(), - rank = row.get(homeFollowingNewsInbox.rank) + val communityPostIds = rows + .filter { it.get(homeFollowingNewsInbox.newsType) == FollowingNewsType.COMMUNITY_POST } + .map { it.get(homeFollowingNewsInbox.targetId)!! } + val likeCounts = communityLikeCounts(communityPostIds) + val commentCounts = communityCommentCounts(communityPostIds) + return rows.map { row -> row.toHomeFollowingNews(creator, newsAudioContent, newsCommunity, likeCounts, commentCounts) } + } + + private fun Tuple.toHomeFollowingNews( + newsCreator: QMember, + newsAudioContent: QAudioContent, + newsCommunity: QCreatorCommunity, + likeCounts: Map, + commentCounts: Map + ): HomeFollowingNews { + val type = get(homeFollowingNewsInbox.newsType)!! + val targetId = get(homeFollowingNewsInbox.targetId)!! + val creatorProfileImageUrl = profileImageUrl(get(newsCreator.profileImage)) + val creatorNickname = get(newsCreator.nickname)!! + val visibleFromAtUtc = get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso() + val rank = get(homeFollowingNewsInbox.rank) + + return HomeFollowingNews( + newsId = get(homeFollowingNewsInbox.id)!!.toString(), + type = type, + visibleFromAtUtc = visibleFromAtUtc, + creatorRanking = if (type == FollowingNewsType.CREATOR_RANKING && rank != null) { + HomeFollowingCreatorRankingNews( + rank = rank, + creatorId = targetId, + nickname = creatorNickname, + profileImageUrl = creatorProfileImageUrl ) + } else { + null + }, + audioContent = if (type == FollowingNewsType.AUDIO_CONTENT) { + HomeFollowingContentNews( + contentId = targetId, + contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost), + title = get(newsAudioContent.title)!!, + creatorProfileImageUrl = creatorProfileImageUrl, + creatorNickname = creatorNickname + ) + } else { + null + }, + communityPost = if (type == FollowingNewsType.COMMUNITY_POST) { + HomeFollowingCommunityPostNews( + postId = targetId, + creatorProfileImage = creatorProfileImageUrl, + creatorNickname = creatorNickname, + imageUrl = get(newsCommunity.imagePath).toCdnUrl(cloudFrontHost), + content = get(newsCommunity.content)!!, + createdAt = get(newsCommunity.createdAt)!!.toUtcIso(), + likeCount = likeCounts[targetId] ?: 0, + commentCount = commentCounts[targetId] ?: 0 + ) + } else { + null } + ) } private fun findLiveSchedules( @@ -284,7 +348,44 @@ class DefaultHomeFollowingQueryRepository( return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse } - private fun activeNewsTargetCondition(): BooleanExpression { + private fun communityLikeCounts(postIds: List): Map { + if (postIds.isEmpty()) return emptyMap() + + return queryFactory + .select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count()) + .from(creatorCommunityLike) + .where( + creatorCommunityLike.creatorCommunity.id.`in`(postIds), + creatorCommunityLike.isActive.isTrue + ) + .groupBy(creatorCommunityLike.creatorCommunity.id) + .fetch() + .associate { + it.get(creatorCommunityLike.creatorCommunity.id)!! to + (it.get(creatorCommunityLike.id.count())?.toInt() ?: 0) + } + } + + private fun communityCommentCounts(postIds: List): Map { + if (postIds.isEmpty()) return emptyMap() + + return queryFactory + .select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count()) + .from(creatorCommunityComment) + .where( + creatorCommunityComment.creatorCommunity.id.`in`(postIds), + creatorCommunityComment.isActive.isTrue, + creatorCommunityComment.parent.isNull + ) + .groupBy(creatorCommunityComment.creatorCommunity.id) + .fetch() + .associate { + it.get(creatorCommunityComment.creatorCommunity.id)!! to + (it.get(creatorCommunityComment.id.count())?.toInt() ?: 0) + } + } + + private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression { val newsAudioContent = QAudioContent("newsAudioContent") val newsCommunity = QCreatorCommunity("newsCommunity") val activeAudioExists = JPAExpressions @@ -292,7 +393,8 @@ class DefaultHomeFollowingQueryRepository( .from(newsAudioContent) .where( newsAudioContent.id.eq(homeFollowingNewsInbox.targetId), - newsAudioContent.isActive.isTrue + newsAudioContent.isActive.isTrue, + adultAudioNewsTargetCondition(canViewAdultContent, newsAudioContent) ) .exists() val activeCommunityExists = JPAExpressions @@ -300,15 +402,44 @@ class DefaultHomeFollowingQueryRepository( .from(newsCommunity) .where( newsCommunity.id.eq(homeFollowingNewsInbox.targetId), - newsCommunity.isActive.isTrue + newsCommunity.isActive.isTrue, + newsCommunity.price.loe(0), + adultCommunityNewsTargetCondition(canViewAdultContent, newsCommunity) ) .exists() return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING) + .and(homeFollowingNewsInbox.rank.isNotNull) .or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists)) .or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists)) } + private fun adultAudioNewsTargetCondition( + canViewAdultContent: Boolean, + newsAudioContent: QAudioContent + ): BooleanExpression? { + return if (canViewAdultContent) null else newsAudioContent.isAdult.isFalse + } + + private fun adultCommunityNewsTargetCondition( + canViewAdultContent: Boolean, + newsCommunity: QCreatorCommunity + ): BooleanExpression? { + return if (canViewAdultContent) null else newsCommunity.isAdult.isFalse + } + + private fun activeFollowingCondition(memberId: Long, creatorIdPath: Expression): BooleanExpression { + return JPAExpressions + .selectOne() + .from(creatorFollowing) + .where( + creatorFollowing.member.id.eq(memberId), + creatorFollowing.creator.id.eq(creatorIdPath), + creatorFollowing.isActive.isTrue + ) + .exists() + } + private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression): BooleanExpression { val blockMember = QBlockMember("homeFollowingBlockMember") return JPAExpressions 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 abc981ff..ccb0b49f 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 @@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.content.AudioContent 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.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -219,8 +221,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( } @Test - @DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다") - fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() { + @DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다") + fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() { val viewer = saveMember("news-viewer", MemberRole.USER) val creator = saveMember("news-creator", MemberRole.CREATOR) val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR) @@ -228,7 +230,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( saveFollowing(viewer, creator) saveFollowing(viewer, blockedCreator) saveFollowing(viewer, nonCreator) - val oldVisible = saveNews(viewer.id!!, creator.id!!, "old", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null) + 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) saveNews(viewer.id!!, creator.id!!, "adult", LocalDateTime.of(2026, 6, 25, 9, 30), isAdult = true) @@ -244,8 +246,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( limit = 30 ) - assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId }) - assertEquals(listOf(3, null), news.map { it.rank }) + assertEquals(listOf(latestVisible.id!!.toString()), news.map { it.newsId }) + assertEquals(listOf(3), news.map { it.creatorRanking?.rank }) } @Test @@ -254,8 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( val viewer = saveMember("news-utc-viewer", MemberRole.USER) val creator = saveMember("news-utc-creator", MemberRole.CREATOR) saveFollowing(viewer, creator) - val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30)) - saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31)) + 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() val news = repository.findRecentNews( @@ -268,6 +270,198 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId }) } + @Test + @DisplayName("최근 소식은 타입별 원천 데이터를 nested payload로 채운다") + fun shouldPopulateRecentNewsNestedPayloadsFromSourceTargets() { + val viewer = saveMember("news-payload-viewer", MemberRole.USER) + val creator = saveMember("news-payload-creator", MemberRole.CREATOR, profileImage = "payload-profile.png") + val otherMember = saveMember("news-payload-other", MemberRole.USER) + val theme = saveTheme("news-payload-theme") + saveFollowing(viewer, creator) + val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 7, 0)).apply { + title = "source audio title" + coverImage = "audio/source-cover.png" + } + val post = saveCommunityPost(creator, "source community content", isActive = true).apply { + imagePath = "community/source-image.png" + } + saveCommunityLike(viewer, post, isActive = true) + saveCommunityLike(otherMember, post, isActive = true) + saveCommunityLike(otherMember, post, isActive = false) + saveCommunityComment(viewer, post, isActive = true) + val inactiveComment = saveCommunityComment(otherMember, post, isActive = false) + val childComment = saveCommunityComment(otherMember, post, isActive = true) + childComment.parent = inactiveComment + post.createdAt = LocalDateTime.of(2026, 6, 25, 6, 30) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "payload-audio", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + newsType = FollowingNewsType.AUDIO_CONTENT, + targetId = audio.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "payload-post", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = post.id!! + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + limit = 30 + ) + + val communityNews = news.first { it.type == FollowingNewsType.COMMUNITY_POST }.communityPost!! + assertEquals(post.id!!, communityNews.postId) + assertEquals("https://cdn.test/payload-profile.png", communityNews.creatorProfileImage) + assertEquals("news-payload-creator", communityNews.creatorNickname) + assertEquals("https://cdn.test/community/source-image.png", communityNews.imageUrl) + assertEquals("source community content", communityNews.content) + assertEquals("2026-06-25T06:30:00Z", communityNews.createdAt) + assertEquals(2, communityNews.likeCount) + assertEquals(1, communityNews.commentCount) + + val audioNews = news.first { it.type == FollowingNewsType.AUDIO_CONTENT }.audioContent!! + assertEquals(audio.id!!, audioNews.contentId) + assertEquals("https://cdn.test/audio/source-cover.png", audioNews.contentImageUrl) + assertEquals("source audio title", audioNews.title) + assertEquals("https://cdn.test/payload-profile.png", audioNews.creatorProfileImageUrl) + assertEquals("news-payload-creator", audioNews.creatorNickname) + } + + @Test + @DisplayName("최근 소식은 현재 활성 팔로우가 아닌 크리에이터 inbox를 제외한다") + fun shouldExcludeRecentNewsWhenFollowingIsInactive() { + val viewer = saveMember("news-inactive-following-viewer", MemberRole.USER) + val creator = saveMember("news-inactive-following-creator", MemberRole.CREATOR) + saveFollowing(viewer, creator, isActive = false) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "inactive-following-news", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + rank = 1 + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + limit = 30 + ) + + assertTrue(news.isEmpty()) + } + + @Test + @DisplayName("최근 소식은 유료 커뮤니티 게시글을 제외한다") + fun shouldExcludePaidCommunityPostRecentNews() { + val viewer = saveMember("news-paid-viewer", MemberRole.USER) + val creator = saveMember("news-paid-creator", MemberRole.CREATOR) + saveFollowing(viewer, creator) + val freePost = saveCommunityPost(creator, "free-post", isActive = true) + val negativeFreePost = saveCommunityPost(creator, "negative-free-post", isActive = true).apply { price = -1 } + val paidPost = saveCommunityPost(creator, "paid-post", isActive = true).apply { price = 10 } + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "free-post", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = freePost.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "negative-free-post", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = negativeFreePost.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "paid-post", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 2), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = paidPost.id!! + ) + flushAndClear() + + val news = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + limit = 30 + ) + + assertEquals(listOf(negativeFreePost.id!!, freePost.id!!), news.map { it.communityPost?.postId }) + assertTrue(news.all { it.audioContent == null }) + } + + @Test + @DisplayName("최근 소식은 비성인 사용자의 오디오와 커뮤니티 원천 target 현재 성인 상태를 반영해 제외한다") + fun shouldExcludeAdultSourceTargetsForNonAdultViewer() { + val viewer = saveMember("news-adult-source-viewer", MemberRole.USER) + val creator = saveMember("news-adult-source-creator", MemberRole.CREATOR) + val theme = saveTheme("news-adult-source-theme") + saveFollowing(viewer, creator) + val adultAudio = saveAudioContent( + creator, + theme, + LocalDateTime.of(2026, 6, 25, 8, 0), + isActive = true, + isAdult = true + ) + val adultPost = saveCommunityPost(creator, "adult-post", isActive = true, isAdult = true) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "adult-audio", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + isAdult = false, + newsType = FollowingNewsType.AUDIO_CONTENT, + targetId = adultAudio.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "adult-post", + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1), + isAdult = false, + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = adultPost.id!! + ) + flushAndClear() + + val hiddenNews = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = false, + nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + limit = 30 + ) + val visibleNews = repository.findRecentNews( + memberId = viewer.id!!, + canViewAdultContent = true, + nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0), + limit = 30 + ) + + assertEquals(emptyList(), hiddenNews.map { it.communityPost?.postId ?: it.audioContent?.contentId }) + assertEquals( + listOf(adultPost.id!!, adultAudio.id!!), + visibleNews.map { it.communityPost?.postId ?: it.audioContent?.contentId } + ) + } + @Test @DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다") fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() { @@ -322,7 +516,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( assertEquals( listOf(activePost.id!!, activeAudio.id!!), - news.map { it.targetId } + news.map { it.communityPost?.postId ?: it.audioContent?.contentId } ) } @@ -387,7 +581,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime, - isActive: Boolean = true + isActive: Boolean = true, + isAdult: Boolean = false ): AudioContent { val content = AudioContent( title = "audio-$releaseDate", @@ -399,17 +594,23 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( this.theme = theme duration = "00:10:00" this.isActive = isActive + this.isAdult = isAdult } entityManager.persist(content) return content } - private fun saveCommunityPost(creator: Member, content: String, isActive: Boolean): CreatorCommunity { + private fun saveCommunityPost( + creator: Member, + content: String, + isActive: Boolean, + isAdult: Boolean = false + ): CreatorCommunity { val post = CreatorCommunity( content = content, price = 0, isCommentAvailable = true, - isAdult = false, + isAdult = isAdult, isActive = isActive ).apply { member = creator @@ -418,6 +619,24 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( return post } + private fun saveCommunityLike(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = isActive).apply { + this.member = member + creatorCommunity = post + } + entityManager.persist(like) + return like + } + + private fun saveCommunityComment(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isActive = isActive).apply { + this.member = member + creatorCommunity = post + } + entityManager.persist(comment) + return comment + } + private fun saveNews( memberId: Long, creatorId: Long, From b3d07cde38aec2395be4c7970cf22eeec2bda7e5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:36:31 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(community):=20=EC=9C=A0=EB=A3=8C=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C?= =?UTF-8?q?=EC=8B=9D=20=EB=B0=9C=ED=96=89=EC=9D=84=20=EB=A7=89=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorCommunityService.kt | 2 ++ .../CreatorCommunityServiceTest.kt | 19 ++++--------------- .../HomeFollowingNewsPublishServiceTest.kt | 4 ++-- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 77dd3649..96cd4fda 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -145,6 +145,8 @@ class CreatorCommunityService( } private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) { + if (post.price > 0) return + val occurredAtUtc = post.createdAt ?: LocalDateTime.now() val newsContent = post.newsContentPreview() afterCommit { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index b7d1dee5..48c91ca1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -330,15 +330,14 @@ class CreatorCommunityServiceTest { } @Test - @DisplayName("유료 커뮤니티 게시글 최근 소식은 전체 본문을 노출하지 않고 미리보기만 발행한다") - fun shouldPublishPaidCommunityPostNewsWithMaskedContent() { + @DisplayName("유료 커뮤니티 게시글 생성 성공 후 최근 소식을 발행하지 않는다") + fun shouldNotPublishNewsAfterPaidCommunityPostCreated() { val creator = createMember(id = 910L, role = MemberRole.CREATOR, nickname = "paid-community-creator") val fullContent = "유료 커뮤니티 게시글 전체 본문은 최근 소식에서 노출되면 안 됩니다" - val createdAt = LocalDateTime.of(2026, 6, 25, 11, 0) Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation -> val post = invocation.getArgument(0) post.id = 911L - post.createdAt = createdAt + post.createdAt = LocalDateTime.of(2026, 6, 25, 11, 0) post } Mockito.`when`( @@ -357,17 +356,7 @@ class CreatorCommunityServiceTest { member = creator ) - Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated( - postId = 911L, - creatorId = creator.id!!, - creatorNickname = creator.nickname!!, - creatorProfileImagePath = creator.profileImage, - title = "유료 커뮤니티 게시글 전체 ...", - body = "유료 커뮤니티 게시글 전체 ...", - thumbnailImagePath = "creator_community/911/911-image.png", - occurredAtUtc = createdAt, - isAdult = false - ) + Mockito.verifyNoInteractions(homeFollowingNewsPublishService) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt index 882bf4cb..a0407c2d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt @@ -10,8 +10,8 @@ import java.time.LocalDateTime class HomeFollowingNewsPublishServiceTest { @Test - @DisplayName("커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다") - fun shouldPublishCommunityPostCreatedToActiveFollowers() { + @DisplayName("무료 커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다") + fun shouldPublishFreeCommunityPostCreatedToActiveFollowers() { val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(1L, 2L)) val service = HomeFollowingNewsPublishService(inboxPort) val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 2, 3) From 49bbd8be4ded322435a7e2c8e12a80274086815f Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:37:42 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C=EC=8B=9D=20E2E=EB=A5=BC=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/HomeFollowingEndToEndTest.kt | 115 ++++++++++++++++-- 1 file changed, 106 insertions(+), 9 deletions(-) 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 ec618a9b..4324eb60 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 @@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.content.AudioContent 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.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter @@ -87,10 +90,34 @@ class HomeFollowingEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}")) .andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}")) .andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString())) + .andExpect(jsonPath("$.data.recentNews[0].type").value("CREATOR_RANKING")) + .andExpect(jsonPath("$.data.recentNews[0].visibleFromAtUtc").exists()) .andExpect(jsonPath("$.data.recentNews[0].creatorId").doesNotExist()) - .andExpect(jsonPath("$.data.recentNews[0].ranking").doesNotExist()) - .andExpect(jsonPath("$.data.recentNews[0].rank").value(7)) - .andExpect(jsonPath("$.data.recentNews[1].rank").value(nullValue())) + .andExpect(jsonPath("$.data.recentNews[0].rank").doesNotExist()) + .andExpect(jsonPath("$.data.recentNews[0].creatorRanking.rank").value(7)) + .andExpect(jsonPath("$.data.recentNews[0].creatorRanking.creatorId").value(fixture.creatorId)) + .andExpect(jsonPath("$.data.recentNews[0].audioContent").value(nullValue())) + .andExpect(jsonPath("$.data.recentNews[0].communityPost").value(nullValue())) + .andExpect(jsonPath("$.data.recentNews[1].type").value("COMMUNITY_POST")) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.postId").value(fixture.freePostId)) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.creatorProfileImage").value("https://cdn.test/creator.png")) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.creatorNickname").value("home-following-creator")) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.imageUrl").value("https://cdn.test/community/free.png")) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.content").value("free community body")) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.createdAt").exists()) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.likeCount").value(1)) + .andExpect(jsonPath("$.data.recentNews[1].communityPost.commentCount").value(1)) + .andExpect(jsonPath("$.data.recentNews[1].creatorRanking").value(nullValue())) + .andExpect(jsonPath("$.data.recentNews[2].type").value("AUDIO_CONTENT")) + .andExpect(jsonPath("$.data.recentNews[2].audioContent.contentId").value(fixture.audioId)) + .andExpect(jsonPath("$.data.recentNews[2].audioContent.title").value("home-following-audio")) + .andExpect( + jsonPath("$.data.recentNews[2].audioContent.contentImageUrl") + .value("https://cdn.test/audio/home-following.png") + ) + .andExpect(jsonPath("$.data.recentNews[2].audioContent.releaseDate").doesNotExist()) + .andExpect(jsonPath("$.data.recentNews[2].communityPost").value(nullValue())) + .andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty) } private fun createFixture(): Fixture { @@ -110,7 +137,36 @@ class HomeFollowingEndToEndTest @Autowired constructor( val live = saveLiveRoom(creator, scheduleBaseUtc.plusHours(1), channelName = "on-air") val theme = saveTheme() val audio = saveAudioContent(creator, theme, scheduleBaseUtc.plusHours(2)) - val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null) + audio.coverImage = "audio/home-following.png" + val freePost = saveCommunityPost(creator, "free community body", price = 0, imagePath = "community/free.png") + val paidPost = saveCommunityPost(creator, "paid community body", price = 10, imagePath = "community/paid.png") + saveCommunityLike(viewer, freePost, isActive = true) + saveCommunityComment(viewer, freePost, isActive = true) + saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null) + val audioNews = saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "audio-news", + visibleFromAtUtc = now.minusHours(3), + newsType = FollowingNewsType.AUDIO_CONTENT, + targetId = audio.id!! + ) + val communityNews = saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "free-community-news", + visibleFromAtUtc = now.minusHours(2), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = freePost.id!! + ) + saveNews( + memberId = viewer.id!!, + creatorId = creator.id!!, + sourceKey = "paid-community-news", + visibleFromAtUtc = now.minusMinutes(30), + newsType = FollowingNewsType.COMMUNITY_POST, + targetId = paidPost.id!! + ) val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7) val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10)) entityManager.flush() @@ -123,7 +179,10 @@ class HomeFollowingEndToEndTest @Autowired constructor( audioId = audio.id!!, chatRoomId = chatRoom.id!!, rankedNewsId = rankedNews.id!!, - oldNewsId = oldNews.id!! + audioNewsId = audioNews.id!!, + communityNewsId = communityNews.id!!, + freePostId = freePost.id!!, + paidPostId = paidPost.id!! ) }!! } @@ -192,14 +251,16 @@ class HomeFollowingEndToEndTest @Autowired constructor( creatorId: Long, sourceKey: String, visibleFromAtUtc: LocalDateTime, - rank: Int? + rank: Int? = null, + newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING, + targetId: Long = creatorId ): HomeFollowingNewsInbox { val news = HomeFollowingNewsInbox( memberId = memberId, creatorId = creatorId, - newsType = FollowingNewsType.CREATOR_RANKING, + newsType = newsType, sourceKey = sourceKey, - targetId = creatorId, + targetId = targetId, occurredAtUtc = visibleFromAtUtc.minusMinutes(30), visibleFromAtUtc = visibleFromAtUtc, creatorNickname = "home-following-creator", @@ -214,6 +275,39 @@ class HomeFollowingEndToEndTest @Autowired constructor( return news } + private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity { + val post = CreatorCommunity( + content = content, + price = price, + isCommentAvailable = true, + isAdult = false, + imagePath = imagePath, + isActive = true + ).apply { + member = creator + } + entityManager.persist(post) + return post + } + + private fun saveCommunityLike(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = isActive).apply { + this.member = member + creatorCommunity = post + } + entityManager.persist(like) + return like + } + + private fun saveCommunityComment(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isActive = isActive).apply { + this.member = member + creatorCommunity = post + } + entityManager.persist(comment) + return comment + } + private fun saveDmChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): UserCreatorChatRoom { val room = UserCreatorChatRoom() entityManager.persist(room) @@ -241,6 +335,9 @@ class HomeFollowingEndToEndTest @Autowired constructor( val audioId: Long, val chatRoomId: Long, val rankedNewsId: Long, - val oldNewsId: Long + val audioNewsId: Long, + val communityNewsId: Long, + val freePostId: Long, + val paidPostId: Long ) }