Compare commits

...

19 Commits

Author SHA1 Message Date
f2be184fc9 docs(home-following): Phase 6 검증 기록을 갱신한다 2026-06-26 03:02:44 +09:00
9a20c54670 docs(home-following): Phase 3-5 기록을 갱신한다 2026-06-26 02:51:57 +09:00
75bd0ced28 feat(home-following): 팔로잉 탭 facade를 통합한다 2026-06-26 02:51:19 +09:00
59439df33e feat(content-ranking): 랭킹 공개 최근 소식을 발행한다 2026-06-26 02:50:51 +09:00
e89b5e1dad feat(community): 커뮤니티 게시글 최근 소식을 발행한다 2026-06-26 02:50:24 +09:00
9fc6643c18 feat(content): 오디오 업로드 최근 소식을 발행한다 2026-06-26 02:49:57 +09:00
36a60c76eb fix(member): 언팔로우 시 최근 소식을 비활성화한다 2026-06-26 02:49:30 +09:00
670b3d9f54 fix(home-following): inbox 중복 insert 처리를 보강한다 2026-06-26 02:49:01 +09:00
e598d2058d feat(home-following): 최근 소식 발행 service를 추가한다 2026-06-26 02:48:29 +09:00
8b5c872b45 feat(home-following): 최근 소식 source key를 추가한다 2026-06-26 02:48:02 +09:00
f5d755b2a6 feat(home-following): 팔로잉 탭 조회 service를 추가한다 2026-06-26 02:47:35 +09:00
45fc8bd21f feat(home-following): 팔로잉 탭 조회 repository를 추가한다 2026-06-26 02:47:06 +09:00
91c648ca44 feat(home-following): 팔로잉 탭 조회 port를 추가한다 2026-06-26 02:46:52 +09:00
b2b4a74adc docs(home-following): 팔로잉 탭 Phase 1-2 기록을 갱신한다 2026-06-25 22:16:29 +09:00
315412fb42 feat(home-following): 팔로잉 소식 inbox 저장 adapter를 추가한다 2026-06-25 22:16:02 +09:00
a28991b585 feat(home-following): 팔로잉 소식 inbox 저장 모델을 추가한다 2026-06-25 22:15:20 +09:00
cbcd87875c feat(home-following): 팔로잉 탭 공개 endpoint를 추가한다 2026-06-25 22:14:43 +09:00
e4052d097a feat(home-following): 팔로잉 탭 응답 모델을 추가한다 2026-06-25 22:14:21 +09:00
3add66ff7a docs(home-following): 팔로잉 탭 API 계획을 추가한다 2026-06-25 17:45:49 +09:00
39 changed files with 4396 additions and 8 deletions

View File

@@ -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);

View File

@@ -0,0 +1,652 @@
# 메인 홈 팔로잉 탭 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 기본 골격
- [x] **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` 실행.
- [x] **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 저장소
- [x] **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과 맞는지 비교한다.
- [x] **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: JPA `saveAndFlush`와 unique 제약 기반 `DataIntegrityViolationException` 처리로 중복 source key를 예외 없이 무시하는 idempotent 저장을 최소 구현한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: H2/MySQL dialect 분기 없이 단일 JPA 경로를 유지하고, 동시 적재 시 inserted count는 best-effort임을 검증 기록에 남긴다.
### Phase 3: 팔로잉 탭 조회 Repository/Service
- [x] **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 중 기존 패턴과 맞는 위치로 정리한다.
- [x] **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로 추출한다.
- [x] **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}` 형식으로 안정적으로 생성한다.
- [x] **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 활성 조건을 과도하게 조인하지 않도록 필요한 조건만 유지한다.
- [x] **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`을 주입해 테스트 시간을 고정한다.
- [x] **Task 3.6: Inbox 중복 insert 충돌 통합 테스트 보강**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
- RED: 실제 `HomeFollowingNewsInboxJpaRepository`로 동일 `memberId/newsType/sourceKey` unique 충돌을 발생시킨 뒤, 같은 테스트 흐름에서 `insertIgnoreAll(records)` 또는 repository 조회가 예외 없이 동작하는 통합 테스트를 작성한다.
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행, 실제 DB 충돌 후 persistence context/transaction 상태 검증 실패를 확인한다.
- GREEN: 필요 시 adapter의 중복 충돌 처리에서 persistence context 정리 또는 트랜잭션 경계를 최소 보강한다.
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다.
### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결
- [x] **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 이름과 불일치하지 않게 정리한다.
- [x] **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를 작게 유지한다.
- [x] **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 스키마는 변경하지 않는다.
- [x] **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`로만 처리한다.
- [x] **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
- [x] **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: 한 섹션 데이터 부족은 빈 배열/가능한 개수로 성공 처리한다.
- [x] **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: 문서/회귀 검증
- [x] **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`
- [x] **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. 검증 기록
- 2026-06-25 Phase 1-2 구현 검증:
- `./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.api.home.following.adapter.in.web.HomeFollowingControllerTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsKotlin` 삭제 충돌이 1회 발생해 동일 명령을 순차 재실행했다.
- 2026-06-25 Phase 3 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 repository/service 미구현 컴파일 오류로 `BUILD FAILED`.
- `./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.home.following.application.HomeFollowingQueryServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-25 Phase 4 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `HomeFollowingNewsSourceKey`, `HomeFollowingNewsPublishService` 미구현 및 생성자 의존성 미연동 컴파일 오류로 `BUILD FAILED`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKeyTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `insertIgnoreAll`은 H2/MySQL dialect 분기 없이 JPA `saveAndFlush`와 unique 제약 기반 중복 예외 재확인 단일 경로로 검증했다.
- 2026-06-25 Phase 5 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`.
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 facade 생성자 미구현으로 `BUILD FAILED`.
- `./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` 길이 정규화를 보강했다.
- 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`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 3-5 2차 리뷰 보완 검증:
- 2차 리뷰 지적 사항에 따라 inbox insert 정상 경로를 row별 `saveAndFlush`에서 기존 memberId 일괄 조회 + `saveAll` + 단일 `flush`로 완화하고, 중복 충돌 fallback은 유지했다.
- 유료 오디오 콘텐츠의 `isFullDetailVisible=false` 상세 설명은 기존 상세 API 정책과 동일하게 미리보기만 최근 소식에 저장하도록 보강했다.
- 오디오/커뮤니티/랭킹 최근 소식 발행 실패가 원 업로드/게시글 생성/랭킹 스냅샷 갱신 성공을 실패로 전파하지 않도록 after-commit 발행 예외를 로그로 격리했다.
- 보완 직후 regression 테스트에서 adapter race 테스트와 Mockito matcher stubbing 불일치 실패를 확인한 뒤 테스트를 새 구현 경로에 맞게 정리했다.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 3-5 3차 리뷰 보완 검증:
- 최근 소식 조회가 `AUDIO_CONTENT`, `COMMUNITY_POST` 원천 target의 `isActive=false` 상태를 최종 제외하도록 보강했다. `CREATOR_RANKING`은 creator 활성/role 필터를 유지하고, 아직 원천 테이블이 없는 예약 타입은 조회에서 노출하지 않는다.
- 이달의 스케줄 정렬을 `scheduledAtUtc asc`, `type.sortOrder asc`, `targetId asc`로 안정화했다.
- inbox insert를 H2/MySQL 공통 JPA portable path로 변경했다. 구현은 `newsType/sourceKey`별 기존 수신 member id를 일괄 조회한 뒤 신규 row만 `saveAll` + `flush`하고, unique 충돌 시 persistence context를 정리한 뒤 한 번 재조회/재시도한다.
- 추후 운영에서 follower 수가 큰 크리에이터 이벤트로 `member_id in (...)` 또는 `saveAll` 배치 크기가 병목이 되면, follower id chunking, outbox table, 비동기 worker, 재시도/모니터링 대시보드 도입을 별도 후속 작업으로 진행한다.
- 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.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterRetryTest"` 실행 결과 target 비활성 필터와 insert retry 미구현으로 `BUILD FAILED`.
- 같은 regression 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- Phase 3-5 전체 대상 테스트 명령 재실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test` 전체 테스트 실행 결과 `BUILD SUCCESSFUL`.
- 2026-06-26 Phase 6 문서/회귀 검증:
- 문서 동기화 확인을 위해 `rg -n "FollowingNewsRankingResponse|ranking\\?|rankChange|isNew|creatorId" docs/20260625_메인_홈_팔로잉_탭_API`를 실행했다. 검색 결과의 `creatorId`는 팔로잉 크리에이터/스케줄 공개 필드, 최근 소식의 `creatorId` 부재 검증 설명, 내부 `creator_id`/port 인자/테스트 설명 맥락으로 확인했으며 삭제된 공개 응답 필드 잔존은 확인되지 않았다.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.

View File

@@ -0,0 +1,366 @@
# 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 방식으로 교체할 수 있어야 한다.
- 현재 구현은 H2/MySQL 공통 검증이 가능한 JPA portable path를 우선 사용한다. follower 수가 큰 크리에이터 이벤트에서 `member_id in (...)` 또는 `saveAll` 배치 크기가 운영 부하를 만들면, 후속 작업에서 follower id chunking, outbox table, 비동기 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` 판단 조건을 같은 조건으로 추출할지 검토한다.

View File

@@ -106,6 +106,7 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
.anyRequest().authenticated() .anyRequest().authenticated()

View File

@@ -39,6 +39,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -47,6 +48,8 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -82,6 +85,7 @@ class AudioContentService(
private val langContext: LangContext, private val langContext: LangContext,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
@Value("\${cloud.aws.s3.content-bucket}") @Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String, private val audioContentBucket: String,
@@ -476,7 +480,8 @@ class AudioContentService(
) )
) )
if (audioContent.releaseDate == null || audioContent.releaseDate!! <= audioContent.createdAt) { val now = LocalDateTime.now()
if (audioContent.releaseDate == null || audioContent.releaseDate!! <= now) {
audioContent.isActive = true audioContent.isActive = true
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
@@ -494,6 +499,10 @@ class AudioContentService(
deepLinkId = contentId deepLinkId = contentId
) )
) )
publishContentUploadedAfterCommit(
audioContent = audioContent,
visibleFromAtUtc = audioContent.releaseDate ?: audioContent.createdAt ?: now
)
} }
} }
@@ -520,9 +529,64 @@ class AudioContentService(
deepLinkId = audioContent.id!! deepLinkId = audioContent.id!!
) )
) )
publishContentUploadedAfterCommit(
audioContent = audioContent,
visibleFromAtUtc = audioContent.releaseDate ?: LocalDateTime.now()
)
} }
} }
private fun publishContentUploadedAfterCommit(audioContent: AudioContent, visibleFromAtUtc: LocalDateTime) {
val creator = audioContent.member!!
val occurredAtUtc = audioContent.createdAt ?: visibleFromAtUtc
val newsBody = audioContent.newsDetailPreview()
afterCommit {
homeFollowingNewsPublishService.publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = newsBody,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
isAdult = audioContent.isAdult
)
}
}
private fun AudioContent.newsDetailPreview(): String {
if (price < 50 || isFullDetailVisible) {
return detail
}
val length = detail.length
return if (length < 60) {
"${detail.take(length / 2)}..."
} else {
"${detail.take(30)}..."
}
}
private fun afterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
return
}
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
}
}
)
}
@Transactional @Transactional
fun getDetail( fun getDetail(
id: Long, id: Long,

View File

@@ -27,11 +27,15 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage import kr.co.vividnext.sodalive.utils.validateImage
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -52,6 +56,7 @@ class CreatorCommunityService(
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
private val messageSource: SodaMessageSource, private val messageSource: SodaMessageSource,
private val langContext: LangContext, private val langContext: LangContext,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val imageBucket: String, private val imageBucket: String,
@@ -62,6 +67,8 @@ class CreatorCommunityService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional @Transactional
fun createCommunityPost( fun createCommunityPost(
audioFile: MultipartFile?, audioFile: MultipartFile?,
@@ -134,6 +141,54 @@ class CreatorCommunityService(
deepLinkId = member.id!! deepLinkId = member.id!!
) )
) )
publishCommunityPostCreatedAfterCommit(post, member)
}
private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) {
val occurredAtUtc = post.createdAt ?: LocalDateTime.now()
val newsContent = post.newsContentPreview()
afterCommit {
homeFollowingNewsPublishService.publishCommunityPostCreated(
postId = post.id!!,
creatorId = member.id!!,
creatorNickname = member.nickname,
creatorProfileImagePath = member.profileImage,
title = newsContent.take(80),
body = newsContent,
thumbnailImagePath = post.imagePath,
occurredAtUtc = occurredAtUtc,
isAdult = post.isAdult
)
}
}
private fun CreatorCommunity.newsContentPreview(): String {
if (price <= 0) {
return content
}
val length = content.codePointCount(0, content.length)
val previewLength = if (length > 15) 15 else length / 2
val endIndex = content.offsetByCodePoints(0, previewLength)
return content.substring(0, endIndex).plus("...")
}
private fun afterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
return
}
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
runCatching(action).onFailure { ex ->
log.warn("event=home_following_news_publish_failure error={}", ex.message, ex)
}
}
}
)
} }
@Transactional @Transactional

View File

@@ -53,6 +53,7 @@ import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.generatePassword import kr.co.vividnext.sodalive.utils.generatePassword
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.CacheManager import org.springframework.cache.CacheManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -109,6 +110,7 @@ class MemberService(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val homeFollowingNewsInboxPort: HomeFollowingNewsInboxPort,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String, private val s3Bucket: String,
@@ -525,6 +527,7 @@ class MemberService(
if (creatorFollowing != null) { if (creatorFollowing != null) {
creatorFollowing.isActive = false creatorFollowing.isActive = false
homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
} }
} }

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/home/following")
class HomeFollowingController(
private val facade: HomeFollowingFacade
) {
@GetMapping
fun getFollowingTab(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getFollowingTab(member))
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.api.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryService
import org.springframework.stereotype.Component
@Component
class HomeFollowingFacade(
private val homeFollowingQueryService: HomeFollowingQueryService,
private val chatRoomListService: ChatRoomListService
) {
fun getFollowingTab(member: Member?): HomeFollowingTabResponse {
if (member == null) {
return HomeFollowingTabResponse.loginRequired()
}
val home = homeFollowingQueryService.findHomeFollowing(member)
val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms
return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats))
}
}

View File

@@ -0,0 +1,142 @@
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
)
}
}
}

View File

@@ -0,0 +1,343 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Expression
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.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 org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
@Repository
class DefaultHomeFollowingQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : HomeFollowingQueryRepository {
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
val creator = QMember("followingCreator")
return queryFactory
.select(creator.id, creator.nickname, creator.profileImage)
.from(creatorFollowing)
.join(creatorFollowing.creator, creator)
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(creatorFollowing.createdAt.desc(), creatorFollowing.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingCreator(
creatorId = row.get(creator.id)!!,
creatorNickname = row.get(creator.nickname)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage))
)
}
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
val creator = QMember("onAirCreator")
return queryFactory
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingLive(
liveId = row.get(liveRoom.id)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage)),
creatorNickname = row.get(creator.nickname)!!,
title = row.get(liveRoom.title)!!,
startedAtUtc = row.get(liveRoom.beginDateTime)!!.toUtcIso()
)
}
}
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
val window = monthlyScheduleWindow(now)
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
return (liveSchedules + audioSchedules)
.sortedWith(
compareBy<HomeFollowingSchedule> { it.scheduledAtUtc }
.thenBy { it.type.sortOrder }
.thenBy { it.targetId }
)
.take(limit)
}
override fun findRecentNews(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime,
limit: Int
): List<HomeFollowingNews> {
val creator = QMember("newsCreator")
return queryFactory
.select(
homeFollowingNewsInbox.id,
homeFollowingNewsInbox.newsType,
homeFollowingNewsInbox.creatorProfileImagePath,
homeFollowingNewsInbox.creatorNickname,
homeFollowingNewsInbox.title,
homeFollowingNewsInbox.body,
homeFollowingNewsInbox.thumbnailImagePath,
homeFollowingNewsInbox.targetId,
homeFollowingNewsInbox.occurredAtUtc,
homeFollowingNewsInbox.visibleFromAtUtc,
homeFollowingNewsInbox.rank
)
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.where(
homeFollowingNewsInbox.memberId.eq(memberId),
homeFollowingNewsInbox.isActive.isTrue,
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition()
)
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingNews(
newsId = row.get(homeFollowingNewsInbox.id)!!.toString(),
type = row.get(homeFollowingNewsInbox.newsType)!!,
creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)),
creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!,
title = row.get(homeFollowingNewsInbox.title)!!,
body = row.get(homeFollowingNewsInbox.body)!!,
thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost),
targetId = row.get(homeFollowingNewsInbox.targetId)!!,
occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(),
visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(),
rank = row.get(homeFollowingNewsInbox.rank)
)
}
}
private fun findLiveSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleLiveCreator")
return queryFactory
.select(
liveRoom.id,
creator.id,
creator.profileImage,
creator.nickname,
liveRoom.title,
liveRoom.beginDateTime,
liveRoom.channelName
)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.beginDateTime.goe(window.startUtc),
liveRoom.beginDateTime.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toLiveSchedule(creator) }
}
private fun findAudioSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleAudioCreator")
return queryFactory
.select(
audioContent.id,
creator.id,
creator.profileImage,
creator.nickname,
audioContent.title,
audioContent.releaseDate
)
.from(audioContent)
.join(audioContent.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.goe(window.startUtc),
audioContent.releaseDate.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultAudioCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toAudioSchedule(creator) }
}
private fun Tuple.toLiveSchedule(creator: QMember): HomeFollowingSchedule {
val liveId = get(liveRoom.id)!!
val channelName = get(liveRoom.channelName)
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.LIVE}:$liveId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(liveRoom.title)!!,
type = CreatorActivityType.LIVE,
targetId = liveId,
scheduledAtUtc = get(liveRoom.beginDateTime)!!.toUtcIso(),
isOnAir = !channelName.isNullOrBlank()
)
}
private fun Tuple.toAudioSchedule(creator: QMember): HomeFollowingSchedule {
val contentId = get(audioContent.id)!!
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.AUDIO}:$contentId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(audioContent.title)!!,
type = CreatorActivityType.AUDIO,
targetId = contentId,
scheduledAtUtc = get(audioContent.releaseDate)!!.toUtcIso(),
isOnAir = false
)
}
private fun monthlyScheduleWindow(now: LocalDateTime): ScheduleWindow {
val kstNow = now.atOffset(ZoneOffset.UTC).atZoneSameInstant(KST_ZONE_ID).toLocalDateTime()
val startKst = kstNow.toLocalDate().atStartOfDay()
val endKst = startKst.toLocalDate().plusMonths(1).withDayOfMonth(1).atStartOfDay()
return ScheduleWindow(startUtc = startKst.toUtcFromKst(), endUtc = endKst.toUtcFromKst())
}
private fun LocalDateTime.toUtcFromKst(): LocalDateTime {
return atZone(KST_ZONE_ID).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
}
private fun adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun adultNewsCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
}
private fun activeNewsTargetCondition(): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
.selectOne()
.from(newsAudioContent)
.where(
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
newsAudioContent.isActive.isTrue
)
.exists()
val activeCommunityExists = JPAExpressions
.selectOne()
.from(newsCommunity)
.where(
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
newsCommunity.isActive.isTrue
)
.exists()
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
val blockMember = QBlockMember("homeFollowingBlockMember")
return JPAExpressions
.selectOne()
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
)
.notExists()
}
private fun profileImageUrl(path: String?): String {
return path.toCdnUrl(cloudFrontHost) ?: "$cloudFrontHost/profile/default-profile.png"
}
private val CreatorActivityType.sortOrder: Int
get() = when (this) {
CreatorActivityType.LIVE -> 0
else -> 1
}
private data class ScheduleWindow(
val startUtc: LocalDateTime,
val endUtc: LocalDateTime
)
companion object {
private val KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
}

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "home_following_news_inbox",
uniqueConstraints = [
UniqueConstraint(
name = "uk_home_following_news_inbox_member_type_source",
columnNames = ["member_id", "news_type", "source_key"]
)
]
)
class HomeFollowingNewsInbox(
@Column(name = "member_id", nullable = false, updatable = false)
val memberId: Long,
@Column(name = "creator_id", nullable = false, updatable = false)
val creatorId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "news_type", nullable = false, updatable = false, length = 30)
val newsType: FollowingNewsType,
@Column(name = "source_key", nullable = false, updatable = false, length = 200)
val sourceKey: String,
@Column(name = "target_id", nullable = false, updatable = false)
val targetId: Long,
@Column(name = "occurred_at_utc", nullable = false, updatable = false)
val occurredAtUtc: LocalDateTime,
@Column(name = "visible_from_at_utc", nullable = false, updatable = false)
val visibleFromAtUtc: LocalDateTime,
@Column(name = "creator_nickname", nullable = false, updatable = false, length = 100)
val creatorNickname: String,
@Column(name = "creator_profile_image_path", updatable = false, length = 500)
val creatorProfileImagePath: String?,
@Column(name = "title", nullable = false, updatable = false, length = 255)
val title: String,
@Column(name = "body", nullable = false, updatable = false, length = 1000)
val body: String,
@Column(name = "thumbnail_image_path", updatable = false, length = 500)
val thumbnailImagePath: String?,
@Column(name = "rank_no", updatable = false)
val rank: Int?,
@Column(name = "is_adult", nullable = false, updatable = false)
val isAdult: Boolean,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity() {
fun deactivate() {
isActive = false
}
}

View File

@@ -0,0 +1,59 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsInbox, Long> {
fun existsByMemberIdAndNewsTypeAndSourceKey(
memberId: Long,
newsType: kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType,
sourceKey: String
): Boolean
@Query(
value = """
select member_id
from home_following_news_inbox
where news_type = :newsType
and source_key = :sourceKey
and member_id in :memberIds
""",
nativeQuery = true
)
fun findExistingMemberIds(
@Param("newsType") newsType: String,
@Param("sourceKey") sourceKey: String,
@Param("memberIds") memberIds: Collection<Long>
): List<Long>
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value = """
update home_following_news_inbox
set is_active = false,
updated_at = current_timestamp
where member_id = :memberId
and creator_id = :creatorId
and is_active = true
""",
nativeQuery = true
)
fun deactivateByMemberIdAndCreatorId(
@Param("memberId") memberId: Long,
@Param("creatorId") creatorId: Long
): Int
@Query(
value = """
select cf.member_id
from creator_following cf
where cf.creator_id = :creatorId
and cf.is_active = true
order by cf.member_id asc
""",
nativeQuery = true
)
fun findActiveFollowerIds(@Param("creatorId") creatorId: Long): List<Long>
}

View File

@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Repository
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@Repository
class HomeFollowingNewsInboxPersistenceAdapter(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager? = null
) : HomeFollowingNewsInboxPort {
private val transactionTemplate = transactionManager?.let {
TransactionTemplate(it).also { template ->
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
if (records.isEmpty()) {
return 0
}
val distinctRecords = records
.distinctBy { Triple(it.memberId, it.newsType, it.sourceKey) }
return insertWithRetry(distinctRecords)
}
@Transactional
override fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long {
return repository.deactivateByMemberIdAndCreatorId(memberId, creatorId).toLong()
}
override fun findActiveFollowerIds(creatorId: Long): List<Long> {
return repository.findActiveFollowerIds(creatorId)
}
private fun insertWithRetry(records: List<HomeFollowingNewsInboxRecord>): Int {
var lastFailure: DataIntegrityViolationException? = null
repeat(MAX_INSERT_ATTEMPTS) {
try {
return executeInsertAttempt(records)
} catch (ex: DataIntegrityViolationException) {
lastFailure = ex
entityManager.clear()
}
}
throw requireNotNull(lastFailure)
}
private fun executeInsertAttempt(records: List<HomeFollowingNewsInboxRecord>): Int {
return transactionTemplate?.execute { insertNewRows(records) } ?: insertNewRows(records)
}
private fun insertNewRows(records: List<HomeFollowingNewsInboxRecord>): Int {
val entities = records
.groupBy { SourceKey(newsType = it.newsType, sourceKey = it.sourceKey) }
.flatMap { (sourceKey, sourceRecords) ->
FollowingNewsType.valueOf(sourceKey.newsType)
val existingMemberIds = repository.findExistingMemberIds(
newsType = sourceKey.newsType,
sourceKey = sourceKey.sourceKey,
memberIds = sourceRecords.map { it.memberId }
).toSet()
sourceRecords
.filterNot { it.memberId in existingMemberIds }
.map { it.toEntity() }
}
if (entities.isEmpty()) {
return 0
}
repository.saveAll(entities)
repository.flush()
return entities.size
}
private fun HomeFollowingNewsInboxRecord.toEntity(): HomeFollowingNewsInbox {
return HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.valueOf(newsType),
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title,
body = body,
thumbnailImagePath = thumbnailImagePath,
rank = rank,
isAdult = isAdult
)
}
private data class SourceKey(
val newsType: String,
val sourceKey: String
)
companion object {
private const val MAX_INSERT_ATTEMPTS = 2
}
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
interface HomeFollowingQueryRepository : HomeFollowingQueryPort

View File

@@ -0,0 +1,146 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKey
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class HomeFollowingNewsPublishService(
private val inboxPort: HomeFollowingNewsInboxPort
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun publishCommunityPostCreated(
postId: Long,
creatorId: Long,
creatorNickname: String,
creatorProfileImagePath: String?,
title: String,
body: String,
thumbnailImagePath: String?,
occurredAtUtc: LocalDateTime,
isAdult: Boolean
): Int {
return publishToFollowers(
creatorId = creatorId,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = HomeFollowingNewsSourceKey.communityPost(postId),
targetId = postId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = occurredAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title,
body = body,
thumbnailImagePath = thumbnailImagePath,
rank = null,
isAdult = isAdult
)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun publishContentUploaded(
contentId: Long,
creatorId: Long,
creatorNickname: String,
creatorProfileImagePath: String?,
title: String,
body: String,
thumbnailImagePath: String?,
occurredAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
isAdult: Boolean
): Int {
return publishToFollowers(
creatorId = creatorId,
newsType = FollowingNewsType.AUDIO_CONTENT,
sourceKey = HomeFollowingNewsSourceKey.audioContent(contentId),
targetId = contentId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title,
body = body,
thumbnailImagePath = thumbnailImagePath,
rank = null,
isAdult = isAdult
)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun publishCreatorRankingVisible(
creatorId: Long,
creatorNickname: String,
creatorProfileImagePath: String?,
aggregationStartAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
rank: Int
): Int {
return publishToFollowers(
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING,
sourceKey = HomeFollowingNewsSourceKey.creatorRanking(creatorId, aggregationStartAtUtc),
targetId = creatorId,
occurredAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = creatorNickname,
body = "$rank",
thumbnailImagePath = creatorProfileImagePath,
rank = rank,
isAdult = false
)
}
private fun publishToFollowers(
creatorId: Long,
newsType: FollowingNewsType,
sourceKey: String,
targetId: Long,
occurredAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
creatorNickname: String,
creatorProfileImagePath: String?,
title: String,
body: String,
thumbnailImagePath: String?,
rank: Int?,
isAdult: Boolean
): Int {
val followerIds = inboxPort.findActiveFollowerIds(creatorId)
if (followerIds.isEmpty()) {
return 0
}
val records = followerIds.map { memberId ->
HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = newsType.name,
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = creatorNickname,
creatorProfileImagePath = creatorProfileImagePath,
title = title.take(TITLE_MAX_LENGTH),
body = body.take(BODY_MAX_LENGTH),
thumbnailImagePath = thumbnailImagePath,
rank = rank,
isAdult = isAdult
)
}
return inboxPort.insertIgnoreAll(records)
}
companion object {
private const val TITLE_MAX_LENGTH = 255
private const val BODY_MAX_LENGTH = 1_000
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class HomeFollowingQueryService(
private val queryPort: HomeFollowingQueryPort,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) }
) {
fun findHomeFollowing(member: Member): HomeFollowing {
val memberId = requireNotNull(member.id)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member)
val now = nowProvider()
return HomeFollowing(
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT),
recentChats = emptyList(),
monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT),
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
)
}
companion object {
private const val FOLLOWING_CREATORS_LIMIT = 20
private const val ON_AIR_LIVES_LIMIT = 10
private const val MONTHLY_SCHEDULES_LIMIT = 3
private const val RECENT_NEWS_LIMIT = 30
}
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.v2.home.following.domain
enum class FollowingNewsType {
CREATOR_RANKING,
CONTENT_RANKING,
COMMUNITY_POST,
AUDIO_CONTENT,
PHOTO_CONTENT
}

View File

@@ -0,0 +1,52 @@
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?
)

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.home.following.domain
import java.time.LocalDateTime
object HomeFollowingNewsSourceKey {
fun creatorRanking(creatorId: Long, aggregationStartAtUtc: LocalDateTime): String {
return "${FollowingNewsType.CREATOR_RANKING.name}:$creatorId:$aggregationStartAtUtc"
}
fun audioContent(contentId: Long): String {
return "${FollowingNewsType.AUDIO_CONTENT.name}:$contentId"
}
fun communityPost(postId: Long): String {
return "${FollowingNewsType.COMMUNITY_POST.name}:$postId"
}
}

View File

@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.v2.home.following.port.out
import java.time.LocalDateTime
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
)

View File

@@ -0,0 +1,27 @@
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>
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
@@ -19,7 +20,8 @@ import java.time.ZonedDateTime
@Service @Service
class CreatorRankingSnapshotRefreshService( class CreatorRankingSnapshotRefreshService(
private val aggregationPort: CreatorRankingAggregationPort, private val aggregationPort: CreatorRankingAggregationPort,
private val snapshotPort: CreatorRankingSnapshotPort private val snapshotPort: CreatorRankingSnapshotPort,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val periodPolicy = CreatorRankingPeriodPolicy() private val periodPolicy = CreatorRankingPeriodPolicy()
@@ -47,6 +49,28 @@ class CreatorRankingSnapshotRefreshService(
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
newSnapshots = snapshots newSnapshots = snapshots
) )
afterCommit {
snapshots.forEachIndexed { index, snapshot ->
runCatching {
homeFollowingNewsPublishService.publishCreatorRankingVisible(
creatorId = snapshot.creatorId,
creatorNickname = snapshot.nickname,
creatorProfileImagePath = snapshot.profileImageUrl,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
rank = index + 1
)
}.onFailure { ex ->
log.warn(
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
snapshot.creatorId,
index + 1,
ex.message,
ex
)
}
}
}
aggregationResult.toLogCounts(storedCount = snapshots.size) aggregationResult.toLogCounts(storedCount = snapshots.size)
}.onSuccess { counts -> }.onSuccess { counts ->
afterCommit { afterCommit {
@@ -92,12 +116,18 @@ class CreatorRankingSnapshotRefreshService(
private fun afterCommit(action: () -> Unit) { private fun afterCommit(action: () -> Unit) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action() runCatching(action).onFailure { ex ->
log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex)
}
return return
} }
TransactionSynchronizationManager.registerSynchronization( TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization { object : TransactionSynchronization {
override fun afterCommit() = action() override fun afterCommit() {
runCatching(action).onFailure { ex ->
log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex)
}
}
} }
) )
} }

View File

@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -57,6 +58,7 @@ class AudioContentServiceTest {
private lateinit var audioContentCloudFront: AudioContentCloudFront private lateinit var audioContentCloudFront: AudioContentCloudFront
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var service: AudioContentService private lateinit var service: AudioContentService
@@ -80,6 +82,7 @@ class AudioContentServiceTest {
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
service = AudioContentService( service = AudioContentService(
repository = repository, repository = repository,
@@ -103,6 +106,7 @@ class AudioContentServiceTest {
messageSource = SodaMessageSource(), messageSource = SodaMessageSource(),
langContext = LangContext(), langContext = LangContext(),
contentThemeTranslationRepository = contentThemeTranslationRepository, contentThemeTranslationRepository = contentThemeTranslationRepository,
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
audioContentBucket = "audio-bucket", audioContentBucket = "audio-bucket",
coverImageBucket = "cover-bucket", coverImageBucket = "cover-bucket",
coverImageHost = "https://cdn.test" coverImageHost = "https://cdn.test"
@@ -273,6 +277,178 @@ class AudioContentServiceTest {
assertTrue(output.out.contains("contentId=${audioContent.id}")) assertTrue(output.out.contains("contentId=${audioContent.id}"))
} }
@Test
@DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다")
fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() {
val creator = createMember(id = 2100L, nickname = "audio-creator")
creator.profileImage = "profile/audio-creator.png"
val audioContent = createAudioContent(creator = creator, isAdult = true)
audioContent.isActive = false
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = audioContent.detail,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.createdAt!!,
isAdult = true
)
}
@Test
@DisplayName("유료 오디오가 상세 비공개이면 최근 소식은 전체 상세를 노출하지 않는다")
fun shouldMaskPaidAudioDetailWhenPublishingNews() {
val creator = createMember(id = 2120L, nickname = "paid-audio-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.isFullDetailVisible = false
audioContent.detail = "유료 오디오 상세 설명 전체 본문은 최근 소식에서 모두 보이면 안 됩니다"
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = "유료 오디오 상세 설명 전체 본문은 ...",
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.createdAt!!,
isAdult = false
)
}
@Test
@DisplayName("최근 소식 발행 실패는 업로드 완료 처리를 실패시키지 않는다")
fun shouldNotFailUploadCompleteWhenNewsPublishFails() {
val creator = createMember(id = 2130L, nickname = "publish-failure-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
Mockito.doAnswer { throw IllegalStateException("publish failed") }
.`when`(homeFollowingNewsPublishService)
.publishContentUploaded(
contentId = anyLongValue(),
creatorId = anyLongValue(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
title = anyStringValue(),
body = anyStringValue(),
thumbnailImagePath = Mockito.anyString(),
occurredAtUtc = anyLocalDateTime(),
visibleFromAtUtc = anyLocalDateTime(),
isAdult = Mockito.anyBoolean()
)
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
assertTrue(audioContent.isActive)
}
@Test
@DisplayName("업로드 완료 시 공개 시각이 생성 이후 업로드 전이면 최근 소식을 발행한다")
fun shouldPublishNewsWhenReleaseDatePassedBeforeUploadComplete() {
val creator = createMember(id = 2150L, nickname = "audio-late-upload-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.createdAt = LocalDateTime.now().minusHours(2)
audioContent.releaseDate = LocalDateTime.now().minusHours(1)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = audioContent.detail,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.releaseDate!!,
isAdult = false
)
}
@Test
@DisplayName("업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다")
fun shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive() {
val creator = createMember(id = 2200L, nickname = "scheduled-creator")
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0)
audioContent.releaseDate = LocalDateTime.of(2026, 6, 26, 9, 0)
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
service.uploadComplete(
contentId = audioContent.id!!,
content = "output/${audioContent.id}/${audioContent.id}-content.mp3",
duration = "00:03:00"
)
Mockito.verifyNoInteractions(homeFollowingNewsPublishService)
}
@Test
@DisplayName("예약 콘텐츠 공개 작업은 활성화 시점에 최근 소식을 발행한다")
fun shouldPublishNewsWhenReleaseContentActivatesScheduledContent() {
val creator = createMember(id = 2300L, nickname = "release-creator")
creator.profileImage = "profile/release-creator.png"
val audioContent = createAudioContent(creator = creator)
audioContent.isActive = false
audioContent.createdAt = LocalDateTime.of(2026, 6, 24, 9, 0)
audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0)
Mockito.`when`(repository.getNotReleaseContent()).thenReturn(listOf(audioContent))
service.releaseContent()
Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded(
contentId = audioContent.id!!,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = audioContent.title,
body = audioContent.detail,
thumbnailImagePath = audioContent.coverImage,
occurredAtUtc = audioContent.createdAt!!,
visibleFromAtUtc = audioContent.releaseDate!!,
isAdult = false
)
}
private fun createMember(id: Long, nickname: String): Member { private fun createMember(id: Long, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",
@@ -283,6 +459,14 @@ class AudioContentServiceTest {
return member return member
} }
private fun anyLongValue(): Long {
return Mockito.anyLong()
}
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent { private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent {
val theme = AudioContentTheme(theme = "수면", image = "sleep.png") val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
theme.id = 300L theme.id = 300L

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.payment.CanPaymentService
@@ -20,6 +22,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
@@ -32,6 +35,8 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.web.multipart.MultipartFile
import java.io.InputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
@@ -41,7 +46,9 @@ class CreatorCommunityServiceTest {
private lateinit var likeRepository: CreatorCommunityLikeRepository private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository private lateinit var useCanRepository: UseCanRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var service: CreatorCommunityService private lateinit var service: CreatorCommunityService
@BeforeEach @BeforeEach
@@ -51,7 +58,9 @@ class CreatorCommunityServiceTest {
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java) likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java) commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java) useCanRepository = Mockito.mock(UseCanRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
service = CreatorCommunityService( service = CreatorCommunityService(
canPaymentService = Mockito.mock(CanPaymentService::class.java), canPaymentService = Mockito.mock(CanPaymentService::class.java),
@@ -60,12 +69,13 @@ class CreatorCommunityServiceTest {
likeRepository = likeRepository, likeRepository = likeRepository,
commentRepository = commentRepository, commentRepository = commentRepository,
useCanRepository = useCanRepository, useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java), s3Uploader = s3Uploader,
objectMapper = ObjectMapper(), objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
applicationEventPublisher = applicationEventPublisher, applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(), messageSource = SodaMessageSource(),
langContext = LangContext(), langContext = LangContext(),
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
imageBucket = "image-bucket", imageBucket = "image-bucket",
contentBucket = "content-bucket", contentBucket = "content-bucket",
imageHost = "https://cdn.test" imageHost = "https://cdn.test"
@@ -286,6 +296,158 @@ class CreatorCommunityServiceTest {
assertNull(post.fixedAt) assertNull(post.fixedAt)
} }
@Test
@DisplayName("커뮤니티 게시글 생성 성공 후 최근 소식을 게시글 정보로 발행한다")
fun shouldPublishNewsAfterCommunityPostCreated() {
val creator = createMember(id = 900L, role = MemberRole.CREATOR, nickname = "community-creator")
creator.profileImage = "profile/community-creator.png"
val createdAt = LocalDateTime.of(2026, 6, 25, 10, 0)
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 901L
post.createdAt = createdAt
post
}
service.createCommunityPost(
audioFile = null,
postImage = null,
requestString = """{"content":"커뮤니티 새 게시글 본문입니다","price":0,"isCommentAvailable":true,"isAdult":true}""",
member = creator
)
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
postId = 901L,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = "커뮤니티 새 게시글 본문입니다",
body = "커뮤니티 새 게시글 본문입니다",
thumbnailImagePath = null,
occurredAtUtc = createdAt,
isAdult = true
)
}
@Test
@DisplayName("유료 커뮤니티 게시글 최근 소식은 전체 본문을 노출하지 않고 미리보기만 발행한다")
fun shouldPublishPaidCommunityPostNewsWithMaskedContent() {
val creator = createMember(id = 910L, role = MemberRole.CREATOR, nickname = "paid-community-creator")
val fullContent = "유료 커뮤니티 게시글 전체 본문은 최근 소식에서 노출되면 안 됩니다"
val createdAt = LocalDateTime.of(2026, 6, 25, 11, 0)
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 911L
post.createdAt = createdAt
post
}
Mockito.`when`(
s3Uploader.upload(
inputStream = anyInputStream(),
bucket = eqValue("image-bucket"),
filePath = anyStringValue(),
metadata = anyObjectMetadata()
)
).thenReturn("creator_community/911/911-image.png")
service.createCommunityPost(
audioFile = null,
postImage = paidPostImage(),
requestString = """{"content":"$fullContent","price":10,"isCommentAvailable":true,"isAdult":false}""",
member = creator
)
Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated(
postId = 911L,
creatorId = creator.id!!,
creatorNickname = creator.nickname!!,
creatorProfileImagePath = creator.profileImage,
title = "유료 커뮤니티 게시글 전체 ...",
body = "유료 커뮤니티 게시글 전체 ...",
thumbnailImagePath = "creator_community/911/911-image.png",
occurredAtUtc = createdAt,
isAdult = false
)
}
@Test
@DisplayName("최근 소식 발행 실패는 커뮤니티 게시글 생성을 실패시키지 않는다")
fun shouldNotFailCommunityPostCreationWhenNewsPublishFails() {
val creator = createMember(id = 920L, role = MemberRole.CREATOR, nickname = "publish-failure-community")
Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation ->
val post = invocation.getArgument<CreatorCommunity>(0)
post.id = 921L
post.createdAt = LocalDateTime.of(2026, 6, 25, 12, 0)
post
}
Mockito.doAnswer { throw IllegalStateException("publish failed") }
.`when`(homeFollowingNewsPublishService)
.publishCommunityPostCreated(
postId = anyLongValue(),
creatorId = anyLongValue(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
title = anyStringValue(),
body = anyStringValue(),
thumbnailImagePath = Mockito.anyString(),
occurredAtUtc = anyLocalDateTime(),
isAdult = Mockito.anyBoolean()
)
service.createCommunityPost(
audioFile = null,
postImage = null,
requestString = """{"content":"커뮤니티 발행 실패 격리","price":0,"isCommentAvailable":true,"isAdult":false}""",
member = creator
)
Mockito.verify(repository).save(Mockito.any(CreatorCommunity::class.java))
}
private fun paidPostImage(): MultipartFile {
val pngBytes = byteArrayOf(
0x89.toByte(),
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A
)
return Mockito.mock(MultipartFile::class.java).also { image ->
Mockito.`when`(image.bytes).thenReturn(pngBytes)
Mockito.`when`(image.size).thenReturn(pngBytes.size.toLong())
Mockito.`when`(image.contentType).thenReturn("image/png")
Mockito.`when`(image.originalFilename).thenReturn("paid.png")
Mockito.`when`(image.inputStream).thenReturn(pngBytes.inputStream())
}
}
private fun anyInputStream(): InputStream {
return Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
}
private fun anyObjectMetadata(): ObjectMetadata {
return Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata()
}
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun anyLongValue(): Long {
return Mockito.anyLong()
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member { private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",

View File

@@ -66,6 +66,7 @@ class MemberServiceCacheEvictionTest {
memberContentPreferenceService = mock<MemberContentPreferenceService>(), memberContentPreferenceService = mock<MemberContentPreferenceService>(),
objectMapper = ObjectMapper(), objectMapper = ObjectMapper(),
cacheManager = cacheManager, cacheManager = cacheManager,
homeFollowingNewsInboxPort = mock(),
s3Bucket = "test-bucket", s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test" cloudFrontHost = "https://cdn.test"
) )

View File

@@ -75,6 +75,7 @@ class MemberServiceContentPreferenceTest {
memberContentPreferenceService = memberContentPreferenceService, memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(), objectMapper = ObjectMapper(),
cacheManager = mock<CacheManager>(), cacheManager = mock<CacheManager>(),
homeFollowingNewsInboxPort = mock(),
s3Bucket = "test-bucket", s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test" cloudFrontHost = "https://cdn.test"
) )

View File

@@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.login.LoginRequest import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -11,6 +14,7 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@SpringBootTest @SpringBootTest
@@ -19,6 +23,7 @@ import javax.persistence.EntityManager
class MemberServiceTest @Autowired constructor( class MemberServiceTest @Autowired constructor(
private val service: MemberService, private val service: MemberService,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@Test @Test
@@ -42,4 +47,51 @@ class MemberServiceTest @Autowired constructor(
assertEquals("common.error.bad_credentials", exception.messageKey) assertEquals("common.error.bad_credentials", exception.messageKey)
} }
@Test
@DisplayName("언팔로우 성공 시 해당 회원과 크리에이터의 활성 최근 소식을 비활성화하고 재팔로우해도 복구하지 않는다")
fun shouldDeactivateFollowingNewsInboxOnCreatorUnFollowAndKeepInactiveAfterRefollow() {
val member = memberRepository.save(Member(email = "follower@test.com", password = "password", nickname = "follower"))
val creator = memberRepository.save(
Member(
email = "creator@test.com",
password = "password",
nickname = "creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
val inbox = homeFollowingNewsInboxJpaRepository.save(
HomeFollowingNewsInbox(
memberId = member.id!!,
creatorId = creator.id!!,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = "COMMUNITY_POST:1",
targetId = 1L,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 1, 0),
creatorNickname = "creator",
creatorProfileImagePath = null,
title = "title",
body = "body",
thumbnailImagePath = null,
rank = null,
isAdult = false
)
)
entityManager.flush()
entityManager.clear()
service.creatorUnFollow(creatorId = creator.id!!, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
} }

View File

@@ -0,0 +1,103 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.SecurityConfig
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade
import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Import
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(HomeFollowingController::class)
@Import(SecurityConfig::class)
class HomeFollowingControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: HomeFollowingFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@MockBean
private lateinit var tokenProvider: TokenProvider
@MockBean
private lateinit var accessDeniedHandler: JwtAccessDeniedHandler
@MockBean
private lateinit var authenticationEntryPoint: JwtAuthenticationEntryPoint
@Test
@DisplayName("팔로잉 탭 조회는 비회원에게 200 OK와 로그인 필요 응답을 반환한다")
fun shouldReturnLoginRequiredForAnonymous() {
Mockito.doReturn(HomeFollowingTabResponse.loginRequired()).`when`(facade).getFollowingTab(null)
mockMvc.perform(get("/api/v2/home/following"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(true))
.andExpect(jsonPath("$.data.followingCreators").isArray)
.andExpect(jsonPath("$.data.onAirLives").isArray)
.andExpect(jsonPath("$.data.recentChats").isArray)
.andExpect(jsonPath("$.data.monthlySchedules").isArray)
.andExpect(jsonPath("$.data.recentNews").isArray)
}
@Test
@DisplayName("팔로잉 탭 조회는 인증 회원을 facade에 전달하고 로그인 불필요 응답을 반환한다")
fun shouldPassAuthenticatedMemberToFacade() {
val member = Member(
email = "viewer@test.com",
password = "password",
nickname = "viewer",
role = MemberRole.USER
).apply { id = 10L }
Mockito.doReturn(loggedInEmptyResponse()).`when`(facade).getFollowingTab(eqValue(member))
mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(member))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(false))
Mockito.verify(facade).getFollowingTab(eqValue(member))
}
private fun loggedInEmptyResponse(): HomeFollowingTabResponse {
return HomeFollowingTabResponse(
isLoginRequired = false,
followingCreators = emptyList(),
onAirLives = emptyList(),
recentChats = emptyList(),
monthlySchedules = emptyList(),
recentNews = emptyList()
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
}

View File

@@ -0,0 +1,237 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:home-following-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class HomeFollowingEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@MockBean
private lateinit var countryContext: CountryContext
@Test
@DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다")
fun shouldReturnLoginRequiredForAnonymous() {
mockMvc.perform(get("/api/v2/home/following"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(true))
.andExpect(jsonPath("$.data.followingCreators").isEmpty)
.andExpect(jsonPath("$.data.onAirLives").isEmpty)
.andExpect(jsonPath("$.data.recentChats").isEmpty)
.andExpect(jsonPath("$.data.monthlySchedules").isEmpty)
.andExpect(jsonPath("$.data.recentNews").isEmpty)
}
@Test
@DisplayName("팔로잉 탭 API는 인증 회원의 팔로잉/On Air/최근 대화/스케줄/최근 소식을 조립해 반환한다")
fun shouldAssembleFollowingTabForMember() {
Mockito.doReturn("US").`when`(countryContext).countryCode
val fixture = createFixture()
mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(fixture.viewer))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.isLoginRequired").value(false))
.andExpect(jsonPath("$.data.followingCreators[0].creatorId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator"))
.andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId))
.andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live"))
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.chatRoomId))
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("DM"))
.andExpect(jsonPath("$.data.recentChats[0].targetName").value("home-following-creator"))
.andExpect(jsonPath("$.data.recentChats[0].lastMessage").value("recent dm"))
.andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}"))
.andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}"))
.andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString()))
.andExpect(jsonPath("$.data.recentNews[0].creatorId").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].ranking").doesNotExist())
.andExpect(jsonPath("$.data.recentNews[0].rank").value(7))
.andExpect(jsonPath("$.data.recentNews[1].rank").value(nullValue()))
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
val viewer = saveMember("home-following-viewer", MemberRole.USER)
val creator = saveMember("home-following-creator", MemberRole.CREATOR, profileImage = "creator.png")
saveFollowing(viewer, creator)
val live = saveLiveRoom(creator, now.plusHours(1), channelName = "on-air")
val theme = saveTheme()
val audio = saveAudioContent(creator, theme, now.plusDays(1))
val oldNews = saveNews(viewer.id!!, creator.id!!, "old-news", now.minusHours(2), rank = null)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
entityManager.flush()
entityManager.clear()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
liveId = live.id!!,
audioId = audio.id!!,
chatRoomId = chatRoom.id!!,
rankedNewsId = rankedNews.id!!,
oldNewsId = oldNews.id!!
)
}!!
}
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
profileImage = profileImage,
role = role,
countryCode = "US"
)
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
val following = CreatorFollowing(isActive = true).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
val liveRoom = LiveRoom(
title = "home-following-live",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = false
).apply {
member = creator
this.channelName = channelName
}
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveTheme(): AudioContentTheme {
val theme = AudioContentTheme(theme = "home-following-theme", image = "theme.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
val audio = AudioContent(
title = "home-following-audio",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
).apply {
member = creator
this.theme = theme
duration = "00:10:00"
isActive = true
}
entityManager.persist(audio)
return audio
}
private fun saveNews(
memberId: Long,
creatorId: Long,
sourceKey: String,
visibleFromAtUtc: LocalDateTime,
rank: Int?
): HomeFollowingNewsInbox {
val news = HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = visibleFromAtUtc.minusMinutes(30),
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = "home-following-creator",
creatorProfileImagePath = "creator.png",
title = "news-$sourceKey",
body = "news body",
thumbnailImagePath = null,
rank = rank,
isAdult = false
)
entityManager.persist(news)
return news
}
private fun saveDmChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): UserCreatorChatRoom {
val room = UserCreatorChatRoom()
entityManager.persist(room)
val viewerParticipant = UserCreatorChatParticipant(room, viewer)
val creatorParticipant = UserCreatorChatParticipant(room, creator)
entityManager.persist(viewerParticipant)
entityManager.persist(creatorParticipant)
val message = UserCreatorChatMessage(
chatRoom = room,
participant = creatorParticipant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = "recent dm"
)
entityManager.persist(message)
entityManager.flush()
message.createdAt = messageCreatedAt
message.updatedAt = messageCreatedAt
return room
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val liveId: Long,
val audioId: Long,
val chatRoomId: Long,
val rankedNewsId: Long,
val oldNewsId: Long
)
}

View File

@@ -0,0 +1,125 @@
package kr.co.vividnext.sodalive.v2.api.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListItemResponse
import kr.co.vividnext.sodalive.v2.chat.dto.ChatRoomListPageResponse
import kr.co.vividnext.sodalive.v2.chat.service.ChatRoomListService
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryService
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
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class HomeFollowingFacadeTest {
private val queryService = Mockito.mock(HomeFollowingQueryService::class.java)
private val chatRoomListService = Mockito.mock(ChatRoomListService::class.java)
private val facade = HomeFollowingFacade(queryService, chatRoomListService)
@Test
@DisplayName("비로그인 회원은 로그인 필요 응답을 반환하고 조회/채팅 서비스를 호출하지 않는다")
fun shouldReturnLoginRequiredWithoutCallingServicesForAnonymous() {
val response = facade.getFollowingTab(null)
assertTrue(response.isLoginRequired)
assertTrue(response.followingCreators.isEmpty())
assertTrue(response.onAirLives.isEmpty())
assertTrue(response.recentChats.isEmpty())
assertTrue(response.monthlySchedules.isEmpty())
assertTrue(response.recentNews.isEmpty())
Mockito.verifyNoInteractions(queryService, chatRoomListService)
}
@Test
@DisplayName("로그인 회원은 팔로잉 홈 조회 결과에 최근 대화 10개를 조립해 반환한다")
fun shouldAssembleFollowingHomeWithRecentChatsForMember() {
val member = Member(
email = "viewer@test.com",
password = "password",
nickname = "viewer",
role = MemberRole.USER
).apply { id = 10L }
val home = homeFollowing()
val recentChat = ChatRoomListItemResponse(
roomId = 30L,
chatType = "DM",
targetName = "creator",
targetImageUrl = "https://cdn.test/creator.png",
lastMessage = "hello",
lastMessageAt = "2026-06-25T01:00:00Z"
)
Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member)
Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null))
.`when`(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
val response = facade.getFollowingTab(member)
assertFalse(response.isLoginRequired)
assertEquals(1L, response.followingCreators.single().creatorId)
assertEquals(2L, response.onAirLives.single().liveId)
assertEquals(listOf(recentChat), response.recentChats)
assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId)
assertEquals("news-5", response.recentNews.single().newsId)
Mockito.verify(queryService).findHomeFollowing(member)
Mockito.verify(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
}
private fun homeFollowing(): HomeFollowing {
return HomeFollowing(
followingCreators = listOf(
HomeFollowingCreator(
creatorId = 1L,
creatorNickname = "creator",
creatorProfileImageUrl = "https://cdn.test/creator.png"
)
),
onAirLives = listOf(
HomeFollowingLive(
liveId = 2L,
creatorProfileImageUrl = "https://cdn.test/live.png",
creatorNickname = "creator",
title = "live",
startedAtUtc = "2026-06-25T00:00:00Z"
)
),
recentChats = emptyList(),
monthlySchedules = listOf(
HomeFollowingSchedule(
scheduleId = "LIVE:4",
creatorId = 1L,
creatorProfileImageUrl = "https://cdn.test/creator.png",
creatorNickname = "creator",
title = "schedule",
type = CreatorActivityType.LIVE,
targetId = 4L,
scheduledAtUtc = "2026-06-25T02:00:00Z",
isOnAir = false
)
),
recentNews = listOf(
HomeFollowingNews(
newsId = "news-5",
type = FollowingNewsType.CREATOR_RANKING,
creatorProfileImageUrl = "https://cdn.test/news.png",
creatorNickname = "creator",
title = "news",
body = "body",
thumbnailImageUrl = null,
targetId = 1L,
occurredAtUtc = "2026-06-25T03:00:00Z",
visibleFromAtUtc = "2026-06-25T04:00:00Z",
rank = 7
)
)
)
}
}

View File

@@ -0,0 +1,114 @@
package kr.co.vividnext.sodalive.v2.api.home.following.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class HomeFollowingTabResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("비로그인 응답은 로그인이 필요하며 모든 섹션을 빈 배열로 반환한다")
fun shouldReturnLoginRequiredResponseWithEmptySections() {
val response = HomeFollowingTabResponse.loginRequired()
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertTrue(response.isLoginRequired)
assertTrue(response.followingCreators.isEmpty())
assertTrue(response.onAirLives.isEmpty())
assertTrue(response.recentChats.isEmpty())
assertTrue(response.monthlySchedules.isEmpty())
assertTrue(response.recentNews.isEmpty())
assertEquals(true, json["isLoginRequired"].asBoolean())
assertTrue(json["followingCreators"].isArray)
assertTrue(json["onAirLives"].isArray)
assertTrue(json["recentChats"].isArray)
assertTrue(json["monthlySchedules"].isArray)
assertTrue(json["recentNews"].isArray)
}
@Test
@DisplayName("팔로잉 탭 도메인은 creatorId 없는 최근 소식과 nullable rank 응답으로 변환한다")
fun shouldMapDomainToResponseWithoutCreatorIdInRecentNews() {
val response = HomeFollowingTabResponse.from(createHomeFollowing())
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertFalse(response.isLoginRequired)
assertEquals(1L, response.followingCreators.first().creatorId)
assertEquals(10L, response.onAirLives.first().liveId)
assertEquals(100L, response.recentChats.first().roomId)
assertEquals("LIVE:20", response.monthlySchedules.first().scheduleId)
assertEquals(3, response.recentNews.first().rank)
assertEquals(false, json["isLoginRequired"].asBoolean())
assertFalse(json["recentNews"][0].has("creatorId"))
assertFalse(json["recentNews"][0].has("ranking"))
assertFalse(json["recentNews"][0].has("rankChange"))
assertFalse(json["recentNews"][0].has("isNew"))
assertEquals(3, json["recentNews"][0]["rank"].asInt())
assertEquals(true, json["monthlySchedules"][0]["isOnAir"].asBoolean())
}
private fun createHomeFollowing(): HomeFollowing {
return HomeFollowing(
followingCreators = listOf(HomeFollowingCreator(1L, "creator", "https://cdn/profile.jpg")),
onAirLives = listOf(
HomeFollowingLive(
liveId = 10L,
creatorProfileImageUrl = "https://cdn/live-profile.jpg",
creatorNickname = "live-creator",
title = "live title",
startedAtUtc = "2026-06-25T00:00:00Z"
)
),
recentChats = listOf(
ChatRoomListItemResponse(
roomId = 100L,
chatType = "DM",
targetName = "creator",
targetImageUrl = "https://cdn/chat.jpg",
lastMessage = "hello",
lastMessageAt = "2026-06-25T00:01:00Z"
)
),
monthlySchedules = listOf(
HomeFollowingSchedule(
scheduleId = "LIVE:20",
creatorId = 1L,
creatorProfileImageUrl = "https://cdn/schedule.jpg",
creatorNickname = "schedule-creator",
title = "schedule title",
type = CreatorActivityType.LIVE,
targetId = 20L,
scheduledAtUtc = "2026-06-26T00:00:00Z",
isOnAir = true
)
),
recentNews = listOf(
HomeFollowingNews(
newsId = "30",
type = FollowingNewsType.CREATOR_RANKING,
creatorProfileImageUrl = "https://cdn/news-profile.jpg",
creatorNickname = "news-creator",
title = "ranking",
body = "ranked",
thumbnailImageUrl = null,
targetId = 1L,
occurredAtUtc = "2026-06-25T00:00:00Z",
visibleFromAtUtc = "2026-06-25T09:00:00Z",
rank = 3
)
)
)
}
}

View File

@@ -0,0 +1,455 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultHomeFollowingQueryRepository(queryFactory, "https://cdn.test")
@Test
@DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 최신 팔로우순으로 조회한다")
fun shouldFindActiveFollowingCreatorsByLatestFollowOrder() {
val viewer = saveMember("following-viewer", MemberRole.USER)
val activeCreator = saveMember("following-active", MemberRole.CREATOR, profileImage = "active.png")
val inactiveCreator = saveMember("following-inactive", MemberRole.CREATOR, isActive = false)
val olderCreator = saveMember("following-older", MemberRole.CREATOR)
val nonCreator = saveMember("following-non-creator", MemberRole.USER)
val olderFollow = saveFollowing(viewer, olderCreator, isActive = true)
val activeFollow = saveFollowing(viewer, activeCreator, isActive = true)
saveFollowing(viewer, inactiveCreator, isActive = true)
saveFollowing(viewer, nonCreator, isActive = true)
saveFollowing(viewer, saveMember("following-disabled", MemberRole.CREATOR), isActive = false)
olderFollow.createdAt = LocalDateTime.of(2026, 6, 24, 0, 0)
activeFollow.createdAt = LocalDateTime.of(2026, 6, 25, 0, 0)
flushAndClear()
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
assertEquals(listOf(activeCreator.id!!, olderCreator.id!!), creators.map { it.creatorId })
assertEquals("https://cdn.test/active.png", creators.first().creatorProfileImageUrl)
}
@Test
@DisplayName("팔로잉 크리에이터는 회원과 크리에이터의 양방향 차단 관계를 제외한다")
fun shouldExcludeBlockedFollowingCreators() {
val viewer = saveMember("blocked-viewer", MemberRole.USER)
val viewerBlockedCreator = saveMember("viewer-blocked", MemberRole.CREATOR)
val creatorBlockedViewer = saveMember("creator-blocked", MemberRole.CREATOR)
val visibleCreator = saveMember("visible", MemberRole.CREATOR)
saveFollowing(viewer, viewerBlockedCreator)
saveFollowing(viewer, creatorBlockedViewer)
saveFollowing(viewer, visibleCreator)
saveBlock(viewer, viewerBlockedCreator)
saveBlock(creatorBlockedViewer, viewer)
flushAndClear()
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
assertEquals(listOf(visibleCreator.id!!), creators.map { it.creatorId })
}
@Test
@DisplayName("On Air는 팔로우한 크리에이터의 진행 중 라이브만 최신순으로 조회하고 성인 라이브를 필터링한다")
fun shouldFindFollowingOnAirLivesWithAdultFilter() {
val viewer = saveMember("live-viewer", MemberRole.USER)
val creator = saveMember("live-creator", MemberRole.CREATOR, profileImage = "live-profile.png")
val otherCreator = saveMember("live-other", MemberRole.CREATOR)
val nonCreator = saveMember("live-non-creator", MemberRole.USER)
saveFollowing(viewer, creator)
saveFollowing(viewer, otherCreator, isActive = false)
saveFollowing(viewer, nonCreator)
val older = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 10, 0), channelName = "older")
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 11, 0), channelName = "adult", isAdult = true)
val latest = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "latest")
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 13, 0), channelName = null)
saveLiveRoom(otherCreator, LocalDateTime.of(2026, 6, 25, 14, 0), channelName = "other")
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
flushAndClear()
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10)
assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId })
assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl)
}
@Test
@DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
fun shouldFindMonthlySchedulesInKstWindow() {
val viewer = saveMember("schedule-viewer", MemberRole.USER)
val creator = saveMember("schedule-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("schedule-blocked", MemberRole.CREATOR)
val nonCreator = saveMember("schedule-non-creator", MemberRole.USER)
val theme = saveTheme("schedule-theme")
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
val live = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 24, 15, 0), channelName = null)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 1, 0), isActive = false)
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 24, 14, 59), channelName = null)
saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 30, 15, 0))
saveLiveRoom(blockedCreator, LocalDateTime.of(2026, 6, 25, 0, 0), channelName = null)
saveAudioContent(nonCreator, theme, LocalDateTime.of(2026, 6, 25, 2, 0))
saveBlock(viewer, blockedCreator)
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 12, 0),
limit = 3
)
assertEquals(listOf("LIVE:${live.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
assertFalse(schedules.first().isOnAir)
}
@Test
@DisplayName("예약 오디오는 공개 전 비활성 상태여도 duration과 월간 releaseDate가 있으면 스케줄에 포함한다")
fun shouldIncludeInactiveScheduledAudioInMonthlySchedules() {
val viewer = saveMember("schedule-inactive-audio-viewer", MemberRole.USER)
val creator = saveMember("schedule-inactive-audio-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-inactive-audio-theme")
saveFollowing(viewer, creator)
val scheduledAudio = saveAudioContent(
creator = creator,
theme = theme,
releaseDate = LocalDateTime.of(2026, 6, 25, 3, 0),
isActive = false
)
saveAudioContent(
creator = creator,
theme = theme,
releaseDate = LocalDateTime.of(2026, 6, 25, 4, 0),
isActive = false
).duration = null
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 3
)
assertEquals(listOf("AUDIO:${scheduledAudio.id!!}"), schedules.map { it.scheduleId })
}
@Test
@DisplayName("이달의 스케줄은 UTC now를 KST로 변환해 KST 저녁의 같은 날 일정을 포함한다")
fun shouldIncludeSameKstDayScheduleWhenUtcNowIsKstEvening() {
val viewer = saveMember("schedule-evening-viewer", MemberRole.USER)
val creator = saveMember("schedule-evening-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-evening-theme")
saveFollowing(viewer, creator)
val sameKstDayEvening = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 14, 45))
saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 14, 20))
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 3
)
assertTrue(schedules.map { it.scheduleId }.contains("AUDIO:${sameKstDayEvening.id!!}"))
}
@Test
@DisplayName("이달의 스케줄은 같은 시각이면 type과 targetId 순으로 안정 정렬한다")
fun shouldSortMonthlySchedulesByTypeAndTargetIdWhenScheduledAtIsSame() {
val viewer = saveMember("schedule-tie-viewer", MemberRole.USER)
val creator = saveMember("schedule-tie-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-tie-theme")
saveFollowing(viewer, creator)
val sameTime = LocalDateTime.of(2026, 6, 25, 1, 0)
val firstLive = saveLiveRoom(creator, sameTime, channelName = null)
val secondLive = saveLiveRoom(creator, sameTime, channelName = null)
val firstAudio = saveAudioContent(creator, theme, sameTime)
val secondAudio = saveAudioContent(creator, theme, sameTime)
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
assertEquals(
listOf(
"LIVE:${firstLive.id!!}",
"LIVE:${secondLive.id!!}",
"AUDIO:${firstAudio.id!!}",
"AUDIO:${secondAudio.id!!}"
),
schedules.map { it.scheduleId }
)
}
@Test
@DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다")
fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() {
val viewer = saveMember("news-viewer", MemberRole.USER)
val creator = saveMember("news-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
val nonCreator = saveMember("news-non-creator", MemberRole.USER)
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
val oldVisible = saveNews(viewer.id!!, creator.id!!, "old", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3)
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
saveNews(viewer.id!!, creator.id!!, "adult", LocalDateTime.of(2026, 6, 25, 9, 30), isAdult = true)
saveNews(viewer.id!!, blockedCreator.id!!, "blocked", LocalDateTime.of(2026, 6, 25, 9, 45))
saveNews(viewer.id!!, nonCreator.id!!, "non-creator", LocalDateTime.of(2026, 6, 25, 9, 15))
saveBlock(viewer, blockedCreator)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 9, 30),
limit = 30
)
assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(3, null), news.map { it.rank })
}
@Test
@DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다")
fun shouldNotExposeNewsVisibleAfterUtcNow() {
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30))
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31))
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 30
)
assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
val viewer = saveMember("news-target-viewer", MemberRole.USER)
val creator = saveMember("news-target-creator", MemberRole.CREATOR)
val theme = saveTheme("news-target-theme")
saveFollowing(viewer, creator)
val activeAudio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isActive = true)
val inactiveAudio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10), isActive = false)
val activePost = saveCommunityPost(creator, "active-post", isActive = true)
val inactivePost = saveCommunityPost(creator, "inactive-post", isActive = false)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "active-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = activeAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = inactiveAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "active-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 2),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = activePost.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 3),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = inactivePost.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
assertEquals(
listOf(activePost.id!!, activeAudio.id!!),
news.map { it.targetId }
)
}
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null, isActive: Boolean = true): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
profileImage = profileImage,
role = role
)
member.isActive = isActive
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean = true): CreatorFollowing {
val following = CreatorFollowing(isActive = isActive).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
val block = BlockMember(isActive = true).apply {
this.member = member
this.blockedMember = blockedMember
}
entityManager.persist(block)
return block
}
private fun saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult
).apply {
member = creator
this.channelName = channelName
}
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveTheme(seed: String): AudioContentTheme {
val theme = AudioContentTheme(theme = seed, image = "$seed.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
isActive: Boolean = true
): AudioContent {
val content = AudioContent(
title = "audio-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
).apply {
member = creator
this.theme = theme
duration = "00:10:00"
this.isActive = isActive
}
entityManager.persist(content)
return content
}
private fun saveCommunityPost(creator: Member, content: String, isActive: Boolean): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
isActive = isActive
).apply {
member = creator
}
entityManager.persist(post)
return post
}
private fun saveNews(
memberId: Long,
creatorId: Long,
sourceKey: String,
visibleFromAtUtc: LocalDateTime,
rank: Int? = null,
isAdult: Boolean = false,
newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING,
targetId: Long = creatorId
): HomeFollowingNewsInbox {
val news = HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = newsType,
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = visibleFromAtUtc.minusHours(1),
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title-$sourceKey",
body = "body",
thumbnailImagePath = null,
rank = rank,
isAdult = isAdult
)
entityManager.persist(news)
return news
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,66 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import javax.persistence.EntityManager
class HomeFollowingNewsInboxPersistenceAdapterRetryTest {
@Test
@DisplayName("insertIgnoreAll은 JPA bulk insert unique 충돌 시 기존 row를 재조회하고 남은 row만 재시도한다")
fun shouldRetryRemainingRowsWhenBulkInsertConflictsWithExistingRow() {
val repository = Mockito.mock(HomeFollowingNewsInboxJpaRepository::class.java)
val entityManager = Mockito.mock(EntityManager::class.java)
val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
val sourceKey = "CREATOR_RANKING:1:2026-06-25"
Mockito.`when`(
repository.findExistingMemberIds(
FollowingNewsType.CREATOR_RANKING.name,
sourceKey,
listOf(10L)
)
).thenReturn(emptyList()).thenReturn(listOf(10L))
Mockito.`when`(repository.saveAll(Mockito.anyList<HomeFollowingNewsInbox>()))
.thenThrow(DataIntegrityViolationException("duplicate"))
val insertedCount = adapter.insertIgnoreAll(
listOf(record(memberId = 10L, creatorId = 1L, sourceKey = sourceKey))
)
assertEquals(0, insertedCount)
Mockito.verify(repository, Mockito.times(2)).findExistingMemberIds(
FollowingNewsType.CREATOR_RANKING.name,
sourceKey,
listOf(10L)
)
Mockito.verify(repository, Mockito.times(1)).saveAll(Mockito.anyList<HomeFollowingNewsInbox>())
}
private fun record(
memberId: Long,
creatorId: Long,
sourceKey: String
): HomeFollowingNewsInboxRecord {
return HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = FollowingNewsType.CREATOR_RANKING.name,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title",
body = "body",
thumbnailImagePath = null,
rank = 1,
isAdult = false
)
}
}

View File

@@ -0,0 +1,161 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.transaction.TestTransaction
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
) {
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
@Test
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
fun shouldInsertOnlyNewRowsWhenUniqueSourceIsDuplicated() {
val firstInsertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = "CREATOR_RANKING:1:2026-06-25")))
val secondInsertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = "CREATOR_RANKING:1:2026-06-25")))
entityManager.flush()
entityManager.clear()
assertEquals(1, firstInsertCount)
assertEquals(0, secondInsertCount)
assertEquals(1, repository.findAll().size)
assertEquals(FollowingNewsType.CREATOR_RANKING, repository.findAll().first().newsType)
}
@Test
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
@DisplayName("실제 unique 중복 무시 이후 insertIgnoreAll을 호출한 트랜잭션은 커밋 가능하다")
fun shouldCommitTransactionAfterRealDuplicateCollisionIsIgnored() {
val sourceKey = "real-duplicate"
adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
entityManager.flush()
entityManager.clear()
val insertCount = adapter.insertIgnoreAll(listOf(record(sourceKey = sourceKey)))
val rows = repository.findAll()
assertEquals(0, insertCount)
assertEquals(1, rows.size)
TestTransaction.flagForCommit()
assertDoesNotThrow { TestTransaction.end() }
}
@Test
@DisplayName("memberId creatorId 기준 활성 inbox row를 비활성화한다")
fun shouldDeactivateActiveRowsByMemberAndCreator() {
adapter.insertIgnoreAll(
listOf(
record(memberId = 10L, creatorId = 1L, sourceKey = "A"),
record(memberId = 10L, creatorId = 1L, sourceKey = "B"),
record(memberId = 11L, creatorId = 1L, sourceKey = "C")
)
)
entityManager.flush()
entityManager.clear()
val deactivatedCount = adapter.deactivateByMemberIdAndCreatorId(memberId = 10L, creatorId = 1L)
entityManager.flush()
entityManager.clear()
val rows = repository.findAll().sortedBy { it.sourceKey }
assertEquals(2L, deactivatedCount)
assertFalse(rows.first { it.sourceKey == "A" }.isActive)
assertFalse(rows.first { it.sourceKey == "B" }.isActive)
assertTrue(rows.first { it.sourceKey == "C" }.isActive)
}
@Test
@DisplayName("findActiveFollowerIds는 활성 팔로우 관계가 있는 회원 id만 반환한다")
fun shouldFindOnlyActiveFollowerIds() {
val creator = saveMember("creator", MemberRole.CREATOR)
val activeFollowerWithoutInbox = saveMember("active-follower", MemberRole.USER)
val inactiveFollowerWithInbox = saveMember("inactive-follower", MemberRole.USER)
val otherCreatorFollower = saveMember("other-follower", MemberRole.USER)
val otherCreator = saveMember("other-creator", MemberRole.CREATOR)
saveFollowing(activeFollowerWithoutInbox, creator, isActive = true)
saveFollowing(inactiveFollowerWithInbox, creator, isActive = false)
saveFollowing(otherCreatorFollower, otherCreator, isActive = true)
adapter.insertIgnoreAll(
listOf(
record(memberId = inactiveFollowerWithInbox.id!!, creatorId = creator.id!!, sourceKey = "inactive-inbox"),
record(memberId = otherCreatorFollower.id!!, creatorId = otherCreator.id!!, sourceKey = "other-inbox")
)
)
entityManager.flush()
entityManager.clear()
val followerIds = adapter.findActiveFollowerIds(creatorId = creator.id!!)
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
}
private fun saveMember(seed: String, role: MemberRole): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
role = role
)
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean): CreatorFollowing {
val following = CreatorFollowing(isActive = isActive).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun record(
memberId: Long = 10L,
creatorId: Long = 1L,
sourceKey: String,
newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING
): HomeFollowingNewsInboxRecord {
return HomeFollowingNewsInboxRecord(
memberId = memberId,
creatorId = creatorId,
newsType = newsType.name,
sourceKey = sourceKey,
targetId = creatorId,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 0, 0, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0, 0),
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title",
body = "body",
thumbnailImagePath = null,
rank = 1,
isAdult = false
)
}
}

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class HomeFollowingNewsPublishServiceTest {
@Test
@DisplayName("커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다")
fun shouldPublishCommunityPostCreatedToActiveFollowers() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(1L, 2L))
val service = HomeFollowingNewsPublishService(inboxPort)
val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 2, 3)
service.publishCommunityPostCreated(
postId = 100L,
creatorId = 9L,
creatorNickname = "creator",
creatorProfileImagePath = "profile.png",
title = "새 커뮤니티 글",
body = "본문",
thumbnailImagePath = "post.png",
occurredAtUtc = occurredAtUtc,
isAdult = true
)
assertEquals(9L, inboxPort.findActiveFollowerIdsCreatorId)
assertEquals(listOf(1L, 2L), inboxPort.records.map { it.memberId })
val record = inboxPort.records.first()
assertEquals(FollowingNewsType.COMMUNITY_POST.name, record.newsType)
assertEquals("COMMUNITY_POST:100", record.sourceKey)
assertEquals(100L, record.targetId)
assertEquals(occurredAtUtc, record.visibleFromAtUtc)
assertEquals("post.png", record.thumbnailImagePath)
assertEquals(true, record.isAdult)
}
@Test
@DisplayName("오디오 콘텐츠 발행은 공개 시각을 visibleFromAtUtc로 저장한다")
fun shouldPublishContentUploadedWithVisibleFromAtUtc() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(3L))
val service = HomeFollowingNewsPublishService(inboxPort)
val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0)
val visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0)
service.publishContentUploaded(
contentId = 200L,
creatorId = 8L,
creatorNickname = "audio-creator",
creatorProfileImagePath = null,
title = "오디오 제목",
body = "오디오 설명",
thumbnailImagePath = "cover.jpg",
occurredAtUtc = occurredAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
isAdult = false
)
val record = inboxPort.records.single()
assertEquals(FollowingNewsType.AUDIO_CONTENT.name, record.newsType)
assertEquals("AUDIO_CONTENT:200", record.sourceKey)
assertEquals(occurredAtUtc, record.occurredAtUtc)
assertEquals(visibleFromAtUtc, record.visibleFromAtUtc)
assertEquals("cover.jpg", record.thumbnailImagePath)
}
@Test
@DisplayName("발행 record의 title과 body는 inbox 컬럼 길이에 맞게 잘린다")
fun shouldTruncateTitleAndBodyToInboxColumnLimits() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(5L))
val service = HomeFollowingNewsPublishService(inboxPort)
service.publishContentUploaded(
contentId = 201L,
creatorId = 8L,
creatorNickname = "audio-creator",
creatorProfileImagePath = null,
title = "".repeat(300),
body = "".repeat(1_200),
thumbnailImagePath = null,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
isAdult = false
)
val record = inboxPort.records.single()
assertEquals(255, record.title.length)
assertEquals(1_000, record.body.length)
}
@Test
@DisplayName("크리에이터 랭킹 발행은 rank와 스냅샷 노출 시각을 저장한다")
fun shouldPublishCreatorRankingVisibleWithRankAndVisibleFromAtUtc() {
val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(4L))
val service = HomeFollowingNewsPublishService(inboxPort)
val aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0)
val visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
service.publishCreatorRankingVisible(
creatorId = 7L,
creatorNickname = "ranker",
creatorProfileImagePath = "ranker.png",
aggregationStartAtUtc = aggregationStartAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
rank = 2
)
val record = inboxPort.records.single()
assertEquals(FollowingNewsType.CREATOR_RANKING.name, record.newsType)
assertEquals("CREATOR_RANKING:7:2026-05-31T15:00", record.sourceKey)
assertEquals(7L, record.targetId)
assertEquals(visibleFromAtUtc, record.occurredAtUtc)
assertEquals(visibleFromAtUtc, record.visibleFromAtUtc)
assertEquals(2, record.rank)
}
}
private class FakeHomeFollowingNewsInboxPort(
private val activeFollowerIds: List<Long>
) : HomeFollowingNewsInboxPort {
val records = mutableListOf<HomeFollowingNewsInboxRecord>()
var findActiveFollowerIdsCreatorId: Long? = null
override fun insertIgnoreAll(records: List<HomeFollowingNewsInboxRecord>): Int {
this.records.addAll(records)
return records.size
}
override fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long = 0
override fun findActiveFollowerIds(creatorId: Long): List<Long> {
findActiveFollowerIdsCreatorId = creatorId
return activeFollowerIds
}
}

View File

@@ -0,0 +1,137 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
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 kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.TimeZone
class HomeFollowingQueryServiceTest {
private val queryPort = RecordingHomeFollowingQueryPort()
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val now = LocalDateTime.of(2026, 6, 25, 12, 0)
private val service = HomeFollowingQueryService(
queryPort,
memberContentPreferenceService
) { now }
@Test
@DisplayName("팔로잉 탭 조회는 각 섹션의 기본 limit와 고정 now를 port에 전달한다")
fun shouldCallQueryPortWithDefaultLimitsAndNow() {
val member = member(10L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
val home = service.findHomeFollowing(member)
assertEquals(listOf(HomeFollowingCreator(1L, "creator", "profile")), home.followingCreators)
assertEquals(emptyList<Any>(), home.recentChats)
assertEquals(20, queryPort.followingCreatorsLimit)
assertEquals(10, queryPort.onAirLivesLimit)
assertEquals(3, queryPort.monthlySchedulesLimit)
assertEquals(30, queryPort.recentNewsLimit)
assertEquals(now, queryPort.monthlySchedulesNow)
assertEquals(now, queryPort.recentNewsNow)
}
@Test
@DisplayName("성인 콘텐츠 노출 가능 여부는 On Air, 스케줄, 최근 소식 조회에 전달된다")
fun shouldPassAdultContentVisibilityToQueryPort() {
val member = member(11L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(false)
service.findHomeFollowing(member)
assertEquals(false, queryPort.onAirCanViewAdultContent)
assertEquals(false, queryPort.monthlySchedulesCanViewAdultContent)
assertEquals(false, queryPort.recentNewsCanViewAdultContent)
assertEquals(11L, queryPort.memberId)
}
@Test
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
val originalTimeZone = TimeZone.getDefault()
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
val defaultQueryPort = RecordingHomeFollowingQueryPort()
val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService)
val member = member(12L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
defaultService.findHomeFollowing(member)
val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1)
val capturedNow = defaultQueryPort.recentNewsNow!!
assertFalse(capturedNow.isBefore(beforeUtc))
assertFalse(capturedNow.isAfter(afterUtc))
assertTrue(LocalDateTime.now().isAfter(capturedNow.plusHours(8)))
} finally {
TimeZone.setDefault(originalTimeZone)
}
}
private fun member(id: Long): Member {
return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id }
}
private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort {
var memberId: Long? = null
var followingCreatorsLimit: Int? = null
var onAirLivesLimit: Int? = null
var onAirCanViewAdultContent: Boolean? = null
var monthlySchedulesLimit: Int? = null
var monthlySchedulesNow: LocalDateTime? = null
var monthlySchedulesCanViewAdultContent: Boolean? = null
var recentNewsLimit: Int? = null
var recentNewsNow: LocalDateTime? = null
var recentNewsCanViewAdultContent: Boolean? = null
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
this.memberId = memberId
followingCreatorsLimit = limit
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
onAirCanViewAdultContent = canViewAdultContent
onAirLivesLimit = limit
return emptyList()
}
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
monthlySchedulesCanViewAdultContent = canViewAdultContent
monthlySchedulesNow = now
monthlySchedulesLimit = limit
return emptyList()
}
override fun findRecentNews(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime,
limit: Int
): List<HomeFollowingNews> {
recentNewsCanViewAdultContent = canViewAdultContent
recentNewsNow = nowUtc
recentNewsLimit = limit
return emptyList()
}
}
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.home.following.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class HomeFollowingNewsSourceKeyTest {
@Test
@DisplayName("크리에이터 랭킹 source key는 타입, 크리에이터, 집계 시작 시각으로 생성한다")
fun shouldCreateCreatorRankingSourceKey() {
val aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0)
val sourceKey = HomeFollowingNewsSourceKey.creatorRanking(
creatorId = 10L,
aggregationStartAtUtc = aggregationStartAtUtc
)
assertEquals("CREATOR_RANKING:10:2026-05-31T15:00", sourceKey)
}
@Test
@DisplayName("오디오 콘텐츠 source key는 타입과 콘텐츠 id로 생성한다")
fun shouldCreateAudioContentSourceKey() {
assertEquals("AUDIO_CONTENT:300", HomeFollowingNewsSourceKey.audioContent(contentId = 300L))
}
@Test
@DisplayName("커뮤니티 게시글 source key는 타입과 게시글 id로 생성한다")
fun shouldCreateCommunityPostSourceKey() {
assertEquals("COMMUNITY_POST:400", HomeFollowingNewsSourceKey.communityPost(postId = 400L))
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
@@ -11,6 +12,7 @@ import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionSynchronizationManager
@@ -165,16 +167,125 @@ class CreatorRankingSnapshotRefreshServiceTest {
assertEquals(true, output.out.contains("error=aggregate failed")) assertEquals(true, output.out.contains("error=aggregate failed"))
} }
@Test
@DisplayName("주간 스냅샷 저장 성공 후 크리에이터 랭킹 최근 소식을 순위와 함께 발행한다")
fun shouldPublishCreatorRankingNewsAfterSnapshotsAreReplaced() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 1L,
creatorNickname = "creator-1",
creatorProfileImagePath = "profile-1.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 2L,
creatorNickname = "creator-2",
creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
}
@Test
@DisplayName("주간 스냅샷 저장 실패 시 크리에이터 랭킹 최근 소식을 발행하지 않는다")
fun shouldNotPublishCreatorRankingNewsWhenReplaceSnapshotsFails() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
snapshotPort.failure = IllegalStateException("replace failed")
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
}
Mockito.verifyNoInteractions(publishService)
}
@Test
@DisplayName("일부 크리에이터 랭킹 최근 소식 발행 실패는 스냅샷 갱신을 실패시키지 않는다")
fun shouldNotFailSnapshotRefreshWhenCreatorRankingNewsPublishFails() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
Mockito.doAnswer { invocation ->
if (invocation.getArgument<Long>(0) == 1L) {
throw IllegalStateException("publish failed")
}
0
}.`when`(publishService)
.publishCreatorRankingVisible(
creatorId = Mockito.anyLong(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
aggregationStartAtUtc = anyLocalDateTime(),
visibleFromAtUtc = anyLocalDateTime(),
rank = Mockito.anyInt()
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 2L,
creatorNickname = "creator-2",
creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
}
private fun service( private fun service(
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(), aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort() snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(),
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
): CreatorRankingSnapshotRefreshService { ): CreatorRankingSnapshotRefreshService {
return CreatorRankingSnapshotRefreshService( return CreatorRankingSnapshotRefreshService(
aggregationPort = aggregationPort, aggregationPort = aggregationPort,
snapshotPort = snapshotPort snapshotPort = snapshotPort,
homeFollowingNewsPublishService = publishService
) )
} }
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
}
private fun candidate( private fun candidate(
creatorId: Long, creatorId: Long,
finalScore: Double = 0.0, finalScore: Double = 0.0,
@@ -247,6 +358,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
var aggregationStartAtUtc: LocalDateTime? = null var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null var aggregationEndAtUtc: LocalDateTime? = null
var visibleFromAtUtc: LocalDateTime? = null var visibleFromAtUtc: LocalDateTime? = null
var failure: RuntimeException? = null
override fun findSnapshotsByAggregationPeriod( override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -281,6 +393,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
visibleFromAtUtc: LocalDateTime, visibleFromAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) { ) {
failure?.let { throw it }
this.rankingType = rankingType this.rankingType = rankingType
this.aggregationStartAtUtc = aggregationStartAtUtc this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc this.aggregationEndAtUtc = aggregationEndAtUtc