From 10e1c1eed066627ca1a51bdb302dfcb2aa695a6a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 16:25:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(explorer):=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260223_크리에이터상세정보조회api추가.md | 18 ++++++ .../sodalive/explorer/ExplorerController.kt | 9 +++ .../explorer/ExplorerQueryRepository.kt | 26 +++++++++ .../sodalive/explorer/ExplorerService.kt | 58 +++++++++++++++++++ .../explorer/GetCreatorDetailResponse.kt | 15 +++++ 5 files changed, 126 insertions(+) create mode 100644 docs/20260223_크리에이터상세정보조회api추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt diff --git a/docs/20260223_크리에이터상세정보조회api추가.md b/docs/20260223_크리에이터상세정보조회api추가.md new file mode 100644 index 00000000..77be446e --- /dev/null +++ b/docs/20260223_크리에이터상세정보조회api추가.md @@ -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)을 확인했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index b634b0ab..46cd9eb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 02d0fe45..e776dce8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -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() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index ed2c9f5f..bd99454d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt new file mode 100644 index 00000000..cee89522 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorDetailResponse.kt @@ -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 +)