Compare commits
23 Commits
a96d9ddc76
...
2c44cb90ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c44cb90ee | |||
| 02d5446888 | |||
| 8e76c2d640 | |||
| 951f6789f0 | |||
| 046ce700c7 | |||
| 13b679d091 | |||
| 7e9e0aa320 | |||
| 14f648cd10 | |||
| 34e05a577e | |||
| e516a7406f | |||
| b2fae3e081 | |||
| 4ffd880440 | |||
| 45fafa9b00 | |||
| bb44eaa8dd | |||
| 408a342f17 | |||
| 2848f07573 | |||
| e2a3aeefc2 | |||
| 0ebb686ce6 | |||
| 90bf4c770c | |||
| 831c26c155 | |||
| 41937c7cce | |||
| dc9ee06bb8 | |||
| b1b6de8c3b |
594
docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md
Normal file
594
docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
# 크리에이터 채널 FanTalk 탭 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 크리에이터 채널 FanTalk 탭의 전체 FanTalk 개수와 페이징된 FanTalk 글 목록, 크리에이터 답글을 조회할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 조립 계층에 둔다. FanTalk 조회 service, page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 두고 `v2.api.*`에 의존하지 않는다. 저장 엔티티는 legacy `CreatorCheers`를 그대로 사용하되, legacy timezone 기반 cheers 응답은 재사용하지 않고 V2 탭 전용 UTC 응답을 만든다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 구현 전 확정 사항
|
||||||
|
|
||||||
|
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/fan-talks`
|
||||||
|
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
||||||
|
- request:
|
||||||
|
- path variable: `creatorId`
|
||||||
|
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
|
||||||
|
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
|
||||||
|
- page 기준: 기존 크리에이터 채널 V2 탭 API와 동일한 0 기반 page index
|
||||||
|
- response:
|
||||||
|
- `fanTalkCount`: 조회자가 조회 가능한 최상위 FanTalk 전체 개수
|
||||||
|
- `fanTalks`: FanTalk 글 목록
|
||||||
|
- `page`: fallback 보정 후 실제 적용된 page index
|
||||||
|
- `size`: fallback 보정 후 실제 적용된 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- FanTalk item:
|
||||||
|
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`, `creatorReplies`
|
||||||
|
- creator reply item:
|
||||||
|
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`
|
||||||
|
- 저장 엔티티: `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`
|
||||||
|
- 최상위 FanTalk 기준: `creatorCheers.creator.id == creatorId`, `creatorCheers.isActive == true`, `creatorCheers.parent is null`
|
||||||
|
- 크리에이터 답글 기준: `creatorCheers.parent.id in parentFanTalkIds`, `creatorCheers.creator.id == creatorId`, `creatorCheers.member.id == creatorId`, `creatorCheers.isActive == true`
|
||||||
|
- 팬끼리 답글 작성은 현재 불가능하므로 응답 대상에 포함하지 않는다. 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 있어도 제외한다.
|
||||||
|
- 목록 정렬:
|
||||||
|
- 최상위 FanTalk: `createdAt desc`, `id desc`
|
||||||
|
- 크리에이터 답글: `createdAt asc`, `id asc`
|
||||||
|
- `fanTalkCount`는 최상위 FanTalk만 계산한다. 답글은 count에 포함하지 않는다.
|
||||||
|
- `hasNext`는 `size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
|
||||||
|
- 차단 필터:
|
||||||
|
- 조회자와 FanTalk 작성자가 서로 차단 관계이면 해당 최상위 FanTalk는 목록과 count에서 제외한다.
|
||||||
|
- 차단으로 제외된 최상위 FanTalk의 답글도 응답에 포함하지 않는다.
|
||||||
|
- 조회자와 조회 대상 크리에이터 사이 차단 관계는 기존 크리에이터 채널 접근 정책과 동일하게 API 접근 자체를 거부한다.
|
||||||
|
- creator 검증:
|
||||||
|
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
|
||||||
|
- `createdAtUtc`는 `CreatorCheers.createdAt`을 `kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
|
||||||
|
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
||||||
|
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답처럼 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||||
|
- `languageCode`는 FanTalk 탭 응답에 포함하지 않는다.
|
||||||
|
- legacy `/profile/{id}/cheers` 공개 endpoint와 응답 스키마는 변경하지 않는다.
|
||||||
|
- 크리에이터 채널 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### FanTalk 탭 신규 API 조립 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||||
|
|
||||||
|
### FanTalk 도메인 조회 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
### 기존 파일 확인/재사용
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRole.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
||||||
|
|
||||||
|
### 문서 산출물
|
||||||
|
- Create: `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`
|
||||||
|
- Verify: `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkTabResponse(
|
||||||
|
val fanTalkCount: Int,
|
||||||
|
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
|
||||||
|
return CreatorChannelFanTalkTabResponse(
|
||||||
|
fanTalkCount = tab.fanTalkCount,
|
||||||
|
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
|
||||||
|
return CreatorChannelFanTalkResponse(
|
||||||
|
fanTalkId = fanTalk.fanTalkId,
|
||||||
|
writerId = fanTalk.writerId,
|
||||||
|
writerNickname = fanTalk.writerNickname,
|
||||||
|
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
|
||||||
|
content = fanTalk.content,
|
||||||
|
createdAtUtc = fanTalk.createdAt.toUtcIso(),
|
||||||
|
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReplyResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
|
||||||
|
return CreatorChannelFanTalkReplyResponse(
|
||||||
|
fanTalkId = reply.fanTalkId,
|
||||||
|
writerId = reply.writerId,
|
||||||
|
writerNickname = reply.writerNickname,
|
||||||
|
writerProfileImageUrl = reply.writerProfileImageUrl,
|
||||||
|
content = reply.content,
|
||||||
|
createdAtUtc = reply.createdAt.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Domain / Port 초안
|
||||||
|
|
||||||
|
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkTab(
|
||||||
|
val fanTalkCount: Int,
|
||||||
|
val fanTalks: List<CreatorChannelFanTalk>,
|
||||||
|
val page: CreatorChannelPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalk(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val creatorReplies: List<CreatorChannelFanTalkReply>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReply(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface CreatorChannelFanTalkQueryPort {
|
||||||
|
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
|
||||||
|
|
||||||
|
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||||
|
|
||||||
|
fun countFanTalks(creatorId: Long, viewerId: Long): Int
|
||||||
|
|
||||||
|
fun findFanTalks(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelFanTalkRecord>
|
||||||
|
|
||||||
|
fun findCreatorReplies(
|
||||||
|
creatorId: Long,
|
||||||
|
parentFanTalkIds: List<Long>
|
||||||
|
): List<CreatorChannelFanTalkReplyRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val role: MemberRole,
|
||||||
|
val nickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkRecord(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImagePath: String?,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReplyRecord(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val parentFanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImagePath: String?,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Query policy 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`에 아래 정책을 둔다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class CreatorChannelFanTalkQueryPolicy {
|
||||||
|
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
||||||
|
return CreatorChannelPage(
|
||||||
|
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
||||||
|
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
||||||
|
return fetched.take(page.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
||||||
|
return fetched.size > page.size
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_PAGE = 0
|
||||||
|
private const val DEFAULT_PAGE_SIZE = 20
|
||||||
|
private const val MIN_PAGE = 0
|
||||||
|
private const val MIN_PAGE_SIZE = 20
|
||||||
|
private const val MAX_PAGE_SIZE = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 TASK
|
||||||
|
|
||||||
|
### Phase 1: FanTalk 도메인 모델과 페이징 정책
|
||||||
|
|
||||||
|
- [x] **Task 1.1: FanTalk 페이징 정책 테스트와 구현**
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||||
|
- RED: `page`, `size` 보정과 `hasNext`, `limitItems` 동작 테스트를 먼저 작성한다.
|
||||||
|
- 테스트 케이스:
|
||||||
|
- `page == null`, `size == null`이면 `page=0`, `size=20`
|
||||||
|
- `page < 0`이면 `0`
|
||||||
|
- `size < 20`이면 `20`
|
||||||
|
- `size > 50`이면 `50`
|
||||||
|
- fetched size가 `size + 1`이면 `hasNext == true`
|
||||||
|
- fetched size가 `size` 이하이면 `hasNext == false`
|
||||||
|
- `limitItems`는 최대 `size`개만 반환
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||||
|
- GREEN: `CreatorChannelFanTalkQueryPolicy`를 `CreatorChannelCommunityQueryPolicy`와 같은 보정 규칙으로 최소 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||||
|
- REFACTOR: 상수와 메서드명이 커뮤니티/시리즈 탭 정책과 일관되는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: FanTalk domain model과 port 계약 추가**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
|
||||||
|
- RED: Task 1.1 테스트에 domain/port 타입 import를 추가해 타입 부재 컴파일 실패를 확인한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||||
|
- GREEN: `CreatorChannelFanTalkTab`, `CreatorChannelFanTalk`, `CreatorChannelFanTalkReply`, `CreatorChannelFanTalkQueryPort`, record data class를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
|
||||||
|
- REFACTOR: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain`과 `port/out`에서 `v2.api` import가 없는지 확인한다.
|
||||||
|
- 확인 명령: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||||
|
|
||||||
|
### Phase 2: API 응답 DTO와 조립 계층
|
||||||
|
|
||||||
|
- [x] **Task 2.1: FanTalk 응답 DTO와 UTC 변환 테스트**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||||
|
- RED: facade 테스트에서 domain tab을 response로 변환했을 때 필드명과 UTC 문자열이 PRD와 일치하는지 검증한다.
|
||||||
|
- 검증 값:
|
||||||
|
- `fanTalkCount`
|
||||||
|
- `fanTalks[0].writerId`
|
||||||
|
- `fanTalks[0].writerNickname`
|
||||||
|
- `fanTalks[0].writerProfileImageUrl`
|
||||||
|
- `fanTalks[0].content`
|
||||||
|
- `fanTalks[0].createdAtUtc`
|
||||||
|
- `fanTalks[0].creatorReplies[0].writerId`
|
||||||
|
- `page`
|
||||||
|
- `size`
|
||||||
|
- JSON 직렬화 필드명 `hasNext`
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||||
|
- GREEN: DTO를 추가하고 `createdAt.toUtcIso()`를 사용해 UTC ISO 문자열을 내려준다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||||
|
- REFACTOR: `languageCode`가 응답 DTO에 포함되지 않았는지 확인한다.
|
||||||
|
- 확인 명령: `rg -n "languageCode" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
||||||
|
|
||||||
|
- [x] **Task 2.2: FanTalk facade 추가**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
|
||||||
|
- RED: facade가 query service의 `getFanTalkTab(creatorId, viewer, page, size, now)` 결과를 `CreatorChannelFanTalkTabResponse`로 변환하는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||||
|
- GREEN: `CreatorChannelFanTalkFacade`를 `@Service`, `@Transactional(readOnly = true)`로 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
|
||||||
|
- REFACTOR: facade가 API DTO와 domain query service 조립 외 책임을 갖지 않는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: FanTalk controller 추가**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
||||||
|
- RED: MockMvc 테스트를 작성한다.
|
||||||
|
- `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=1&size=20` 요청이 facade에 `creatorId`, `page=1`, `size=20`을 전달한다.
|
||||||
|
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
||||||
|
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/fan-talks")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 구조로 controller를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
||||||
|
- REFACTOR: controller가 `ApiResponse.ok(...)`와 `requireMember` 외 응답 가공 책임을 갖지 않는지 확인한다.
|
||||||
|
|
||||||
|
### Phase 3: FanTalk 조회 서비스
|
||||||
|
|
||||||
|
- [x] **Task 3.1: query service의 creator 검증과 접근 차단 처리**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- RED: query service 테스트를 작성한다.
|
||||||
|
- creator가 없으면 `SodaException(messageKey = "member.validation.user_not_found")`
|
||||||
|
- creator role이 `MemberRole.CREATOR`가 아니면 `SodaException(messageKey = "member.validation.creator_not_found")`
|
||||||
|
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 채널 접근 차단 오류
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||||
|
- GREEN: `CreatorChannelFanTalkQueryService`를 추가하고 `findCreator`, `existsBlockedBetween`, role 검증을 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||||
|
- REFACTOR: 에러 키와 차단 메시지 흐름이 커뮤니티/홈 query service와 같은지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: query service의 page/count/list/reply 조립**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||||
|
- RED: query service 테스트를 추가한다.
|
||||||
|
- `page=-1`, `size=10` 요청 시 port에는 `offset=0`, `limit=21`이 전달되고 응답 `page=0`, `size=20`
|
||||||
|
- fetched FanTalk가 `size + 1`개이면 응답 목록은 `size`개이고 `hasNext=true`
|
||||||
|
- fetched FanTalk가 비어 있으면 `fanTalks=[]`, `hasNext=false`
|
||||||
|
- `countFanTalks` 결과가 `fanTalkCount`로 내려간다.
|
||||||
|
- `findCreatorReplies` 결과는 parent id 기준으로 각 FanTalk의 `creatorReplies`에 묶인다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||||
|
- GREEN: `CreatorChannelFanTalkQueryPolicy`로 page를 만들고, `countFanTalks`, `findFanTalks`, `findCreatorReplies`를 호출해 `CreatorChannelFanTalkTab`을 조립한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||||
|
- REFACTOR: reply 조회는 page에 포함된 parent FanTalk id만 대상으로 호출하는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: query service의 URL/닉네임 변환**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||||
|
- RED: query service 테스트를 추가한다.
|
||||||
|
- writer profile path가 있으면 CDN URL로 변환한다.
|
||||||
|
- writer profile path가 없으면 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
||||||
|
- writer nickname은 `removeDeletedNicknamePrefix()` 결과를 내려준다.
|
||||||
|
- reply writer도 같은 URL/닉네임 변환을 적용한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||||
|
- GREEN: `String?.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()`와 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
|
||||||
|
- REFACTOR: default profile URL 생성 방식이 홈/커뮤니티 query service와 일관되는지 확인한다.
|
||||||
|
|
||||||
|
### Phase 4: QueryDSL repository
|
||||||
|
|
||||||
|
- [x] **Task 4.1: FanTalk repository 기본 creator/차단 조회**
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- RED: repository 테스트를 작성한다.
|
||||||
|
- `findCreator`가 creator id, role, nickname을 조회한다.
|
||||||
|
- `existsBlockedBetween`가 양방향 활성 차단 관계를 감지한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||||
|
- GREEN: `JPAQueryFactory` 기반 repository를 추가하고 홈/커뮤니티 repository와 같은 creator/차단 조건을 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||||
|
- REFACTOR: repository class 이름은 `Default...Repository` 접두사 규칙을 따른다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: 최상위 FanTalk count/list 조회**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||||
|
- RED: repository 테스트를 추가한다.
|
||||||
|
- `countFanTalks`는 `creator.id`, `isActive=true`, `parent is null` 조건만 count한다.
|
||||||
|
- 비활성 FanTalk는 count/list에서 제외한다.
|
||||||
|
- 답글 FanTalk는 count/list에서 제외한다.
|
||||||
|
- 조회자와 작성자 사이 차단 관계가 있으면 count/list에서 제외한다.
|
||||||
|
- 목록 정렬은 `createdAt desc`, `id desc`다.
|
||||||
|
- `offset`, `limit`이 적용된다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||||
|
- GREEN: `countFanTalks`, `findFanTalks`를 구현한다. projection은 `CreatorChannelFanTalkRecord`를 사용한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||||
|
- REFACTOR: 홈 API의 `fanTalkSummaryCondition`과 조건 의미가 일치하는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: 크리에이터 답글 조회**
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
|
||||||
|
- RED: repository 테스트를 추가한다.
|
||||||
|
- `findCreatorReplies`는 parent id 목록에 속한 활성 답글만 조회한다.
|
||||||
|
- 답글 작성자가 조회 대상 크리에이터인 데이터만 조회한다.
|
||||||
|
- 크리에이터가 아닌 회원의 답글은 제외한다.
|
||||||
|
- 비활성 답글은 제외한다.
|
||||||
|
- 답글 정렬은 `createdAt asc`, `id asc`다.
|
||||||
|
- `parentFanTalkIds`가 빈 목록이면 빈 목록을 반환하고 DB 조회 결과가 없어야 한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||||
|
- GREEN: `findCreatorReplies`를 구현한다. projection은 `CreatorChannelFanTalkReplyRecord`를 사용한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
|
||||||
|
- REFACTOR: reply 조회가 최상위 FanTalk page 결과 외 parent를 가져오지 않는지 확인한다.
|
||||||
|
|
||||||
|
### Phase 5: API 통합과 회귀 검증
|
||||||
|
|
||||||
|
- [x] **Task 5.1: FanTalk End-to-End 테스트**
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
|
||||||
|
- RED: E2E 테스트를 작성한다.
|
||||||
|
- 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=0&size=20` 호출 시 200 OK
|
||||||
|
- 응답 JSON에 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext`가 포함된다.
|
||||||
|
- 최상위 FanTalk의 `createdAtUtc`는 UTC ISO 문자열이다.
|
||||||
|
- 크리에이터 답글은 `creatorReplies`에 포함된다.
|
||||||
|
- 팬이 작성한 비정상 답글 데이터는 응답에 포함되지 않는다.
|
||||||
|
- page 범위를 벗어나면 빈 목록과 `hasNext=false`를 반환하되 count는 유지한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
||||||
|
- GREEN: Phase 1~4 구현을 연결해 E2E 테스트를 통과시킨다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
||||||
|
- REFACTOR: 테스트 데이터가 다른 크리에이터 채널 탭 테스트와 충돌하지 않도록 독립 fixture를 사용한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 패키지 의존 방향과 기존 API 회귀 확인**
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
|
||||||
|
- RED: 신규 테스트 추가는 없다. 이 task는 문서화된 구조 검증 task다.
|
||||||
|
- TDD 예외 사유: 패키지 의존 방향과 기존 endpoint 비변경 여부는 정적 검색과 기존 회귀 테스트가 더 직접적인 검증이다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
- `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||||
|
- `rg -n "fan-talks|/profile/\\{id\\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer`
|
||||||
|
- GREEN: `v2.creator.channel.fantalk` 하위에서 `v2.api.*` import 검색 결과가 0건인지 확인한다.
|
||||||
|
- 통과 확인:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
|
||||||
|
- REFACTOR: 홈 API와 legacy cheers endpoint의 공개 응답 스키마를 변경한 파일 diff가 없는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.3: 전체 FanTalk 관련 테스트와 ktlint 검증**
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
|
||||||
|
- RED: 신규 테스트 추가는 없다. 이 task는 구현 완료 후 회귀 검증 task다.
|
||||||
|
- TDD 예외 사유: 전체 회귀와 ktlint는 구현 완료 상태를 검증하는 명령 실행 task다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- GREEN: 실패하는 FanTalk 관련 테스트나 ktlint 오류가 있으면 해당 task의 구현 단계로 돌아가 수정한다.
|
||||||
|
- 통과 확인:
|
||||||
|
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- REFACTOR: 필요한 경우 `./gradlew test`를 추가 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 구현 시 주의사항
|
||||||
|
|
||||||
|
- 구현 전에 이 문서와 `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`가 같은 endpoint, page 기준, response field를 말하는지 다시 확인한다.
|
||||||
|
- 신규 공개 API 스키마 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 수정한다.
|
||||||
|
- `CreatorCheers` 엔티티 자체 구조는 변경하지 않는다.
|
||||||
|
- legacy `ExplorerQueryRepository.getCheersList`는 timezone 표시 문자열을 만들기 때문에 신규 V2 응답 DTO에 재사용하지 않는다.
|
||||||
|
- FanTalk 탭 query service는 홈 API query service에 의존하지 않는다.
|
||||||
|
- 홈 API의 `findFanTalkSummary`는 이번 작업에서 수정하지 않는 것을 기본으로 한다. 수정이 필요해지면 PRD와 이 문서를 먼저 갱신한다.
|
||||||
|
- controller/facade/DTO 조립 계층은 `v2.api.creator.channel.fantalk`에만 둔다.
|
||||||
|
- domain/application/port/repository 조회 계층은 `v2.creator.channel.fantalk`에만 둔다.
|
||||||
|
- 테스트 작성 시 Redis가 필요 없는 JPA/QueryDSL slice 테스트는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 관례를 따른다.
|
||||||
|
- 테스트 완료 후 각 task 아래에 실행 명령과 결과를 한국어로 누적 기록한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 검증 기록
|
||||||
|
|
||||||
|
- 문서 생성 시점에는 구현 코드를 작성하지 않았으므로 신규 테스트는 실행하지 않았다.
|
||||||
|
- 문서 변경 검증으로 `./gradlew tasks --all`을 실행했다.
|
||||||
|
- sandbox 일반 실행은 Gradle wrapper가 `/Users/klaus/.gradle/wrapper/dists/gradle-8.1.1-bin/9wiye5v2saajue4irfo8ybqfp/gradle-8.1.1-bin.zip.lck`에 접근하지 못해 `Operation not permitted`로 실패했다.
|
||||||
|
- 권한 승인 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- Phase 1 Task 1.1/1.2 구현 검증을 진행했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` 실행 시 `CreatorChannelFanTalkQueryPolicy`, FanTalk domain model, FanTalk port record 미존재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: FanTalk 페이징 정책, domain model, port 계약 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 의존 방향 확인: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
||||||
|
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
||||||
|
- Phase 2 Task 2.1/2.2 구현 검증을 진행했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` 실행 시 FanTalk 응답 DTO, FanTalk facade, FanTalk query service 타입 미존재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: FanTalk 응답 DTO, FanTalk facade, Phase 3 구현 전 facade 컴파일을 위한 `CreatorChannelFanTalkQueryService` 최소 shell 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- Phase 2 범위 준수: `CreatorChannelFanTalkQueryService`는 최종 public method signature만 두고 조회/검증/DB/port 구현은 추가하지 않았다.
|
||||||
|
- Phase 2 Task 2.3 구현 검증을 진행했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 시 `CreatorChannelFanTalkController` 미존재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: FanTalk controller 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
||||||
|
- Phase 3 Task 3.1/3.2/3.3 구현 검증을 진행했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` 실행 시 `CreatorChannelFanTalkQueryService` 생성자 의존성 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: FanTalk query service의 creator 검증, 접근 차단, page/count/list/reply 조립, CDN URL/default profile URL, 탈퇴 닉네임 prefix 제거 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
||||||
|
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 assertion 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
||||||
|
|
||||||
|
- Phase 4 Task 4.1/4.2/4.3 구현 검증을 진행했다.
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 시 `DefaultCreatorChannelFanTalkQueryRepository` 미존재로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- GREEN: FanTalk QueryDSL repository의 creator 조회, 양방향 차단 조회, 최상위 FanTalk count/list, 크리에이터 답글 조회 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
||||||
|
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
||||||
|
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 fixture 호출 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- Phase 4 코드 리뷰 및 재검증을 진행했다.
|
||||||
|
- 리뷰 범위: `DefaultCreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryPort`, `DefaultCreatorChannelFanTalkQueryRepositoryTest`, `CreatorChannelFanTalkQueryService` 연동부를 PRD/plan의 Phase 4 요구사항과 대조했다.
|
||||||
|
- 리뷰 결과: creator/차단 조회, 최상위 FanTalk count/list 조건, 정렬, offset/limit, 크리에이터 답글 필터와 빈 parent 목록 처리에서 수정이 필요한 결함을 발견하지 않았다.
|
||||||
|
- 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 관련 회귀 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- FanTalk 전체 재검증: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 의존 방향 재확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
||||||
|
- ktlint 재확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- Phase 5 Task 5.1 구현 검증을 진행했다.
|
||||||
|
- GREEN: `CreatorChannelFanTalkEndToEndTest`를 추가해 인증 회원의 FanTalk 탭 200 OK, 응답 필드, UTC ISO 문자열, 크리에이터 답글 포함, 팬 작성 답글 제외, 범위 밖 page의 빈 목록/count 유지 동작을 검증했다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
|
||||||
|
- Phase 5 Task 5.2 회귀 검증을 진행했다.
|
||||||
|
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
|
||||||
|
- API 참조 확인: `rg -n "fan-talks|/profile/\{id\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer` 실행 결과 신규 `fan-talks` controller 매핑과 기존 legacy cheers/home latestFanTalk 참조만 확인했다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- Phase 5 Task 5.3 전체 FanTalk 관련 테스트와 ktlint 검증을 진행했다.
|
||||||
|
- FanTalk 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- ktlint 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
216
docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md
Normal file
216
docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# PRD: 크리에이터 채널 FanTalk 탭 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
크리에이터 채널의 FanTalk 탭에서 전체 FanTalk 개수와 FanTalk 글 목록을 페이징 조회하는 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 크리에이터 채널 홈 API는 FanTalk 전체 개수와 최신 FanTalk 1건만 요약으로 제공한다.
|
||||||
|
- FanTalk 탭은 전체 개수, 페이징된 글 목록, 각 글에 달린 크리에이터 답글을 함께 표시해야 한다.
|
||||||
|
- legacy `/profile/{id}/cheers` API는 FanTalk를 조회하지만 날짜를 timezone 기반 표시 문자열로 내려주므로, V2 크리에이터 채널 탭 API에서 요구하는 UTC 기반 응답 계약과 맞지 않는다.
|
||||||
|
- FanTalk 엔티티는 legacy `CreatorCheers`를 사용하되, 신규 API 조립 계층과 도메인 조회 계층은 기존 V2 크리에이터 채널 탭 패턴처럼 분리해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 크리에이터 채널 FanTalk 탭 조회 API를 제공한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
|
||||||
|
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위 조립 계층에 둔다.
|
||||||
|
- FanTalk 목록, 전체 개수, 답글 조회, 페이징 보정, 차단 필터링 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 도메인 조회 계층에 둔다.
|
||||||
|
- FanTalk 저장 엔티티는 기존 `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`를 사용한다.
|
||||||
|
- 응답에는 조회 가능한 전체 FanTalk 개수, FanTalk 글 목록, page, size, hasNext를 포함한다.
|
||||||
|
- FanTalk 글 item에는 글쓴이 닉네임, 글쓴이 ID, 글쓴이 프로필 이미지, 글쓴이가 쓴 글, 글 쓴 시간 UTC, 크리에이터가 쓴 답글 목록을 포함한다.
|
||||||
|
- 크리에이터 답글 item도 FanTalk 글과 동일한 작성자/본문/시간 필드 구조를 사용한다.
|
||||||
|
- 페이징 요청값은 기존 V2 크리에이터 채널 커뮤니티/시리즈 탭 API와 같은 보정 규칙을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- FanTalk 작성, 수정, 삭제 API는 포함하지 않는다.
|
||||||
|
- FanTalk 답글 작성, 수정, 삭제 API는 포함하지 않는다.
|
||||||
|
- 팬 회원 간 답글 작성/조회 기능은 포함하지 않는다. 현재 팬끼리 답글을 작성할 수 없으므로 FanTalk 탭 응답에서도 팬 간 답글을 고려하지 않는다.
|
||||||
|
- legacy `/profile/{id}/cheers` API의 공개 endpoint나 응답 스키마 변경은 포함하지 않는다.
|
||||||
|
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
- 앱 표시용 상대 시간 문구나 timezone 변환 문자열은 서버에서 새로 조합하지 않는다.
|
||||||
|
- 신고, 언어 감지, 푸시 알림 정책 변경은 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 크리에이터 채널 FanTalk 탭에서 다른 팬들의 FanTalk 글과 크리에이터 답글을 탐색하는 사용자
|
||||||
|
- 앱 클라이언트: FanTalk 탭 구성에 필요한 전체 개수와 페이징 목록을 단일 API 응답으로 표시하려는 클라이언트
|
||||||
|
- 서버 개발자: 기존 `CreatorCheers` 저장 구조를 유지하면서 V2 조회 계층을 분리하려는 개발자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터 채널 FanTalk 탭에 들어가면 전체 FanTalk 개수를 확인하고 싶다.
|
||||||
|
- 사용자는 FanTalk 글을 최신순으로 추가 로딩하고 싶다.
|
||||||
|
- 사용자는 각 FanTalk 글에 크리에이터가 남긴 답글을 같은 화면에서 확인하고 싶다.
|
||||||
|
- 사용자는 글쓴이 닉네임, ID, 프로필 이미지, 본문, 작성 시간을 목록 item에서 바로 확인하고 싶다.
|
||||||
|
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
|
||||||
|
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 크리에이터 채널 FanTalk 탭 조회 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
|
||||||
|
- `creatorId`는 path variable로 받는다.
|
||||||
|
- FanTalk 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
||||||
|
- `page`는 기존 V2 탭 API와 동일하게 0부터 시작하는 page index로 처리한다.
|
||||||
|
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||||
|
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||||
|
- `page`가 0보다 작으면 `0`으로 보정한다.
|
||||||
|
- `size`가 20보다 작으면 `20`으로 보정한다.
|
||||||
|
- `size`가 50보다 크면 `50`으로 보정한다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||||
|
- 조회 가능한 FanTalk가 없어도 전체 API는 성공 처리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
||||||
|
- 요청한 page 범위에 FanTalk가 없으면 `fanTalks`는 빈 배열, `hasNext`는 `false`로 내려주되 `fanTalkCount`는 전체 개수를 유지한다.
|
||||||
|
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||||
|
|
||||||
|
### Feature B. 응답 스키마
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
||||||
|
- 응답 최상위 DTO 이름은 `CreatorChannelFanTalkTabResponse`로 한다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- `fanTalkCount`: 조회자가 조회 가능한 전체 FanTalk 개수
|
||||||
|
- `fanTalks`: FanTalk 글 목록
|
||||||
|
- `page`: 현재 응답의 page index
|
||||||
|
- `size`: 현재 응답의 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- `fanTalkCount`는 최상위 FanTalk 글만 계산한다.
|
||||||
|
- `fanTalkCount`에는 현재 page에 포함되지 않은 FanTalk 글도 포함한다.
|
||||||
|
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
||||||
|
- `hasNext`는 같은 조건에서 다음 page에 노출할 FanTalk 글이 있으면 `true`로 내려준다.
|
||||||
|
- 응답 스키마 예시는 다음과 같다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelFanTalkTabResponse(
|
||||||
|
val fanTalkCount: Int,
|
||||||
|
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReplyResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회 가능한 FanTalk가 없으면 `fanTalkCount`는 `0`, `fanTalks`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||||
|
- FanTalk 글에 크리에이터 답글이 없으면 `creatorReplies`는 빈 배열로 내려준다.
|
||||||
|
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
|
||||||
|
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답 정책을 따른다.
|
||||||
|
- `createdAtUtc`는 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
|
||||||
|
- Boolean 응답 필드는 현재 스키마에 없지만, 추후 추가 시 Jackson 직렬화 필드명을 명시해야 한다.
|
||||||
|
|
||||||
|
### Feature C. FanTalk 목록과 개수
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 조회 대상은 지정한 `creatorId`의 FanTalk로 제한한다.
|
||||||
|
- 저장 엔티티는 `CreatorCheers`를 사용한다.
|
||||||
|
- 최상위 FanTalk 글은 `CreatorCheers.parent is null`인 활성 데이터로 정의한다.
|
||||||
|
- 활성 데이터는 `CreatorCheers.isActive == true`인 데이터로 정의한다.
|
||||||
|
- 목록은 최상위 FanTalk 글만 페이징한다.
|
||||||
|
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
|
||||||
|
- 전체 개수는 목록과 같은 creator, active, parent, 차단 필터 조건을 적용해 계산한다.
|
||||||
|
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
||||||
|
- 글쓴이 ID는 `CreatorCheers.member.id`를 사용한다.
|
||||||
|
- 글쓴이 닉네임은 `CreatorCheers.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
|
||||||
|
- 글쓴이 프로필 이미지는 `CreatorCheers.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
|
||||||
|
- 글쓴이가 쓴 글은 `CreatorCheers.cheers`를 사용한다.
|
||||||
|
- 글 쓴 시간은 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
|
||||||
|
- `languageCode`는 이번 FanTalk 탭 응답에 포함하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `CreatorCheers.createdAt`이 nullable 기반 엔티티 필드에서 온 경우에도 조회 결과 응답에는 null이 나오지 않아야 한다.
|
||||||
|
- FanTalk 작성자가 조회자와 차단 관계이면 해당 최상위 글은 목록과 개수에서 제외한다.
|
||||||
|
- 차단으로 제외된 최상위 글의 답글도 응답에 포함하지 않는다.
|
||||||
|
- 같은 작성자의 FanTalk가 여러 건 있어도 각각 별도 item으로 내려준다.
|
||||||
|
|
||||||
|
### Feature D. 크리에이터 답글 포함
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 각 FanTalk 글에는 크리에이터가 쓴 활성 답글 목록을 `creatorReplies`로 포함한다.
|
||||||
|
- 답글은 `CreatorCheers.parent`가 해당 최상위 FanTalk 글인 데이터로 조회한다.
|
||||||
|
- 답글 작성자가 조회 대상 크리에이터인 데이터만 포함한다.
|
||||||
|
- 답글도 `CreatorCheers.isActive == true`인 데이터만 포함한다.
|
||||||
|
- 답글 item의 필드 구조는 최상위 FanTalk 글과 동일한 작성자 ID, 닉네임, 프로필 이미지, 본문, UTC 작성 시간을 사용한다.
|
||||||
|
- 답글 정렬은 오래된 답글부터 확인할 수 있도록 `createdAt asc`, `id asc`를 따른다.
|
||||||
|
- 현재 팬끼리 답글을 작성할 수 없으므로 크리에이터가 아닌 회원의 답글은 정상 응답 대상이 아니다.
|
||||||
|
- 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 존재하더라도 응답에 포함하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 크리에이터 답글이 여러 개면 모두 `creatorReplies`에 포함한다.
|
||||||
|
- 크리에이터가 작성했지만 비활성 처리된 답글은 포함하지 않는다.
|
||||||
|
- 답글 작성자인 크리에이터 프로필 이미지가 없으면 기본 프로필 이미지 URL을 내려준다.
|
||||||
|
- 답글 작성자인 크리에이터가 조회자와 차단 관계인 경우는 이미 채널 접근 차단 조건에서 처리된다.
|
||||||
|
|
||||||
|
### Feature E. V2 재사용 범위와 계층 분리
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위에 둔다.
|
||||||
|
- FanTalk 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 둔다.
|
||||||
|
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
||||||
|
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
||||||
|
- 의존 방향은 항상 `v2.api.creator.channel.fantalk -> v2.creator.channel.fantalk`이다.
|
||||||
|
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
|
||||||
|
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
|
||||||
|
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
|
||||||
|
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
|
||||||
|
- 기존 홈 API의 FanTalk 요약 조회 로직은 참고하되, 홈 도메인 repository에 신규 탭 페이징 책임을 추가하지 않는다.
|
||||||
|
- legacy `ExplorerQueryRepository.getCheersList`의 timezone 기반 날짜 포맷 응답은 신규 V2 API에서 재사용하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 신규 `fantalk` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
|
||||||
|
- 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
|
||||||
|
- legacy FanTalk 작성/수정/삭제 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
||||||
|
- 언어/런타임은 Kotlin + Java 17을 따른다.
|
||||||
|
- 프레임워크는 Spring Boot 2.7.14를 따른다.
|
||||||
|
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
|
||||||
|
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
|
||||||
|
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Decisions
|
||||||
|
- endpoint 이름은 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 확정한다.
|
||||||
|
- `page`는 기존 크리에이터 채널 V2 탭 API와 동일하게 0 기반 page index로 처리한다.
|
||||||
544
docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md
Normal file
544
docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# 크리에이터 채널 후원 탭 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/donations`로 크리에이터 채널 후원 탭의 전체 채널 후원 개수, 후원 순위 Top 8, 페이징된 채널 후원 목록을 조회할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 조립 계층에 둔다. 후원 탭 조회 service, page/month 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 두고 `v2.api.*`에 의존하지 않는다. 채널 후원 목록은 기존 `ChannelDonationMessage`와 홈 API 후원 섹션 조건을 따르고, 후원 순위는 legacy `CreatorDonationRankingService`를 통해 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일하게 재사용한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 구현 전 확정 사항
|
||||||
|
|
||||||
|
- API endpoint: `GET /api/v2/creator-channels/{creatorId}/donations`
|
||||||
|
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
||||||
|
- request:
|
||||||
|
- path variable: `creatorId`
|
||||||
|
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 보정
|
||||||
|
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 보정
|
||||||
|
- response:
|
||||||
|
- `donationCount`: 조회자가 조회 가능한 현재 KST 월 범위의 전체 채널 후원 개수
|
||||||
|
- `rankings`: 후원 순위 Top 8 목록
|
||||||
|
- `donations`: 채널 후원 목록
|
||||||
|
- `page`: 보정 후 실제 적용된 page index
|
||||||
|
- `size`: 보정 후 실제 적용된 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- channel donation item:
|
||||||
|
- `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`
|
||||||
|
- ranking item:
|
||||||
|
- `userId`, `nickname`, `profileImage`, `donationCan`
|
||||||
|
- 채널 후원 목록 기준:
|
||||||
|
- 저장 엔티티는 `kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage`를 사용한다.
|
||||||
|
- 기간은 홈 후원 섹션과 동일하게 현재 KST 월 시작 이상, 다음 달 KST 월 시작 미만을 UTC `LocalDateTime`으로 변환해 사용한다.
|
||||||
|
- 정렬은 `createdAt desc`, `id desc`를 따른다.
|
||||||
|
- `hasNext`는 `size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
|
||||||
|
- 비공개 후원 노출:
|
||||||
|
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
|
||||||
|
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
|
||||||
|
- 후원 순위 기준:
|
||||||
|
- `CreatorDonationRankingService.getMemberDonationRanking(...)`를 통해 legacy `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과를 재사용한다.
|
||||||
|
- Top 8 조회는 `offset = 0`, `limit = 8`을 사용한다.
|
||||||
|
- 기간은 크리에이터의 `donationRankingPeriod`를 따르고, 값이 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
|
||||||
|
- `DonationRankingPeriod.WEEKLY`이면 legacy service의 주간 범위를 그대로 사용한다.
|
||||||
|
- `DonationRankingPeriod.CUMULATIVE`이면 legacy service의 전체 누적 범위를 그대로 사용한다.
|
||||||
|
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank`가 `true`이면 `rankings`를 내려준다.
|
||||||
|
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank`가 `false`이면 `rankings`는 빈 배열이다.
|
||||||
|
- `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 조회한다.
|
||||||
|
- `donationCan`은 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
|
||||||
|
- creator 검증:
|
||||||
|
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
|
||||||
|
- `createdAtUtc`는 `ChannelDonationMessage.createdAt`을 `kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
|
||||||
|
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
|
||||||
|
- 후원자 닉네임은 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||||
|
- 후원 메시지는 홈 API와 동일하게 `additionalMessage`가 없으면 빈 문자열로 내려준다. 레거시 `ChannelDonationService.buildMessage` 기본 문구 조합은 사용하지 않는다.
|
||||||
|
- legacy `/explorer/profile/channel-donation` 공개 endpoint와 응답 스키마는 변경하지 않는다.
|
||||||
|
- 크리에이터 채널 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### 후원 탭 신규 API 조립 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
|
||||||
|
|
||||||
|
### 후원 도메인 조회 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
|
||||||
|
|
||||||
|
### 기존 파일 확인/재사용
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
|
||||||
|
|
||||||
|
### 문서 산출물
|
||||||
|
- Create: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
|
||||||
|
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
||||||
|
|
||||||
|
data class CreatorChannelDonationTabResponse(
|
||||||
|
val donationCount: Int,
|
||||||
|
val rankings: List<MemberDonationRankingResponse>,
|
||||||
|
val donations: List<CreatorChannelDonationResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
|
||||||
|
return CreatorChannelDonationTabResponse(
|
||||||
|
donationCount = tab.donationCount,
|
||||||
|
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
|
||||||
|
donations = tab.donations.map(CreatorChannelDonationResponse::from),
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MemberDonationRankingResponse(
|
||||||
|
@JsonProperty("userId") val userId: Long,
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("profileImage") val profileImage: String,
|
||||||
|
@JsonProperty("donationCan") val donationCan: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
|
||||||
|
return MemberDonationRankingResponse(
|
||||||
|
userId = ranking.userId,
|
||||||
|
nickname = ranking.nickname,
|
||||||
|
profileImage = ranking.profileImage,
|
||||||
|
donationCan = ranking.donationCan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationResponse(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
val message: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
|
||||||
|
return CreatorChannelDonationResponse(
|
||||||
|
nickname = donation.nickname,
|
||||||
|
profileImageUrl = donation.profileImageUrl,
|
||||||
|
can = donation.can,
|
||||||
|
message = donation.message,
|
||||||
|
createdAtUtc = donation.createdAt.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Domain / Port 초안
|
||||||
|
|
||||||
|
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class CreatorChannelDonationTab(
|
||||||
|
val donationCount: Int,
|
||||||
|
val rankings: List<CreatorChannelDonationRanking>,
|
||||||
|
val donations: List<CreatorChannelDonation>,
|
||||||
|
val page: CreatorChannelPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationRanking(
|
||||||
|
val userId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImage: String,
|
||||||
|
val donationCan: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonation(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
val message: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface CreatorChannelDonationQueryPort {
|
||||||
|
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord?
|
||||||
|
|
||||||
|
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||||
|
|
||||||
|
fun countChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelDonationRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatorChannelDonationRankingPort {
|
||||||
|
fun findTopRankings(
|
||||||
|
creatorId: Long,
|
||||||
|
period: DonationRankingPeriod,
|
||||||
|
withDonationCan: Boolean
|
||||||
|
): List<CreatorChannelDonationRankingRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val role: MemberRole,
|
||||||
|
val nickname: String,
|
||||||
|
val isVisibleDonationRank: Boolean,
|
||||||
|
val donationRankingPeriod: DonationRankingPeriod?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationRecord(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImagePath: String?,
|
||||||
|
val can: Int,
|
||||||
|
val message: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationRankingRecord(
|
||||||
|
val userId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImage: String,
|
||||||
|
val donationCan: Int
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 Tasks
|
||||||
|
|
||||||
|
### Phase 1: 공개 계약과 순수 정책 추가
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 후원 탭 domain model, port, page/month 정책 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
|
||||||
|
- RED: `CreatorChannelDonationQueryPolicyTest`를 먼저 작성한다.
|
||||||
|
- null page/size가 `0/20`, fetchLimit `21`로 보정되는지 검증한다.
|
||||||
|
- `page = -1`, `size = 10`이 `0/20`으로 보정되는지 검증한다.
|
||||||
|
- `page = 2`, `size = 100`이 `2/50`, offset `100`, fetchLimit `51`로 보정되는지 검증한다.
|
||||||
|
- fetched 21개에서 응답 item 20개와 `hasNext = true`가 계산되는지 검증한다.
|
||||||
|
- `now = 2026-06-22T03:00:00` 기준 KST 월 범위가 `2026-05-31T15:00:00` 이상, `2026-06-30T15:00:00` 미만 UTC로 계산되는지 검증한다.
|
||||||
|
- domain/port record가 PRD 필드를 보존하는지 생성 테스트로 검증한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
|
||||||
|
- Expected: 신규 클래스가 없어 컴파일 실패한다.
|
||||||
|
- GREEN: domain model, port, `CreatorChannelDonationQueryPolicy`를 최소 구현한다.
|
||||||
|
- `createPage(page, size)`는 기존 FanTalk 정책과 같은 보정값을 사용한다.
|
||||||
|
- `limitItems(fetched, page)`는 `fetched.take(page.size)`를 반환한다.
|
||||||
|
- `hasNext(fetched, page)`는 `fetched.size > page.size`를 반환한다.
|
||||||
|
- `currentKstMonthRange(now)`는 홈 후원 섹션과 동일한 KST 월 범위 UTC 변환을 반환한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 중복 상수와 월 범위 계산을 읽기 쉽게 정리하되 기존 `CreatorChannelPage`를 재사용한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest` 실행, 신규 domain/port/policy 타입 부재로 `compileTestKotlin` 실패 확인.
|
||||||
|
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: response DTO와 facade 매핑 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||||
|
- RED: `CreatorChannelDonationFacadeTest`를 먼저 작성한다.
|
||||||
|
- `CreatorChannelDonationTabResponse.from(...)`이 `donationCount`, `rankings`, `donations`, `page`, `size`, `hasNext`를 공개 필드로 매핑하는지 검증한다.
|
||||||
|
- `rankings[0]`의 JSON 필드가 `userId`, `nickname`, `profileImage`, `donationCan`인지 검증한다.
|
||||||
|
- `donations[0]`의 JSON 필드가 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`인지 검증한다.
|
||||||
|
- `hasNext`가 JSON에서 `hasNext`로 직렬화되는지 검증한다.
|
||||||
|
- facade가 query service 결과를 `CreatorChannelDonationTabResponse`로 변환하는지 검증한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
|
||||||
|
- Expected: DTO/facade가 없어 컴파일 실패한다.
|
||||||
|
- GREEN: DTO와 facade를 최소 구현한다.
|
||||||
|
- `CreatorChannelDonationFacade.getDonationTab(creatorId, viewer, page, size, now)`는 query service를 호출하고 `CreatorChannelDonationTabResponse.from(...)`을 반환한다.
|
||||||
|
- DTO의 `createdAtUtc` 변환은 기존 `toUtcIso`를 사용한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: DTO가 도메인 model만 import하고 persistence/legacy 타입을 import하지 않는지 확인한다.
|
||||||
|
- Run: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
||||||
|
- Expected: 검색 결과 0건
|
||||||
|
- 실행 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest` 실행, DTO/facade/query service 경계 부재로 `compileTestKotlin` 실패 확인.
|
||||||
|
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 보완: Phase 2 전 공개 endpoint가 내부 `UnsupportedOperationException`으로 실패하지 않도록 query service placeholder를 `SodaException(messageKey = "common.error.invalid_request")`로 고정하고 `CreatorChannelDonationQueryServiceTest`를 추가했다.
|
||||||
|
- 보완 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, RED에서 `UnsupportedOperationException` 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
|
||||||
|
- REFACTOR: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
||||||
|
|
||||||
|
- [x] **Task 1.3: controller와 인증/API 계약 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
|
||||||
|
- RED: `CreatorChannelDonationControllerTest`를 먼저 작성한다.
|
||||||
|
- 비회원 요청 `GET /api/v2/creator-channels/1/donations`는 401 또는 기존 테스트 보안 설정 기준 인증 실패로 거부되는지 검증한다.
|
||||||
|
- 인증 회원 요청은 `page`, `size`, `creatorId`, `viewer`를 facade에 전달하는지 검증한다.
|
||||||
|
- 성공 응답 JSON에 `data.donationCount`, `data.rankings[0].userId`, `data.donations[0].createdAtUtc`, `data.page`, `data.size`, `data.hasNext`가 포함되는지 검증한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
|
||||||
|
- Expected: controller가 없어 컴파일 실패한다.
|
||||||
|
- GREEN: controller를 최소 구현한다.
|
||||||
|
- `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/donations")`를 사용한다.
|
||||||
|
- `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")`로 회원을 받고, null이면 `SodaException(messageKey = "common.error.bad_credentials")`를 던진다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 기존 FanTalk/커뮤니티 controller와 request mapping 스타일이 같은지 확인한다.
|
||||||
|
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
||||||
|
- Expected: controller class와 endpoint mapping 각 1건 확인
|
||||||
|
- 실행 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, controller 부재로 `compileTestKotlin` 실패 확인.
|
||||||
|
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 보완: Phase 2 전 미완성 endpoint가 기본 운영 컨텍스트에 노출되지 않도록 `@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`를 추가했다.
|
||||||
|
- 보완 검증: controller annotation 계약 테스트를 추가하고 RED에서 조건부 등록 annotation 부재 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
|
||||||
|
- REFACTOR: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, controller class와 endpoint mapping 각 1건 확인.
|
||||||
|
|
||||||
|
### Phase 2: 도메인 조회 서비스와 legacy ranking 재사용 추가
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 후원 탭 query service 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
|
||||||
|
- RED: fake `CreatorChannelDonationQueryPort`, fake `CreatorChannelDonationRankingPort`를 사용해 query service 테스트를 먼저 작성한다.
|
||||||
|
- creator가 없으면 `member.validation.user_not_found` 예외를 던지는지 검증한다.
|
||||||
|
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던지는지 검증한다.
|
||||||
|
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 차단 메시지 예외를 던지는지 검증한다.
|
||||||
|
- `page = -1`, `size = 10` 요청이 `offset = 0`, `limit = 21`로 port에 전달되고 응답은 size 20으로 잘리는지 검증한다.
|
||||||
|
- 조회자 본인이 크리에이터이면 `isVisibleDonationRank = false`여도 ranking port를 호출하고 `withDonationCan = true`가 전달되는지 검증한다.
|
||||||
|
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = WEEKLY`이면 ranking port에 `period = WEEKLY`, `withDonationCan = false`가 전달되고 `rankings`가 반환되는지 검증한다.
|
||||||
|
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = CUMULATIVE`이면 ranking port에 `period = CUMULATIVE`, `withDonationCan = false`가 전달되는지 검증한다.
|
||||||
|
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = null`이면 ranking port에 `period = CUMULATIVE`가 전달되는지 검증한다.
|
||||||
|
- 조회자 본인이 아니고 `isVisibleDonationRank = false`이면 ranking port를 호출하지 않고 `rankings`가 빈 배열인지 검증한다.
|
||||||
|
- 후원 순위가 비공개라 `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`가 정상 조립되는지 검증한다.
|
||||||
|
- donation 작성자 닉네임의 삭제 prefix 제거, profileImagePath CDN 변환, 기본 프로필 이미지 fallback, null message의 빈 문자열 변환을 검증한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
|
||||||
|
- Expected: query service가 없어 컴파일 실패한다.
|
||||||
|
- GREEN: query service를 최소 구현한다.
|
||||||
|
- `ObjectProvider<CreatorChannelDonationQueryPort>` 패턴을 사용해 기존 FanTalk query service와 같은 순환 의존 회피 스타일을 따른다.
|
||||||
|
- `CreatorChannelDonationRankingPort`는 생성자 주입한다.
|
||||||
|
- `DonationRankingPeriod`는 creator record 값이 null이면 `DonationRankingPeriod.CUMULATIVE`로 보정한다.
|
||||||
|
- `isVisibleDonationRank`가 false이고 조회자가 크리에이터 본인이 아니면 ranking port를 호출하지 않는다.
|
||||||
|
- `isVisibleDonationRank`가 true이거나 조회자가 크리에이터 본인이면 ranking port를 호출하고 creator의 ranking period를 그대로 전달한다.
|
||||||
|
- `findChannelDonations(...)` 결과는 `limitItems` 적용 후 domain으로 변환한다.
|
||||||
|
- `hasNext`는 fetch 결과 크기로 계산한다.
|
||||||
|
- Phase 1 임시 보호장치를 함께 정리한다.
|
||||||
|
- `CreatorChannelDonationQueryService.getDonationTab(...)`의 placeholder `SodaException(messageKey = "common.error.invalid_request")`를 실제 구현으로 대체한다.
|
||||||
|
- placeholder 전용 `CreatorChannelDonationQueryServiceTest`는 실제 query service 동작 테스트로 교체하고, placeholder 오류 검증은 제거한다.
|
||||||
|
- `CreatorChannelDonationController`의 `@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`와 관련 import를 제거해 endpoint가 기본 Spring context에 등록되도록 한다.
|
||||||
|
- `CreatorChannelDonationControllerTest`의 `@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"])`와 conditional annotation 검증 테스트를 제거한다.
|
||||||
|
- 별도 feature flag rollout 정책을 유지하기로 결정한 경우에만 위 controller 조건부 등록을 남기고, 그 결정 사유와 활성화 설정 위치를 이 문서에 추가한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: query service가 API DTO를 import하지 않는지 확인한다.
|
||||||
|
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
||||||
|
- Expected: 검색 결과 0건
|
||||||
|
- 실행 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, 새 fake port 기반 테스트가 기존 placeholder service 생성자/동작과 맞지 않아 `compileTestKotlin` 실패 확인. 같은 실행에서 당시 존재하던 Phase 2.2 repository 테스트의 미구현 repository 참조도 함께 컴파일 실패로 노출됨.
|
||||||
|
- GREEN 보정 전: 동일 명령 실행, service 구현 후 테스트 실행까지 진행됐고 차단 메시지 기대값이 실제 `explorer.creator.blocked_access` 한국어 템플릿과 달라 1건 실패 확인.
|
||||||
|
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- Controller regression: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- REFACTOR: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
||||||
|
- REFACTOR: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 채널 후원 QueryDSL repository 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
|
||||||
|
- RED: `@DataJpaTest`로 repository 테스트를 먼저 작성한다.
|
||||||
|
- 활성 creator는 role, nickname, `isVisibleDonationRank`, `donationRankingPeriod`를 조회하고 비활성 회원은 조회하지 않는지 검증한다.
|
||||||
|
- 조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회하는지 검증한다.
|
||||||
|
- 현재 KST 월 범위의 채널 후원만 count/list에 포함되는지 검증한다.
|
||||||
|
- 크리에이터 본인은 비공개 후원까지 count/list에 포함되는지 검증한다.
|
||||||
|
- 일반 조회자는 공개 후원과 본인의 비공개 후원만 count/list에 포함되는지 검증한다.
|
||||||
|
- 목록 정렬이 `createdAt desc`, `id desc`인지 검증한다.
|
||||||
|
- `offset`, `limit`이 적용되는지 검증한다.
|
||||||
|
- projection이 `selectFrom(channelDonationMessage)`가 아니라 필요한 컬럼 projection을 사용하는지 소스 문자열 또는 동작 테스트로 확인한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
|
||||||
|
- Expected: repository가 없어 컴파일 실패한다.
|
||||||
|
- GREEN: QueryDSL repository를 최소 구현한다.
|
||||||
|
- `findCreator(...)`는 활성 회원만 조회하고 role이 USER인 회원도 record로 반환해 service에서 `creator_not_found`를 판단하게 한다.
|
||||||
|
- `existsBlockedBetween(...)`은 기존 홈/FanTalk repository의 차단 조건과 동일하게 구현한다.
|
||||||
|
- `countChannelDonations(...)`와 `findChannelDonations(...)`는 `CreatorChannelDonationQueryPolicy.currentKstMonthRange(now)` 결과와 같은 월 범위 조건을 적용한다.
|
||||||
|
- `donationVisibilityCondition(creatorId, viewerId)`는 홈 API의 조건과 동일하게 구현한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 홈 repository의 기존 `findChannelDonations` 공개 동작이 변경되지 않았는지 관련 테스트를 실행한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest` 실행, 신규 repository 부재로 `Unresolved reference: DefaultCreatorChannelDonationQueryRepository` 실패 확인.
|
||||||
|
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 보완: `ktlintCheck`에서 repository 테스트의 긴 `saveDonation(...)` 호출 1곳이 실패해 줄바꿈만 수정했다.
|
||||||
|
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: legacy 후원 랭킹 adapter 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
|
||||||
|
- RED: mock `CreatorDonationRankingService`를 사용해 adapter 테스트를 먼저 작성한다.
|
||||||
|
- `findTopRankings(creatorId = 1, period = CUMULATIVE, withDonationCan = false)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = false`, 같은 period가 전달되는지 검증한다.
|
||||||
|
- `findTopRankings(creatorId = 1, period = WEEKLY, withDonationCan = true)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = true`, `period = WEEKLY`가 전달되는지 검증한다.
|
||||||
|
- legacy `MemberDonationRankingResponse` 결과가 `CreatorChannelDonationRankingRecord`로 필드 손실 없이 변환되는지 검증한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
|
||||||
|
- Expected: adapter가 없어 컴파일 실패한다.
|
||||||
|
- GREEN: `CreatorChannelDonationRankingPort` 구현체를 최소 구현한다.
|
||||||
|
- `CreatorDonationRankingService.getMemberDonationRanking(creatorId, offset = 0, limit = 8, withDonationCan, period)`를 호출한다.
|
||||||
|
- 반환값의 `userId`, `nickname`, `profileImage`, `donationCan`을 record로 복사한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 랭킹 산식이나 기간 계산을 V2 코드에 중복 구현하지 않았는지 확인한다.
|
||||||
|
- Run: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
||||||
|
- Expected: 검색 결과 0건
|
||||||
|
- 실행 기록:
|
||||||
|
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest` 실행, 신규 adapter 부재로 `Unresolved reference: LegacyCreatorChannelDonationRankingAdapter` 실패 확인.
|
||||||
|
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- REFACTOR: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
||||||
|
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
|
||||||
|
### Phase 3: 통합 검증과 회귀 확인
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 후원 탭 End-to-End 테스트 추가**
|
||||||
|
- 파일:
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
|
||||||
|
- RED: `@SpringBootTest` + `MockMvc` 통합 테스트를 먼저 작성한다.
|
||||||
|
- 별도 `creator-channel.donation-tab.enabled` 테스트 property 없이 기본 Spring context에서 후원 탭 endpoint가 등록되는지 검증한다.
|
||||||
|
- controller-service-repository를 거쳐 후원 탭 API가 `donationCount`, `donations`, `page`, `size`, `hasNext`를 반환하는지 검증한다.
|
||||||
|
- `page` 범위 밖 요청은 빈 `donations`, 유지된 `donationCount`, `hasNext = false`를 반환하는지 검증한다.
|
||||||
|
- `page = -1`, `size = 100` 요청은 응답의 `page = 0`, `size = 50`으로 보정되는지 검증한다.
|
||||||
|
- 일반 조회자에게 크리에이터의 비공개 후원은 숨기고 조회자 본인의 비공개 후원은 노출하는지 검증한다.
|
||||||
|
- 일반 조회자가 `isVisibleDonationRank = false`인 크리에이터 채널을 조회하면 `rankings`는 빈 배열이고 `donationCount`, `donations`, `page`, `size`, `hasNext`는 정상 반환되는지 검증한다.
|
||||||
|
- 크리에이터 본인 조회 시 비공개 후원과 `donationCan` 값이 포함된 ranking이 내려오는지 검증한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
||||||
|
- Expected: 통합 wiring 또는 신규 API가 없어 실패한다.
|
||||||
|
- GREEN: 누락된 Spring bean wiring, package scan, constructor 주입 문제를 최소 수정한다.
|
||||||
|
- 신규 repository/adapter/service/controller가 component scan 대상 package에 들어가야 한다.
|
||||||
|
- 테스트 데이터는 `ChannelDonationMessage`, `UseCan`, `UseCanCalculate` 등 기존 엔티티 저장 방식에 맞춰 생성한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: End-to-End 테스트 fixture helper 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 기록:
|
||||||
|
- E2E: `CreatorChannelDonationEndToEndTest`를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, 기존 Phase 2 wiring으로 `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 검증 범위: 기본 Spring context endpoint 등록, controller-service-repository-legacy ranking 통합, page 범위 밖 응답, page/size 보정, 일반 조회자 비공개 후원/랭킹 숨김, 크리에이터 본인 비공개 후원 및 `donationCan` 노출을 확인.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 관련 테스트와 아키텍처 의존 방향 검증**
|
||||||
|
- 파일:
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
||||||
|
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
|
||||||
|
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
|
||||||
|
- RED: 이 task는 신규 실패 테스트 작성 대상이 아니라 구현 완료 후 회귀/아키텍처 검증 task다.
|
||||||
|
- TDD 예외 사유: 개별 동작 실패 테스트는 Task 1.1부터 Task 3.1까지 작성한다. 이 task는 전체 검증과 문서 상태 확인만 담당한다.
|
||||||
|
- 대체 검증 방법: 관련 단일 테스트 묶음, import 검색, ktlint를 실행한다.
|
||||||
|
- GREEN: 관련 테스트를 묶어서 실행한다.
|
||||||
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 의존 방향과 포맷을 검증한다.
|
||||||
|
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
|
||||||
|
- Expected: 검색 결과 0건
|
||||||
|
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2`
|
||||||
|
- Expected: 후원 탭 controller와 endpoint mapping 각 1건 확인
|
||||||
|
- Run: `rg -n "ConditionalOnProperty|creator-channel\\.donation-tab\\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
|
||||||
|
- Expected: 별도 feature flag rollout 정책을 유지하기로 문서화한 경우가 아니라면 검색 결과 0건
|
||||||
|
- Run: `./gradlew ktlintCheck`
|
||||||
|
- Expected: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 기록:
|
||||||
|
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
- 의존 방향: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
||||||
|
- endpoint mapping: `rg -n "class CreatorChannelDonationController|/\{creatorId\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행, controller class와 endpoint mapping 각 1건 확인.
|
||||||
|
- feature flag: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
|
||||||
|
- format: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL` 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 순서
|
||||||
|
|
||||||
|
1. Phase 1에서 공개 계약, domain/port, page/month 정책, facade/controller를 먼저 고정한다.
|
||||||
|
2. Phase 2에서 query service, QueryDSL repository, legacy ranking adapter를 TDD로 추가한다.
|
||||||
|
3. Phase 3에서 End-to-End 테스트와 아키텍처/포맷 검증을 수행한다.
|
||||||
|
4. 각 task 완료 즉시 해당 체크박스를 `- [x]`로 변경하고, 실행한 명령과 결과를 task 아래에 한국어로 누적 기록한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 전체 검증 기록
|
||||||
|
|
||||||
|
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
|
||||||
|
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
|
||||||
|
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색, `ktlintCheck` 모두 성공했다.
|
||||||
246
docs/20260622_크리에이터_채널_후원_탭_API/prd.md
Normal file
246
docs/20260622_크리에이터_채널_후원_탭_API/prd.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# PRD: 크리에이터 채널 후원 탭 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
크리에이터 채널의 후원 탭에서 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록을 페이징 조회하는 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 크리에이터 채널 홈 API는 후원 섹션에 최신 채널 후원 일부만 제공한다.
|
||||||
|
- 후원 탭은 홈 요약보다 더 많은 채널 후원 목록을 추가 로딩해야 하고, 전체 채널 후원 개수와 후원 순위 Top 8을 함께 표시해야 한다.
|
||||||
|
- 레거시 채널 후원 목록 API는 `/explorer/profile/channel-donation`에 있고, V2 크리에이터 채널 탭 API의 패키지 분리 구조와 맞지 않는다.
|
||||||
|
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 하므로 새 집계 기준을 임의로 만들면 안 된다.
|
||||||
|
- 레거시 프로필의 후원 순위는 크리에이터 설정에 따라 비공개, 주간 공개, 전체 공개가 가능하므로 후원 탭 API도 같은 공개 범위와 기간 정책을 따라야 한다.
|
||||||
|
- 신규 API는 기존 V2 크리에이터 채널 탭과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 크리에이터 채널 후원 탭 조회 API를 제공한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
|
||||||
|
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위 조립 계층에 둔다.
|
||||||
|
- 후원 개수, 후원 순위, 후원 목록, 페이징 보정, 비공개 후원 노출 조건 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 도메인 조회 계층에 둔다.
|
||||||
|
- 응답에는 조회 가능한 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록, page, size, hasNext를 포함한다.
|
||||||
|
- 채널 후원 목록 item의 내용은 크리에이터 채널 홈 API의 `channelDonations` 섹션과 동일한 필드 의미를 사용한다.
|
||||||
|
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 동일한 결과 리스트 구조를 사용한다.
|
||||||
|
- 페이징 요청값은 page 기본값 `0`, size 기본값 `20`, size 허용 범위 `20..50`으로 보정한다.
|
||||||
|
- V2 패키지에 있는 기존 크리에이터 채널 탭 패턴과 홈 후원 섹션 조회 로직 중 재사용 가능한 것을 확인하고 재사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 채널 후원 생성 API는 포함하지 않는다.
|
||||||
|
- 채널 후원 수정, 삭제, 환불 API는 포함하지 않는다.
|
||||||
|
- 후원 순위 산식, 포함 `CanUsage`, 정렬 기준 변경은 포함하지 않는다.
|
||||||
|
- 크리에이터의 후원 순위 노출 설정 변경 API는 포함하지 않는다.
|
||||||
|
- 레거시 `/explorer/profile/channel-donation` endpoint나 응답 스키마 변경은 포함하지 않는다.
|
||||||
|
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
- 후원 메시지 기본 문구 조합 정책을 새로 만들지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 크리에이터 채널 후원 탭에서 다른 팬들의 채널 후원 내역과 후원 순위를 확인하는 사용자
|
||||||
|
- 크리에이터: 자신의 채널 후원 내역과 후원 순위를 확인하는 사용자
|
||||||
|
- 앱 클라이언트: 후원 탭 구성에 필요한 개수, 랭킹, 목록, 추가 로딩 상태를 단일 API 응답으로 표시하려는 클라이언트
|
||||||
|
- 서버 개발자: 레거시 후원 저장 구조와 랭킹 쿼리를 보존하면서 V2 조회 계층을 분리하려는 개발자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터 채널 후원 탭에 들어가면 전체 채널 후원 개수를 확인하고 싶다.
|
||||||
|
- 사용자는 해당 크리에이터의 후원 순위 Top 8을 확인하고 싶다.
|
||||||
|
- 사용자는 크리에이터가 후원 순위를 공개하지 않은 채널에서는 후원 순위 없이 채널 후원 목록만 확인한다.
|
||||||
|
- 크리에이터는 후원 순위를 공개하지 않은 경우에도 본인 채널에서 자신의 후원 순위를 확인하고 싶다.
|
||||||
|
- 사용자는 채널 후원 목록을 최신순으로 추가 로딩하고 싶다.
|
||||||
|
- 사용자는 후원자 닉네임, 프로필 이미지, 후원 캔 수, 메시지, 후원 시간을 목록 item에서 바로 확인하고 싶다.
|
||||||
|
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
|
||||||
|
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 크리에이터 채널 후원 탭 조회 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
|
||||||
|
- `creatorId`는 path variable로 받는다.
|
||||||
|
- 채널 후원 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
||||||
|
- `page`는 0부터 시작하는 page index로 처리한다.
|
||||||
|
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||||
|
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||||
|
- `page`가 0보다 작으면 `0`으로 보정한다.
|
||||||
|
- `size`가 20보다 작으면 `20`으로 보정한다.
|
||||||
|
- `size`가 50보다 크면 `50`으로 보정한다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||||
|
- 조회 가능한 채널 후원이 없어도 전체 API는 성공 처리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
||||||
|
- 요청한 page 범위에 채널 후원이 없으면 `donations`는 빈 배열, `hasNext`는 `false`로 내려주되 `donationCount`는 전체 개수를 유지한다.
|
||||||
|
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||||
|
|
||||||
|
### Feature B. 응답 스키마
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
||||||
|
- 응답 최상위 DTO 이름은 `CreatorChannelDonationTabResponse`로 한다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- `donationCount`: 조회자가 조회 가능한 전체 채널 후원 개수
|
||||||
|
- `rankings`: 후원 순위 Top 8 목록
|
||||||
|
- `donations`: 채널 후원 목록
|
||||||
|
- `page`: 현재 응답의 page index
|
||||||
|
- `size`: 현재 응답의 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- `donationCount`는 현재 page에 포함되지 않은 채널 후원도 포함한다.
|
||||||
|
- `rankings`는 최대 8개만 내려준다.
|
||||||
|
- `rankings` item은 기존 `MemberDonationRankingResponse`와 동일하게 `userId`, `nickname`, `profileImage`, `donationCan`을 포함한다.
|
||||||
|
- `donations` item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 동일하게 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`를 포함한다.
|
||||||
|
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
||||||
|
- `hasNext`는 같은 조건에서 다음 page에 노출할 채널 후원이 있으면 `true`로 내려준다.
|
||||||
|
- 응답 스키마 예시는 다음과 같다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelDonationTabResponse(
|
||||||
|
val donationCount: Int,
|
||||||
|
val rankings: List<MemberDonationRankingResponse>,
|
||||||
|
val donations: List<CreatorChannelDonationResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MemberDonationRankingResponse(
|
||||||
|
@JsonProperty("userId") val userId: Long,
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("profileImage") val profileImage: String,
|
||||||
|
@JsonProperty("donationCan") val donationCan: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationResponse(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
val message: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회 가능한 채널 후원이 없으면 `donationCount`는 `0`, `donations`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||||
|
- 노출 가능한 후원 순위가 없으면 `rankings`는 빈 배열로 내려준다.
|
||||||
|
- 크리에이터가 후원 순위를 공개하지 않았고 조회자가 크리에이터 본인이 아니면 채널 후원 목록은 정상 조회하되 `rankings`만 빈 배열로 내려준다.
|
||||||
|
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
|
||||||
|
- `createdAtUtc`는 `ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
|
||||||
|
- Boolean 응답 필드는 Jackson 직렬화 필드명을 명시한다.
|
||||||
|
|
||||||
|
### Feature C. 전체 채널 후원 개수와 목록
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 조회 대상은 지정한 `creatorId`의 채널 후원 메시지로 제한한다.
|
||||||
|
- 저장 엔티티는 기존 `ChannelDonationMessage`를 사용한다.
|
||||||
|
- 채널 후원 목록은 크리에이터 채널 홈 API의 후원 섹션과 동일하게 현재 KST 월 범위의 후원 메시지를 대상으로 한다.
|
||||||
|
- 현재 KST 월 범위는 `now`를 UTC로 받은 뒤 Asia/Seoul 기준 월 시작 이상, 다음 달 월 시작 미만으로 변환해 계산한다.
|
||||||
|
- 전체 채널 후원 개수는 목록과 같은 creator, 월 범위, 비공개 후원 노출 조건을 적용해 계산한다.
|
||||||
|
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
|
||||||
|
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
||||||
|
- 후원자 닉네임은 `ChannelDonationMessage.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
|
||||||
|
- 후원자 프로필 이미지는 `ChannelDonationMessage.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
|
||||||
|
- 후원 캔 수는 `ChannelDonationMessage.can`을 사용한다.
|
||||||
|
- 후원 메시지는 크리에이터 채널 홈 API와 동일하게 `ChannelDonationMessage.additionalMessage`가 없으면 빈 문자열로 내려준다.
|
||||||
|
- 후원 시간은 `ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
|
||||||
|
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
|
||||||
|
- 비회원 조회는 허용하지 않으므로 비회원 기준 비공개 후원 필터는 별도로 만들지 않는다.
|
||||||
|
- 같은 회원이 여러 번 후원한 경우 목록에서는 각각 별도 item으로 내려준다.
|
||||||
|
|
||||||
|
### Feature D. 후원 순위 Top 8
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 한다.
|
||||||
|
- API 응답에는 Top 8만 내려준다.
|
||||||
|
- 호출 offset은 `0`, limit은 `8`을 사용한다.
|
||||||
|
- 순위 산식과 포함 후원 유형은 레거시 쿼리 기준을 따른다.
|
||||||
|
- `CanUsage.DONATION`
|
||||||
|
- `CanUsage.SPIN_ROULETTE`
|
||||||
|
- `CanUsage.LIVE`
|
||||||
|
- `CanUsage.CHANNEL_DONATION`
|
||||||
|
- 환불된 사용 내역은 제외한다.
|
||||||
|
- 비활성 회원은 제외한다.
|
||||||
|
- 정렬은 레거시 쿼리와 동일하게 `donationCan desc`, `member.id desc`를 따른다.
|
||||||
|
- 기간은 크리에이터의 `donationRankingPeriod` 설정을 따른다.
|
||||||
|
- `donationRankingPeriod`가 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
|
||||||
|
- `DonationRankingPeriod.WEEKLY`는 기존 레거시 서비스의 주간 범위 계산을 따른다.
|
||||||
|
- `DonationRankingPeriod.CUMULATIVE`는 기존 레거시 서비스의 전체 누적 범위 계산을 따른다.
|
||||||
|
- 후원 순위 노출 정책은 기존 프로필 정책과 동일하게 유지한다.
|
||||||
|
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank`가 `true`이면 `rankings`를 내려준다.
|
||||||
|
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank`가 `false`이면 `rankings`는 빈 배열로 내려준다.
|
||||||
|
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank`가 `false`인 경우에도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 정상 조회한다.
|
||||||
|
- `donationCan` 노출 여부는 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 순위 대상 회원이 8명보다 적으면 있는 만큼만 내려준다.
|
||||||
|
- 같은 후원 캔 금액이면 레거시 쿼리와 동일하게 회원 ID 내림차순으로 정렬한다.
|
||||||
|
- 순위 조회 결과가 없어도 후원 탭 API는 성공 처리한다.
|
||||||
|
- 후원 순위 비공개로 `rankings`가 빈 배열인 경우와 실제 순위 결과가 없어 `rankings`가 빈 배열인 경우 모두 같은 응답 스키마를 사용한다.
|
||||||
|
|
||||||
|
### Feature E. V2 재사용 범위와 계층 분리
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위에 둔다.
|
||||||
|
- 후원 탭 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 둔다.
|
||||||
|
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
||||||
|
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
||||||
|
- 의존 방향은 항상 `v2.api.creator.channel.donation -> v2.creator.channel.donation`이다.
|
||||||
|
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
|
||||||
|
- `page`, `size`, `hasNext`, `limitItems` 정책은 기존 FanTalk/커뮤니티/시리즈 탭의 query policy 패턴을 재사용한다.
|
||||||
|
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
|
||||||
|
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
|
||||||
|
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
|
||||||
|
- 홈 API의 `findChannelDonations` 조회 조건과 응답 필드는 참고하되, 홈 도메인 repository에 후원 탭 페이징 책임을 추가하지 않는다.
|
||||||
|
- 후원 순위는 레거시 repository 또는 같은 쿼리 기준을 감싼 V2 port를 통해 재사용한다.
|
||||||
|
- 레거시 채널 후원 목록 API의 기본 메시지 조합(`buildMessage`)은 이번 V2 후원 탭 목록 응답에 재사용하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 신규 `donation` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
|
||||||
|
- 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
|
||||||
|
- legacy 후원 생성/목록 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
||||||
|
- 언어/런타임은 Kotlin + Java 17을 따른다.
|
||||||
|
- 프레임워크는 Spring Boot 2.7.14를 따른다.
|
||||||
|
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
|
||||||
|
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
|
||||||
|
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Decisions
|
||||||
|
- endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 확정한다.
|
||||||
|
- page는 0 기반 page index로 처리한다.
|
||||||
|
- page 기본값은 `0`, size 기본값은 `20`으로 한다.
|
||||||
|
- page가 0 미만이면 `0`으로 보정한다.
|
||||||
|
- size가 20 미만이면 `20`, 50 초과이면 `50`으로 보정한다.
|
||||||
|
- 채널 후원 목록 item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 같은 필드 의미를 사용한다.
|
||||||
|
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 같은 필드 의미를 사용한다.
|
||||||
|
- 후원 순위 산식은 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 기준을 변경하지 않는다.
|
||||||
|
- 후원 순위 공개 여부는 `isVisibleDonationRank`, 기간은 `donationRankingPeriod` 기준으로 판단한다.
|
||||||
|
- 채널 후원 목록과 개수의 기간은 홈 후원 섹션과 동일하게 현재 KST 월 범위로 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open Questions
|
||||||
|
- 없음. 구현 중 공개 응답 필드 추가나 기간 정책 변경이 필요하면 이 PRD를 먼저 갱신한다.
|
||||||
@@ -10,7 +10,7 @@ import javax.persistence.ManyToOne
|
|||||||
import javax.persistence.OneToMany
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorCheers(
|
class CreatorCheers(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var cheers: String,
|
var cheers: String,
|
||||||
var languageCode: String?,
|
var languageCode: String?,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/creator-channels")
|
||||||
|
class CreatorChannelDonationController(
|
||||||
|
private val creatorChannelDonationFacade: CreatorChannelDonationFacade
|
||||||
|
) {
|
||||||
|
@GetMapping("/{creatorId}/donations")
|
||||||
|
fun getDonationTab(
|
||||||
|
@PathVariable creatorId: Long,
|
||||||
|
@RequestParam(required = false) page: Int?,
|
||||||
|
@RequestParam(required = false) size: Int?,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
creatorChannelDonationFacade.getDonationTab(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewer = requireMember(member),
|
||||||
|
page = page,
|
||||||
|
size = size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireMember(member: Member?): Member {
|
||||||
|
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorChannelDonationFacade(
|
||||||
|
private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService
|
||||||
|
) {
|
||||||
|
fun getDonationTab(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelDonationTabResponse {
|
||||||
|
return CreatorChannelDonationTabResponse.from(
|
||||||
|
creatorChannelDonationQueryService.getDonationTab(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewer = viewer,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
||||||
|
|
||||||
|
data class CreatorChannelDonationTabResponse(
|
||||||
|
val donationCount: Int,
|
||||||
|
val rankings: List<MemberDonationRankingResponse>,
|
||||||
|
val donations: List<CreatorChannelDonationResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
|
||||||
|
return CreatorChannelDonationTabResponse(
|
||||||
|
donationCount = tab.donationCount,
|
||||||
|
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
|
||||||
|
donations = tab.donations.map(CreatorChannelDonationResponse::from),
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MemberDonationRankingResponse(
|
||||||
|
@JsonProperty("userId") val userId: Long,
|
||||||
|
@JsonProperty("nickname") val nickname: String,
|
||||||
|
@JsonProperty("profileImage") val profileImage: String,
|
||||||
|
@JsonProperty("donationCan") val donationCan: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
|
||||||
|
return MemberDonationRankingResponse(
|
||||||
|
userId = ranking.userId,
|
||||||
|
nickname = ranking.nickname,
|
||||||
|
profileImage = ranking.profileImage,
|
||||||
|
donationCan = ranking.donationCan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationResponse(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
val message: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
|
||||||
|
return CreatorChannelDonationResponse(
|
||||||
|
nickname = donation.nickname,
|
||||||
|
profileImageUrl = donation.profileImageUrl,
|
||||||
|
can = donation.can,
|
||||||
|
message = donation.message,
|
||||||
|
createdAtUtc = donation.createdAt.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacade
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/creator-channels")
|
||||||
|
class CreatorChannelFanTalkController(
|
||||||
|
private val creatorChannelFanTalkFacade: CreatorChannelFanTalkFacade
|
||||||
|
) {
|
||||||
|
@GetMapping("/{creatorId}/fan-talks")
|
||||||
|
fun getFanTalkTab(
|
||||||
|
@PathVariable creatorId: Long,
|
||||||
|
@RequestParam(required = false) page: Int?,
|
||||||
|
@RequestParam(required = false) size: Int?,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
creatorChannelFanTalkFacade.getFanTalkTab(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewer = requireMember(member),
|
||||||
|
page = page,
|
||||||
|
size = size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireMember(member: Member?): Member {
|
||||||
|
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorChannelFanTalkFacade(
|
||||||
|
private val creatorChannelFanTalkQueryService: CreatorChannelFanTalkQueryService
|
||||||
|
) {
|
||||||
|
fun getFanTalkTab(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelFanTalkTabResponse {
|
||||||
|
return CreatorChannelFanTalkTabResponse.from(
|
||||||
|
creatorChannelFanTalkQueryService.getFanTalkTab(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewer = viewer,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkTabResponse(
|
||||||
|
val fanTalkCount: Int,
|
||||||
|
val fanTalks: List<CreatorChannelFanTalkResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
|
||||||
|
return CreatorChannelFanTalkTabResponse(
|
||||||
|
fanTalkCount = tab.fanTalkCount,
|
||||||
|
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
|
||||||
|
return CreatorChannelFanTalkResponse(
|
||||||
|
fanTalkId = fanTalk.fanTalkId,
|
||||||
|
writerId = fanTalk.writerId,
|
||||||
|
writerNickname = fanTalk.writerNickname,
|
||||||
|
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
|
||||||
|
content = fanTalk.content,
|
||||||
|
createdAtUtc = fanTalk.createdAt.toUtcIso(),
|
||||||
|
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReplyResponse(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
|
||||||
|
return CreatorChannelFanTalkReplyResponse(
|
||||||
|
fanTalkId = reply.fanTalkId,
|
||||||
|
writerId = reply.writerId,
|
||||||
|
writerNickname = reply.writerNickname,
|
||||||
|
writerProfileImageUrl = reply.writerProfileImageUrl,
|
||||||
|
content = reply.content,
|
||||||
|
createdAtUtc = reply.createdAt.toUtcIso()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LegacyCreatorChannelDonationRankingAdapter(
|
||||||
|
private val creatorDonationRankingService: CreatorDonationRankingService
|
||||||
|
) : CreatorChannelDonationRankingPort {
|
||||||
|
override fun findTopRankings(
|
||||||
|
creatorId: Long,
|
||||||
|
period: DonationRankingPeriod,
|
||||||
|
withDonationCan: Boolean
|
||||||
|
): List<CreatorChannelDonationRankingRecord> {
|
||||||
|
return creatorDonationRankingService.getMemberDonationRanking(
|
||||||
|
creatorId = creatorId,
|
||||||
|
offset = 0L,
|
||||||
|
limit = 8L,
|
||||||
|
withDonationCan = withDonationCan,
|
||||||
|
period = period
|
||||||
|
).map {
|
||||||
|
CreatorChannelDonationRankingRecord(
|
||||||
|
userId = it.userId,
|
||||||
|
nickname = it.nickname,
|
||||||
|
profileImage = it.profileImage,
|
||||||
|
donationCan = it.donationCan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
|
||||||
|
|
||||||
|
interface CreatorChannelDonationQueryRepository : CreatorChannelDonationQueryPort
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultCreatorChannelDonationQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : CreatorChannelDonationQueryRepository {
|
||||||
|
private val queryPolicy = CreatorChannelDonationQueryPolicy()
|
||||||
|
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? {
|
||||||
|
val creator = queryFactory
|
||||||
|
.select(
|
||||||
|
member.id,
|
||||||
|
member.role,
|
||||||
|
member.nickname,
|
||||||
|
member.isVisibleDonationRank,
|
||||||
|
member.donationRankingPeriod
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.id.eq(creatorId),
|
||||||
|
member.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetchFirst() ?: return null
|
||||||
|
|
||||||
|
return CreatorChannelDonationCreatorRecord(
|
||||||
|
creatorId = creator.get(member.id)!!,
|
||||||
|
role = creator.get(member.role)!!,
|
||||||
|
nickname = creator.get(member.nickname)!!,
|
||||||
|
isVisibleDonationRank = creator.get(member.isVisibleDonationRank)!!,
|
||||||
|
donationRankingPeriod = creator.get(member.donationRankingPeriod)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||||
|
val blockMember = QBlockMember("creatorChannelDonationBlockMember")
|
||||||
|
return queryFactory
|
||||||
|
.select(blockMember.id)
|
||||||
|
.from(blockMember)
|
||||||
|
.where(
|
||||||
|
blockMember.isActive.isTrue,
|
||||||
|
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
|
||||||
|
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
|
||||||
|
)
|
||||||
|
.fetchFirst() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime
|
||||||
|
): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(channelDonationMessage.id.count())
|
||||||
|
.from(channelDonationMessage)
|
||||||
|
.where(channelDonationCondition(creatorId, viewerId, now))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelDonationRecord> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
CreatorChannelDonationRecord::class.java,
|
||||||
|
channelDonationMessage.member.nickname,
|
||||||
|
channelDonationMessage.member.profileImage,
|
||||||
|
channelDonationMessage.can,
|
||||||
|
channelDonationMessage.additionalMessage,
|
||||||
|
channelDonationMessage.createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(channelDonationMessage)
|
||||||
|
.where(channelDonationCondition(creatorId, viewerId, now))
|
||||||
|
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channelDonationCondition(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime
|
||||||
|
): BooleanExpression {
|
||||||
|
val monthRange = queryPolicy.currentKstMonthRange(now)
|
||||||
|
return channelDonationMessage.creator.id.eq(creatorId)
|
||||||
|
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
|
||||||
|
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
|
||||||
|
.and(donationVisibilityCondition(creatorId, viewerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression {
|
||||||
|
return if (creatorId == viewerId) {
|
||||||
|
channelDonationMessage.id.isNotNull
|
||||||
|
} else {
|
||||||
|
channelDonationMessage.isSecret.isFalse
|
||||||
|
.or(channelDonationMessage.member.id.eq(viewerId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorChannelDonationQueryService(
|
||||||
|
private val queryPortProvider: ObjectProvider<CreatorChannelDonationQueryPort>,
|
||||||
|
private val rankingPort: CreatorChannelDonationRankingPort,
|
||||||
|
private val queryPolicy: CreatorChannelDonationQueryPolicy,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
fun getDonationTab(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime
|
||||||
|
): CreatorChannelDonationTab {
|
||||||
|
val donationPage = queryPolicy.createPage(page, size)
|
||||||
|
val queryPort = queryPortProvider.getObject()
|
||||||
|
val viewerId = viewer.id!!
|
||||||
|
val creator = queryPort.findCreator(creatorId, viewerId)
|
||||||
|
?: throw SodaException(messageKey = "member.validation.user_not_found")
|
||||||
|
|
||||||
|
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
|
||||||
|
val messageTemplate = messageSource
|
||||||
|
.getMessage("explorer.creator.blocked_access", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
throw SodaException(message = String.format(messageTemplate, creator.nickname))
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCreatorRole(creator)
|
||||||
|
|
||||||
|
val fetchedDonations = queryPort.findChannelDonations(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewerId = viewerId,
|
||||||
|
now = now,
|
||||||
|
offset = donationPage.offset,
|
||||||
|
limit = donationPage.fetchLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreatorChannelDonationTab(
|
||||||
|
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
|
||||||
|
rankings = findRankings(creator, viewerId),
|
||||||
|
donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain() },
|
||||||
|
page = donationPage,
|
||||||
|
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateCreatorRole(creator: CreatorChannelDonationCreatorRecord) {
|
||||||
|
when (creator.role) {
|
||||||
|
MemberRole.CREATOR -> return
|
||||||
|
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findRankings(
|
||||||
|
creator: CreatorChannelDonationCreatorRecord,
|
||||||
|
viewerId: Long
|
||||||
|
): List<CreatorChannelDonationRanking> {
|
||||||
|
val isViewerCreator = viewerId == creator.creatorId
|
||||||
|
if (!isViewerCreator && !creator.isVisibleDonationRank) return emptyList()
|
||||||
|
|
||||||
|
return rankingPort.findTopRankings(
|
||||||
|
creatorId = creator.creatorId,
|
||||||
|
period = creator.donationRankingPeriod ?: DonationRankingPeriod.CUMULATIVE,
|
||||||
|
withDonationCan = isViewerCreator
|
||||||
|
).map { it.toDomain() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
|
||||||
|
nickname = nickname.removeDeletedNicknamePrefix(),
|
||||||
|
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||||
|
can = can,
|
||||||
|
message = message.orEmpty(),
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking(
|
||||||
|
userId = userId,
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = profileImage,
|
||||||
|
donationCan = donationCan
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class CreatorChannelDonationQueryPolicy {
|
||||||
|
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
||||||
|
return CreatorChannelPage(
|
||||||
|
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
||||||
|
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
||||||
|
return fetched.take(page.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
||||||
|
return fetched.size > page.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentKstMonthRange(now: LocalDateTime): CreatorChannelDonationMonthRange {
|
||||||
|
val nowKst = now.atZone(UTC_ZONE_ID).withZoneSameInstant(KST_ZONE_ID)
|
||||||
|
val start = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(KST_ZONE_ID)
|
||||||
|
.withZoneSameInstant(UTC_ZONE_ID)
|
||||||
|
.toLocalDateTime()
|
||||||
|
val end = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(KST_ZONE_ID).plusMonths(1)
|
||||||
|
.withZoneSameInstant(UTC_ZONE_ID)
|
||||||
|
.toLocalDateTime()
|
||||||
|
|
||||||
|
return CreatorChannelDonationMonthRange(
|
||||||
|
startInclusiveUtc = start,
|
||||||
|
endExclusiveUtc = end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_PAGE = 0
|
||||||
|
private const val DEFAULT_PAGE_SIZE = 20
|
||||||
|
private const val MIN_PAGE = 0
|
||||||
|
private const val MIN_PAGE_SIZE = 20
|
||||||
|
private const val MAX_PAGE_SIZE = 50
|
||||||
|
private val KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationMonthRange(
|
||||||
|
val startInclusiveUtc: LocalDateTime,
|
||||||
|
val endExclusiveUtc: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class CreatorChannelDonationTab(
|
||||||
|
val donationCount: Int,
|
||||||
|
val rankings: List<CreatorChannelDonationRanking>,
|
||||||
|
val donations: List<CreatorChannelDonation>,
|
||||||
|
val page: CreatorChannelPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationRanking(
|
||||||
|
val userId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImage: String,
|
||||||
|
val donationCan: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonation(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImageUrl: String,
|
||||||
|
val can: Int,
|
||||||
|
val message: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface CreatorChannelDonationQueryPort {
|
||||||
|
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord?
|
||||||
|
|
||||||
|
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||||
|
|
||||||
|
fun countChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelDonationRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val role: MemberRole,
|
||||||
|
val nickname: String,
|
||||||
|
val isVisibleDonationRank: Boolean,
|
||||||
|
val donationRankingPeriod: DonationRankingPeriod?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelDonationRecord(
|
||||||
|
val nickname: String,
|
||||||
|
val profileImagePath: String?,
|
||||||
|
val can: Int,
|
||||||
|
val message: String?,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
|
||||||
|
interface CreatorChannelDonationRankingPort {
|
||||||
|
fun findTopRankings(
|
||||||
|
creatorId: Long,
|
||||||
|
period: DonationRankingPeriod,
|
||||||
|
withDonationCan: Boolean
|
||||||
|
): List<CreatorChannelDonationRankingRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelDonationRankingRecord(
|
||||||
|
val userId: Long,
|
||||||
|
val nickname: String,
|
||||||
|
val profileImage: String,
|
||||||
|
val donationCan: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
|
||||||
|
|
||||||
|
interface CreatorChannelFanTalkQueryRepository : CreatorChannelFanTalkQueryPort
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultCreatorChannelFanTalkQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : CreatorChannelFanTalkQueryRepository {
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? {
|
||||||
|
val creator = queryFactory
|
||||||
|
.select(member.id, member.role, member.nickname)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.id.eq(creatorId),
|
||||||
|
member.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetchFirst() ?: return null
|
||||||
|
|
||||||
|
return CreatorChannelFanTalkCreatorRecord(
|
||||||
|
creatorId = creator.get(member.id)!!,
|
||||||
|
role = creator.get(member.role)!!,
|
||||||
|
nickname = creator.get(member.nickname)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||||
|
val blockMember = QBlockMember("creatorChannelFanTalkBlockMember")
|
||||||
|
return queryFactory
|
||||||
|
.select(blockMember.id)
|
||||||
|
.from(blockMember)
|
||||||
|
.where(
|
||||||
|
blockMember.isActive.isTrue,
|
||||||
|
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
|
||||||
|
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
|
||||||
|
)
|
||||||
|
.fetchFirst() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countFanTalks(creatorId: Long, viewerId: Long): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCheers.id.count())
|
||||||
|
.from(creatorCheers)
|
||||||
|
.where(fanTalkCondition(creatorId, viewerId))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findFanTalks(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelFanTalkRecord> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
CreatorChannelFanTalkRecord::class.java,
|
||||||
|
creatorCheers.id,
|
||||||
|
creatorCheers.member.id,
|
||||||
|
creatorCheers.member.nickname,
|
||||||
|
creatorCheers.member.profileImage,
|
||||||
|
creatorCheers.cheers,
|
||||||
|
creatorCheers.createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(creatorCheers)
|
||||||
|
.where(fanTalkCondition(creatorId, viewerId))
|
||||||
|
.orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCreatorReplies(
|
||||||
|
creatorId: Long,
|
||||||
|
parentFanTalkIds: List<Long>
|
||||||
|
): List<CreatorChannelFanTalkReplyRecord> {
|
||||||
|
if (parentFanTalkIds.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
CreatorChannelFanTalkReplyRecord::class.java,
|
||||||
|
creatorCheers.id,
|
||||||
|
creatorCheers.parent.id,
|
||||||
|
creatorCheers.member.id,
|
||||||
|
creatorCheers.member.nickname,
|
||||||
|
creatorCheers.member.profileImage,
|
||||||
|
creatorCheers.cheers,
|
||||||
|
creatorCheers.createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(creatorCheers)
|
||||||
|
.where(
|
||||||
|
creatorCheers.creator.id.eq(creatorId),
|
||||||
|
creatorCheers.member.id.eq(creatorId),
|
||||||
|
creatorCheers.isActive.isTrue,
|
||||||
|
creatorCheers.parent.id.`in`(parentFanTalkIds)
|
||||||
|
)
|
||||||
|
.orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fanTalkCondition(creatorId: Long, viewerId: Long): BooleanExpression {
|
||||||
|
return creatorCheers.creator.id.eq(creatorId)
|
||||||
|
.and(creatorCheers.isActive.isTrue)
|
||||||
|
.and(creatorCheers.parent.isNull)
|
||||||
|
.and(notBlockedFanTalkWriterCondition(viewerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notBlockedFanTalkWriterCondition(viewerId: Long): BooleanExpression {
|
||||||
|
val viewerBlock = QBlockMember("viewerBlockFanTalkTabWriter")
|
||||||
|
val writerBlock = QBlockMember("writerBlockFanTalkTabViewer")
|
||||||
|
return creatorCheers.member.id.notIn(
|
||||||
|
queryFactory
|
||||||
|
.select(viewerBlock.blockedMember.id)
|
||||||
|
.from(viewerBlock)
|
||||||
|
.where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue)
|
||||||
|
).and(
|
||||||
|
creatorCheers.member.id.notIn(
|
||||||
|
queryFactory
|
||||||
|
.select(writerBlock.member.id)
|
||||||
|
.from(writerBlock)
|
||||||
|
.where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
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.v2.common.domain.toCdnUrl
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class CreatorChannelFanTalkQueryService(
|
||||||
|
private val queryPortProvider: ObjectProvider<CreatorChannelFanTalkQueryPort>,
|
||||||
|
private val queryPolicy: CreatorChannelFanTalkQueryPolicy,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val cloudFrontHost: String
|
||||||
|
) {
|
||||||
|
fun getFanTalkTab(
|
||||||
|
creatorId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelFanTalkTab {
|
||||||
|
val fanTalkPage = queryPolicy.createPage(page, size)
|
||||||
|
val queryPort = queryPortProvider.getObject()
|
||||||
|
val viewerId = viewer.id!!
|
||||||
|
val creator = queryPort.findCreator(creatorId, viewerId)
|
||||||
|
?: throw SodaException(messageKey = "member.validation.user_not_found")
|
||||||
|
|
||||||
|
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
|
||||||
|
val messageTemplate = messageSource
|
||||||
|
.getMessage("explorer.creator.blocked_access", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
throw SodaException(message = String.format(messageTemplate, creator.nickname))
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCreatorRole(creator)
|
||||||
|
|
||||||
|
val fetchedFanTalks = queryPort.findFanTalks(
|
||||||
|
creatorId = creatorId,
|
||||||
|
viewerId = viewerId,
|
||||||
|
offset = fanTalkPage.offset,
|
||||||
|
limit = fanTalkPage.fetchLimit
|
||||||
|
)
|
||||||
|
val fanTalkRecords = queryPolicy.limitItems(fetchedFanTalks, fanTalkPage)
|
||||||
|
val repliesByParentId = findRepliesByParentId(queryPort, creatorId, fanTalkRecords)
|
||||||
|
|
||||||
|
return CreatorChannelFanTalkTab(
|
||||||
|
fanTalkCount = queryPort.countFanTalks(creatorId, viewerId),
|
||||||
|
fanTalks = fanTalkRecords.map { it.toDomain(repliesByParentId[it.fanTalkId].orEmpty()) },
|
||||||
|
page = fanTalkPage,
|
||||||
|
hasNext = queryPolicy.hasNext(fetchedFanTalks, fanTalkPage)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateCreatorRole(creator: CreatorChannelFanTalkCreatorRecord) {
|
||||||
|
when (creator.role) {
|
||||||
|
MemberRole.CREATOR -> return
|
||||||
|
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findRepliesByParentId(
|
||||||
|
queryPort: CreatorChannelFanTalkQueryPort,
|
||||||
|
creatorId: Long,
|
||||||
|
fanTalkRecords: List<CreatorChannelFanTalkRecord>
|
||||||
|
): Map<Long, List<CreatorChannelFanTalkReply>> {
|
||||||
|
val parentFanTalkIds = fanTalkRecords.map { it.fanTalkId }
|
||||||
|
if (parentFanTalkIds.isEmpty()) return emptyMap()
|
||||||
|
return queryPort.findCreatorReplies(creatorId, parentFanTalkIds)
|
||||||
|
.groupBy(
|
||||||
|
keySelector = { it.parentFanTalkId },
|
||||||
|
valueTransform = { it.toDomain() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorChannelFanTalkRecord.toDomain(
|
||||||
|
creatorReplies: List<CreatorChannelFanTalkReply>
|
||||||
|
) = CreatorChannelFanTalk(
|
||||||
|
fanTalkId = fanTalkId,
|
||||||
|
writerId = writerId,
|
||||||
|
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
|
||||||
|
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||||
|
content = content,
|
||||||
|
createdAt = createdAt,
|
||||||
|
creatorReplies = creatorReplies
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun CreatorChannelFanTalkReplyRecord.toDomain() = CreatorChannelFanTalkReply(
|
||||||
|
fanTalkId = fanTalkId,
|
||||||
|
writerId = writerId,
|
||||||
|
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
|
||||||
|
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||||
|
content = content,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class CreatorChannelFanTalkQueryPolicy {
|
||||||
|
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
||||||
|
return CreatorChannelPage(
|
||||||
|
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
||||||
|
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
||||||
|
return fetched.take(page.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
||||||
|
return fetched.size > page.size
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_PAGE = 0
|
||||||
|
private const val DEFAULT_PAGE_SIZE = 20
|
||||||
|
private const val MIN_PAGE = 0
|
||||||
|
private const val MIN_PAGE_SIZE = 20
|
||||||
|
private const val MAX_PAGE_SIZE = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkTab(
|
||||||
|
val fanTalkCount: Int,
|
||||||
|
val fanTalks: List<CreatorChannelFanTalk>,
|
||||||
|
val page: CreatorChannelPage,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalk(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val creatorReplies: List<CreatorChannelFanTalkReply>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReply(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface CreatorChannelFanTalkQueryPort {
|
||||||
|
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
|
||||||
|
|
||||||
|
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||||
|
|
||||||
|
fun countFanTalks(creatorId: Long, viewerId: Long): Int
|
||||||
|
|
||||||
|
fun findFanTalks(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelFanTalkRecord>
|
||||||
|
|
||||||
|
fun findCreatorReplies(
|
||||||
|
creatorId: Long,
|
||||||
|
parentFanTalkIds: List<Long>
|
||||||
|
): List<CreatorChannelFanTalkReplyRecord>
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val role: MemberRole,
|
||||||
|
val nickname: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkRecord(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImagePath: String?,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelFanTalkReplyRecord(
|
||||||
|
val fanTalkId: Long,
|
||||||
|
val parentFanTalkId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerNickname: String,
|
||||||
|
val writerProfileImagePath: String?,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
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.creator.channel.donation.application.CreatorChannelDonationFacade
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.MemberDonationRankingResponse
|
||||||
|
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.context.TestConfiguration
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||||
|
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 java.time.LocalDateTime
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@WebMvcTest(CreatorChannelDonationController::class)
|
||||||
|
@Import(CreatorChannelDonationControllerTest.TestSecurityConfig::class)
|
||||||
|
class CreatorChannelDonationControllerTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var facade: CreatorChannelDonationFacade
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var langContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TestSecurityConfig {
|
||||||
|
@Bean
|
||||||
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
return http
|
||||||
|
.csrf().disable()
|
||||||
|
.authorizeRequests()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
.and()
|
||||||
|
.exceptionHandling()
|
||||||
|
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||||
|
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||||
|
.and()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 후원 탭 조회는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousCreatorChannelDonationRequest() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/donations")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
|
||||||
|
fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createResponse(page = 1, size = 20)).`when`(facade).getDonationTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(1),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/donations")
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.donationCount").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.rankings").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].userId").value(10))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].nickname").value("fan"))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/fan.png"))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(100))
|
||||||
|
.andExpect(jsonPath("$.data.donations").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].nickname").value("donor"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donor.png"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].can").value(50))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].message").value("thanks"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").value("2026-06-21T03:30:00Z"))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getDonationTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(1),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> anyValue(fallback: T): T {
|
||||||
|
return Mockito.any<T>() ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createResponse(
|
||||||
|
page: Int = 0,
|
||||||
|
size: Int = 20
|
||||||
|
): CreatorChannelDonationTabResponse {
|
||||||
|
return CreatorChannelDonationTabResponse(
|
||||||
|
donationCount = 3,
|
||||||
|
rankings = listOf(
|
||||||
|
MemberDonationRankingResponse(
|
||||||
|
userId = 10L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImage = "https://cdn.test/fan.png",
|
||||||
|
donationCan = 100
|
||||||
|
)
|
||||||
|
),
|
||||||
|
donations = listOf(
|
||||||
|
CreatorChannelDonationResponse(
|
||||||
|
nickname = "donor",
|
||||||
|
profileImageUrl = "https://cdn.test/donor.png",
|
||||||
|
can = 50,
|
||||||
|
message = "thanks",
|
||||||
|
createdAtUtc = "2026-06-21T03:30:00Z"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
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.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
||||||
|
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.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
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 javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:creator-channel-donation-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class CreatorChannelDonationEndToEndTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
|
||||||
|
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
|
||||||
|
val fixture = createFixture("donation-e2e-success", isVisibleDonationRank = true)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.donationCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.rankings.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].nickname").value(fixture.viewer.nickname))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}"))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.donations.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].nickname").value("donation-e2e-success-viewer"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].can").value(200))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].message").value("own secret"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").exists())
|
||||||
|
.andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-other"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[1].can").value(100))
|
||||||
|
.andExpect(jsonPath("$.data.donations[1].message").value("public"))
|
||||||
|
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 API는 page 범위 밖 요청에 빈 목록과 유지된 count를 반환한다")
|
||||||
|
fun shouldReturnEmptyDonationsAndKeepCountForOutOfRangePage() {
|
||||||
|
val fixture = createFixture("donation-e2e-out-of-range", isVisibleDonationRank = true)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.donationCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.donations.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 API는 page와 size를 정책 범위로 보정한다")
|
||||||
|
fun shouldClampPageAndSize() {
|
||||||
|
val fixture = createFixture("donation-e2e-clamp", isVisibleDonationRank = true)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
|
||||||
|
.param("page", "-1")
|
||||||
|
.param("size", "100")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(50))
|
||||||
|
.andExpect(jsonPath("$.data.donations.length()").value(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 랭킹 비공개 크리에이터는 일반 조회자에게 빈 랭킹과 정상 후원 목록을 반환한다")
|
||||||
|
fun shouldReturnEmptyRankingsAndDonationTabForHiddenRankingCreator() {
|
||||||
|
val fixture = createFixture("donation-e2e-hidden-ranking", isVisibleDonationRank = false)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.rankings.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.donationCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.donations.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 본인 조회는 비공개 후원과 실제 donationCan 랭킹을 포함한다")
|
||||||
|
fun shouldReturnPrivateDonationsAndDonationCanForCreatorSelf() {
|
||||||
|
val fixture = createFixture("donation-e2e-self", isVisibleDonationRank = false)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
|
||||||
|
.with(user(MemberAdapter(fixture.creator)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.donationCount").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.rankings.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
|
||||||
|
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(500))
|
||||||
|
.andExpect(jsonPath("$.data.donations.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val monthStart = CreatorChannelDonationQueryPolicy()
|
||||||
|
.currentKstMonthRange(LocalDateTime.now())
|
||||||
|
.startInclusiveUtc
|
||||||
|
val now = monthStart.plusDays(10)
|
||||||
|
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember(
|
||||||
|
"$prefix-creator",
|
||||||
|
MemberRole.CREATOR,
|
||||||
|
isVisibleDonationRank = isVisibleDonationRank
|
||||||
|
)
|
||||||
|
val otherDonor = saveMember("$prefix-other", MemberRole.USER)
|
||||||
|
|
||||||
|
saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public")
|
||||||
|
saveDonation(creator, viewer, 200, now.minusHours(2), isSecret = true, additionalMessage = "own secret")
|
||||||
|
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
|
||||||
|
saveRankingDonation(viewer, creator, can = 450, rewardCan = 50)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
Fixture(
|
||||||
|
viewer = viewer,
|
||||||
|
creator = creator,
|
||||||
|
creatorId = creator.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(
|
||||||
|
nickname: String,
|
||||||
|
role: MemberRole,
|
||||||
|
isVisibleDonationRank: Boolean = true
|
||||||
|
): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = "$nickname.png",
|
||||||
|
role = role,
|
||||||
|
isVisibleDonationRank = isVisibleDonationRank,
|
||||||
|
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveDonation(
|
||||||
|
creator: Member,
|
||||||
|
donor: Member,
|
||||||
|
can: Int,
|
||||||
|
createdAt: LocalDateTime,
|
||||||
|
isSecret: Boolean = false,
|
||||||
|
additionalMessage: String
|
||||||
|
): ChannelDonationMessage {
|
||||||
|
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
||||||
|
donation.creator = creator
|
||||||
|
donation.member = donor
|
||||||
|
entityManager.persist(donation)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
|
||||||
|
return donation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) {
|
||||||
|
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false)
|
||||||
|
useCan.member = donor
|
||||||
|
entityManager.persist(useCan)
|
||||||
|
|
||||||
|
val calculate = UseCanCalculate(
|
||||||
|
can = can + rewardCan,
|
||||||
|
paymentGateway = PaymentGateway.PG,
|
||||||
|
status = UseCanCalculateStatus.RECEIVED
|
||||||
|
)
|
||||||
|
calculate.useCan = useCan
|
||||||
|
calculate.recipientCreatorId = creator.id
|
||||||
|
entityManager.persist(calculate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val creator: Member,
|
||||||
|
val creatorId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
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
|
||||||
|
|
||||||
|
class CreatorChannelDonationFacadeTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 응답 DTO는 domain tab 값을 공개 응답 필드와 UTC 문자열로 매핑한다")
|
||||||
|
fun shouldMapDonationTabDomainToPublicResponse() {
|
||||||
|
val response = CreatorChannelDonationTabResponse.from(createTab())
|
||||||
|
|
||||||
|
assertEquals(3, response.donationCount)
|
||||||
|
assertEquals(10L, response.rankings.first().userId)
|
||||||
|
assertEquals("fan", response.rankings.first().nickname)
|
||||||
|
assertEquals("https://cdn.test/fan.png", response.rankings.first().profileImage)
|
||||||
|
assertEquals(100, response.rankings.first().donationCan)
|
||||||
|
assertEquals("donor", response.donations.first().nickname)
|
||||||
|
assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl)
|
||||||
|
assertEquals(50, response.donations.first().can)
|
||||||
|
assertEquals("thanks", response.donations.first().message)
|
||||||
|
assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc)
|
||||||
|
assertEquals(1, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
|
||||||
|
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
|
||||||
|
val json = mapper.readTree(mapper.writeValueAsString(response))
|
||||||
|
assertEquals(10L, json["rankings"][0]["userId"].asLong())
|
||||||
|
assertEquals("fan", json["rankings"][0]["nickname"].asText())
|
||||||
|
assertEquals("https://cdn.test/fan.png", json["rankings"][0]["profileImage"].asText())
|
||||||
|
assertEquals(100, json["rankings"][0]["donationCan"].asInt())
|
||||||
|
assertEquals("donor", json["donations"][0]["nickname"].asText())
|
||||||
|
assertEquals("https://cdn.test/donor.png", json["donations"][0]["profileImageUrl"].asText())
|
||||||
|
assertEquals(50, json["donations"][0]["can"].asInt())
|
||||||
|
assertEquals("thanks", json["donations"][0]["message"].asText())
|
||||||
|
assertEquals("2026-06-21T03:30:00Z", json["donations"][0]["createdAtUtc"].asText())
|
||||||
|
assertTrue(json["hasNext"].asBoolean())
|
||||||
|
assertFalse(json.has("languageCode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
|
||||||
|
fun shouldMapDonationTabQueryResultToPublicResponse() {
|
||||||
|
val service = Mockito.mock(CreatorChannelDonationQueryService::class.java)
|
||||||
|
val facade = CreatorChannelDonationFacade(service)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
||||||
|
Mockito.doReturn(createTab()).`when`(service).getDonationTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
page = -1,
|
||||||
|
size = 100,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = facade.getDonationTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
page = -1,
|
||||||
|
size = 100,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3, response.donationCount)
|
||||||
|
assertEquals(10L, response.rankings.first().userId)
|
||||||
|
assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl)
|
||||||
|
assertEquals(1, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTab(): CreatorChannelDonationTab {
|
||||||
|
return CreatorChannelDonationTab(
|
||||||
|
donationCount = 3,
|
||||||
|
rankings = listOf(
|
||||||
|
CreatorChannelDonationRanking(
|
||||||
|
userId = 10L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImage = "https://cdn.test/fan.png",
|
||||||
|
donationCan = 100
|
||||||
|
)
|
||||||
|
),
|
||||||
|
donations = listOf(
|
||||||
|
CreatorChannelDonation(
|
||||||
|
nickname = "donor",
|
||||||
|
profileImageUrl = "https://cdn.test/donor.png",
|
||||||
|
can = 50,
|
||||||
|
message = "thanks",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = CreatorChannelPage(page = 1, size = 20),
|
||||||
|
hasNext = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
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.creator.channel.fantalk.application.CreatorChannelFanTalkFacade
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
|
||||||
|
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.context.TestConfiguration
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint
|
||||||
|
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 java.time.LocalDateTime
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@WebMvcTest(CreatorChannelFanTalkController::class)
|
||||||
|
@Import(CreatorChannelFanTalkControllerTest.TestSecurityConfig::class)
|
||||||
|
class CreatorChannelFanTalkControllerTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@MockBean
|
||||||
|
private lateinit var facade: CreatorChannelFanTalkFacade
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var countryContext: CountryContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var langContext: LangContext
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var sodaMessageSource: SodaMessageSource
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class TestSecurityConfig {
|
||||||
|
@Bean
|
||||||
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
|
return http
|
||||||
|
.csrf().disable()
|
||||||
|
.authorizeRequests()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
.and()
|
||||||
|
.exceptionHandling()
|
||||||
|
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||||
|
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
|
||||||
|
.and()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousCreatorChannelFanTalkRequest() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/fan-talks")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 채널 FanTalk 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
|
||||||
|
fun shouldReturnCreatorChannelFanTalkTabForAuthenticatedMember() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createResponse(page = 1, size = 20)).`when`(facade).getFanTalkTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(1),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/1/fan-talks")
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalkCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].fanTalkId").value(101))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].writerId").value(10))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].writerProfileImageUrl").value("https://cdn.test/fan.png"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].fanTalkId").value(201))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getFanTalkTab(
|
||||||
|
eqValue(1L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(1),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> anyValue(fallback: T): T {
|
||||||
|
return Mockito.any<T>() ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createResponse(
|
||||||
|
page: Int = 0,
|
||||||
|
size: Int = 20
|
||||||
|
): CreatorChannelFanTalkTabResponse {
|
||||||
|
return CreatorChannelFanTalkTabResponse(
|
||||||
|
fanTalkCount = 2,
|
||||||
|
fanTalks = listOf(
|
||||||
|
CreatorChannelFanTalkResponse(
|
||||||
|
fanTalkId = 101L,
|
||||||
|
writerId = 10L,
|
||||||
|
writerNickname = "fan",
|
||||||
|
writerProfileImageUrl = "https://cdn.test/fan.png",
|
||||||
|
content = "fan talk",
|
||||||
|
createdAtUtc = "2026-06-21T03:30:00Z",
|
||||||
|
creatorReplies = listOf(
|
||||||
|
CreatorChannelFanTalkReplyResponse(
|
||||||
|
fanTalkId = 201L,
|
||||||
|
writerId = 1L,
|
||||||
|
writerNickname = "creator",
|
||||||
|
writerProfileImageUrl = "https://cdn.test/creator.png",
|
||||||
|
content = "creator reply",
|
||||||
|
createdAtUtc = "2026-06-21T03:35:00Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||||
|
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.support.EmbeddedRedisInitializer
|
||||||
|
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.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
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 javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
properties = [
|
||||||
|
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:creator-channel-fantalk-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class CreatorChannelFanTalkEndToEndTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val transactionTemplate: TransactionTemplate
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 API는 controller-service-repository를 거쳐 글과 크리에이터 답글을 반환한다")
|
||||||
|
fun shouldReturnFanTalkTabThroughControllerServiceAndRepository() {
|
||||||
|
val fixture = createFixture("fantalk-e2e-success")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/fan-talks")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalkCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].fanTalkId").value(fixture.newerFanTalkId))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].writerId").value(fixture.writerId))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].writerNickname").value("fan-writer"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].writerProfileImageUrl").value("https://cdn.test/fan-writer.png"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].content").value("newer fan talk"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].createdAtUtc").value("2026-06-22T12:00:00Z"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].fanTalkId").value(fixture.creatorReplyId))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].writerId").value(fixture.creatorId))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].writerNickname").value("creator"))
|
||||||
|
.andExpect(
|
||||||
|
jsonPath("$.data.fanTalks[0].creatorReplies[0].writerProfileImageUrl")
|
||||||
|
.value("https://cdn.test/creator.png")
|
||||||
|
)
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].content").value("creator reply"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].createdAtUtc").value("2026-06-22T12:05:00Z"))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[?(@.fanTalkId == ${fixture.fanReplyId})]").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 API는 page 범위 밖 요청에 빈 목록과 유지된 count를 반환한다")
|
||||||
|
fun shouldReturnEmptyListAndKeepCountForOutOfRangePage() {
|
||||||
|
val fixture = createFixture("fantalk-e2e-out-of-range")
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/${fixture.creatorId}/fan-talks")
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalkCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.fanTalks.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.page").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFixture(prefix: String): Fixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||||
|
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("$prefix-creator", MemberRole.CREATOR, nickname = "creator", profileImage = "creator.png")
|
||||||
|
val writer = saveMember("$prefix-writer", MemberRole.USER, nickname = "fan-writer", profileImage = "fan-writer.png")
|
||||||
|
val newerFanTalk = saveCheers(writer, creator, "newer fan talk", isActive = true, createdAt = now)
|
||||||
|
saveCheers(writer, creator, "older fan talk", isActive = true, createdAt = now.minusHours(1))
|
||||||
|
val creatorReply = saveCheers(
|
||||||
|
creator,
|
||||||
|
creator,
|
||||||
|
"creator reply",
|
||||||
|
isActive = true,
|
||||||
|
createdAt = now.plusMinutes(5),
|
||||||
|
parent = newerFanTalk
|
||||||
|
)
|
||||||
|
val fanReply = saveCheers(
|
||||||
|
writer,
|
||||||
|
creator,
|
||||||
|
"fan reply should be excluded",
|
||||||
|
isActive = true,
|
||||||
|
createdAt = now.plusMinutes(10),
|
||||||
|
parent = newerFanTalk
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
Fixture(
|
||||||
|
viewer = viewer,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
writerId = writer.id!!,
|
||||||
|
newerFanTalkId = newerFanTalk.id!!,
|
||||||
|
creatorReplyId = creatorReply.id!!,
|
||||||
|
fanReplyId = fanReply.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(
|
||||||
|
emailPrefix: String,
|
||||||
|
role: MemberRole,
|
||||||
|
nickname: String = emailPrefix,
|
||||||
|
profileImage: String? = "$emailPrefix.png"
|
||||||
|
): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$emailPrefix@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = profileImage,
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCheers(
|
||||||
|
member: Member,
|
||||||
|
creator: Member,
|
||||||
|
cheers: String,
|
||||||
|
isActive: Boolean,
|
||||||
|
createdAt: LocalDateTime,
|
||||||
|
parent: CreatorCheers? = null
|
||||||
|
): CreatorCheers {
|
||||||
|
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive)
|
||||||
|
creatorCheers.member = member
|
||||||
|
creatorCheers.creator = creator
|
||||||
|
creatorCheers.parent = parent
|
||||||
|
entityManager.persist(creatorCheers)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCreatedAt(creatorCheers.id!!, createdAt)
|
||||||
|
return creatorCheers
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCreatedAt(id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update CreatorCheers e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Fixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val creatorId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val newerFanTalkId: Long,
|
||||||
|
val creatorReplyId: Long,
|
||||||
|
val fanReplyId: Long
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
|
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
|
||||||
|
|
||||||
|
class CreatorChannelFanTalkFacadeTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 응답 DTO는 domain tab 값을 공개 응답 필드와 UTC 문자열로 매핑한다")
|
||||||
|
fun shouldMapFanTalkTabDomainToPublicResponse() {
|
||||||
|
val response = CreatorChannelFanTalkTabResponse.from(createTab())
|
||||||
|
|
||||||
|
assertEquals(2, response.fanTalkCount)
|
||||||
|
assertEquals(101L, response.fanTalks.first().fanTalkId)
|
||||||
|
assertEquals(10L, response.fanTalks.first().writerId)
|
||||||
|
assertEquals("fan", response.fanTalks.first().writerNickname)
|
||||||
|
assertEquals("https://cdn.test/fan.png", response.fanTalks.first().writerProfileImageUrl)
|
||||||
|
assertEquals("fan talk", response.fanTalks.first().content)
|
||||||
|
assertEquals("2026-06-21T03:30:00Z", response.fanTalks.first().createdAtUtc)
|
||||||
|
assertEquals(201L, response.fanTalks.first().creatorReplies.first().fanTalkId)
|
||||||
|
assertEquals(1L, response.fanTalks.first().creatorReplies.first().writerId)
|
||||||
|
assertEquals("creator", response.fanTalks.first().creatorReplies.first().writerNickname)
|
||||||
|
assertEquals("https://cdn.test/creator.png", response.fanTalks.first().creatorReplies.first().writerProfileImageUrl)
|
||||||
|
assertEquals("creator reply", response.fanTalks.first().creatorReplies.first().content)
|
||||||
|
assertEquals("2026-06-21T03:35:00Z", response.fanTalks.first().creatorReplies.first().createdAtUtc)
|
||||||
|
assertEquals(1, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
|
||||||
|
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
|
||||||
|
val json = mapper.readTree(mapper.writeValueAsString(response))
|
||||||
|
assertTrue(json["hasNext"].asBoolean())
|
||||||
|
assertFalse(json.has("languageCode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
|
||||||
|
fun shouldMapFanTalkTabQueryResultToPublicResponse() {
|
||||||
|
val service = Mockito.mock(CreatorChannelFanTalkQueryService::class.java)
|
||||||
|
val facade = CreatorChannelFanTalkFacade(service)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
||||||
|
Mockito.doReturn(createTab()).`when`(service).getFanTalkTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
page = -1,
|
||||||
|
size = 100,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = facade.getFanTalkTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
page = -1,
|
||||||
|
size = 100,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, response.fanTalkCount)
|
||||||
|
assertEquals(101L, response.fanTalks.first().fanTalkId)
|
||||||
|
assertEquals("https://cdn.test/fan.png", response.fanTalks.first().writerProfileImageUrl)
|
||||||
|
assertEquals(201L, response.fanTalks.first().creatorReplies.first().fanTalkId)
|
||||||
|
assertEquals(1, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTab(): CreatorChannelFanTalkTab {
|
||||||
|
return CreatorChannelFanTalkTab(
|
||||||
|
fanTalkCount = 2,
|
||||||
|
fanTalks = listOf(
|
||||||
|
CreatorChannelFanTalk(
|
||||||
|
fanTalkId = 101L,
|
||||||
|
writerId = 10L,
|
||||||
|
writerNickname = "fan",
|
||||||
|
writerProfileImageUrl = "https://cdn.test/fan.png",
|
||||||
|
content = "fan talk",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30),
|
||||||
|
creatorReplies = listOf(
|
||||||
|
CreatorChannelFanTalkReply(
|
||||||
|
fanTalkId = 201L,
|
||||||
|
writerId = 1L,
|
||||||
|
writerNickname = "creator",
|
||||||
|
writerProfileImageUrl = "https://cdn.test/creator.png",
|
||||||
|
content = "creator reply",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 21, 3, 35)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = CreatorChannelPage(page = 1, size = 20),
|
||||||
|
hasNext = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.explorer.MemberDonationRankingResponse
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
|
||||||
|
class LegacyCreatorChannelDonationRankingAdapterTest {
|
||||||
|
private val creatorDonationRankingService = Mockito.mock(CreatorDonationRankingService::class.java)
|
||||||
|
private val adapter = LegacyCreatorChannelDonationRankingAdapter(creatorDonationRankingService)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("누적 기간 후원 랭킹 Top 8을 legacy service에 위임한다")
|
||||||
|
fun shouldDelegateCumulativeRankingRequestToLegacyService() {
|
||||||
|
Mockito.`when`(
|
||||||
|
creatorDonationRankingService.getMemberDonationRanking(
|
||||||
|
creatorId = 10L,
|
||||||
|
offset = 0L,
|
||||||
|
limit = 8L,
|
||||||
|
withDonationCan = false,
|
||||||
|
period = DonationRankingPeriod.CUMULATIVE
|
||||||
|
)
|
||||||
|
).thenReturn(emptyList())
|
||||||
|
|
||||||
|
val rankings = adapter.findTopRankings(
|
||||||
|
creatorId = 10L,
|
||||||
|
period = DonationRankingPeriod.CUMULATIVE,
|
||||||
|
withDonationCan = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<Any>(), rankings)
|
||||||
|
Mockito.verify(creatorDonationRankingService).getMemberDonationRanking(
|
||||||
|
creatorId = 10L,
|
||||||
|
offset = 0L,
|
||||||
|
limit = 8L,
|
||||||
|
withDonationCan = false,
|
||||||
|
period = DonationRankingPeriod.CUMULATIVE
|
||||||
|
)
|
||||||
|
Mockito.verifyNoMoreInteractions(creatorDonationRankingService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("주간 기간 후원 랭킹 Top 8을 legacy service에 위임하고 필드를 그대로 매핑한다")
|
||||||
|
fun shouldDelegateWeeklyRankingRequestAndMapFields() {
|
||||||
|
Mockito.`when`(
|
||||||
|
creatorDonationRankingService.getMemberDonationRanking(
|
||||||
|
creatorId = 20L,
|
||||||
|
offset = 0L,
|
||||||
|
limit = 8L,
|
||||||
|
withDonationCan = true,
|
||||||
|
period = DonationRankingPeriod.WEEKLY
|
||||||
|
)
|
||||||
|
).thenReturn(
|
||||||
|
listOf(
|
||||||
|
MemberDonationRankingResponse(
|
||||||
|
userId = 30L,
|
||||||
|
nickname = "donor",
|
||||||
|
profileImage = "https://cdn.test/profile.png",
|
||||||
|
donationCan = 1234
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val rankings = adapter.findTopRankings(
|
||||||
|
creatorId = 20L,
|
||||||
|
period = DonationRankingPeriod.WEEKLY,
|
||||||
|
withDonationCan = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, rankings.size)
|
||||||
|
assertEquals(30L, rankings[0].userId)
|
||||||
|
assertEquals("donor", rankings[0].nickname)
|
||||||
|
assertEquals("https://cdn.test/profile.png", rankings[0].profileImage)
|
||||||
|
assertEquals(1234, rankings[0].donationCan)
|
||||||
|
Mockito.verify(creatorDonationRankingService).getMemberDonationRanking(
|
||||||
|
creatorId = 20L,
|
||||||
|
offset = 0L,
|
||||||
|
limit = 8L,
|
||||||
|
withDonationCan = true,
|
||||||
|
period = DonationRankingPeriod.WEEKLY
|
||||||
|
)
|
||||||
|
Mockito.verifyNoMoreInteractions(creatorDonationRankingService)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
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.nio.file.Paths
|
||||||
|
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 DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
private val repository = DefaultCreatorChannelDonationQueryRepository(queryFactory)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("활성 회원은 후원 랭킹 설정과 role을 조회하고 비활성 회원은 조회하지 않는다")
|
||||||
|
fun shouldFindOnlyActiveCreatorWithDonationRankingSettings() {
|
||||||
|
val viewer = saveMember("donation-lookup-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember(
|
||||||
|
"donation-active-creator",
|
||||||
|
MemberRole.CREATOR,
|
||||||
|
isVisibleDonationRank = false,
|
||||||
|
donationRankingPeriod = DonationRankingPeriod.WEEKLY
|
||||||
|
)
|
||||||
|
val inactiveCreator = saveMember("donation-inactive-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
val nonCreator = saveMember("donation-non-creator", MemberRole.USER)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!)
|
||||||
|
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
|
||||||
|
val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!)
|
||||||
|
|
||||||
|
assertNotNull(creatorRecord)
|
||||||
|
assertEquals(creator.id, creatorRecord!!.creatorId)
|
||||||
|
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||||
|
assertEquals(creator.nickname, creatorRecord.nickname)
|
||||||
|
assertFalse(creatorRecord.isVisibleDonationRank)
|
||||||
|
assertEquals(DonationRankingPeriod.WEEKLY, creatorRecord.donationRankingPeriod)
|
||||||
|
assertNull(inactiveRecord)
|
||||||
|
assertEquals(MemberRole.USER, nonCreatorRecord!!.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다")
|
||||||
|
fun shouldFindActiveBlockInBothDirections() {
|
||||||
|
val viewer = saveMember("donation-block-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("donation-block-creator", MemberRole.CREATOR)
|
||||||
|
val otherCreator = saveMember("donation-other-creator", MemberRole.CREATOR)
|
||||||
|
saveBlock(viewer, creator, isActive = true)
|
||||||
|
saveBlock(otherCreator, viewer, isActive = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
|
||||||
|
assertTrue(repository.existsBlockedBetween(creator.id!!, viewer.id!!))
|
||||||
|
assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다")
|
||||||
|
fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 22, 3, 0)
|
||||||
|
val creator = saveMember("donation-self-creator", MemberRole.CREATOR)
|
||||||
|
val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png")
|
||||||
|
val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
|
val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null)
|
||||||
|
val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true)
|
||||||
|
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59))
|
||||||
|
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val count = repository.countChannelDonations(creator.id!!, creator.id!!, now)
|
||||||
|
val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(2, count)
|
||||||
|
assertEquals(listOf(secret.can, monthStart.can), records.map { it.can })
|
||||||
|
assertEquals(donor.nickname, records.last().nickname)
|
||||||
|
assertEquals(donor.profileImage, records.last().profileImagePath)
|
||||||
|
assertNull(records.last().message)
|
||||||
|
assertEquals(monthStartCreatedAt, records.last().createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다")
|
||||||
|
fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||||
|
val creator = saveMember("donation-visible-creator", MemberRole.CREATOR)
|
||||||
|
val viewer = saveMember("donation-visible-viewer", MemberRole.USER)
|
||||||
|
val otherDonor = saveMember("donation-visible-other", MemberRole.USER)
|
||||||
|
val publicDonation = saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public")
|
||||||
|
val ownSecretDonation = saveDonation(
|
||||||
|
creator,
|
||||||
|
viewer,
|
||||||
|
200,
|
||||||
|
now.minusHours(2),
|
||||||
|
isSecret = true,
|
||||||
|
additionalMessage = "own secret"
|
||||||
|
)
|
||||||
|
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now)
|
||||||
|
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(2, count)
|
||||||
|
assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can })
|
||||||
|
assertEquals(listOf("own secret", "public"), records.map { it.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("채널 후원 목록은 createdAt desc, id desc로 정렬하고 offset/limit을 적용한다")
|
||||||
|
fun shouldOrderByCreatedAtAndIdDescWithOffsetAndLimit() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||||
|
val creator = saveMember("donation-order-creator", MemberRole.CREATOR)
|
||||||
|
val viewer = saveMember("donation-order-viewer", MemberRole.USER)
|
||||||
|
val donor = saveMember("donation-order-donor", MemberRole.USER)
|
||||||
|
val sameCreatedAt = now.minusHours(1)
|
||||||
|
val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first")
|
||||||
|
val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second")
|
||||||
|
saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest")
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 1, limit = 2)
|
||||||
|
|
||||||
|
assertEquals(listOf(second.can, first.can), records.map { it.can })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
|
||||||
|
fun shouldUseProjectionForDonationList() {
|
||||||
|
val source = Paths.get(
|
||||||
|
"src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/" +
|
||||||
|
"DefaultCreatorChannelDonationQueryRepository.kt"
|
||||||
|
)
|
||||||
|
.toFile()
|
||||||
|
.readText()
|
||||||
|
|
||||||
|
assertFalse(source.contains(".selectFrom(channelDonationMessage)"), "donation list must not fetch entity rows")
|
||||||
|
assertTrue(
|
||||||
|
source.contains(
|
||||||
|
"""Projections.constructor(
|
||||||
|
CreatorChannelDonationRecord::class.java"""
|
||||||
|
),
|
||||||
|
"donation list must use constructor projection for direct record mapping"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(
|
||||||
|
nickname: String,
|
||||||
|
role: MemberRole,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
profileImage: String? = "$nickname.png",
|
||||||
|
isVisibleDonationRank: Boolean = true,
|
||||||
|
donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE
|
||||||
|
): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = profileImage,
|
||||||
|
role = role,
|
||||||
|
isVisibleDonationRank = isVisibleDonationRank,
|
||||||
|
donationRankingPeriod = donationRankingPeriod,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean): BlockMember {
|
||||||
|
val block = BlockMember(isActive = isActive)
|
||||||
|
block.member = member
|
||||||
|
block.blockedMember = blockedMember
|
||||||
|
entityManager.persist(block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveDonation(
|
||||||
|
creator: Member,
|
||||||
|
donor: Member,
|
||||||
|
can: Int,
|
||||||
|
createdAt: LocalDateTime,
|
||||||
|
isSecret: Boolean = false,
|
||||||
|
additionalMessage: String? = "thanks"
|
||||||
|
): ChannelDonationMessage {
|
||||||
|
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
||||||
|
donation.creator = creator
|
||||||
|
donation.member = donor
|
||||||
|
entityManager.persist(donation)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
|
||||||
|
return donation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushAndClear() {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelDonationQueryServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회 대상 회원이 없으면 user_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowUserNotFoundWhenCreatorMissing() {
|
||||||
|
val queryPort = FakeDonationQueryPort(creator = null)
|
||||||
|
val service = createService(queryPort = queryPort)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getDonationTab(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
viewer = createMember(VIEWER_ID),
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
now = NOW
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.user_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회 대상 회원이 크리에이터가 아니면 creator_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
|
||||||
|
val queryPort = FakeDonationQueryPort(
|
||||||
|
creator = createCreator(role = MemberRole.USER)
|
||||||
|
)
|
||||||
|
val service = createService(queryPort = queryPort)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getDonationTab(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
viewer = createMember(VIEWER_ID),
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
now = NOW
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자와 크리에이터 사이 차단 관계가 있으면 차단 메시지 예외를 던진다")
|
||||||
|
fun shouldThrowBlockedAccessMessageWhenBlocked() {
|
||||||
|
val queryPort = FakeDonationQueryPort(blocked = true)
|
||||||
|
val service = createService(queryPort = queryPort)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getDonationTab(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
viewer = createMember(VIEWER_ID),
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
now = NOW
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("creator-nickname님의 요청으로 채널 접근이 제한됩니다.", exception.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("페이지 보정값으로 목록을 조회하고 응답 목록과 hasNext를 조립한다")
|
||||||
|
fun shouldUseResolvedPageForDonationQueryAndLimitResponseItems() {
|
||||||
|
val queryPort = FakeDonationQueryPort(
|
||||||
|
donations = (1..21).map {
|
||||||
|
createDonationRecord(nickname = "donor$it", message = "message$it")
|
||||||
|
},
|
||||||
|
donationCount = 30
|
||||||
|
)
|
||||||
|
val service = createService(queryPort = queryPort)
|
||||||
|
|
||||||
|
val tab = service.getDonationTab(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
viewer = createMember(VIEWER_ID),
|
||||||
|
page = -1,
|
||||||
|
size = 10,
|
||||||
|
now = NOW
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(0L, queryPort.lastFindDonationRequest?.offset)
|
||||||
|
assertEquals(21, queryPort.lastFindDonationRequest?.limit)
|
||||||
|
assertEquals(CREATOR_ID, queryPort.lastCountDonationRequest?.creatorId)
|
||||||
|
assertEquals(VIEWER_ID, queryPort.lastCountDonationRequest?.viewerId)
|
||||||
|
assertEquals(NOW, queryPort.lastCountDonationRequest?.now)
|
||||||
|
assertEquals(30, tab.donationCount)
|
||||||
|
assertEquals(20, tab.donations.size)
|
||||||
|
assertEquals("donor1", tab.donations.first().nickname)
|
||||||
|
assertEquals("message1", tab.donations.first().message)
|
||||||
|
assertEquals(0, tab.page.page)
|
||||||
|
assertEquals(20, tab.page.size)
|
||||||
|
assertEquals(true, tab.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 목록은 닉네임, 프로필 이미지, 메시지를 도메인 응답 값으로 변환한다")
|
||||||
|
fun shouldMapDonationRecordsToDomainValues() {
|
||||||
|
val queryPort = FakeDonationQueryPort(
|
||||||
|
donations = listOf(
|
||||||
|
createDonationRecord(
|
||||||
|
nickname = "deleted_donor",
|
||||||
|
profileImagePath = "profile/donor.png",
|
||||||
|
message = null
|
||||||
|
),
|
||||||
|
createDonationRecord(
|
||||||
|
nickname = "default-image-donor",
|
||||||
|
profileImagePath = null,
|
||||||
|
message = "thanks"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val service = createService(queryPort = queryPort)
|
||||||
|
|
||||||
|
val tab = service.getDonationTab(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
viewer = createMember(VIEWER_ID),
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
now = NOW
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("donor", tab.donations[0].nickname)
|
||||||
|
assertEquals("https://cdn.test/profile/donor.png", tab.donations[0].profileImageUrl)
|
||||||
|
assertEquals("", tab.donations[0].message)
|
||||||
|
assertEquals("default-image-donor", tab.donations[1].nickname)
|
||||||
|
assertEquals("https://cdn.test/profile/default-profile.png", tab.donations[1].profileImageUrl)
|
||||||
|
assertEquals("thanks", tab.donations[1].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다")
|
||||||
|
fun shouldFetchRankingsWithDonationCanForCreatorViewer() {
|
||||||
|
val queryPort = FakeDonationQueryPort(
|
||||||
|
creator = createCreator(isVisibleDonationRank = false, donationRankingPeriod = DonationRankingPeriod.WEEKLY)
|
||||||
|
)
|
||||||
|
val rankingPort = FakeDonationRankingPort()
|
||||||
|
val service = createService(queryPort = queryPort, rankingPort = rankingPort)
|
||||||
|
|
||||||
|
val tab = service.getDonationTab(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
viewer = createMember(CREATOR_ID),
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
now = NOW
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, true), rankingPort.requests.single())
|
||||||
|
assertEquals(createRankingRecord(), rankingPort.records.single())
|
||||||
|
assertEquals(tab.rankings.single().userId, rankingPort.records.single().userId)
|
||||||
|
assertEquals(tab.rankings.single().nickname, rankingPort.records.single().nickname)
|
||||||
|
assertEquals(tab.rankings.single().profileImage, rankingPort.records.single().profileImage)
|
||||||
|
assertEquals(tab.rankings.single().donationCan, rankingPort.records.single().donationCan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 조회자는 공개 랭킹을 크리에이터 설정 기간과 donationCan 제외 조건으로 조회한다")
|
||||||
|
fun shouldFetchVisibleRankingsForNonCreatorViewerWithConfiguredPeriod() {
|
||||||
|
val weeklyRankingPort = FakeDonationRankingPort()
|
||||||
|
createService(
|
||||||
|
queryPort = FakeDonationQueryPort(
|
||||||
|
creator = createCreator(
|
||||||
|
isVisibleDonationRank = true,
|
||||||
|
donationRankingPeriod = DonationRankingPeriod.WEEKLY
|
||||||
|
)
|
||||||
|
),
|
||||||
|
rankingPort = weeklyRankingPort
|
||||||
|
).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
|
||||||
|
|
||||||
|
val cumulativeRankingPort = FakeDonationRankingPort()
|
||||||
|
createService(
|
||||||
|
queryPort = FakeDonationQueryPort(
|
||||||
|
creator = createCreator(
|
||||||
|
isVisibleDonationRank = true,
|
||||||
|
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
|
||||||
|
)
|
||||||
|
),
|
||||||
|
rankingPort = cumulativeRankingPort
|
||||||
|
).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
|
||||||
|
|
||||||
|
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, false), weeklyRankingPort.requests.single())
|
||||||
|
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), cumulativeRankingPort.requests.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 랭킹 기간이 없으면 누적 랭킹으로 조회한다")
|
||||||
|
fun shouldUseCumulativeRankingPeriodWhenCreatorPeriodIsNull() {
|
||||||
|
val rankingPort = FakeDonationRankingPort()
|
||||||
|
val service = createService(
|
||||||
|
queryPort = FakeDonationQueryPort(
|
||||||
|
creator = createCreator(isVisibleDonationRank = true, donationRankingPeriod = null)
|
||||||
|
),
|
||||||
|
rankingPort = rankingPort
|
||||||
|
)
|
||||||
|
|
||||||
|
service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
|
||||||
|
|
||||||
|
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), rankingPort.requests.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일반 조회자에게 랭킹이 비공개이면 랭킹 조회 없이 후원 탭 본문을 조립한다")
|
||||||
|
fun shouldSkipRankingsWhenHiddenFromNonCreatorViewer() {
|
||||||
|
val queryPort = FakeDonationQueryPort(
|
||||||
|
creator = createCreator(isVisibleDonationRank = false),
|
||||||
|
donationCount = 2,
|
||||||
|
donations = listOf(
|
||||||
|
createDonationRecord(nickname = "donor1"),
|
||||||
|
createDonationRecord(nickname = "donor2")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val rankingPort = FakeDonationRankingPort()
|
||||||
|
val service = createService(queryPort = queryPort, rankingPort = rankingPort)
|
||||||
|
|
||||||
|
val tab = service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
|
||||||
|
|
||||||
|
assertEquals(emptyList<RankingRequest>(), rankingPort.requests)
|
||||||
|
assertEquals(emptyList<Any>(), tab.rankings)
|
||||||
|
assertEquals(2, tab.donationCount)
|
||||||
|
assertEquals(2, tab.donations.size)
|
||||||
|
assertEquals(0, tab.page.page)
|
||||||
|
assertEquals(20, tab.page.size)
|
||||||
|
assertFalse(tab.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createService(
|
||||||
|
queryPort: FakeDonationQueryPort = FakeDonationQueryPort(),
|
||||||
|
rankingPort: FakeDonationRankingPort = FakeDonationRankingPort()
|
||||||
|
): CreatorChannelDonationQueryService {
|
||||||
|
val provider = Mockito.mock(ObjectProvider::class.java) as ObjectProvider<CreatorChannelDonationQueryPort>
|
||||||
|
Mockito.doReturn(queryPort).`when`(provider).getObject()
|
||||||
|
return CreatorChannelDonationQueryService(
|
||||||
|
queryPortProvider = provider,
|
||||||
|
rankingPort = rankingPort,
|
||||||
|
queryPolicy = CreatorChannelDonationQueryPolicy(),
|
||||||
|
messageSource = SodaMessageSource(),
|
||||||
|
langContext = LangContext(),
|
||||||
|
cloudFrontHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCreator(
|
||||||
|
role: MemberRole = MemberRole.CREATOR,
|
||||||
|
isVisibleDonationRank: Boolean = true,
|
||||||
|
donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE
|
||||||
|
): CreatorChannelDonationCreatorRecord {
|
||||||
|
return CreatorChannelDonationCreatorRecord(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
role = role,
|
||||||
|
nickname = "creator-nickname",
|
||||||
|
isVisibleDonationRank = isVisibleDonationRank,
|
||||||
|
donationRankingPeriod = donationRankingPeriod
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDonationRecord(
|
||||||
|
nickname: String = "donor",
|
||||||
|
profileImagePath: String? = "profile/donor.png",
|
||||||
|
can: Int = 100,
|
||||||
|
message: String? = "thanks",
|
||||||
|
createdAt: LocalDateTime = NOW
|
||||||
|
): CreatorChannelDonationRecord {
|
||||||
|
return CreatorChannelDonationRecord(
|
||||||
|
nickname = nickname,
|
||||||
|
profileImagePath = profileImagePath,
|
||||||
|
can = can,
|
||||||
|
message = message,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRankingRecord(): CreatorChannelDonationRankingRecord {
|
||||||
|
return CreatorChannelDonationRankingRecord(
|
||||||
|
userId = VIEWER_ID,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImage = "https://cdn.test/fan.png",
|
||||||
|
donationCan = 300
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "viewer$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "viewer$id",
|
||||||
|
role = MemberRole.USER
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeDonationQueryPort(
|
||||||
|
private val creator: CreatorChannelDonationCreatorRecord? = defaultCreator(),
|
||||||
|
private val blocked: Boolean = false,
|
||||||
|
private val donationCount: Int = 0,
|
||||||
|
private val donations: List<CreatorChannelDonationRecord> = emptyList()
|
||||||
|
) : CreatorChannelDonationQueryPort {
|
||||||
|
var lastCountDonationRequest: CountDonationRequest? = null
|
||||||
|
private set
|
||||||
|
var lastFindDonationRequest: FindDonationRequest? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? {
|
||||||
|
return creator
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||||
|
return blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countChannelDonations(creatorId: Long, viewerId: Long, now: LocalDateTime): Int {
|
||||||
|
lastCountDonationRequest = CountDonationRequest(creatorId, viewerId, now)
|
||||||
|
return donationCount
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findChannelDonations(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelDonationRecord> {
|
||||||
|
lastFindDonationRequest = FindDonationRequest(creatorId, viewerId, now, offset, limit)
|
||||||
|
return donations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeDonationRankingPort(
|
||||||
|
val records: List<CreatorChannelDonationRankingRecord> = listOf(defaultRankingRecord())
|
||||||
|
) : CreatorChannelDonationRankingPort {
|
||||||
|
val requests = mutableListOf<RankingRequest>()
|
||||||
|
|
||||||
|
override fun findTopRankings(
|
||||||
|
creatorId: Long,
|
||||||
|
period: DonationRankingPeriod,
|
||||||
|
withDonationCan: Boolean
|
||||||
|
): List<CreatorChannelDonationRankingRecord> {
|
||||||
|
requests += RankingRequest(creatorId, period, withDonationCan)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CountDonationRequest(
|
||||||
|
val creatorId: Long,
|
||||||
|
val viewerId: Long,
|
||||||
|
val now: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class FindDonationRequest(
|
||||||
|
val creatorId: Long,
|
||||||
|
val viewerId: Long,
|
||||||
|
val now: LocalDateTime,
|
||||||
|
val offset: Long,
|
||||||
|
val limit: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class RankingRequest(
|
||||||
|
val creatorId: Long,
|
||||||
|
val period: DonationRankingPeriod,
|
||||||
|
val withDonationCan: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CREATOR_ID = 1L
|
||||||
|
private const val VIEWER_ID = 10L
|
||||||
|
private val NOW: LocalDateTime = LocalDateTime.of(2026, 6, 22, 3, 0)
|
||||||
|
|
||||||
|
private fun defaultCreator(): CreatorChannelDonationCreatorRecord {
|
||||||
|
return CreatorChannelDonationCreatorRecord(
|
||||||
|
creatorId = CREATOR_ID,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
nickname = "creator-nickname",
|
||||||
|
isVisibleDonationRank = true,
|
||||||
|
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultRankingRecord(): CreatorChannelDonationRankingRecord {
|
||||||
|
return CreatorChannelDonationRankingRecord(
|
||||||
|
userId = VIEWER_ID,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImage = "https://cdn.test/fan.png",
|
||||||
|
donationCan = 300
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelDonationQueryPolicyTest {
|
||||||
|
private val policy = CreatorChannelDonationQueryPolicy()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다")
|
||||||
|
fun shouldFallbackNullPageAndSizeForDonationTab() {
|
||||||
|
val page = policy.createPage(page = null, size = null)
|
||||||
|
|
||||||
|
assertEquals(0, page.page)
|
||||||
|
assertEquals(20, page.size)
|
||||||
|
assertEquals(0L, page.offset)
|
||||||
|
assertEquals(21, page.fetchLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다")
|
||||||
|
fun shouldFallbackPageAndSizeForDonationTab() {
|
||||||
|
val minimumPage = policy.createPage(page = -1, size = 10)
|
||||||
|
val maximumPage = policy.createPage(page = 2, size = 100)
|
||||||
|
|
||||||
|
assertEquals(0, minimumPage.page)
|
||||||
|
assertEquals(20, minimumPage.size)
|
||||||
|
assertEquals(0L, minimumPage.offset)
|
||||||
|
assertEquals(21, minimumPage.fetchLimit)
|
||||||
|
assertEquals(2, maximumPage.page)
|
||||||
|
assertEquals(50, maximumPage.size)
|
||||||
|
assertEquals(100L, maximumPage.offset)
|
||||||
|
assertEquals(51, maximumPage.fetchLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
|
||||||
|
fun shouldLimitItemsAndCalculateHasNext() {
|
||||||
|
val page = policy.createPage(page = 0, size = 20)
|
||||||
|
val fetched = (1..21).toList()
|
||||||
|
|
||||||
|
val items = policy.limitItems(fetched, page)
|
||||||
|
|
||||||
|
assertEquals((1..20).toList(), items)
|
||||||
|
assertTrue(policy.hasNext(fetched, page))
|
||||||
|
assertFalse(policy.hasNext((1..20).toList(), page))
|
||||||
|
assertFalse(policy.hasNext(emptyList<Int>(), page))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 월 범위 정책은 현재 UTC 시각 기준 KST 월 시작과 다음 월 시작을 UTC로 계산한다")
|
||||||
|
fun shouldCalculateCurrentKstMonthRangeAsUtc() {
|
||||||
|
val range = policy.currentKstMonthRange(LocalDateTime.of(2026, 6, 22, 3, 0))
|
||||||
|
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), range.startInclusiveUtc)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 6, 30, 15, 0), range.endExclusiveUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("후원 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
|
||||||
|
fun shouldKeepDomainAndPortContract() {
|
||||||
|
val createdAt = LocalDateTime.of(2026, 6, 22, 10, 0)
|
||||||
|
val page = policy.createPage(page = 0, size = 20)
|
||||||
|
val ranking = CreatorChannelDonationRanking(
|
||||||
|
userId = 10L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImage = "https://cdn.test/fan.png",
|
||||||
|
donationCan = 100
|
||||||
|
)
|
||||||
|
val donation = CreatorChannelDonation(
|
||||||
|
nickname = "donor",
|
||||||
|
profileImageUrl = "https://cdn.test/donor.png",
|
||||||
|
can = 50,
|
||||||
|
message = "thanks",
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
val tab = CreatorChannelDonationTab(
|
||||||
|
donationCount = 1,
|
||||||
|
rankings = listOf(ranking),
|
||||||
|
donations = listOf(donation),
|
||||||
|
page = page,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
val creatorRecord = CreatorChannelDonationCreatorRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
nickname = "creator",
|
||||||
|
isVisibleDonationRank = true,
|
||||||
|
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
|
||||||
|
)
|
||||||
|
val donationRecord = CreatorChannelDonationRecord(
|
||||||
|
nickname = "donor",
|
||||||
|
profileImagePath = null,
|
||||||
|
can = 50,
|
||||||
|
message = "thanks",
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
val rankingRecord = CreatorChannelDonationRankingRecord(
|
||||||
|
userId = 10L,
|
||||||
|
nickname = "fan",
|
||||||
|
profileImage = "https://cdn.test/fan.png",
|
||||||
|
donationCan = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, tab.donationCount)
|
||||||
|
assertEquals(ranking, tab.rankings.first())
|
||||||
|
assertEquals(donation, tab.donations.first())
|
||||||
|
assertEquals(page, tab.page)
|
||||||
|
assertFalse(tab.hasNext)
|
||||||
|
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||||
|
assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod)
|
||||||
|
assertNull(donationRecord.profileImagePath)
|
||||||
|
assertEquals(100, rankingRecord.donationCan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
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 DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
private val repository = DefaultCreatorChannelFanTalkQueryRepository(queryFactory)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("활성 회원은 role과 닉네임을 조회하고 비활성 회원은 조회하지 않는다")
|
||||||
|
fun shouldFindOnlyActiveCreator() {
|
||||||
|
val viewer = saveMember("creator-lookup-viewer", MemberRole.USER)
|
||||||
|
val activeCreator = saveMember("active-fantalk-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("inactive-fantalk-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
val nonCreator = saveMember("fantalk-non-creator", MemberRole.USER)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!)
|
||||||
|
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
|
||||||
|
val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!)
|
||||||
|
|
||||||
|
assertNotNull(activeRecord)
|
||||||
|
assertEquals(activeCreator.id, activeRecord!!.creatorId)
|
||||||
|
assertEquals(MemberRole.CREATOR, activeRecord.role)
|
||||||
|
assertEquals(activeCreator.nickname, activeRecord.nickname)
|
||||||
|
assertNull(inactiveRecord)
|
||||||
|
assertEquals(MemberRole.USER, nonCreatorRecord!!.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다")
|
||||||
|
fun shouldFindActiveBlockInBothDirections() {
|
||||||
|
val viewer = saveMember("fantalk-block-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("fantalk-block-creator", MemberRole.CREATOR)
|
||||||
|
val otherCreator = saveMember("fantalk-other-creator", MemberRole.CREATOR)
|
||||||
|
saveBlock(viewer, creator, isActive = true)
|
||||||
|
saveBlock(otherCreator, viewer, isActive = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
|
||||||
|
assertTrue(repository.existsBlockedBetween(creator.id!!, viewer.id!!))
|
||||||
|
assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최상위 FanTalk 수와 목록은 활성 루트 글만 세고 작성자 차단을 제외한다")
|
||||||
|
fun shouldCountAndFindOnlyVisibleTopLevelFanTalks() {
|
||||||
|
val viewer = saveMember("fantalk-list-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("fantalk-list-creator", MemberRole.CREATOR)
|
||||||
|
val otherCreator = saveMember("fantalk-list-other-creator", MemberRole.CREATOR)
|
||||||
|
val visibleWriter = saveMember("visible-writer", MemberRole.USER, profileImage = "visible.png")
|
||||||
|
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
|
||||||
|
val writerBlockingViewer = saveMember("writer-blocking-viewer", MemberRole.USER)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||||
|
val older = saveCheers(visibleWriter, creator, "older", isActive = true, createdAt = now.minusHours(2))
|
||||||
|
val newer = saveCheers(visibleWriter, creator, "newer", isActive = true, createdAt = now.minusHours(1))
|
||||||
|
saveCheers(visibleWriter, creator, "inactive", isActive = false, createdAt = now)
|
||||||
|
saveCheers(visibleWriter, otherCreator, "other creator", isActive = true, createdAt = now)
|
||||||
|
saveCheers(visibleWriter, creator, "reply", isActive = true, createdAt = now, parent = older)
|
||||||
|
saveCheers(blockedWriter, creator, "viewer blocked", isActive = true, createdAt = now.plusHours(1))
|
||||||
|
saveCheers(writerBlockingViewer, creator, "writer blocked", isActive = true, createdAt = now.plusHours(2))
|
||||||
|
saveBlock(viewer, blockedWriter, isActive = true)
|
||||||
|
saveBlock(writerBlockingViewer, viewer, isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val count = repository.countFanTalks(creator.id!!, viewer.id!!)
|
||||||
|
val firstPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 1)
|
||||||
|
val secondPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 1, limit = 2)
|
||||||
|
|
||||||
|
assertEquals(2, count)
|
||||||
|
assertEquals(listOf(newer.id), firstPage.map { it.fanTalkId })
|
||||||
|
assertEquals(listOf(older.id), secondPage.map { it.fanTalkId })
|
||||||
|
assertEquals(visibleWriter.id, firstPage.first().writerId)
|
||||||
|
assertEquals(visibleWriter.nickname, firstPage.first().writerNickname)
|
||||||
|
assertEquals(visibleWriter.profileImage, firstPage.first().writerProfileImagePath)
|
||||||
|
assertEquals("newer", firstPage.first().content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최상위 FanTalk 목록은 createdAt desc, id desc 순서로 정렬한다")
|
||||||
|
fun shouldOrderFanTalksByCreatedAtDescAndIdDesc() {
|
||||||
|
val viewer = saveMember("fantalk-order-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("fantalk-order-creator", MemberRole.CREATOR)
|
||||||
|
val writer = saveMember("fantalk-order-writer", MemberRole.USER)
|
||||||
|
val sameCreatedAt = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||||
|
val first = saveCheers(writer, creator, "first", isActive = true, createdAt = sameCreatedAt)
|
||||||
|
val second = saveCheers(writer, creator, "second", isActive = true, createdAt = sameCreatedAt)
|
||||||
|
val newest = saveCheers(writer, creator, "newest", isActive = true, createdAt = sameCreatedAt.plusMinutes(1))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val records = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(listOf(newest.id, second.id, first.id), records.map { it.fanTalkId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 답글은 지정한 부모의 활성 크리에이터 작성 답글만 오래된 순으로 조회한다")
|
||||||
|
fun shouldFindOnlyActiveCreatorRepliesForRequestedParents() {
|
||||||
|
val creator = saveMember("reply-creator", MemberRole.CREATOR)
|
||||||
|
val writer = saveMember("reply-writer", MemberRole.USER)
|
||||||
|
val otherCreator = saveMember("reply-other-creator", MemberRole.CREATOR)
|
||||||
|
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||||
|
val parent = saveCheers(writer, creator, "parent", isActive = true, createdAt = now.minusHours(3))
|
||||||
|
val otherParent = saveCheers(writer, creator, "other parent", isActive = true, createdAt = now.minusHours(2))
|
||||||
|
val newerReply = saveCheers(creator, creator, "newer reply", isActive = true, createdAt = now, parent = parent)
|
||||||
|
val olderReply = saveCheers(
|
||||||
|
creator,
|
||||||
|
creator,
|
||||||
|
"older reply",
|
||||||
|
isActive = true,
|
||||||
|
createdAt = now.minusMinutes(1),
|
||||||
|
parent = parent
|
||||||
|
)
|
||||||
|
saveCheers(writer, creator, "fan reply", isActive = true, createdAt = now.plusMinutes(1), parent = parent)
|
||||||
|
saveCheers(creator, creator, "inactive reply", isActive = false, createdAt = now.plusMinutes(2), parent = parent)
|
||||||
|
saveCheers(
|
||||||
|
creator,
|
||||||
|
otherCreator,
|
||||||
|
"other creator reply",
|
||||||
|
isActive = true,
|
||||||
|
createdAt = now.plusMinutes(3),
|
||||||
|
parent = parent
|
||||||
|
)
|
||||||
|
saveCheers(
|
||||||
|
creator,
|
||||||
|
creator,
|
||||||
|
"other parent reply",
|
||||||
|
isActive = true,
|
||||||
|
createdAt = now.plusMinutes(4),
|
||||||
|
parent = otherParent
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val replies = repository.findCreatorReplies(creator.id!!, listOf(parent.id!!))
|
||||||
|
val emptyReplies = repository.findCreatorReplies(creator.id!!, emptyList())
|
||||||
|
|
||||||
|
assertEquals(listOf(olderReply.id, newerReply.id), replies.map { it.fanTalkId })
|
||||||
|
assertEquals(parent.id, replies.first().parentFanTalkId)
|
||||||
|
assertEquals(creator.id, replies.first().writerId)
|
||||||
|
assertEquals(creator.nickname, replies.first().writerNickname)
|
||||||
|
assertEquals(creator.profileImage, replies.first().writerProfileImagePath)
|
||||||
|
assertEquals("older reply", replies.first().content)
|
||||||
|
assertTrue(emptyReplies.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(
|
||||||
|
nickname: String,
|
||||||
|
role: MemberRole,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
profileImage: String? = "$nickname.png"
|
||||||
|
): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = profileImage,
|
||||||
|
role = role,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean): BlockMember {
|
||||||
|
val block = BlockMember(isActive = isActive)
|
||||||
|
block.member = member
|
||||||
|
block.blockedMember = blockedMember
|
||||||
|
entityManager.persist(block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCheers(
|
||||||
|
member: Member,
|
||||||
|
creator: Member,
|
||||||
|
cheers: String,
|
||||||
|
isActive: Boolean,
|
||||||
|
createdAt: LocalDateTime,
|
||||||
|
parent: CreatorCheers? = null
|
||||||
|
): CreatorCheers {
|
||||||
|
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive)
|
||||||
|
creatorCheers.member = member
|
||||||
|
creatorCheers.creator = creator
|
||||||
|
creatorCheers.parent = parent
|
||||||
|
entityManager.persist(creatorCheers)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt)
|
||||||
|
return creatorCheers
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushAndClear() {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelFanTalkQueryServiceTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
|
||||||
|
val port = FakeCreatorChannelFanTalkQueryPort().apply { creator = null }
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.user_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
|
||||||
|
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
|
||||||
|
val port = FakeCreatorChannelFanTalkQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
|
||||||
|
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
|
||||||
|
val port = FakeCreatorChannelFanTalkQueryPort().apply { blocked = true }
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(exception.messageKey)
|
||||||
|
assertEquals("Channel access is restricted at creator's request.", exception.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
|
||||||
|
fun shouldResolveRequestFallbacksAndAssembleFanTalkTab() {
|
||||||
|
val port = FakeCreatorChannelFanTalkQueryPort().apply {
|
||||||
|
fanTalkCount = 60
|
||||||
|
fanTalks = (1L..21L).map { fanTalkRecord(it) }
|
||||||
|
creatorReplies = listOf(
|
||||||
|
fanTalkReplyRecord(fanTalkId = 101L, parentFanTalkId = 1L),
|
||||||
|
fanTalkReplyRecord(fanTalkId = 102L, parentFanTalkId = 2L),
|
||||||
|
fanTalkReplyRecord(fanTalkId = 103L, parentFanTalkId = 21L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val tab = service.getFanTalkTab(
|
||||||
|
creatorId = 1L,
|
||||||
|
viewer = viewer,
|
||||||
|
page = -1,
|
||||||
|
size = 10,
|
||||||
|
now = LocalDateTime.of(2026, 6, 21, 10, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(60, tab.fanTalkCount)
|
||||||
|
assertEquals(0, tab.page.page)
|
||||||
|
assertEquals(20, tab.page.size)
|
||||||
|
assertEquals(0L, port.listOffset)
|
||||||
|
assertEquals(21, port.listLimit)
|
||||||
|
assertEquals(20, tab.fanTalks.size)
|
||||||
|
assertTrue(tab.hasNext)
|
||||||
|
assertEquals(
|
||||||
|
(1L..20L).toList(),
|
||||||
|
port.replyParentFanTalkIds
|
||||||
|
)
|
||||||
|
assertEquals(101L, tab.fanTalks[0].creatorReplies.single().fanTalkId)
|
||||||
|
assertEquals(102L, tab.fanTalks[1].creatorReplies.single().fanTalkId)
|
||||||
|
assertEquals(emptyList<Long>(), tab.fanTalks[19].creatorReplies.map { it.fanTalkId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 목록이 비어 있으면 답글 조회 없이 빈 목록과 hasNext=false를 반환한다")
|
||||||
|
fun shouldReturnEmptyFanTalksWithoutFindingReplies() {
|
||||||
|
val port = FakeCreatorChannelFanTalkQueryPort().apply {
|
||||||
|
fanTalkCount = 5
|
||||||
|
fanTalks = emptyList()
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val tab = service.getFanTalkTab(1L, viewer, 3, 20, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||||
|
|
||||||
|
assertEquals(5, tab.fanTalkCount)
|
||||||
|
assertEquals(emptyList<Long>(), tab.fanTalks.map { it.fanTalkId })
|
||||||
|
assertEquals(false, tab.hasNext)
|
||||||
|
assertNull(port.replyParentFanTalkIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk과 creator reply 작성자의 프로필 URL과 탈퇴 닉네임 prefix를 변환한다")
|
||||||
|
fun shouldConvertWriterProfileUrlsAndDeletedNicknamePrefixes() {
|
||||||
|
val port = FakeCreatorChannelFanTalkQueryPort().apply {
|
||||||
|
fanTalks = listOf(
|
||||||
|
fanTalkRecord(
|
||||||
|
fanTalkId = 1L,
|
||||||
|
writerNickname = "deleted_fan",
|
||||||
|
writerProfileImagePath = "profile/fan.png"
|
||||||
|
),
|
||||||
|
fanTalkRecord(
|
||||||
|
fanTalkId = 2L,
|
||||||
|
writerNickname = "normal",
|
||||||
|
writerProfileImagePath = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
creatorReplies = listOf(
|
||||||
|
fanTalkReplyRecord(
|
||||||
|
fanTalkId = 101L,
|
||||||
|
parentFanTalkId = 1L,
|
||||||
|
writerNickname = "deleted_creator",
|
||||||
|
writerProfileImagePath = "https://images.test/creator.png"
|
||||||
|
),
|
||||||
|
fanTalkReplyRecord(
|
||||||
|
fanTalkId = 102L,
|
||||||
|
parentFanTalkId = 2L,
|
||||||
|
writerNickname = "creator",
|
||||||
|
writerProfileImagePath = " "
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val fanTalks = service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
|
||||||
|
.fanTalks
|
||||||
|
|
||||||
|
assertEquals("fan", fanTalks[0].writerNickname)
|
||||||
|
assertEquals("https://cdn.test/profile/fan.png", fanTalks[0].writerProfileImageUrl)
|
||||||
|
assertEquals("creator", fanTalks[0].creatorReplies.single().writerNickname)
|
||||||
|
assertEquals("https://images.test/creator.png", fanTalks[0].creatorReplies.single().writerProfileImageUrl)
|
||||||
|
assertEquals("normal", fanTalks[1].writerNickname)
|
||||||
|
assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].writerProfileImageUrl)
|
||||||
|
assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].creatorReplies.single().writerProfileImageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createService(port: FakeCreatorChannelFanTalkQueryPort): CreatorChannelFanTalkQueryService {
|
||||||
|
val langContext = LangContext()
|
||||||
|
langContext.setLang(Lang.EN)
|
||||||
|
return CreatorChannelFanTalkQueryService(
|
||||||
|
queryPortProvider = FixedCreatorChannelFanTalkQueryPortProvider(port),
|
||||||
|
queryPolicy = CreatorChannelFanTalkQueryPolicy(),
|
||||||
|
messageSource = SodaMessageSource(),
|
||||||
|
langContext = langContext,
|
||||||
|
cloudFrontHost = "https://cdn.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(id: Long): Member {
|
||||||
|
return Member(
|
||||||
|
email = "member$id@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "member$id",
|
||||||
|
provider = MemberProvider.EMAIL
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FixedCreatorChannelFanTalkQueryPortProvider(
|
||||||
|
private val port: CreatorChannelFanTalkQueryPort
|
||||||
|
) : ObjectProvider<CreatorChannelFanTalkQueryPort> {
|
||||||
|
override fun getObject(vararg args: Any?): CreatorChannelFanTalkQueryPort = port
|
||||||
|
|
||||||
|
override fun getIfAvailable(): CreatorChannelFanTalkQueryPort = port
|
||||||
|
|
||||||
|
override fun getIfUnique(): CreatorChannelFanTalkQueryPort = port
|
||||||
|
|
||||||
|
override fun getObject(): CreatorChannelFanTalkQueryPort = port
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPort {
|
||||||
|
var creator: CreatorChannelFanTalkCreatorRecord? = CreatorChannelFanTalkCreatorRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
nickname = "creator"
|
||||||
|
)
|
||||||
|
var blocked = false
|
||||||
|
var fanTalkCount = 1
|
||||||
|
var fanTalks = listOf(fanTalkRecord(1L))
|
||||||
|
var creatorReplies = emptyList<CreatorChannelFanTalkReplyRecord>()
|
||||||
|
var listOffset: Long? = null
|
||||||
|
var listLimit: Int? = null
|
||||||
|
var replyParentFanTalkIds: List<Long>? = null
|
||||||
|
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? = creator
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
|
||||||
|
|
||||||
|
override fun countFanTalks(creatorId: Long, viewerId: Long): Int = fanTalkCount
|
||||||
|
|
||||||
|
override fun findFanTalks(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelFanTalkRecord> {
|
||||||
|
listOffset = offset
|
||||||
|
listLimit = limit
|
||||||
|
return fanTalks
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCreatorReplies(
|
||||||
|
creatorId: Long,
|
||||||
|
parentFanTalkIds: List<Long>
|
||||||
|
): List<CreatorChannelFanTalkReplyRecord> {
|
||||||
|
replyParentFanTalkIds = parentFanTalkIds
|
||||||
|
return creatorReplies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fanTalkRecord(
|
||||||
|
fanTalkId: Long,
|
||||||
|
writerId: Long = 10L + fanTalkId,
|
||||||
|
writerNickname: String = "fan-$fanTalkId",
|
||||||
|
writerProfileImagePath: String? = "profile/$fanTalkId.png"
|
||||||
|
): CreatorChannelFanTalkRecord {
|
||||||
|
return CreatorChannelFanTalkRecord(
|
||||||
|
fanTalkId = fanTalkId,
|
||||||
|
writerId = writerId,
|
||||||
|
writerNickname = writerNickname,
|
||||||
|
writerProfileImagePath = writerProfileImagePath,
|
||||||
|
content = "content-$fanTalkId",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(fanTalkId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fanTalkReplyRecord(
|
||||||
|
fanTalkId: Long,
|
||||||
|
parentFanTalkId: Long,
|
||||||
|
writerId: Long = 1L,
|
||||||
|
writerNickname: String = "creator",
|
||||||
|
writerProfileImagePath: String? = "profile/creator.png"
|
||||||
|
): CreatorChannelFanTalkReplyRecord {
|
||||||
|
return CreatorChannelFanTalkReplyRecord(
|
||||||
|
fanTalkId = fanTalkId,
|
||||||
|
parentFanTalkId = parentFanTalkId,
|
||||||
|
writerId = writerId,
|
||||||
|
writerNickname = writerNickname,
|
||||||
|
writerProfileImagePath = writerProfileImagePath,
|
||||||
|
content = "reply-$fanTalkId",
|
||||||
|
createdAt = LocalDateTime.of(2026, 6, 21, 11, 0).plusMinutes(fanTalkId)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class CreatorChannelFanTalkQueryPolicyTest {
|
||||||
|
private val policy = CreatorChannelFanTalkQueryPolicy()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다")
|
||||||
|
fun shouldFallbackNullPageAndSizeForFanTalkTab() {
|
||||||
|
val page = policy.createPage(page = null, size = null)
|
||||||
|
|
||||||
|
assertEquals(0, page.page)
|
||||||
|
assertEquals(20, page.size)
|
||||||
|
assertEquals(0L, page.offset)
|
||||||
|
assertEquals(21, page.fetchLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다")
|
||||||
|
fun shouldFallbackPageAndSizeForFanTalkTab() {
|
||||||
|
val minimumPage = policy.createPage(page = -1, size = 10)
|
||||||
|
val maximumPage = policy.createPage(page = 2, size = 100)
|
||||||
|
|
||||||
|
assertEquals(0, minimumPage.page)
|
||||||
|
assertEquals(20, minimumPage.size)
|
||||||
|
assertEquals(0L, minimumPage.offset)
|
||||||
|
assertEquals(21, minimumPage.fetchLimit)
|
||||||
|
assertEquals(2, maximumPage.page)
|
||||||
|
assertEquals(50, maximumPage.size)
|
||||||
|
assertEquals(100L, maximumPage.offset)
|
||||||
|
assertEquals(51, maximumPage.fetchLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
|
||||||
|
fun shouldLimitItemsAndCalculateHasNext() {
|
||||||
|
val page = policy.createPage(page = 0, size = 20)
|
||||||
|
val fetched = (1..21).toList()
|
||||||
|
|
||||||
|
val items = policy.limitItems(fetched, page)
|
||||||
|
|
||||||
|
assertEquals((1..20).toList(), items)
|
||||||
|
assertTrue(policy.hasNext(fetched, page))
|
||||||
|
assertFalse(policy.hasNext((1..20).toList(), page))
|
||||||
|
assertFalse(policy.hasNext(emptyList<Int>(), page))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("FanTalk 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
|
||||||
|
fun shouldKeepDomainAndPortContract() {
|
||||||
|
val createdAt = LocalDateTime.of(2026, 6, 22, 10, 0)
|
||||||
|
val page = policy.createPage(page = 0, size = 20)
|
||||||
|
val reply = CreatorChannelFanTalkReply(
|
||||||
|
fanTalkId = 11L,
|
||||||
|
writerId = 1L,
|
||||||
|
writerNickname = "creator",
|
||||||
|
writerProfileImageUrl = "https://cdn.test/creator.png",
|
||||||
|
content = "reply",
|
||||||
|
createdAt = createdAt.plusMinutes(1)
|
||||||
|
)
|
||||||
|
val tab = CreatorChannelFanTalkTab(
|
||||||
|
fanTalkCount = 1,
|
||||||
|
fanTalks = listOf(
|
||||||
|
CreatorChannelFanTalk(
|
||||||
|
fanTalkId = 10L,
|
||||||
|
writerId = 2L,
|
||||||
|
writerNickname = "fan",
|
||||||
|
writerProfileImageUrl = "https://cdn.test/fan.png",
|
||||||
|
content = "fan talk",
|
||||||
|
createdAt = createdAt,
|
||||||
|
creatorReplies = listOf(reply)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = page,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
val creatorRecord = CreatorChannelFanTalkCreatorRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
role = MemberRole.CREATOR,
|
||||||
|
nickname = "creator"
|
||||||
|
)
|
||||||
|
val fanTalkRecord = CreatorChannelFanTalkRecord(
|
||||||
|
fanTalkId = 10L,
|
||||||
|
writerId = 2L,
|
||||||
|
writerNickname = "fan",
|
||||||
|
writerProfileImagePath = null,
|
||||||
|
content = "fan talk",
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
val replyRecord = CreatorChannelFanTalkReplyRecord(
|
||||||
|
fanTalkId = 11L,
|
||||||
|
parentFanTalkId = 10L,
|
||||||
|
writerId = 1L,
|
||||||
|
writerNickname = "creator",
|
||||||
|
writerProfileImagePath = null,
|
||||||
|
content = "reply",
|
||||||
|
createdAt = createdAt.plusMinutes(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, tab.fanTalkCount)
|
||||||
|
assertEquals("fan", tab.fanTalks.first().writerNickname)
|
||||||
|
assertEquals(reply, tab.fanTalks.first().creatorReplies.first())
|
||||||
|
assertEquals(page, tab.page)
|
||||||
|
assertFalse(tab.hasNext)
|
||||||
|
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||||
|
assertNull(fanTalkRecord.writerProfileImagePath)
|
||||||
|
assertEquals(10L, replyRecord.parentFanTalkId)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user