docs(home-following): 팔로잉 탭 API 계획을 추가한다
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
-- MySQL 메인 홈 팔로잉 탭 최근 소식 inbox 테이블
|
||||
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
|
||||
|
||||
create table home_following_news_inbox (
|
||||
id bigint not null auto_increment comment '팔로잉 최근 소식 inbox ID',
|
||||
member_id bigint not null comment '수신 회원 ID(member.id)',
|
||||
creator_id bigint not null comment '소식 발신 크리에이터 회원 ID(member.id)',
|
||||
news_type varchar(30) not null comment '소식 타입(CREATOR_RANKING, CONTENT_RANKING, COMMUNITY_POST, AUDIO_CONTENT, PHOTO_CONTENT)',
|
||||
source_key varchar(200) not null comment '중복 방지용 원천 소식 식별자',
|
||||
target_id bigint not null comment '터치 액션 대상 ID',
|
||||
occurred_at_utc timestamp not null comment '소식 발생 시각(UTC)',
|
||||
visible_from_at_utc timestamp not null comment '소식 노출 시작 시각(UTC)',
|
||||
creator_nickname varchar(100) not null comment '소식 생성 시점 크리에이터 닉네임',
|
||||
creator_profile_image_path varchar(500) null comment '소식 생성 시점 크리에이터 프로필 이미지 path',
|
||||
title varchar(255) not null comment '소식 제목',
|
||||
body varchar(1000) not null comment '소식 본문',
|
||||
thumbnail_image_path varchar(500) null comment '소식 썸네일 이미지 path',
|
||||
rank_no int null comment '랭킹 소식 순위',
|
||||
is_adult tinyint(1) not null default 0 comment '성인 콘텐츠 또는 성인 소식 여부',
|
||||
is_active tinyint(1) not null default 1 comment '활성 여부',
|
||||
created_at timestamp not null default current_timestamp comment '생성 시각',
|
||||
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
|
||||
primary key (id)
|
||||
) engine=InnoDB default charset=utf8mb4 comment='메인 홈 팔로잉 탭 사용자별 최근 소식 inbox';
|
||||
|
||||
create unique index uk_home_following_news_inbox_member_type_source
|
||||
on home_following_news_inbox (member_id, news_type, source_key);
|
||||
|
||||
create index idx_home_following_news_inbox_member_visible
|
||||
on home_following_news_inbox (member_id, is_active, visible_from_at_utc desc, id desc);
|
||||
|
||||
create index idx_home_following_news_inbox_member_creator_active
|
||||
on home_following_news_inbox (member_id, creator_id, is_active);
|
||||
|
||||
create index idx_home_following_news_inbox_creator_type_source
|
||||
on home_following_news_inbox (creator_id, news_type, source_key);
|
||||
591
docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md
Normal file
591
docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# 메인 홈 팔로잉 탭 API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** `GET /api/v2/home/following`으로 메인 홈 팔로잉 탭의 팔로잉 크리에이터, On Air, 최근 대화, 이달의 스케줄, 최근 소식을 조회한다.
|
||||
|
||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.home.following` 조립 계층에 둔다. 팔로잉 탭 조회 service, 최근 소식 publish service, domain model, port, QueryDSL/JPA repository는 `kr.co.vividnext.sodalive.v2.home.following` 하위에 두고 `v2.api.*`에 의존하지 않는다. 최근 소식은 별도 inbox table에 사용자별 row를 저장하고, 이번 범위에서는 외부 MQ/outbox/worker 없이 내부 publish service에서 follower 조회와 bulk insert를 수행한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, MySQL, JUnit 5, MockMvc, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 확정 사항
|
||||
|
||||
- API endpoint: `GET /api/v2/home/following`
|
||||
- 인증 정책: 비로그인 조회 허용. 비로그인 응답은 `isLoginRequired = true`와 빈 섹션 배열을 내려준다.
|
||||
- 로그인 회원 응답은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다.
|
||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
||||
- 섹션별 기본 노출 수:
|
||||
- `followingCreators`: 최신 팔로우순 20개
|
||||
- `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개
|
||||
- `recentChats`: DM/AI 채팅 최신순 10개
|
||||
- `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개
|
||||
- `recentNews`: `visibleFromAtUtc desc`, `newsId desc` 기준 30개
|
||||
- 최근 대화는 기존 `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는 사용하지 않는다.
|
||||
- 최근 소식 inbox table DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`을 기준으로 한다.
|
||||
- inbox 중복 방지는 `memberId`, `newsType`, `sourceKey` 기준 unique 정책으로 보장한다.
|
||||
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다. 재팔로우 시 기존 비활성 row는 복구하지 않는다.
|
||||
- 이미지 URL은 기존 `v2.common.domain.CdnUrlExtensions.toCdnUrl` 패턴을 따른다.
|
||||
- UTC 문자열 변환은 기존 `toUtcIso` 패턴을 따른다.
|
||||
- 성인 콘텐츠 노출 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)`를 사용한다.
|
||||
- 차단 관계가 있는 크리에이터의 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 노출하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### 신규 API 조립 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponseTest.kt`
|
||||
|
||||
### 신규 도메인 조회 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt`
|
||||
- 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/HomeFollowingNewsSourceKey.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
|
||||
- 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/port/out/HomeFollowingQueryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.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/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
||||
|
||||
### 기존 파일 수정
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTask.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/scheduler/AudioContentReleaseScheduledTaskTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
|
||||
|
||||
### 문서/DDL
|
||||
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
|
||||
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
||||
|
||||
---
|
||||
|
||||
## 2. Response data class 초안
|
||||
|
||||
`src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`에는 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 필드 계약을 바꾸는 작업은 먼저 PRD와 이 문서를 갱신한 뒤 별도 변경으로 처리한다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.api.home.following.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
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.HomeFollowingCreator
|
||||
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
|
||||
|
||||
data class HomeFollowingTabResponse(
|
||||
@JsonProperty("isLoginRequired")
|
||||
val isLoginRequired: Boolean,
|
||||
val followingCreators: List<FollowingCreatorResponse>,
|
||||
val onAirLives: List<FollowingLiveResponse>,
|
||||
val recentChats: List<ChatRoomListItemResponse>,
|
||||
val monthlySchedules: List<FollowingScheduleResponse>,
|
||||
val recentNews: List<FollowingNewsResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun loginRequired(): HomeFollowingTabResponse {
|
||||
return HomeFollowingTabResponse(
|
||||
isLoginRequired = true,
|
||||
followingCreators = emptyList(),
|
||||
onAirLives = emptyList(),
|
||||
recentChats = emptyList(),
|
||||
monthlySchedules = emptyList(),
|
||||
recentNews = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
fun from(home: HomeFollowing): HomeFollowingTabResponse {
|
||||
return HomeFollowingTabResponse(
|
||||
isLoginRequired = false,
|
||||
followingCreators = home.followingCreators.map(FollowingCreatorResponse::from),
|
||||
onAirLives = home.onAirLives.map(FollowingLiveResponse::from),
|
||||
recentChats = home.recentChats,
|
||||
monthlySchedules = home.monthlySchedules.map(FollowingScheduleResponse::from),
|
||||
recentNews = home.recentNews.map(FollowingNewsResponse::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FollowingCreatorResponse(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImageUrl: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(creator: HomeFollowingCreator): FollowingCreatorResponse {
|
||||
return FollowingCreatorResponse(
|
||||
creatorId = creator.creatorId,
|
||||
creatorNickname = creator.creatorNickname,
|
||||
creatorProfileImageUrl = creator.creatorProfileImageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FollowingLiveResponse(
|
||||
val liveId: Long,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val title: String,
|
||||
val startedAtUtc: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(live: HomeFollowingLive): FollowingLiveResponse {
|
||||
return FollowingLiveResponse(
|
||||
liveId = live.liveId,
|
||||
creatorProfileImageUrl = live.creatorProfileImageUrl,
|
||||
creatorNickname = live.creatorNickname,
|
||||
title = live.title,
|
||||
startedAtUtc = live.startedAtUtc
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FollowingScheduleResponse(
|
||||
val scheduleId: String,
|
||||
val creatorId: Long,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val title: String,
|
||||
val type: CreatorActivityType,
|
||||
val targetId: Long,
|
||||
val scheduledAtUtc: String,
|
||||
@JsonProperty("isOnAir")
|
||||
val isOnAir: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(schedule: HomeFollowingSchedule): FollowingScheduleResponse {
|
||||
return FollowingScheduleResponse(
|
||||
scheduleId = schedule.scheduleId,
|
||||
creatorId = schedule.creatorId,
|
||||
creatorProfileImageUrl = schedule.creatorProfileImageUrl,
|
||||
creatorNickname = schedule.creatorNickname,
|
||||
title = schedule.title,
|
||||
type = schedule.type,
|
||||
targetId = schedule.targetId,
|
||||
scheduledAtUtc = schedule.scheduledAtUtc,
|
||||
isOnAir = schedule.isOnAir
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Domain / Port 초안
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.home.following.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||
|
||||
data class HomeFollowing(
|
||||
val followingCreators: List<HomeFollowingCreator>,
|
||||
val onAirLives: List<HomeFollowingLive>,
|
||||
val recentChats: List<ChatRoomListItemResponse>,
|
||||
val monthlySchedules: List<HomeFollowingSchedule>,
|
||||
val recentNews: List<HomeFollowingNews>
|
||||
)
|
||||
|
||||
data class HomeFollowingCreator(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImageUrl: String
|
||||
)
|
||||
|
||||
data class HomeFollowingLive(
|
||||
val liveId: Long,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val title: String,
|
||||
val startedAtUtc: String
|
||||
)
|
||||
|
||||
data class HomeFollowingSchedule(
|
||||
val scheduleId: String,
|
||||
val creatorId: Long,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val title: String,
|
||||
val type: CreatorActivityType,
|
||||
val targetId: Long,
|
||||
val scheduledAtUtc: String,
|
||||
val isOnAir: Boolean
|
||||
)
|
||||
|
||||
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?
|
||||
)
|
||||
|
||||
enum class FollowingNewsType {
|
||||
CREATOR_RANKING,
|
||||
CONTENT_RANKING,
|
||||
COMMUNITY_POST,
|
||||
AUDIO_CONTENT,
|
||||
PHOTO_CONTENT
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.home.following.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
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
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface HomeFollowingQueryPort {
|
||||
fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator>
|
||||
fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
|
||||
fun findMonthlySchedules(memberId: Long, canViewAdultContent: Boolean, now: LocalDateTime, limit: Int): List<HomeFollowingSchedule>
|
||||
fun findRecentNews(memberId: Long, canViewAdultContent: Boolean, nowUtc: LocalDateTime, limit: Int): List<HomeFollowingNews>
|
||||
}
|
||||
|
||||
interface HomeFollowingNewsInboxPort {
|
||||
fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int
|
||||
fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long
|
||||
fun findActiveFollowerIds(creatorId: Long): List<Long>
|
||||
}
|
||||
|
||||
data class HomeFollowingNewsInboxRecord(
|
||||
val memberId: Long,
|
||||
val creatorId: Long,
|
||||
val newsType: String,
|
||||
val sourceKey: String,
|
||||
val targetId: Long,
|
||||
val occurredAtUtc: LocalDateTime,
|
||||
val visibleFromAtUtc: LocalDateTime,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImagePath: String?,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val thumbnailImagePath: String?,
|
||||
val rank: Int?,
|
||||
val isAdult: Boolean
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase / Task 계획
|
||||
|
||||
### Phase 1: 응답 DTO, 도메인 모델, Security 기본 골격
|
||||
|
||||
- [ ] **Task 1.1: 팔로잉 탭 응답 DTO와 domain model 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/dto/HomeFollowingTabResponse.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.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`
|
||||
- RED: `HomeFollowingTabResponse.loginRequired()`가 `isLoginRequired=true`와 빈 배열을 반환하는 테스트를 작성한다.
|
||||
- RED: `FollowingNewsResponse` 변환 결과가 `creatorId` 없이 `rank: Int?`만 포함하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest"` 실행, DTO 미구현으로 실패 확인.
|
||||
- GREEN: DTO/domain enum/model을 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: import, `JsonProperty`, nullable 필드 정리 후 `./gradlew --no-daemon ktlintCheck` 실행.
|
||||
|
||||
- [ ] **Task 1.2: Controller와 Security permitAll 추가**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt`
|
||||
- RED: 비로그인 `GET /api/v2/home/following`이 200과 `isLoginRequired=true`를 반환하는 MockMvc 테스트를 작성한다.
|
||||
- RED: 로그인 회원 요청이 facade를 호출하고 `isLoginRequired=false` 응답을 반환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행, endpoint 미구현 또는 security 미설정 실패 확인.
|
||||
- GREEN: controller, facade 빈 골격, `SecurityConfig` permitAll을 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 기존 `HomeRecommendationController`의 `@AuthenticationPrincipal` 패턴과 응답 wrapper 스타일에 맞춘다.
|
||||
|
||||
### Phase 2: 최근 소식 Inbox 저장소
|
||||
|
||||
- [ ] **Task 2.1: Inbox Entity/JPA repository/DDL 정합성 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInbox.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
|
||||
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
||||
- RED: 같은 `memberId/newsType/sourceKey` 중복 저장이 1건만 유지되어야 하는 테스트를 작성한다.
|
||||
- RED: `memberId/creatorId` 기준 활성 row 비활성화가 동작하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, entity/repository 미구현 실패 확인.
|
||||
- GREEN: Entity와 JPA repository를 DDL 컬럼명에 맞춰 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 컬럼명, enum 저장 방식, timestamp nullable 정책이 DDL과 맞는지 비교한다.
|
||||
|
||||
- [ ] **Task 2.2: Inbox persistence adapter 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
||||
- RED: `insertIgnoreAll(records)`가 중복 source key를 예외 없이 무시하고 신규 row만 저장하는 테스트를 작성한다.
|
||||
- RED: `findActiveFollowerIds(creatorId)`가 활성 팔로워만 반환하는 테스트를 작성한다.
|
||||
- 실패 확인: Task 2.1과 같은 단일 테스트 명령 실행, port/adapter 미구현 실패 확인.
|
||||
- GREEN: MySQL `INSERT IGNORE` 기반 bulk 저장으로 unique 충돌을 무시하는 idempotent 저장을 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: batch insert가 불필요하게 N+1 flush를 만들지 않도록 adapter 내부를 정리한다.
|
||||
|
||||
### Phase 3: 팔로잉 탭 조회 Repository/Service
|
||||
|
||||
- [ ] **Task 3.1: 팔로잉 크리에이터 조회**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
|
||||
- Create: `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: 활성 팔로우/활성 크리에이터만 최신 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다.
|
||||
- RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인.
|
||||
- GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 기본 프로필 이미지와 CDN 변환 책임은 service/facade 중 기존 패턴과 맞는 위치로 정리한다.
|
||||
|
||||
- [ ] **Task 3.2: On Air 조회**
|
||||
- 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: 팔로우한 크리에이터의 `live_room.is_active=true`, `channel_name` 존재 라이브만 `beginDateTime desc, id desc`로 10개 조회하는 테스트를 작성한다.
|
||||
- RED: 성인 콘텐츠 노출 불가이면 19금 라이브가 제외되는 테스트를 작성한다.
|
||||
- 실패 확인: Task 3.1의 repository 단일 테스트 명령 실행, On Air 미구현 실패 확인.
|
||||
- GREEN: 기존 `DefaultHomeRecommendationQueryRepository.findLiveRecommendations(...)` 조건을 팔로잉 필터로 확장해 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 라이브 진행 중 판단 조건이 스케줄 `isOnAir`와 중복되면 private helper로 추출한다.
|
||||
|
||||
- [ ] **Task 3.3: 이달의 스케줄 조회**
|
||||
- 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: KST 오늘 00:00 이상 다음 달 00:00 미만의 라이브/오디오 일정을 `scheduledAt asc`로 3개 조회하는 테스트를 작성한다.
|
||||
- RED: 오늘 이전 일정과 차단 크리에이터 일정이 제외되는 테스트를 작성한다.
|
||||
- 실패 확인: repository 단일 테스트 명령 실행, schedule 미구현 실패 확인.
|
||||
- GREEN: 기존 `CreatorChannelHomeQueryRepository.findSchedules(...)`의 live/audio 조건을 팔로잉 전체 조회로 확장한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: `scheduleId`는 `{TYPE}:{targetId}` 형식으로 안정적으로 생성한다.
|
||||
|
||||
- [ ] **Task 3.4: 최근 소식 조회**
|
||||
- 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: `memberId`, `isActive=true`, `visibleFromAtUtc <= nowUtc` 조건으로 `visibleFromAtUtc desc, id desc` 30개를 조회하는 테스트를 작성한다.
|
||||
- RED: `creatorId`가 응답 domain에 노출되지 않고 `rank`만 nullable로 내려가는 테스트를 작성한다.
|
||||
- 실패 확인: repository 단일 테스트 명령 실행, recent news 미구현 실패 확인.
|
||||
- GREEN: inbox table 조회와 `HomeFollowingNews` 변환을 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 조회 시 차단/성인/target 활성 조건을 과도하게 조인하지 않도록 필요한 조건만 유지한다.
|
||||
|
||||
- [ ] **Task 3.5: HomeFollowingQueryService 조립**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
|
||||
- RED: query service가 팔로잉 크리에이터 20, On Air 10, 스케줄 3, 최근 소식 30 limit로 port를 호출하는 테스트를 작성한다.
|
||||
- RED: `MemberContentPreferenceService.canViewAdultContent(member)` 결과가 조회 port에 전달되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"` 실행, service 미구현 실패 확인.
|
||||
- GREEN: service에서 now/limit/성인 노출 정책을 조립한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: `nowProvider: () -> LocalDateTime`을 주입해 테스트 시간을 고정한다.
|
||||
|
||||
### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결
|
||||
|
||||
- [ ] **Task 4.1: sourceKey 생성 정책 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKey.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowingNewsSourceKeyTest.kt`
|
||||
- RED: `CREATOR_RANKING:{creatorId}:{aggregationStartAtUtc}` 형식 source key 생성 테스트를 작성한다.
|
||||
- RED: `AUDIO_CONTENT:{contentId}`와 `COMMUNITY_POST:{postId}` source key 생성 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행, source key 미구현 실패 확인.
|
||||
- GREEN: source key 생성 object를 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 문자열 상수는 `FollowingNewsType` enum 이름과 불일치하지 않게 정리한다.
|
||||
|
||||
- [ ] **Task 4.2: HomeFollowingNewsPublishService 구현**
|
||||
- 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: `publishContentUploaded(...)`가 `visibleFromAtUtc`를 콘텐츠 공개 시각으로 저장하는 테스트를 작성한다.
|
||||
- RED: `publishCreatorRankingVisible(...)`이 `rank`와 랭킹 스냅샷 `visibleFromAtUtc`를 저장하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행, service 미구현 실패 확인.
|
||||
- GREEN: publish service에서 follower 조회, record 변환, `insertIgnoreAll` 호출을 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 외부 MQ/outbox 없이 동작하되 호출부가 service 메서드에만 의존하도록 public API를 작게 유지한다.
|
||||
|
||||
- [ ] **Task 4.3: 언팔로우 시 inbox 비활성화 연동**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
||||
- RED: 언팔로우 시 해당 `memberId/creatorId`의 active inbox row가 `isActive=false`가 되는 테스트를 작성한다.
|
||||
- RED: 재팔로우 시 기존 비활성 row가 복구되지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행, inbox 비활성화 미연동 실패 확인.
|
||||
- GREEN: 기존 언팔로우 처리 성공 후 `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 팔로잉 공개 API 스키마는 변경하지 않는다.
|
||||
|
||||
- [ ] **Task 4.4: 크리에이터 랭킹 소식 발행 연결**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
|
||||
- RED: `refreshLastCompletedWeek(...)`가 스냅샷 저장 성공 후 `publishCreatorRankingVisible(...)`을 `visibleFromAtUtc`, `rank`, `creatorId`로 호출하는 테스트를 작성한다.
|
||||
- RED: `snapshotPort.replaceSnapshots(...)` 실패 시 `publishCreatorRankingVisible(...)`이 호출되지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행, publish 미연동 실패 확인.
|
||||
- GREEN: `snapshotPort.replaceSnapshots(...)` 성공 직후 `snapshots.mapIndexed { index, snapshot -> rank = index + 1 }`로 publish service를 호출한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 월요일 01:00 생성, 09:00 노출 정책은 inbox `visibleFromAtUtc`로만 처리한다.
|
||||
|
||||
- [ ] **Task 4.5: 콘텐츠/커뮤니티 업로드 소식 발행 연결**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.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/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt`
|
||||
- RED: `CreatorCommunityService.createCommunityPost(...)` 성공 후 `publishCommunityPostCreated(...)`가 post id, creator id, 본문 요약, 생성 시각으로 호출되는 테스트를 작성한다.
|
||||
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate <= now`인 즉시 공개 콘텐츠 저장 성공 후 `publishContentUploaded(...)`가 호출되는 테스트를 작성한다.
|
||||
- RED: `AudioContentService.createAudioContent(...)`에서 `releaseDate > now`인 예약 공개 콘텐츠 생성 시점에는 `publishContentUploaded(...)`가 호출되지 않는 테스트를 작성한다.
|
||||
- RED: `AudioContentService.releaseContent()`가 예약 콘텐츠를 active로 바꾸는 시점에 `publishContentUploaded(...)`를 호출하는 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"`
|
||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`
|
||||
- 기대 결과: publish 미연동으로 FAIL
|
||||
- GREEN: `AudioContentService.createAudioContent(...)`, `AudioContentService.releaseContent()`, `CreatorCommunityService.createCommunityPost(...)`의 트랜잭션 성공 경로에서 publish service를 호출한다.
|
||||
- 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인.
|
||||
- REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다.
|
||||
|
||||
### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End
|
||||
|
||||
- [ ] **Task 5.1: HomeFollowingFacade 통합**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
|
||||
- RED: `member == null`이면 query/chat 서비스를 호출하지 않고 `HomeFollowingTabResponse.loginRequired()`를 반환하는 테스트를 작성한다.
|
||||
- RED: 로그인 회원이면 query service와 `ChatRoomListService.getRooms(member, "ALL", null, 10)`를 호출해 응답을 조립하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행, facade 미구현 실패 확인.
|
||||
- GREEN: facade 조립 로직을 최소 구현한다.
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: 한 섹션 데이터 부족은 빈 배열/가능한 개수로 성공 처리한다.
|
||||
|
||||
- [ ] **Task 5.2: End-to-End API 통합 테스트**
|
||||
- Files:
|
||||
- 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 테스트를 작성한다.
|
||||
- 실패 확인: `./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 6: 문서/회귀 검증
|
||||
|
||||
- [ ] **Task 6.1: 문서 동기화 확인**
|
||||
- Files:
|
||||
- Verify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
|
||||
- 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`는 허용한다.
|
||||
- 실행 명령: `./gradlew tasks --all`
|
||||
- 기대 결과: `BUILD SUCCESSFUL`
|
||||
|
||||
- [ ] **Task 6.2: 전체 회귀 검증**
|
||||
- Files:
|
||||
- Verify: 전체 Kotlin source/test
|
||||
- TDD 예외 사유: 전체 회귀 검증 task이며 신규 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- `./gradlew --no-daemon test`
|
||||
- `./gradlew --no-daemon ktlintCheck`
|
||||
- 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL`
|
||||
- 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 순서 요약
|
||||
|
||||
1. DTO/domain/controller/security 기본 응답을 먼저 만든다.
|
||||
2. inbox entity/repository/adapter와 unique 정책을 만든다.
|
||||
3. 팔로잉 크리에이터, On Air, 스케줄, 최근 소식 조회 repository를 만든다.
|
||||
4. query service와 facade에서 섹션을 조립한다.
|
||||
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
|
||||
6. End-to-End 테스트와 전체 회귀 검증을 수행한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 기록
|
||||
|
||||
- 문서 작성 시점에는 구현 검증 기록 없음.
|
||||
365
docs/20260625_메인_홈_팔로잉_탭_API/prd.md
Normal file
365
docs/20260625_메인_홈_팔로잉_탭_API/prd.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# PRD: 메인 홈 팔로잉 탭 API
|
||||
|
||||
## 1. Overview
|
||||
메인 홈의 내부 팔로잉 탭에서 사용할 팔로잉 크리에이터, 진행 중인 라이브, 최근 대화, 이달의 스케줄, 최근 소식을 한 번에 조회하는 v2 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 팔로잉 탭 화면은 로그인 사용자가 팔로우한 크리에이터 기준으로 여러 섹션을 조립해야 한다.
|
||||
- 기존 v2 홈 추천 API는 추천/랭킹 중심이며, 팔로잉 관계를 기준으로 섹션 전체를 구성하지 않는다.
|
||||
- 기존 채팅 목록 API, 크리에이터 채널 홈 API, 크리에이터 랭킹 스냅샷 패턴에는 재사용 가능한 코드가 있지만, 팔로잉 탭의 공개 응답 필드는 화면 요구사항과 다르다.
|
||||
- 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다.
|
||||
- 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다.
|
||||
- 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||
- 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다.
|
||||
- 사용자가 팔로우한 크리에이터 목록을 최신 팔로우순 20개 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다.
|
||||
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다.
|
||||
- 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다.
|
||||
- 새로 팔로우한 사용자는 과거 소식을 받지 않는다.
|
||||
- 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다.
|
||||
- 재팔로우해도 기존에 비활성화된 inbox row는 복구하지 않고, 재팔로우 이후 새 이벤트부터 새 inbox row를 생성한다.
|
||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 기존 `GET /api/v2/home/recommendations` 공개 API 스키마를 변경하지 않는다.
|
||||
- 기존 `GET /api/v2/chat/rooms` 공개 API 스키마를 변경하지 않는다.
|
||||
- 기존 크리에이터 채널 홈/라이브/커뮤니티/콘텐츠 API 공개 스키마를 변경하지 않는다.
|
||||
- 팔로잉 추가/해제 공개 API 스키마 변경은 이번 범위에 포함하지 않는다.
|
||||
- 단, 최근 소식 정책을 위해 기존 팔로잉/언팔로잉 처리에 inbox 적재/비활성화 연동이 필요하면 내부 동작 보강 범위에 포함한다.
|
||||
- 채팅방 생성, 메시지 전송, 읽음 처리 정책 변경은 포함하지 않는다.
|
||||
- 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다.
|
||||
- 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다.
|
||||
- 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다.
|
||||
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 홈 팔로잉 탭에서 자신이 팔로우한 크리에이터의 활동을 빠르게 확인하는 사용자
|
||||
- 비회원: 홈 팔로잉 탭에 진입했을 때 로그인 필요 상태를 확인하고 로그인 화면으로 이동하는 사용자
|
||||
- 앱 클라이언트: 팔로잉 탭 첫 화면의 여러 섹션을 하나의 API 응답으로 구성하려는 클라이언트
|
||||
- 운영자: 최근 소식 inbox 적재와 노출 정책이 안정적으로 동작하기를 기대하는 내부 사용자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 내가 팔로우한 크리에이터 목록을 최근 팔로우한 순서로 보고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
|
||||
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
|
||||
- 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 메인 홈 팔로잉 탭 통합 조회 API
|
||||
|
||||
#### Requirements
|
||||
- 신규 API endpoint는 `GET /api/v2/home/following`으로 정의한다.
|
||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
||||
- 비로그인 요청도 성공 응답으로 처리한다.
|
||||
- 비로그인 요청은 `isLoginRequired = true`와 빈 섹션 배열을 내려주고, 앱 클라이언트가 로그인 유도 화면을 표시한다.
|
||||
- 로그인 회원 요청은 `isLoginRequired = false`와 팔로잉 탭 데이터를 내려준다.
|
||||
- 인증 회원 조회는 기존 v2 컨트롤러 패턴과 동일하게 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?`를 사용한다.
|
||||
- 별도 query parameter는 정의하지 않는다.
|
||||
- API 조립 계층은 섹션별 도메인 조회 결과를 받아 공개 응답 DTO로 변환한다.
|
||||
- 한 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
||||
- 섹션별 데이터가 없으면 빈 배열을 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- 비로그인 요청에서는 팔로잉 크리에이터, On Air, 최근 대화, 스케줄, 최근 소식을 모두 빈 배열로 내려준다.
|
||||
- 비로그인 요청에서는 팔로잉/채팅/스케줄/최근 소식 도메인 조회를 수행하지 않는다.
|
||||
- 사용자가 팔로우한 크리에이터가 없으면 팔로잉 크리에이터, On Air, 스케줄, 최근 소식은 빈 배열로 내려준다.
|
||||
- 최근 대화는 팔로잉 여부와 무관하게 해당 회원의 DM/AI 채팅 최신순 10개를 내려준다.
|
||||
- 조회 중 차단 관계가 있는 크리에이터의 라이브, 스케줄, 최근 소식은 노출하지 않는다.
|
||||
|
||||
### Feature B. 팔로잉 크리에이터
|
||||
|
||||
#### Requirements
|
||||
- 사용자가 팔로우한 활성 크리에이터를 최신 팔로우순으로 최대 20개 조회한다.
|
||||
- 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다.
|
||||
- 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다.
|
||||
- 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다.
|
||||
- 프로필 이미지는 `v2.common.domain.CdnUrlExtensions.toCdnUrl(...)` 패턴으로 CDN URL 변환한다.
|
||||
- 프로필 이미지가 없으면 기존 채팅/홈 추천과 동일한 기본 프로필 이미지 정책을 따른다.
|
||||
|
||||
#### Edge Cases
|
||||
- 팔로잉 row는 활성 상태지만 크리에이터가 비활성 상태이면 제외한다.
|
||||
- 차단 관계가 있는 크리에이터는 제외한다.
|
||||
|
||||
### Feature C. On Air
|
||||
|
||||
#### Requirements
|
||||
- 사용자가 팔로우한 활성 크리에이터의 현재 진행 중인 라이브를 최신순으로 최대 10개 조회한다.
|
||||
- 현재 진행 중인 라이브는 기존 홈 추천 라이브와 동일하게 `live_room.is_active = true`, `channel_name is not null`, `channel_name <> ''` 조건을 기본으로 한다.
|
||||
- 정렬은 `live_room.begin_date_time desc`, `live_room.id desc`로 한다.
|
||||
- 응답 필드는 `liveId`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `startedAtUtc`를 포함한다.
|
||||
- 19금 라이브 노출 여부는 기존 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 반영한다.
|
||||
- 성별 제한, 크리에이터 입장 제한처럼 기존 라이브 조회에서 필요한 접근 조건이 있으면 구현 계획 단계에서 기존 라이브/크리에이터 채널 라이브 조회 정책과 맞춘다.
|
||||
|
||||
#### Edge Cases
|
||||
- 라이브 제목이 비어 있으면 기존 라이브 조회 API의 제목 fallback 정책을 확인해 따른다.
|
||||
- 차단 관계가 있는 크리에이터의 라이브는 제외한다.
|
||||
|
||||
### Feature D. 최근 대화
|
||||
|
||||
#### Requirements
|
||||
- DM/AI 채팅방 중 최신 대화순으로 최대 10개 조회한다.
|
||||
- 기존 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)` 재사용을 우선한다.
|
||||
- 터치 시 해당 채팅방으로 이동할 수 있도록 `roomId`와 `chatType`을 응답에 포함한다.
|
||||
- 기존 채팅 목록 응답 `ChatRoomListItemResponse`는 필드가 팔로잉 탭 요구와 맞으므로 직접 재사용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 채팅방이 없으면 빈 배열을 내려준다.
|
||||
- AI/DM 메시지 preview 규칙은 기존 `ChatRoomListService`의 `previewMessage()` 정책을 그대로 따른다.
|
||||
|
||||
### Feature E. 이달의 스케줄
|
||||
|
||||
#### Requirements
|
||||
- 사용자가 팔로우한 크리에이터들의 이번 달 스케줄을 최대 3개 조회한다.
|
||||
- 조회 범위는 KST 기준 오늘 00:00:00 이상, 다음 달 00:00:00 미만으로 한다.
|
||||
- 오늘 이전의 데이터는 노출하지 않는다.
|
||||
- 정렬은 `scheduledAt asc`, 같은 시각이면 기존 `CreatorActivityType` 정렬 정책과 target id 순으로 안정화한다.
|
||||
- 스케줄 원천은 기존 크리에이터 채널 홈 스케줄 정책을 팔로잉 전체로 확장한다.
|
||||
- 라이브 예약: `live_room.begin_date_time`
|
||||
- 오디오 콘텐츠 예약: `content.release_date`
|
||||
- 응답 필드는 `scheduleId`, `creatorId`, `creatorProfileImageUrl`, `creatorNickname`, `title`, `type`, `targetId`, `scheduledAtUtc`, `isOnAir`를 포함한다.
|
||||
- `type`은 기존 `CreatorActivityType`을 우선 재사용한다.
|
||||
- 화면의 `On Air` 표시를 위해 예약 라이브가 이미 진행 중이면 `isOnAir = true`로 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- 오늘 이전 일정은 제외하되, 오늘 시작해서 현재 진행 중인 라이브는 스케줄에 포함할 수 있다.
|
||||
- 이번 달 남은 일정이 3개 미만이면 가능한 개수만 내려준다.
|
||||
- 19금 스케줄은 회원의 성인 콘텐츠 노출 가능 여부를 따른다.
|
||||
- 차단 관계가 있는 크리에이터의 스케줄은 제외한다.
|
||||
|
||||
### Feature F. 최근 소식
|
||||
|
||||
#### Requirements
|
||||
- 사용자가 팔로우한 크리에이터들의 소식을 최신 노출 가능 시각순으로 최대 30개 조회한다.
|
||||
- 최근 소식은 사용자별 Inbox Feed로 저장한다.
|
||||
- 크리에이터 이벤트 발생 시점에 해당 크리에이터를 현재 팔로우 중인 회원별 inbox row를 생성한다.
|
||||
- 이번 범위에서는 별도 비동기 이벤트 발송 시스템을 도입하지 않는다.
|
||||
- 이벤트 발생 처리 흐름에서 내부 publish service를 호출해 follower 조회와 inbox bulk insert를 수행한다.
|
||||
- publish service는 콘텐츠/커뮤니티/랭킹 도메인 코드에 직접 흩어지지 않고, 향후 outbox/worker로 전환할 수 있는 단일 경계로 둔다.
|
||||
- follower가 많아져 동기 bulk insert가 운영 부하를 만들면 publish service 내부 구현을 outbox/worker 방식으로 교체할 수 있어야 한다.
|
||||
- 새로 팔로우한 사용자는 팔로우 이전에 발생한 과거 소식을 받지 않는다.
|
||||
- 언팔로우 시 해당 크리에이터가 보낸 기존 inbox row를 `isActive = false`로 비활성화한다.
|
||||
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
|
||||
- 재팔로우 이후 새로 발생한 이벤트부터 새 inbox row를 생성한다.
|
||||
- 최근 소식 item 타입은 최소 아래를 지원한다.
|
||||
- `CREATOR_RANKING`: 크리에이터 순위 소식
|
||||
- `CONTENT_RANKING`: 향후 콘텐츠 순위 소식
|
||||
- `COMMUNITY_POST`: 커뮤니티 게시글 업로드
|
||||
- `AUDIO_CONTENT`: 오디오 콘텐츠 업로드
|
||||
- `PHOTO_CONTENT`: 향후 화보 콘텐츠 업로드
|
||||
- 이번 범위에서 `CONTENT_RANKING`은 생성하지 않는다.
|
||||
- `PHOTO_CONTENT`는 화보 기능 구현 전에는 생성되지 않지만, 클라이언트 계약 확장을 위해 enum에 포함한다.
|
||||
- 최근 소식은 매 요청마다 모든 팔로잉 크리에이터 원천 데이터를 직접 집계하지 않는다.
|
||||
- inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다.
|
||||
- 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`로 저장할 수 있다.
|
||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 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`로 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
|
||||
- inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다.
|
||||
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
|
||||
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
|
||||
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
|
||||
- 콘텐츠 썸네일이 없으면 `thumbnailImageUrl`은 `null`로 내려준다.
|
||||
|
||||
### Feature G. Response 재사용 정책
|
||||
|
||||
#### Requirements
|
||||
- 공개 응답 DTO는 화면 계약이 명확해야 하므로 팔로잉 탭 전용 최상위 응답 `HomeFollowingTabResponse`를 신규로 만든다.
|
||||
- 기존 응답 DTO를 무조건 새로 만들지는 않는다.
|
||||
- `recentChats`는 기존 `ChatRoomListItemResponse`를 직접 재사용한다.
|
||||
- `followingCreators`는 기존 `HomeCreatorItem`과 필드 의미가 유사하지만 `v2.api.home.dto.recommendation` 패키지의 추천 탭 전용 DTO이므로, API 결합을 줄이기 위해 팔로잉 탭 전용 `FollowingCreatorResponse`를 만든다.
|
||||
- `onAirLives`는 기존 `HomeLiveItem`에 title/start time이 없고, `CreatorChannelLiveResponse`에는 creator profile/nickname이 없어 그대로 재사용하지 않는다.
|
||||
- `monthlySchedules`는 기존 `CreatorChannelScheduleResponse`에 creator 정보와 `isOnAir`가 없어 그대로 재사용하지 않는다.
|
||||
- `recentNews`는 타입별 target/action이 필요한 신규 피드이므로 전용 DTO를 만든다.
|
||||
- DTO를 새로 만들더라도 CDN URL 변환, UTC ISO 변환, 채팅 목록 조회, 성인 콘텐츠 노출 판단, 차단 관계 필터, 크리에이터 랭킹 스냅샷 visible 시각 정책은 기존 코드를 재사용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 기존 `ChatRoomListItemResponse` 변경이 팔로잉 탭 공개 스키마에도 영향을 줄 수 있으므로, 채팅 목록 API 변경 시 팔로잉 탭 회귀 테스트를 함께 수행한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. API Endpoint
|
||||
|
||||
```http
|
||||
GET /api/v2/home/following
|
||||
Authorization: Bearer {accessToken} (optional)
|
||||
```
|
||||
|
||||
- 비로그인 조회를 허용한다.
|
||||
- 별도 query parameter는 정의하지 않는다.
|
||||
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
||||
- 컨트롤러에서 `member == null`이면 `isLoginRequired = true`와 빈 섹션 배열을 담은 응답을 반환한다.
|
||||
- 앱 클라이언트는 `isLoginRequired = true`일 때 팔로잉 탭 본문 대신 로그인 유도 화면을 표시한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Response Data Class
|
||||
|
||||
```kotlin
|
||||
data class HomeFollowingTabResponse(
|
||||
@JsonProperty("isLoginRequired")
|
||||
val isLoginRequired: Boolean,
|
||||
val followingCreators: List<FollowingCreatorResponse>,
|
||||
val onAirLives: List<FollowingLiveResponse>,
|
||||
val recentChats: List<ChatRoomListItemResponse>,
|
||||
val monthlySchedules: List<FollowingScheduleResponse>,
|
||||
val recentNews: List<FollowingNewsResponse>
|
||||
)
|
||||
|
||||
data class FollowingCreatorResponse(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImageUrl: String
|
||||
)
|
||||
|
||||
data class FollowingLiveResponse(
|
||||
val liveId: Long,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val title: String,
|
||||
val startedAtUtc: String
|
||||
)
|
||||
|
||||
data class FollowingScheduleResponse(
|
||||
val scheduleId: String,
|
||||
val creatorId: Long,
|
||||
val creatorProfileImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val title: String,
|
||||
val type: CreatorActivityType,
|
||||
val targetId: Long,
|
||||
val scheduledAtUtc: String,
|
||||
@JsonProperty("isOnAir")
|
||||
val isOnAir: Boolean
|
||||
)
|
||||
|
||||
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?
|
||||
)
|
||||
|
||||
enum class FollowingNewsType {
|
||||
CREATOR_RANKING,
|
||||
CONTENT_RANKING,
|
||||
COMMUNITY_POST,
|
||||
AUDIO_CONTENT,
|
||||
PHOTO_CONTENT
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
|
||||
- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. Technical Constraints
|
||||
|
||||
### 패키지 구조
|
||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.home.following` 하위에 둔다.
|
||||
- Controller: `...adapter.in.web`
|
||||
- Facade: `...application`
|
||||
- Response DTO: `...dto`
|
||||
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.home.following` 하위에 둔다.
|
||||
- Query service: `...application`
|
||||
- 최근 소식 publish service: `...application`
|
||||
- 도메인 모델/정책: `...domain`
|
||||
- 조회 port: `...port.out`
|
||||
- QueryDSL/JPA 구현: `...adapter.out.persistence`
|
||||
- 의존 방향은 `v2.api.home.following -> v2.home.following`만 허용한다.
|
||||
|
||||
### V2 공통화/재사용 대상
|
||||
- `v2.chat.service.ChatRoomListService`: 최근 대화 조회
|
||||
- `v2.chat.dto.ChatRoomListItemResponse`: 최근 대화 공개 응답 직접 재사용
|
||||
- `v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepository.findSchedules(...)`: 스케줄 조회 조건 참고
|
||||
- `v2.creator.channel.home.domain.CreatorChannelSchedule`: 스케줄 도메인 의미 참고
|
||||
- `v2.common.domain.CreatorActivityType`: 스케줄/소식 타입 중 활동 타입 재사용
|
||||
- `v2.common.domain.CdnUrlExtensions.toCdnUrl`: 이미지 URL 변환
|
||||
- `v2.api.home.dto.recommendation.toUtcIso`: UTC ISO 문자열 변환 패턴
|
||||
- `MemberContentPreferenceService.canViewAdultContent(...)`: 성인 콘텐츠 노출 가능 여부 판단
|
||||
- `v2.ranking`: 크리에이터 랭킹 스냅샷, `visibleFromAtUtc`, `rank` 의미 참고
|
||||
|
||||
### 최근 소식 Inbox
|
||||
- 신규 Entity와 DB table을 생성한다.
|
||||
- MySQL DDL은 `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`에 기록한다.
|
||||
- inbox는 사용자별 소식 저장소다.
|
||||
- inbox table의 `creator_id`는 언팔로우 비활성화, 차단 관계 확인, 운영 조회를 위한 내부 컬럼이며 공개 응답의 별도 `creatorId` 필드로 내려주지 않는다.
|
||||
- 커뮤니티/콘텐츠 업로드 소식은 업로드 또는 공개 이벤트에서 현재 follower 회원별로 적재한다.
|
||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시점에 현재 follower 회원별로 적재하되, `visibleFromAtUtc`는 랭킹 스냅샷의 공개 시각을 사용한다.
|
||||
- 이번 구현은 외부 MQ, outbox table, 별도 worker 없이 내부 publish service에서 follower 조회와 inbox bulk insert를 수행하는 최소 구조로 한다.
|
||||
- 콘텐츠/커뮤니티/랭킹 생성 로직은 inbox 저장소를 직접 호출하지 않고 publish service만 호출한다.
|
||||
- publish service는 `publishContentUploaded(...)`, `publishCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다.
|
||||
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
|
||||
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
|
||||
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
|
||||
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다.
|
||||
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
|
||||
- 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다.
|
||||
- 조회 시 차단 관계, 성인 노출 여부, 원천 target 활성 여부는 최종 확인한다.
|
||||
- 중복 방지를 위해 `memberId`, `newsType`, `sourceKey` 기준의 유니크 정책을 필수로 둔다.
|
||||
- `sourceKey`는 `{TYPE}:{targetId}:{periodKey}`처럼 같은 소식을 안정적으로 식별할 수 있는 값으로 정의한다.
|
||||
- 언팔로우 비활성화와 사용자별 조회 성능을 위해 `memberId`, `creatorId`, `isActive` 축의 인덱스를 고려한다.
|
||||
- 최신 30개 조회 성능을 위해 `memberId`, `isActive`, `visibleFromAtUtc` 축의 인덱스를 고려한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Metrics
|
||||
- `GET /api/v2/home/following` 응답 시간
|
||||
- 섹션별 item count
|
||||
- 최근 소식 inbox 적재 성공/실패 횟수
|
||||
- 최근 소식 inbox 적재 지연 시간
|
||||
- 최근 소식 조회 시 필터링 후 노출 수
|
||||
- 빈 섹션 비율
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
- 현재 PRD 기준의 미결정 요구사항은 없다.
|
||||
- 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다.
|
||||
Reference in New Issue
Block a user