@@ -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`를 사용하도록 계약과 테스트를 갱신했다.
|
||||
|
||||
@@ -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 값만 예약하고, 이번 범위에서는 생성하지 않는다.
|
||||
|
||||
@@ -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`를 제공하도록 별도 후속 작업에서 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<HomeFollowingNews> {
|
||||
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<Long, Int>,
|
||||
commentCounts: Map<Long, Int>
|
||||
): 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<Long>): Map<Long, Int> {
|
||||
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<Long>): Map<Long, Int> {
|
||||
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<Long>): 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<Long>): BooleanExpression {
|
||||
val blockMember = QBlockMember("homeFollowingBlockMember")
|
||||
return JPAExpressions
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<CreatorCommunity>(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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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<Long>(), 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user