docs(home): 팔로잉 최근 소식 계약을 갱신한다
This commit is contained in:
@@ -26,8 +26,13 @@
|
|||||||
- 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`와 `ChatRoomListItemResponse`를 재사용한다.
|
- 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`와 `ChatRoomListItemResponse`를 재사용한다.
|
||||||
- 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다.
|
- 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다.
|
||||||
- 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다.
|
- 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다.
|
||||||
- 최근 소식 응답에는 별도 `creatorId`를 내려주지 않는다. 크리에이터 채널 이동이 필요한 `CREATOR_RANKING`은 `targetId`가 크리에이터 회원 id다.
|
- 최근 소식 응답 최상위에는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 내려준다.
|
||||||
- 최근 소식 랭킹 값은 `rank: Int?`만 사용한다. `rankChange`, `isNew`, nested `ranking` object는 사용하지 않는다.
|
- 최근 소식 상세 값은 타입별 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 table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다.
|
||||||
- inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다.
|
- inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다.
|
||||||
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다.
|
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다.
|
||||||
@@ -206,34 +211,94 @@ data class FollowingScheduleResponse(
|
|||||||
data class FollowingNewsResponse(
|
data class FollowingNewsResponse(
|
||||||
val newsId: String,
|
val newsId: String,
|
||||||
val type: FollowingNewsType,
|
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 visibleFromAtUtc: String,
|
||||||
val rank: Int?
|
val creatorRanking: FollowingCreatorRankingNewsResponse?,
|
||||||
|
val audioContent: FollowingContentNewsResponse?,
|
||||||
|
val photoContent: FollowingContentNewsResponse?,
|
||||||
|
val contentRanking: FollowingContentRankingNewsResponse?,
|
||||||
|
val communityPost: FollowingCommunityPostNewsResponse?
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(news: HomeFollowingNews): FollowingNewsResponse {
|
fun from(news: HomeFollowingNews): FollowingNewsResponse {
|
||||||
return FollowingNewsResponse(
|
return FollowingNewsResponse(
|
||||||
newsId = news.newsId,
|
newsId = news.newsId,
|
||||||
type = news.type,
|
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,
|
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(
|
data class HomeFollowingNews(
|
||||||
val newsId: String,
|
val newsId: String,
|
||||||
val type: FollowingNewsType,
|
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 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 {
|
enum class FollowingNewsType {
|
||||||
@@ -356,7 +451,7 @@ data class HomeFollowingNewsInboxRecord(
|
|||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt`
|
- 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`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
|
||||||
- RED: `HomeFollowingTabResponse.loginRequired()`가 `isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다.
|
- 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 미구현으로 실패 확인.
|
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인.
|
||||||
- GREEN: DTO/domain enum/model을 최소 구현한다.
|
- GREEN: DTO/domain enum/model을 최소 구현한다.
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||||
@@ -444,7 +539,7 @@ data class HomeFollowingNewsInboxRecord(
|
|||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.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: `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 미구현 실패 확인.
|
- 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인.
|
||||||
- GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다.
|
- GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다.
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||||
@@ -487,7 +582,7 @@ data class HomeFollowingNewsInboxRecord(
|
|||||||
- Files:
|
- Files:
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
|
- 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`
|
- 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: `publishContentUploaded(...)`가 `visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다.
|
||||||
- RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다.
|
- RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다.
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인.
|
- 실패 확인: `./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`
|
- 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/content/AudioContentServiceTest.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.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.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다.
|
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다.
|
||||||
- RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `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`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||||
- RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다.
|
- RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다.
|
||||||
- RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다.
|
- 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"` 실행, 통합 미구현 실패 확인.
|
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인.
|
||||||
- GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다.
|
- GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다.
|
||||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||||
- REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다.
|
- 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: 문서/회귀 검증
|
### Phase 6: 문서/회귀 검증
|
||||||
|
|
||||||
- [x] **Task 6.1: 문서 동기화 확인**
|
- [x] **Task 6.1: 문서 동기화 확인**
|
||||||
@@ -568,7 +758,7 @@ data class HomeFollowingNewsInboxRecord(
|
|||||||
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
||||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
||||||
- TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다.
|
- 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`
|
- 실행 명령: `./gradlew tasks --all`
|
||||||
- 기대 결과: `BUILD SUCCESSFUL`
|
- 기대 결과: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
@@ -591,7 +781,8 @@ data class HomeFollowingNewsInboxRecord(
|
|||||||
3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다.
|
3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다.
|
||||||
4. query service와 facade에서 섹션을 조립한다.
|
4. query service와 facade에서 섹션을 조립한다.
|
||||||
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
|
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.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `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 리뷰 보완 검증:
|
- 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 테스트 실패를 확인했다.
|
- 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`.
|
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||||
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||||
@@ -650,3 +841,21 @@ data class HomeFollowingNewsInboxRecord(
|
|||||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||||
- `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
- `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
||||||
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `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를 최신순으로 조회한다.
|
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
|
||||||
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
|
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
|
||||||
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
|
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
|
||||||
- 응답 필드는 `newsId`, `type`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `visibleFromAtUtc`, `rank`를 포함한다.
|
- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다.
|
||||||
- 응답에는 `creatorId`를 별도 필드로 내려주지 않는다.
|
- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
|
||||||
- `CREATOR_RANKING` 터치 액션은 해당 크리에이터 채널 이동이므로 `targetId`는 크리에이터 회원 id다.
|
- `CREATOR_RANKING`은 `creatorRanking`에 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함한다.
|
||||||
- `CONTENT_RANKING` 터치 액션은 향후 콘텐츠 상세 이동이므로 `targetId`는 콘텐츠 id로 정의한다.
|
- `CONTENT_RANKING`은 `contentRanking`에 `rank`, `contentId`, `contentImageUrl`, `title`을 포함한다.
|
||||||
- `COMMUNITY_POST` 터치 액션은 게시글 상세 이동이므로 `targetId`는 커뮤니티 게시글 id다.
|
- `AUDIO_CONTENT`는 `audioContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
|
||||||
- `AUDIO_CONTENT` 터치 액션은 오디오 상세 이동이므로 `targetId`는 오디오 콘텐츠 id다.
|
- `PHOTO_CONTENT`는 `photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
|
||||||
- `PHOTO_CONTENT` 터치 액션은 향후 화보 상세 이동이므로 `targetId`는 화보 콘텐츠 id로 정의한다.
|
- `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 포함한다. `imageUrl`은 nullable이고 `createdAt` timezone은 UTC다.
|
||||||
- 화면의 상대 시간 표시는 `visibleFromAtUtc` 기준을 기본으로 한다.
|
- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 팔로잉 최근 소식 inbox row를 생성하지 않는다.
|
||||||
- 커뮤니티 게시글 업로드 소식의 `occurredAtUtc`와 `visibleFromAtUtc`는 게시글 생성 시각을 기본값으로 한다.
|
- 타입별 터치 액션 target은 각 nested DTO의 id를 사용한다. `creatorRanking.creatorId`, `contentRanking.contentId`, `audioContent.contentId`, `photoContent.contentId`, `communityPost.postId`가 이동 대상 id다.
|
||||||
- 오디오 콘텐츠 업로드 소식의 `occurredAtUtc`는 콘텐츠 업로드 또는 공개 예약 생성 시각, `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다.
|
- 화면의 상대 시간 표시는 최상위 `visibleFromAtUtc` 기준을 기본으로 한다.
|
||||||
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = occurredAtUtc`로 저장할 수 있다.
|
- 커뮤니티 게시글 업로드 소식의 `visibleFromAtUtc`와 `communityPost.createdAt`은 게시글 생성 시각을 기본값으로 한다.
|
||||||
|
- 오디오/화보 콘텐츠 업로드 소식의 `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다.
|
||||||
|
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다.
|
||||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
|
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
|
||||||
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
|
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
|
||||||
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다.
|
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다.
|
||||||
- 랭킹 소식은 이번에 몇 위에 올랐는지를 나타내는 `rank`를 내려준다.
|
|
||||||
- `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`의 `rank`는 `null`로 내려준다.
|
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
|
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
|
||||||
@@ -198,7 +198,8 @@
|
|||||||
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
|
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
|
||||||
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
|
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
|
||||||
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
|
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
|
||||||
- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl`은 `null`로 내려준다.
|
- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다.
|
||||||
|
- 무료 커뮤니티 게시글이 생성되더라도 조회 시 원천 게시글이 비활성화되었거나 성인/차단 정책에 의해 제외되면 `COMMUNITY_POST` 최근 소식은 노출하지 않는다.
|
||||||
|
|
||||||
### Feature G. Response 재사용 정책
|
### Feature G. Response 재사용 정책
|
||||||
|
|
||||||
@@ -275,15 +276,45 @@ data class FollowingScheduleResponse(
|
|||||||
data class FollowingNewsResponse(
|
data class FollowingNewsResponse(
|
||||||
val newsId: String,
|
val newsId: String,
|
||||||
val type: FollowingNewsType,
|
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 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 {
|
enum class FollowingNewsType {
|
||||||
@@ -297,7 +328,7 @@ enum class FollowingNewsType {
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
|
- `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`에 기록한다.
|
- MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다.
|
||||||
- inbox는 사용자별 소식 저장소다.
|
- inbox는 사용자별 소식 저장소다.
|
||||||
- inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다.
|
- inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다.
|
||||||
- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다.
|
- 콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다. 커뮤니티 업로드 소식은 무료 커뮤니티 게시글 생성 이벤트에서만 현재 follower 회원별로 적재한다.
|
||||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다.
|
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다.
|
||||||
- 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다.
|
- 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다.
|
||||||
- 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다.
|
- 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다.
|
||||||
- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다.
|
- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다.
|
||||||
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
|
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
|
||||||
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
|
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
|
||||||
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
|
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user