feat(explorer): 크리에이터 상세정보 조회 API를 추가한다

This commit is contained in:
2026-02-23 16:25:57 +09:00
parent cc74628107
commit 10e1c1eed0
5 changed files with 126 additions and 0 deletions

View 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)을 확인했다.

View File

@@ -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") @GetMapping("/profile/{id}/donation-rank")
fun getCreatorProfileDonationRanking( fun getCreatorProfileDonationRanking(
@PathVariable("id") creatorId: Long, @PathVariable("id") creatorId: Long,

View File

@@ -565,6 +565,30 @@ class ExplorerQueryRepository(
.fetchFirst() .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 { fun getLiveTime(creatorId: Long): Long {
val diffs = queryFactory val diffs = queryFactory
.select( .select(
@@ -708,6 +732,8 @@ class ExplorerQueryRepository(
.where( .where(
audioContent.isActive.isTrue audioContent.isActive.isTrue
.and(audioContent.member.id.eq(creatorId)) .and(audioContent.member.id.eq(creatorId))
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(LocalDateTime.now()))
) )
.fetchFirst() .fetchFirst()
} }

View File

@@ -37,6 +37,7 @@ import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
import kotlin.random.Random import kotlin.random.Random
@@ -192,6 +193,63 @@ class ExplorerService(
.toList() .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( fun getCreatorProfile(
creatorId: Long, creatorId: Long,
timezone: String, timezone: String,

View File

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