feat(explorer): 크리에이터 상세정보 조회 API를 추가한다
This commit is contained in:
18
docs/20260223_크리에이터상세정보조회api추가.md
Normal file
18
docs/20260223_크리에이터상세정보조회api추가.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 크리에이터 상세정보 조회 API 추가 작업 계획
|
||||
|
||||
- [x] `ExplorerController`에 크리에이터 상세정보 조회 엔드포인트 추가
|
||||
- [x] `ExplorerService`에 상세정보 조회 비즈니스 로직 추가
|
||||
- [x] `ExplorerQueryRepository`에 데뷔일/활동요약 조회 쿼리 추가
|
||||
- [x] 응답 DTO 추가 및 `Member` SNS URL 매핑 연결
|
||||
- [x] 정적 진단/테스트/빌드 검증 및 결과 기록
|
||||
|
||||
## 검증 기록
|
||||
- 무엇을:
|
||||
- 1차 구현: 크리에이터 상세정보 조회 API(`/explorer/profile/{id}/detail`)와 응답 DTO를 추가하고, 데뷔일(라이브 `beginDateTime`/콘텐츠 `releaseDate` 최솟값), `D+N`, 활동요약, SNS URL 반환을 구현했다.
|
||||
- 2차 수정: 상세 조회에 차단 관계 검사를 추가하고, 활동요약의 `contentCount`를 오픈된 콘텐츠(`releaseDate <= now`) 기준으로 집계하도록 기존 쿼리를 보정했다.
|
||||
- 왜:
|
||||
- 1차 구현: 탐색 화면에서 크리에이터 기본 정보·활동 통계·데뷔 정보·SNS를 한 번에 조회할 수 있어야 했다.
|
||||
- 2차 수정: 차단 관계에서도 상세정보가 노출되는 우회가 있었고, 예약 공개 콘텐츠가 포함되어 요구사항의 “오픈한 콘텐츠 수”와 불일치할 수 있었다.
|
||||
- 어떻게:
|
||||
- 1차 구현/2차 수정 모두 Kotlin LSP 부재로 `lsp_diagnostics`는 불가를 확인했다.
|
||||
- 1차 구현 시점과 2차 수정 시점에 각각 `./gradlew ktlintCheck test build`를 실행해 정적검사/테스트/빌드 성공(Exit code 0)을 확인했다.
|
||||
@@ -73,6 +73,15 @@ class ExplorerController(
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/profile/{id}/detail")
|
||||
fun getCreatorDetail(
|
||||
@PathVariable("id") creatorId: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
ApiResponse.ok(service.getCreatorDetail(creatorId = creatorId, memberId = member.id!!))
|
||||
}
|
||||
|
||||
@GetMapping("/profile/{id}/donation-rank")
|
||||
fun getCreatorProfileDonationRanking(
|
||||
@PathVariable("id") creatorId: Long,
|
||||
|
||||
@@ -565,6 +565,30 @@ class ExplorerQueryRepository(
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
fun getFirstLiveBeginDateTime(creatorId: Long): LocalDateTime? {
|
||||
return queryFactory
|
||||
.select(liveRoom.beginDateTime.min())
|
||||
.from(liveRoom)
|
||||
.where(
|
||||
liveRoom.member.id.eq(creatorId)
|
||||
.and(liveRoom.channelName.isNotNull)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
fun getFirstContentReleaseDate(creatorId: Long): LocalDateTime? {
|
||||
return queryFactory
|
||||
.select(audioContent.releaseDate.min())
|
||||
.from(audioContent)
|
||||
.where(
|
||||
audioContent.member.id.eq(creatorId)
|
||||
.and(audioContent.isActive.isTrue)
|
||||
.and(audioContent.releaseDate.isNotNull)
|
||||
.and(audioContent.releaseDate.loe(LocalDateTime.now()))
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
fun getLiveTime(creatorId: Long): Long {
|
||||
val diffs = queryFactory
|
||||
.select(
|
||||
@@ -708,6 +732,8 @@ class ExplorerQueryRepository(
|
||||
.where(
|
||||
audioContent.isActive.isTrue
|
||||
.and(audioContent.member.id.eq(creatorId))
|
||||
.and(audioContent.releaseDate.isNotNull)
|
||||
.and(audioContent.releaseDate.loe(LocalDateTime.now()))
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import kotlin.random.Random
|
||||
|
||||
@@ -192,6 +193,63 @@ class ExplorerService(
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getCreatorDetail(creatorId: Long, memberId: Long): GetCreatorDetailResponse {
|
||||
val creatorAccount = queryRepository.getMember(creatorId)
|
||||
?: throw SodaException(messageKey = "member.validation.user_not_found")
|
||||
|
||||
if (isBlockedBetweenMembers(memberId = memberId, otherMemberId = creatorId)) {
|
||||
val messageTemplate = messageSource
|
||||
.getMessage("explorer.creator.blocked_access", langContext.lang)
|
||||
.orEmpty()
|
||||
throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname))
|
||||
}
|
||||
|
||||
if (creatorAccount.role != MemberRole.CREATOR) {
|
||||
throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||
}
|
||||
|
||||
val liveCount = queryRepository.getLiveCount(creatorId) ?: 0
|
||||
val liveTime = queryRepository.getLiveTime(creatorId)
|
||||
val liveContributorCount = queryRepository.getLiveContributorCount(creatorId) ?: 0
|
||||
val contentCount = queryRepository.getContentCount(creatorId) ?: 0
|
||||
val activitySummary = GetCreatorActivitySummary(
|
||||
liveCount = liveCount,
|
||||
liveTime = liveTime,
|
||||
liveContributorCount = liveContributorCount,
|
||||
contentCount = contentCount
|
||||
)
|
||||
|
||||
val debutDateTime = listOfNotNull(
|
||||
queryRepository.getFirstLiveBeginDateTime(creatorId),
|
||||
queryRepository.getFirstContentReleaseDate(creatorId)
|
||||
).minOrNull()
|
||||
|
||||
val debutDate = debutDateTime?.toLocalDate()
|
||||
val dDay = if (debutDate != null) {
|
||||
"D+${ChronoUnit.DAYS.between(debutDate, LocalDate.now())}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return GetCreatorDetailResponse(
|
||||
nickname = creatorAccount.nickname,
|
||||
profileImageUrl = if (creatorAccount.profileImage != null) {
|
||||
"$cloudFrontHost/${creatorAccount.profileImage}"
|
||||
} else {
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
},
|
||||
debutDate = debutDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "",
|
||||
dDay = dDay,
|
||||
activitySummary = activitySummary,
|
||||
instagramUrl = creatorAccount.instagramUrl,
|
||||
fancimmUrl = creatorAccount.fancimmUrl,
|
||||
xUrl = creatorAccount.xUrl,
|
||||
youtubeUrl = creatorAccount.youtubeUrl,
|
||||
websiteUrl = creatorAccount.websiteUrl,
|
||||
blogUrl = creatorAccount.blogUrl
|
||||
)
|
||||
}
|
||||
|
||||
fun getCreatorProfile(
|
||||
creatorId: Long,
|
||||
timezone: String,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.explorer
|
||||
|
||||
data class GetCreatorDetailResponse(
|
||||
val nickname: String,
|
||||
val profileImageUrl: String,
|
||||
val debutDate: String,
|
||||
val dDay: String,
|
||||
val activitySummary: GetCreatorActivitySummary,
|
||||
val instagramUrl: String,
|
||||
val fancimmUrl: String,
|
||||
val xUrl: String,
|
||||
val youtubeUrl: String,
|
||||
val websiteUrl: String,
|
||||
val blogUrl: String
|
||||
)
|
||||
Reference in New Issue
Block a user