diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md index 97c43e0d..759ee98b 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md @@ -26,8 +26,13 @@ - 최근 대화는 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`와 `ChatRoomListItemResponse`를 재사용한다. - 최근 소식 타입은 `CREATOR_RANKING`, `CONTENT_RANKING`, `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`를 정의한다. - 이번 범위에서 생성하는 랭킹 소식은 `CREATOR_RANKING`만이다. `CONTENT_RANKING`은 향후 확장용으로 enum/table 값만 예약한다. -- 최근 소식 응답에는 별도 `creatorId`를 내려주지 않는다. 크리에이터 채널 이동이 필요한 `CREATOR_RANKING`은 `targetId`가 크리에이터 회원 id다. -- 최근 소식 랭킹 값은 `rank: Int?`만 사용한다. `rankChange`, `isNew`, nested `ranking` object는 사용하지 않는다. +- 최근 소식 응답 최상위에는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 내려준다. +- 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다. +- `CREATOR_RANKING`은 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다. +- `CONTENT_RANKING`은 `contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다. +- `AUDIO_CONTENT`, `PHOTO_CONTENT`는 각각 `audioContent`/`photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. +- `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다. +- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다. - 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다. - inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다. - 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다. @@ -206,34 +211,94 @@ data class FollowingScheduleResponse( data class FollowingNewsResponse( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: FollowingCreatorRankingNewsResponse?, + val audioContent: FollowingContentNewsResponse?, + val photoContent: FollowingContentNewsResponse?, + val contentRanking: FollowingContentRankingNewsResponse?, + val communityPost: FollowingCommunityPostNewsResponse? ) { companion object { fun from(news: HomeFollowingNews): FollowingNewsResponse { return FollowingNewsResponse( newsId = news.newsId, type = news.type, - creatorProfileImageUrl = news.creatorProfileImageUrl, - creatorNickname = news.creatorNickname, - title = news.title, - body = news.body, - thumbnailImageUrl = news.thumbnailImageUrl, - targetId = news.targetId, - occurredAtUtc = news.occurredAtUtc, visibleFromAtUtc = news.visibleFromAtUtc, - rank = news.rank + creatorRanking = news.creatorRanking?.toResponse(), + audioContent = news.audioContent?.toContentResponse(), + photoContent = news.photoContent?.toContentResponse(), + contentRanking = news.contentRanking?.toResponse(), + communityPost = news.communityPost?.toResponse() ) } } } + +data class FollowingCreatorRankingNewsResponse( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class FollowingContentNewsResponse( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class FollowingContentRankingNewsResponse( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class FollowingCommunityPostNewsResponse( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int +) + +private fun HomeFollowingCreatorRankingNews.toResponse() = FollowingCreatorRankingNewsResponse( + rank = rank, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl +) + +private fun HomeFollowingContentNews.toContentResponse() = FollowingContentNewsResponse( + contentId = contentId, + contentImageUrl = contentImageUrl, + title = title, + creatorProfileImageUrl = creatorProfileImageUrl, + creatorNickname = creatorNickname +) + +private fun HomeFollowingContentRankingNews.toResponse() = FollowingContentRankingNewsResponse( + rank = rank, + contentId = contentId, + contentImageUrl = contentImageUrl, + title = title +) + +private fun HomeFollowingCommunityPostNews.toResponse() = FollowingCommunityPostNewsResponse( + postId = postId, + creatorProfileImage = creatorProfileImage, + creatorNickname = creatorNickname, + imageUrl = imageUrl, + content = content, + createdAt = createdAt, + likeCount = likeCount, + commentCount = commentCount +) ``` --- @@ -283,15 +348,45 @@ data class HomeFollowingSchedule( data class HomeFollowingNews( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: HomeFollowingCreatorRankingNews? = null, + val audioContent: HomeFollowingContentNews? = null, + val photoContent: HomeFollowingContentNews? = null, + val contentRanking: HomeFollowingContentRankingNews? = null, + val communityPost: HomeFollowingCommunityPostNews? = null +) + +data class HomeFollowingCreatorRankingNews( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class HomeFollowingContentNews( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class HomeFollowingContentRankingNews( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class HomeFollowingCommunityPostNews( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int ) enum class FollowingNewsType { @@ -356,7 +451,7 @@ data class HomeFollowingNewsInboxRecord( - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/FollowingNewsType.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` - RED: `HomeFollowingTabResponse.loginRequired()`가 `isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다. - - RED: `FollowingNewsResponse` 변환 결과가 `creatorId` 없이 `rank: Int?`만 포함하는 테스트를 작성한다. + - RED: `FollowingNewsResponse` 변환 결과의 최상위 필드가 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable nested DTO만 포함하는지 테스트를 작성한다. `CREATOR_RANKING`은 `creatorRanking`만 non-null이고 다른 nested DTO는 null이어야 한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인. - GREEN: DTO/domain enum/model을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. @@ -444,7 +539,7 @@ data class HomeFollowingNewsInboxRecord( - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - RED: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다. - - RED: `creatorId`가 응답 domain에 노출되지 않고 `rank`만 nullable로 내려가는 테스트를 작성한다. + - RED: 최근 소식 domain이 타입별 nested DTO를 채우고, `COMMUNITY_POST`/`AUDIO_CONTENT` target 비활성 필터를 유지하는 테스트를 작성한다. - 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인. - GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. @@ -487,7 +582,7 @@ data class HomeFollowingNewsInboxRecord( - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` - - RED: `publishCommunityPostCreated(...)`가 현재 active follower에게만 inbox record를 생성하는 테스트를 작성한다. + - RED: `publishFreeCommunityPostCreated(...)`가 무료 커뮤니티 게시글에 대해서만 현재 active follower에게 inbox record를 생성하고, 유료 커뮤니티 게시글 생성 흐름은 publish service를 호출하지 않는 테스트를 작성한다. - RED: `publishContentUploaded(...)`가 `visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다. - RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인. @@ -524,7 +619,7 @@ data class HomeFollowingNewsInboxRecord( - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` - - RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 `publishCommunityPostCreated(...)`가 post id, creator id, 본문 요약, 생성 시각으로 호출되는 테스트를 작성한다. + - RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 무료 게시글이면 `publishFreeCommunityPostCreated(...)`가 post id, creator id, 본문, 생성 시각으로 호출되고, 유료 게시글이면 호출되지 않는 테스트를 작성한다. - RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다. - RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다. - RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다. @@ -554,12 +649,107 @@ data class HomeFollowingNewsInboxRecord( - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` - RED: 비로그인 호출이 200, `isLoginRequired=true`, 모든 배열 빈 값인지 검증하는 통합 테스트를 작성한다. - RED: 로그인 회원 호출이 팔로잉 크리에이터/On Air/최근 대화/스케줄/최근 소식을 모두 조립하는 통합 테스트를 작성한다. - - RED: `FollowingNewsResponse`에 `creatorId`와 nested `ranking`이 없고 `rank`만 있는지 JSON path 테스트를 작성한다. + - RED: `FollowingNewsResponse` 최상위에 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 없고, 타입과 일치하는 nested DTO만 non-null인지 JSON path 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 통합 미구현 실패 확인. - GREEN: 누락된 wiring, bean 등록, security 설정을 최소 수정한다. - 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인. - REFACTOR: 테스트 데이터 builder가 과하게 커지면 테스트 내부 private helper로만 분리한다. +### Phase 5.5: 최근 소식 응답 계약 nested DTO 변경 후속 구현 + +> 이 Phase는 Phase 1-5 구현 완료 후 변경된 공개 응답 계약을 실제 코드에 반영하기 위한 후속 작업이다. 기존 flat `FollowingNewsResponse`를 제거하고, 타입별 nullable nested DTO 계약과 무료 커뮤니티 게시글 전용 발행 계약을 구현한다. + +#### 작업 의존성 + +1. `Task 5.5.1` DTO/domain 계약 변경과 `Task 5.5.3` 무료 커뮤니티 발행 제한은 서로 독립적으로 시작할 수 있다. +2. `Task 5.5.2` repository enrichment는 `Task 5.5.1`의 domain model 변경 이후 진행한다. +3. `Task 5.5.4` E2E 검증은 `Task 5.5.1`, `Task 5.5.2`, `Task 5.5.3` 완료 후 진행한다. +4. `Task 5.5.5` 회귀 검증은 모든 구현 task 완료 후 진행한다. + +- [x] **Task 5.5.1: FollowingNewsResponse / HomeFollowingNews nested DTO 모델 전환** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt` + - RED: `FollowingNewsResponse` 최상위 JSON에 `newsId`, `type`, `visibleFromAtUtc`, `creatorRanking`, `contentRanking`, `audioContent`, `photoContent`, `communityPost`만 존재하는 테스트를 작성한다. + - RED: 최상위 JSON에 기존 flat 필드인 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 테스트를 작성한다. + - RED: `CREATOR_RANKING`이면 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는 테스트를 작성한다. + - RED: `CONTENT_RANKING`이면 `contentRanking`만 non-null이고 `rank`, `contentId`, `contentImageUrl`, `title`을 포함하는 테스트를 작성한다. + - RED: `AUDIO_CONTENT`이면 `audioContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다. + - RED: `PHOTO_CONTENT`이면 `photoContent`만 non-null이고 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함하며 `releaseDate`가 없음을 검증하는 테스트를 작성한다. + - RED: `COMMUNITY_POST`이면 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는 테스트를 작성한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, 기존 flat DTO/domain 구조로 실패 확인. + - GREEN: `HomeFollowingNews`에 공통 필드 `newsId`, `type`, `visibleFromAtUtc`와 타입별 nullable domain payload를 추가한다. + - GREEN: `FollowingNewsResponse`에 공통 필드와 타입별 nullable response payload를 추가하고, domain payload를 response payload로 1:1 변환한다. + - REFACTOR: 타입별 DTO 변환 helper는 `HomeFollowingTabResponse.kt` 내부 private function 또는 companion object로만 유지하고, sealed class/상속 구조는 도입하지 않는다. + - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.2: 최근 소식 조회 repository 타입별 nested payload enrichment 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` + - RED: `CREATOR_RANKING` row가 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 채우고 다른 nested payload는 null인 테스트를 작성한다. + - RED: `AUDIO_CONTENT` row가 `AudioContent` 원천에서 `contentId`, `contentImageUrl`, `title`, creator profile/nickname을 채우고 공개 시각은 최상위 `visibleFromAtUtc`를 사용하는 테스트를 작성한다. + - RED: `COMMUNITY_POST` row가 무료 `CreatorCommunity` 원천에서 `postId`, `creatorProfileImage`, `creatorNickname`, CDN `imageUrl`, 전체 `content`, UTC `createdAt`, active `likeCount`, active top-level `commentCount`를 채우는 테스트를 작성한다. + - RED: `COMMUNITY_POST` 원천 게시글이 `price > 0`이면 inbox row가 있더라도 최근 소식에서 제외되는 테스트를 작성한다. + - RED: 기존 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target 비활성 제외 테스트가 nested domain 구조에서도 유지되도록 수정한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, flat `HomeFollowingNews` 매핑과 enrichment 미구현으로 실패 확인. + - GREEN: 기존 inbox 후보 조회는 유지하되, 조회된 `targetId`를 타입별로 모아 batch 조회한다. + - GREEN: `AUDIO_CONTENT`는 `audio_content` 원천에서 active row만 조회하고 이미지 URL을 변환해 `audioContent` payload를 만든다. + - GREEN: `COMMUNITY_POST`는 active이고 `price <= 0`인 원천 게시글만 조회하며, active like count와 active top-level comment count를 postIds 기준으로 batch 집계해 `communityPost` payload를 만든다. + - GREEN: `CREATOR_RANKING`은 inbox/creator 정보와 `rank_no`로 `creatorRanking` payload를 만든다. + - GREEN: 아직 생성하지 않는 `CONTENT_RANKING`, `PHOTO_CONTENT`는 domain/DTO 타입만 유지하고, 원천 조회 경로가 없으면 API에 노출하지 않는다. + - REFACTOR: enrichment helper는 `DefaultHomeFollowingQueryRepository.kt` private method로 둔다. 새 port/repository interface는 이 task에서 만들지 않는다. + - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.3: 무료 커뮤니티 게시글만 COMMUNITY_POST 최근 소식 발행** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt` + - RED: 무료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 post id, creator id, creator nickname/profile, 본문, 이미지 path, 생성 시각으로 호출되는 테스트를 작성한다. + - RED: 유료 커뮤니티 게시글 생성 성공 후 `HomeFollowingNewsPublishService`가 호출되지 않는 테스트를 작성한다. + - RED: 무료 커뮤니티 게시글 최근 소식 발행 실패가 원 게시글 생성 성공을 실패로 전파하지 않는 기존 after-commit 격리 테스트를 유지한다. + - RED: `HomeFollowingNewsPublishServiceTest`의 커뮤니티 발행 테스트명/설명을 무료 게시글 발행 계약에 맞게 갱신한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, 유료 게시글도 발행하는 기존 구현으로 실패 확인. + - GREEN: `CreatorCommunityService`에서 `post.price <= 0`인 경우에만 최근 소식 publish를 예약한다. + - GREEN: publish service 메서드명은 구현 시 선택한다. 이름을 `publishFreeCommunityPostCreated(...)`로 바꾸면 모든 호출부/테스트를 함께 갱신하고, 기존 이름을 유지하면 호출 조건으로 무료 게시글 전용 계약을 보장한다. + - REFACTOR: 유료 게시글 미리보기 마스킹 로직이 최근 소식 발행에서 더 이상 쓰이지 않으면 제거하되, 커뮤니티 탭/상세의 유료 콘텐츠 정책은 건드리지 않는다. + - 통과 확인: 위 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.4: 팔로잉 탭 API E2E nested response 계약 검증** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` + - RED: 로그인 회원 API 응답에 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST` 최근 소식 fixture를 포함한다. + - RED: 각 recentNews item의 최상위 flat 필드 `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `rank`가 존재하지 않는 JSON path 테스트를 작성한다. + - RED: `CREATOR_RANKING` item은 `creatorRanking`만 non-null이고 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함하는지 검증한다. + - RED: `AUDIO_CONTENT` item은 `audioContent`만 non-null이고 `releaseDate`가 없으며 최상위 `visibleFromAtUtc`가 존재하는지 검증한다. + - RED: `COMMUNITY_POST` item은 `communityPost`만 non-null이고 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 포함하는지 검증한다. + - RED: 유료 커뮤니티 게시글은 최근 소식 응답에 노출되지 않는지 검증한다. + - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행, 기존 flat API 응답으로 실패 확인. + - GREEN: Task 5.5.1-5.5.3 구현으로 대부분 통과해야 한다. wiring 누락이 있으면 관련 production file만 최소 수정한다. + - REFACTOR: E2E fixture helper는 테스트 파일 내부 private helper로만 분리한다. + - 통과 확인: 위 단일 테스트 명령 재실행, PASS 확인. + +- [x] **Task 5.5.5: Phase 5.5 문서/회귀 검증 기록** + - Files: + - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` + - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` + - Verify: `docs/20260629_커뮤니티_게시글_좋아요_상태/prd.md` + - Verify: Phase 5.5에서 변경한 Kotlin source/test + - TDD 예외 사유: 문서/회귀 검증 작업이며 신규 production behavior를 만들지 않는다. + - 대체 검증 방법: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` + - `./gradlew --no-daemon test` + - `./gradlew --no-daemon ktlintCheck` + - 문서 검증 grep: + - `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API` + - 검색 결과가 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼/과거 검증 기록 맥락인지 확인하고, stale flat `FollowingNewsResponse` 공개 계약이면 수정한다. + - 검증 결과 기록: Phase 5.5 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 `## 6. 검증 기록`에 한국어로 누적 기록한다. + ### Phase 6: 문서/회귀 검증 - [x] **Task 6.1: 문서 동기화 확인** @@ -568,7 +758,7 @@ data class HomeFollowingNewsInboxRecord( - Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql` - Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md` - TDD 예외 사유: 문서 검증 작업이며 실행 코드가 없다. - - 대체 검증 방법: `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`로 삭제된 공개 응답 필드가 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄의 `creatorId`와 DDL 내부 컬럼 `creator_id`는 허용한다. + - 대체 검증 방법: `rg -n "creatorProfileImageUrl|creatorNickname|thumbnailImageUrl|targetId|occurredAtUtc|rank: Int\\?|publishCommunityPostCreated|유료 커뮤니티 최근 소식 미리보기" docs/20260625_메인_홈_팔로잉_탭_API`로 최근 소식 flat 공개 응답 필드와 유료 커뮤니티 발행 계약이 남아 있는지 확인한다. 단, 팔로잉 크리에이터/스케줄/타입별 nested DTO/내부 inbox 컬럼 맥락은 허용한다. - 실행 명령: `./gradlew tasks --all` - 기대 결과: `BUILD SUCCESSFUL` @@ -591,7 +781,8 @@ data class HomeFollowingNewsInboxRecord( 3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다. 4. query service와 facade에서 섹션을 조립한다. 5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다. -6. End-to-End 테스트와 전체 회귀 검증을 수행한다. +6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다. +7. End-to-End 테스트와 전체 회귀 검증을 수행한다. --- @@ -622,7 +813,7 @@ data class HomeFollowingNewsInboxRecord( - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - 2026-06-26 Phase 3-5 리뷰 보완 검증: - - 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 유료 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다. + - 리뷰 지적 사항에 따라 팔로잉 탭 조회의 크리에이터 role 필터, 오디오 공개 시각 판정, 커뮤니티 최근 소식 미리보기 마스킹, 최근 소식 발행 `REQUIRES_NEW` 트랜잭션, inbox `title/body` 길이 정규화를 보강했다. 이후 계약 변경으로 `COMMUNITY_POST` 최근 소식은 무료 게시글만 발행하도록 재정의했다. - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 reviewer 보완 전 7개 regression 테스트 실패를 확인했다. - 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. - Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`. @@ -650,3 +841,21 @@ data class HomeFollowingNewsInboxRecord( - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`. - `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. + +- 2026-06-30 Phase 5.5 구현 검증: + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 nested domain/DTO 미구현 컴파일 오류로 `BUILD FAILED`. + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 원천 payload enrichment와 유료 커뮤니티 제외 미구현으로 `BUILD FAILED`. + - RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 유료 커뮤니티 게시글도 최근 소식을 발행해 `BUILD FAILED`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldPopulateRecentNewsNestedPayloadsFromSourceTargets" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest.shouldExcludePaidCommunityPostRecentNews"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldPublishNewsAfterCommunityPostCreated" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldNotPublishNewsAfterPaidCommunityPostCreated"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember"` 실행 결과 `BUILD SUCCESSFUL`. + - Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon ktlintCheck` 실행 결과 import 정렬 위반 2건을 수정한 뒤 재실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완: `CREATOR_RANKING`은 `rank`가 있는 row만 노출하도록 수정했고, `AUDIO_CONTENT`/`COMMUNITY_POST` 최근 소식은 현재 원천 target의 성인 상태를 함께 반영하도록 보강했다. + - 리뷰 보완: `HomeFollowingEndToEndTest`가 `CREATOR_RANKING`, `AUDIO_CONTENT`, 무료 `COMMUNITY_POST`, 유료 커뮤니티 미노출 JSON surface를 모두 검증하도록 fixture/assertion을 확장했다. + - 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완 후 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완 후 Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. + - 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. + - 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse`의 `releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md index 4a9d99a7..98539465 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md @@ -175,22 +175,22 @@ - API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다. - 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다. - 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다. -- 응답 필드는 `newsId`, `type`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `body`, `thumbnailImageUrl`, `targetId`, `occurredAtUtc`, `visibleFromAtUtc`, `rank`를 포함한다. -- 응답에는 `creatorId`를 별도 필드로 내려주지 않는다. -- `CREATOR_RANKING` 터치 액션은 해당 크리에이터 채널 이동이므로 `targetId`는 크리에이터 회원 id다. -- `CONTENT_RANKING` 터치 액션은 향후 콘텐츠 상세 이동이므로 `targetId`는 콘텐츠 id로 정의한다. -- `COMMUNITY_POST` 터치 액션은 게시글 상세 이동이므로 `targetId`는 커뮤니티 게시글 id다. -- `AUDIO_CONTENT` 터치 액션은 오디오 상세 이동이므로 `targetId`는 오디오 콘텐츠 id다. -- `PHOTO_CONTENT` 터치 액션은 향후 화보 상세 이동이므로 `targetId`는 화보 콘텐츠 id로 정의한다. -- 화면의 상대 시간 표시는 `visibleFromAtUtc` 기준을 기본으로 한다. -- 커뮤니티 게시글 업로드 소식의 `occurredAtUtc`와 `visibleFromAtUtc`는 게시글 생성 시각을 기본값으로 한다. -- 오디오 콘텐츠 업로드 소식의 `occurredAtUtc`는 콘텐츠 업로드 또는 공개 예약 생성 시각, `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다. -- 즉시 공개 콘텐츠는 `visibleFromAtUtc = occurredAtUtc`로 저장할 수 있다. +- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다. +- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다. +- `CREATOR_RANKING`은 `creatorRanking`에 `rank`, `creatorId`, `nickname`, `profileImageUrl`을 포함한다. +- `CONTENT_RANKING`은 `contentRanking`에 `rank`, `contentId`, `contentImageUrl`, `title`을 포함한다. +- `AUDIO_CONTENT`는 `audioContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. +- `PHOTO_CONTENT`는 `photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 포함한다. 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다. +- `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 포함한다. `imageUrl`은 nullable이고 `createdAt` timezone은 UTC다. +- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 팔로잉 최근 소식 inbox row를 생성하지 않는다. +- 타입별 터치 액션 target은 각 nested DTO의 id를 사용한다. `creatorRanking.creatorId`, `contentRanking.contentId`, `audioContent.contentId`, `photoContent.contentId`, `communityPost.postId`가 이동 대상 id다. +- 화면의 상대 시간 표시는 최상위 `visibleFromAtUtc` 기준을 기본으로 한다. +- 커뮤니티 게시글 업로드 소식의 `visibleFromAtUtc`와 `communityPost.createdAt`은 게시글 생성 시각을 기본값으로 한다. +- 오디오/화보 콘텐츠 업로드 소식의 `visibleFromAtUtc`는 콘텐츠 공개 시각을 기본값으로 한다. +- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다. - 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다. - 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다. -- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. -- 랭킹 소식은 이번에 몇 위에 올랐는지를 나타내는 `rank`를 내려준다. -- `COMMUNITY_POST`, `AUDIO_CONTENT`, `PHOTO_CONTENT`의 `rank`는 `null`로 내려준다. +- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다. #### Edge Cases - inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다. @@ -198,7 +198,8 @@ - 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다. - 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다. - 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다. -- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl`은 `null`로 내려준다. +- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다. +- 무료 커뮤니티 게시글이 생성되더라도 조회 시 원천 게시글이 비활성화되었거나 성인/차단 정책에 의해 제외되면 `COMMUNITY_POST` 최근 소식은 노출하지 않는다. ### Feature G. Response 재사용 정책 @@ -275,15 +276,45 @@ data class FollowingScheduleResponse( data class FollowingNewsResponse( val newsId: String, val type: FollowingNewsType, - val creatorProfileImageUrl: String, - val creatorNickname: String, - val title: String, - val body: String, - val thumbnailImageUrl: String?, - val targetId: Long, - val occurredAtUtc: String, val visibleFromAtUtc: String, - val rank: Int? + val creatorRanking: FollowingCreatorRankingNewsResponse?, + val audioContent: FollowingContentNewsResponse?, + val photoContent: FollowingContentNewsResponse?, + val contentRanking: FollowingContentRankingNewsResponse?, + val communityPost: FollowingCommunityPostNewsResponse? +) + +data class FollowingCreatorRankingNewsResponse( + val rank: Int, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String +) + +data class FollowingContentNewsResponse( + val contentId: Long, + val contentImageUrl: String?, + val title: String, + val creatorProfileImageUrl: String, + val creatorNickname: String +) + +data class FollowingContentRankingNewsResponse( + val rank: Int, + val contentId: Long, + val contentImageUrl: String?, + val title: String +) + +data class FollowingCommunityPostNewsResponse( + val postId: Long, + val creatorProfileImage: String, + val creatorNickname: String, + val imageUrl: String?, + val content: String, + val createdAt: String, + val likeCount: Int, + val commentCount: Int ) enum class FollowingNewsType { @@ -297,7 +328,7 @@ enum class FollowingNewsType { ``` - `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다. -- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. +- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다. --- @@ -332,11 +363,11 @@ enum class FollowingNewsType { - MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다. - inbox는 사용자별 소식 저장소다. - inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다. -- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다. +- 콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다. 커뮤니티 업로드 소식은 무료 커뮤니티 게시글 생성 이벤트에서만 현재 follower 회원별로 적재한다. - 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다. - 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다. - 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다. -- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. +- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다. - 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다. - `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다. - `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.