Compare commits
19 Commits
e411beb649
...
f2be184fc9
| Author | SHA1 | Date | |
|---|---|---|---|
| f2be184fc9 | |||
| 9a20c54670 | |||
| 75bd0ced28 | |||
| 59439df33e | |||
| e89b5e1dad | |||
| 9fc6643c18 | |||
| 36a60c76eb | |||
| 670b3d9f54 | |||
| e598d2058d | |||
| 8b5c872b45 | |||
| f5d755b2a6 | |||
| 45fc8bd21f | |||
| 91c648ca44 | |||
| b2b4a74adc | |||
| 315412fb42 | |||
| a28991b585 | |||
| cbcd87875c | |||
| e4052d097a | |||
| 3add66ff7a |
@@ -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);
|
||||
652
docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md
Normal file
652
docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md
Normal 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`.
|
||||
366
docs/20260625_메인_홈_팔로잉_탭_API/prd.md
Normal file
366
docs/20260625_메인_홈_팔로잉_탭_API/prd.md
Normal 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` 판단 조건을 같은 조건으로 추출할지 검토한다.
|
||||
@@ -106,6 +106,7 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
|
||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -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.contentpreference.isAdultVisibleByPolicy
|
||||
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 org.slf4j.LoggerFactory
|
||||
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.stereotype.Service
|
||||
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 java.text.SimpleDateFormat
|
||||
import java.time.LocalDateTime
|
||||
@@ -82,6 +85,7 @@ class AudioContentService(
|
||||
private val langContext: LangContext,
|
||||
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
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
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
@@ -494,6 +499,10 @@ class AudioContentService(
|
||||
deepLinkId = contentId
|
||||
)
|
||||
)
|
||||
publishContentUploadedAfterCommit(
|
||||
audioContent = audioContent,
|
||||
visibleFromAtUtc = audioContent.releaseDate ?: audioContent.createdAt ?: now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,9 +529,64 @@ class AudioContentService(
|
||||
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
|
||||
fun getDetail(
|
||||
id: Long,
|
||||
|
||||
@@ -27,11 +27,15 @@ import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
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.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
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 java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
@@ -52,6 +56,7 @@ class CreatorCommunityService(
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val imageBucket: String,
|
||||
@@ -62,6 +67,8 @@ class CreatorCommunityService(
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Transactional
|
||||
fun createCommunityPost(
|
||||
audioFile: MultipartFile?,
|
||||
@@ -134,6 +141,54 @@ class CreatorCommunityService(
|
||||
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
|
||||
|
||||
@@ -53,6 +53,7 @@ import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||
import kr.co.vividnext.sodalive.point.MemberPointRepository
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
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.cache.CacheManager
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -109,6 +110,7 @@ class MemberService(
|
||||
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val cacheManager: CacheManager,
|
||||
private val homeFollowingNewsInboxPort: HomeFollowingNewsInboxPort,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
@@ -525,6 +527,7 @@ class MemberService(
|
||||
|
||||
if (creatorFollowing != null) {
|
||||
creatorFollowing.isActive = false
|
||||
homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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.CreatorRankingScorePolicy
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
|
||||
@@ -19,7 +20,8 @@ import java.time.ZonedDateTime
|
||||
@Service
|
||||
class CreatorRankingSnapshotRefreshService(
|
||||
private val aggregationPort: CreatorRankingAggregationPort,
|
||||
private val snapshotPort: CreatorRankingSnapshotPort
|
||||
private val snapshotPort: CreatorRankingSnapshotPort,
|
||||
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val periodPolicy = CreatorRankingPeriodPolicy()
|
||||
@@ -47,6 +49,28 @@ class CreatorRankingSnapshotRefreshService(
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
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)
|
||||
}.onSuccess { counts ->
|
||||
afterCommit {
|
||||
@@ -92,12 +116,18 @@ class CreatorRankingSnapshotRefreshService(
|
||||
|
||||
private fun afterCommit(action: () -> Unit) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
action()
|
||||
runCatching(action).onFailure { ex ->
|
||||
log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex)
|
||||
}
|
||||
return
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
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 org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
@@ -57,6 +58,7 @@ class AudioContentServiceTest {
|
||||
private lateinit var audioContentCloudFront: AudioContentCloudFront
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
|
||||
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
|
||||
private lateinit var service: AudioContentService
|
||||
|
||||
@@ -80,6 +82,7 @@ class AudioContentServiceTest {
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
|
||||
service = AudioContentService(
|
||||
repository = repository,
|
||||
@@ -103,6 +106,7 @@ class AudioContentServiceTest {
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
contentThemeTranslationRepository = contentThemeTranslationRepository,
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
audioContentBucket = "audio-bucket",
|
||||
coverImageBucket = "cover-bucket",
|
||||
coverImageHost = "https://cdn.test"
|
||||
@@ -273,6 +277,178 @@ class AudioContentServiceTest {
|
||||
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 {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
@@ -283,6 +459,14 @@ class AudioContentServiceTest {
|
||||
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 {
|
||||
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
|
||||
theme.id = 300L
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.module.kotlin.KotlinModule
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
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.MemberRole
|
||||
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.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
@@ -32,6 +35,8 @@ import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
@@ -41,7 +46,9 @@ class CreatorCommunityServiceTest {
|
||||
private lateinit var likeRepository: CreatorCommunityLikeRepository
|
||||
private lateinit var commentRepository: CreatorCommunityCommentRepository
|
||||
private lateinit var useCanRepository: UseCanRepository
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
|
||||
private lateinit var service: CreatorCommunityService
|
||||
|
||||
@BeforeEach
|
||||
@@ -51,7 +58,9 @@ class CreatorCommunityServiceTest {
|
||||
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
|
||||
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
|
||||
useCanRepository = Mockito.mock(UseCanRepository::class.java)
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java)
|
||||
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
|
||||
service = CreatorCommunityService(
|
||||
canPaymentService = Mockito.mock(CanPaymentService::class.java),
|
||||
@@ -60,12 +69,13 @@ class CreatorCommunityServiceTest {
|
||||
likeRepository = likeRepository,
|
||||
commentRepository = commentRepository,
|
||||
useCanRepository = useCanRepository,
|
||||
s3Uploader = Mockito.mock(S3Uploader::class.java),
|
||||
objectMapper = ObjectMapper(),
|
||||
s3Uploader = s3Uploader,
|
||||
objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
|
||||
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||
applicationEventPublisher = applicationEventPublisher,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
|
||||
imageBucket = "image-bucket",
|
||||
contentBucket = "content-bucket",
|
||||
imageHost = "https://cdn.test"
|
||||
@@ -286,6 +296,158 @@ class CreatorCommunityServiceTest {
|
||||
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 {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
|
||||
@@ -66,6 +66,7 @@ class MemberServiceCacheEvictionTest {
|
||||
memberContentPreferenceService = mock<MemberContentPreferenceService>(),
|
||||
objectMapper = ObjectMapper(),
|
||||
cacheManager = cacheManager,
|
||||
homeFollowingNewsInboxPort = mock(),
|
||||
s3Bucket = "test-bucket",
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
|
||||
@@ -75,6 +75,7 @@ class MemberServiceContentPreferenceTest {
|
||||
memberContentPreferenceService = memberContentPreferenceService,
|
||||
objectMapper = ObjectMapper(),
|
||||
cacheManager = mock<CacheManager>(),
|
||||
homeFollowingNewsInboxPort = mock(),
|
||||
s3Bucket = "test-bucket",
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
|
||||
@@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.member
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||
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.assertThrows
|
||||
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.test.context.ContextConfiguration
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@@ -19,6 +23,7 @@ import javax.persistence.EntityManager
|
||||
class MemberServiceTest @Autowired constructor(
|
||||
private val service: MemberService,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@@ -42,4 +47,51 @@ class MemberServiceTest @Autowired constructor(
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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.CreatorRankingType
|
||||
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.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
@@ -165,16 +167,125 @@ class CreatorRankingSnapshotRefreshServiceTest {
|
||||
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(
|
||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
|
||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(),
|
||||
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
): CreatorRankingSnapshotRefreshService {
|
||||
return CreatorRankingSnapshotRefreshService(
|
||||
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(
|
||||
creatorId: Long,
|
||||
finalScore: Double = 0.0,
|
||||
@@ -247,6 +358,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||
var aggregationStartAtUtc: LocalDateTime? = null
|
||||
var aggregationEndAtUtc: LocalDateTime? = null
|
||||
var visibleFromAtUtc: LocalDateTime? = null
|
||||
var failure: RuntimeException? = null
|
||||
|
||||
override fun findSnapshotsByAggregationPeriod(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
@@ -281,6 +393,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||
visibleFromAtUtc: LocalDateTime,
|
||||
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||
) {
|
||||
failure?.let { throw it }
|
||||
this.rankingType = rankingType
|
||||
this.aggregationStartAtUtc = aggregationStartAtUtc
|
||||
this.aggregationEndAtUtc = aggregationEndAtUtc
|
||||
|
||||
Reference in New Issue
Block a user