From c25b105d4d4c75b7d9bc8dfdfdaddb80cb3585c6 Mon Sep 17 00:00:00 2001
From: Klaus <klaus@vividnext.co.kr>
Date: Tue, 1 Aug 2023 14:40:52 +0900
Subject: [PATCH] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0?=
 =?UTF-8?q?=20=EC=B1=84=EB=84=90=20API?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../sodalive/explorer/CreatorResponse.kt      |  15 +
 .../sodalive/explorer/ExplorerController.kt   |  57 +++
 .../explorer/ExplorerQueryRepository.kt       | 456 ++++++++++++++++++
 .../sodalive/explorer/ExplorerService.kt      | 164 +++++++
 .../sodalive/explorer/GetCheersResponse.kt    |  15 +
 .../explorer/GetCreatorProfileResponse.kt     |  19 +
 .../sodalive/explorer/LiveRoomResponse.kt     |  22 +
 .../explorer/SimilarCreatorResponse.kt        |   8 +
 .../explorer/UserDonationRankingResponse.kt   |  16 +
 .../explorer/follower/GetFollowerListDto.kt   |  11 +
 .../follower/GetFollowerListResponse.kt       |  13 +
 .../explorer/profile/ChannelNotice.kt         |  19 +
 .../profile/ChannelNoticeRepository.kt        |   7 +
 .../explorer/profile/CreatorCheers.kt         |  36 ++
 .../profile/CreatorCheersRepository.kt        |   7 +
 .../profile/PostCreatorNoticeRequest.kt       |   3 +
 .../profile/PostWriteCheersRequest.kt         |   7 +
 .../explorer/profile/PutWriteCheersRequest.kt |   6 +
 .../explorer/profile/TimeDifferenceResult.kt  |   8 +
 19 files changed, 889 insertions(+)
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt

diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt
new file mode 100644
index 0000000..c5b8f8b
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/CreatorResponse.kt
@@ -0,0 +1,15 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class CreatorResponse(
+    val creatorId: Long,
+    val profileUrl: String,
+    val nickname: String,
+    val tags: List<String>,
+    val introduce: String = "",
+    val instagramUrl: String? = null,
+    val youtubeUrl: String? = null,
+    val websiteUrl: String? = null,
+    val blogUrl: String? = null,
+    val isNotification: Boolean,
+    val notificationRecipientCount: Int
+)
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 574e2ca..b5d9f7b 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt
@@ -2,9 +2,18 @@ package kr.co.vividnext.sodalive.explorer
 
 import kr.co.vividnext.sodalive.common.ApiResponse
 import kr.co.vividnext.sodalive.common.SodaException
+import kr.co.vividnext.sodalive.explorer.profile.PostCreatorNoticeRequest
+import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
+import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
 import kr.co.vividnext.sodalive.member.Member
+import org.springframework.data.domain.Pageable
+import org.springframework.security.access.prepost.PreAuthorize
 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.PostMapping
+import org.springframework.web.bind.annotation.PutMapping
+import org.springframework.web.bind.annotation.RequestBody
 import org.springframework.web.bind.annotation.RequestMapping
 import org.springframework.web.bind.annotation.RequestParam
 import org.springframework.web.bind.annotation.RestController
@@ -29,4 +38,52 @@ class ExplorerController(private val service: ExplorerService) {
         if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
         ApiResponse.ok(service.getSearchChannel(channel, member))
     }
+
+    @GetMapping("/profile/{id}")
+    fun getCreatorProfile(
+        @PathVariable("id") creatorId: Long,
+        @RequestParam timezone: String,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.getCreatorProfile(creatorId, timezone, member))
+    }
+
+    @PostMapping("/profile/cheers")
+    fun writeCheers(
+        @RequestBody request: PostWriteCheersRequest,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.writeCheers(request, member), "등록되었습니다.")
+    }
+
+    @PutMapping("/profile/cheers")
+    fun modifyCheers(
+        @RequestBody request: PutWriteCheersRequest,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.modifyCheers(request, member), "수정되었습니다.")
+    }
+
+    @PostMapping("/profile/notice")
+    @PreAuthorize("hasAnyRole('CREATOR')")
+    fun postCreatorNotice(
+        @RequestBody request: PostCreatorNoticeRequest,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.saveNotice(member, request.notice), "공지사항이 저장되었습니다.")
+    }
+
+    @GetMapping("/profile/{id}/follower-list")
+    fun getFollowerList(
+        @PathVariable("id") creatorId: Long,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
+        pageable: Pageable
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.getFollowerList(creatorId, member, pageable))
+    }
 }
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 6a13f67..eb29c23 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt
@@ -1,22 +1,40 @@
 package kr.co.vividnext.sodalive.explorer
 
+import com.querydsl.core.types.Predicate
+import com.querydsl.core.types.Projections
 import com.querydsl.core.types.dsl.Expressions
 import com.querydsl.jpa.impl.JPAQueryFactory
 import kr.co.vividnext.sodalive.can.use.CanUsage
 import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
+import kr.co.vividnext.sodalive.common.SodaException
+import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListDto
+import kr.co.vividnext.sodalive.explorer.follower.QGetFollowerListDto
+import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice
+import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
+import kr.co.vividnext.sodalive.explorer.profile.QChannelNotice.channelNotice
+import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
+import kr.co.vividnext.sodalive.explorer.profile.TimeDifferenceResult
 import kr.co.vividnext.sodalive.explorer.section.ExplorerSection
 import kr.co.vividnext.sodalive.explorer.section.QExplorerSection.explorerSection
+import kr.co.vividnext.sodalive.live.room.LiveRoom
+import kr.co.vividnext.sodalive.live.room.LiveRoomType
 import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
+import kr.co.vividnext.sodalive.live.room.cancel.QLiveRoomCancel.liveRoomCancel
+import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit
 import kr.co.vividnext.sodalive.member.Member
 import kr.co.vividnext.sodalive.member.MemberRole
 import kr.co.vividnext.sodalive.member.QMember
 import kr.co.vividnext.sodalive.member.QMember.member
+import kr.co.vividnext.sodalive.member.auth.QAuth.auth
 import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
 import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag
 import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag
 import org.springframework.beans.factory.annotation.Value
 import org.springframework.stereotype.Repository
+import java.time.Duration
 import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
 
 @Repository
 class ExplorerQueryRepository(
@@ -141,4 +159,442 @@ class ExplorerQueryRepository(
             )
             .fetch()
     }
+
+    fun getAccount(userId: Long): Member? {
+        return queryFactory
+            .selectFrom(member)
+            .where(member.id.eq(userId))
+            .fetchFirst()
+    }
+
+    fun getUserDonationRanking(
+        creatorId: Long,
+        limit: Long,
+        offset: Long = 0,
+        withDonationCan: Boolean
+    ): List<UserDonationRankingResponse> {
+        val creatorMember = QMember("creator")
+        val userMember = QMember("user")
+
+        val donation = useCan.rewardCan.add(useCan.can).sum()
+        return queryFactory
+            .select(userMember, donation)
+            .from(useCan)
+            .join(useCan.room, liveRoom)
+            .join(liveRoom.member, creatorMember)
+            .join(useCan.member, userMember)
+            .offset(offset)
+            .limit(limit)
+            .where(
+                useCan.canUsage.eq(CanUsage.DONATION)
+                    .and(useCan.isRefund.isFalse)
+                    .and(creatorMember.id.eq(creatorId))
+            )
+            .groupBy(useCan.member.id)
+            .orderBy(donation.desc(), userMember.id.desc())
+            .fetch()
+            .map {
+                val account = it.get(userMember)!!
+                val donationCan = it.get(donation)!!
+                UserDonationRankingResponse(
+                    account.id!!,
+                    account.nickname,
+                    if (account.profileImage != null) {
+                        "$cloudFrontHost/${account.profileImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    },
+                    if (withDonationCan) donationCan else 0
+                )
+            }
+    }
+
+    fun getSimilarCreatorList(creatorId: Long): List<SimilarCreatorResponse> {
+        val creator = queryFactory
+            .selectFrom(member)
+            .where(member.id.eq(creatorId))
+            .fetchFirst() ?: throw SodaException("없는 사용자 입니다.")
+
+        val creatorTagCount = creator.tags
+            .asSequence()
+            .filter { it.tag.isActive }
+            .map { it.tag }
+            .toList().size
+
+        if (creatorTagCount <= 0) {
+            val where = member.role.eq(MemberRole.CREATOR)
+                .and(member.id.ne(creatorId))
+                .and(member.tags.size().gt(0))
+                .and(memberCreatorTag.tag.isActive.isTrue)
+
+            return getRandomCreatorList(where = where, limit = 3)
+        }
+
+        val cnt = memberCreatorTag.member.count()
+        val similarCreators = queryFactory
+            .select(member, cnt)
+            .from(memberCreatorTag)
+            .innerJoin(memberCreatorTag.member, member)
+            .innerJoin(memberCreatorTag.tag, creatorTag)
+            .leftJoin(member.auth, auth)
+            .where(
+                member.role.eq(MemberRole.CREATOR)
+                    .and(member.id.ne(creatorId))
+                    .and(
+                        memberCreatorTag.tag.`in`(
+                            creator.tags
+                                .asSequence()
+                                .filter { it.tag.isActive }
+                                .map { it.tag }
+                                .toList()
+                        )
+                    )
+            )
+            .groupBy(memberCreatorTag.member.id)
+            .orderBy(cnt.desc())
+            .limit(3)
+            .fetch()
+            .map {
+                val account = it.get(member)!!
+                SimilarCreatorResponse(
+                    account.id!!,
+                    account.nickname,
+                    if (account.profileImage != null) {
+                        "$cloudFrontHost/${account.profileImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    },
+                    account.tags
+                        .asSequence()
+                        .filter { accountCounselorTag -> accountCounselorTag.tag.isActive }
+                        .map { accountCounselorTag -> accountCounselorTag.tag.tag }
+                        .toList()
+                )
+            }
+
+        if (similarCreators.size < 3) {
+            val userIds = similarCreators.map { it.userId }
+            var where = member.role.eq(MemberRole.CREATOR)
+                .and(member.id.ne(creatorId))
+                .and(member.tags.size().gt(0))
+                .and(memberCreatorTag.tag.isActive.isTrue)
+
+            for (userId in userIds) {
+                where = where.and(member.id.ne(userId))
+            }
+
+            val additionalCreator = getRandomCreatorList(where = where, limit = (3 - similarCreators.size).toLong())
+            return similarCreators + additionalCreator
+        } else {
+            return similarCreators
+        }
+    }
+
+    private fun getRandomCreatorList(where: Predicate, limit: Long): List<SimilarCreatorResponse> {
+        val result = queryFactory
+            .select(member)
+            .from(memberCreatorTag)
+            .join(memberCreatorTag.member, member)
+            .innerJoin(memberCreatorTag.tag, creatorTag)
+            .leftJoin(member.auth, auth)
+            .groupBy(memberCreatorTag.member.id)
+            .where(where)
+            .limit(limit)
+            .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc())
+            .fetch()
+            .map {
+                SimilarCreatorResponse(
+                    it.id!!,
+                    it.nickname,
+                    if (it.profileImage != null) {
+                        "$cloudFrontHost/${it.profileImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    },
+                    it.tags
+                        .asSequence()
+                        .filter { accountCounselorTag -> accountCounselorTag.tag.isActive }
+                        .map { accountCounselorTag -> accountCounselorTag.tag.tag }
+                        .toList()
+                )
+            }
+        return result
+    }
+
+    fun getLiveRoomList(
+        creatorId: Long,
+        userMember: Member,
+        timezone: String,
+        limit: Int,
+        offset: Long = 0
+    ): List<LiveRoomResponse> {
+        var where = liveRoom.member.id.eq(creatorId)
+            .and(liveRoom.cancel.id.isNull)
+            .and(liveRoom.isActive.isTrue)
+
+        if (userMember.auth == null) {
+            where = where.and(liveRoom.isAdult.isFalse)
+        }
+
+        val result = mutableListOf<LiveRoom>()
+
+        if (offset == 0L) {
+            result.addAll(
+                queryFactory
+                    .selectFrom(liveRoom)
+                    .innerJoin(liveRoom.member, member)
+                    .leftJoin(liveRoom.cancel, liveRoomCancel)
+                    .where(where)
+                    .orderBy(liveRoom.beginDateTime.asc())
+                    .fetch()
+            )
+        }
+
+        return result
+            .map {
+                val reservations = it.reservations
+                    .filter { reservation ->
+                        reservation.member!!.id!! == userMember.id!! && reservation.isActive
+                    }
+
+                val beginDateTime = it.beginDateTime
+                    .atZone(ZoneId.of("UTC"))
+                    .withZoneSameInstant(ZoneId.of(timezone))
+
+                val isPaid = if (it.channelName != null) {
+                    val useCan = queryFactory
+                        .selectFrom(useCan)
+                        .where(
+                            useCan.member.id.eq(member.id)
+                                .and(useCan.room.id.eq(it.id!!))
+                                .and(useCan.canUsage.eq(CanUsage.LIVE))
+                        )
+                        .orderBy(useCan.id.desc())
+                        .fetchFirst()
+
+                    useCan != null
+                } else {
+                    false
+                }
+
+                LiveRoomResponse(
+                    roomId = it.id!!,
+                    title = it.title,
+                    content = it.notice,
+                    isPaid = isPaid,
+                    beginDateTime = beginDateTime.format(
+                        DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")
+                    ),
+                    isAdult = it.isAdult,
+                    price = it.price,
+                    channelName = it.channelName,
+                    managerNickname = it.member!!.nickname,
+                    coverImageUrl = if (it.coverImage!!.startsWith("https://")) {
+                        it.coverImage!!
+                    } else {
+                        "$cloudFrontHost/${it.coverImage!!}"
+                    },
+                    isReservation = reservations.isNotEmpty(),
+                    isActive = it.isActive,
+                    isPrivateRoom = it.type == LiveRoomType.PRIVATE
+                )
+            }
+    }
+
+    fun getNoticeString(creatorId: Long): String {
+        return queryFactory
+            .select(channelNotice.notice)
+            .from(channelNotice)
+            .where(channelNotice.member.id.eq(creatorId))
+            .fetchFirst() ?: ""
+    }
+
+    fun getCheersList(creatorId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse {
+        val totalCount = queryFactory
+            .selectFrom(creatorCheers)
+            .where(
+                creatorCheers.creator.id.eq(creatorId)
+                    .and(creatorCheers.isActive.isTrue)
+                    .and(creatorCheers.parent.isNull)
+            )
+            .fetch()
+            .count()
+
+        val cheers = queryFactory
+            .selectFrom(creatorCheers)
+            .where(
+                creatorCheers.creator.id.eq(creatorId)
+                    .and(creatorCheers.isActive.isTrue)
+                    .and(creatorCheers.parent.isNull)
+            )
+            .offset(offset)
+            .limit(limit)
+            .orderBy(creatorCheers.id.desc())
+            .fetch()
+            .asSequence()
+            .map {
+                val date = it.createdAt!!
+                    .atZone(ZoneId.of("UTC"))
+                    .withZoneSameInstant(ZoneId.of(timezone))
+
+                GetCheersResponseItem(
+                    cheersId = it.id!!,
+                    nickname = it.member!!.nickname,
+                    profileUrl = if (it.member!!.profileImage != null) {
+                        "$cloudFrontHost/${it.member!!.profileImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    },
+                    content = it.cheers,
+                    date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
+                    replyList = it.children.asSequence()
+                        .map { cheers ->
+                            val replyDate = cheers.createdAt!!
+                                .atZone(ZoneId.of("UTC"))
+                                .withZoneSameInstant(ZoneId.of(timezone))
+
+                            GetCheersResponseItem(
+                                cheersId = cheers.id!!,
+                                nickname = cheers.member!!.nickname,
+                                profileUrl = if (cheers.member!!.profileImage != null) {
+                                    "$cloudFrontHost/${cheers.member!!.profileImage}"
+                                } else {
+                                    "$cloudFrontHost/profile/default-profile.png"
+                                },
+                                content = cheers.cheers,
+                                date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
+                                replyList = listOf()
+                            )
+                        }
+                        .toList()
+                )
+            }
+            .toList()
+
+        return GetCheersResponse(
+            totalCount = totalCount,
+            cheers = cheers
+        )
+    }
+
+    fun getLiveCount(creatorId: Long): Long? {
+        return queryFactory
+            .select(liveRoom.id.count())
+            .from(liveRoom)
+            .where(
+                liveRoom.member.id.eq(creatorId)
+                    .and(liveRoom.channelName.isNotNull)
+            )
+            .fetchFirst()
+    }
+
+    fun getLiveTime(creatorId: Long): Long {
+        val diffs = queryFactory
+            .select(
+                Projections.constructor(
+                    TimeDifferenceResult::class.java,
+                    liveRoom.beginDateTime,
+                    liveRoom.updatedAt
+                )
+            )
+            .from(liveRoom)
+            .where(
+                liveRoom.member.id.eq(creatorId)
+                    .and(liveRoom.channelName.isNotNull)
+            )
+            .fetch()
+
+        return diffs
+            .asSequence()
+            .map { Duration.between(it.beginDateTime, it.updatedAt).toSeconds() }
+            .sum() / 3600
+    }
+
+    fun getLiveContributorCount(creatorId: Long): Long? {
+        return queryFactory
+            .select(liveRoomVisit.member.count())
+            .from(liveRoomVisit)
+            .innerJoin(liveRoomVisit.room, liveRoom)
+            .where(
+                liveRoom.member.id.eq(creatorId)
+                    .and(liveRoom.channelName.isNotNull)
+            )
+            .fetchFirst()
+    }
+
+    fun getFollowerListTotalCount(creatorId: Long): Int {
+        return queryFactory.select(creatorFollowing.id)
+            .from(creatorFollowing)
+            .innerJoin(creatorFollowing.member, member)
+            .where(
+                member.isActive.isTrue
+                    .and(creatorFollowing.isActive.isTrue)
+                    .and(creatorFollowing.creator.id.eq(creatorId))
+                    .and(creatorFollowing.member.id.ne(creatorId))
+            )
+            .fetch()
+            .size
+    }
+
+    fun getFollowerList(
+        creatorId: Long,
+        offset: Long,
+        limit: Long
+    ): List<GetFollowerListDto> {
+        return queryFactory
+            .select(
+                QGetFollowerListDto(
+                    member.id,
+                    member.profileImage.prepend("/").prepend(cloudFrontHost),
+                    member.nickname,
+                    member.role
+                )
+            )
+            .from(creatorFollowing)
+            .innerJoin(creatorFollowing.member, member)
+            .where(
+                member.isActive.isTrue
+                    .and(creatorFollowing.isActive.isTrue)
+                    .and(creatorFollowing.creator.id.eq(creatorId))
+                    .and(creatorFollowing.member.id.ne(creatorId))
+            )
+            .offset(offset)
+            .limit(limit)
+            .fetch()
+    }
+
+    fun isFollow(creatorId: Long, memberId: Long): Boolean {
+        return queryFactory
+            .select(creatorFollowing.isActive)
+            .from(creatorFollowing)
+            .where(
+                creatorFollowing.creator.id.eq(creatorId)
+                    .and(creatorFollowing.member.id.eq(memberId))
+            )
+            .fetchOne() ?: false
+    }
+
+    fun getCreatorCheers(cheersId: Long): CreatorCheers? {
+        return queryFactory
+            .selectFrom(creatorCheers)
+            .where(creatorCheers.id.eq(cheersId))
+            .fetchFirst()
+    }
+
+    fun getCheers(cheersId: Long, memberId: Long): CreatorCheers? {
+        return queryFactory
+            .selectFrom(creatorCheers)
+            .where(
+                creatorCheers.id.eq(cheersId)
+                    .and(creatorCheers.member.id.eq(memberId))
+            )
+            .fetchFirst()
+    }
+
+    fun getNotice(creatorId: Long): ChannelNotice? {
+        return queryFactory
+            .selectFrom(channelNotice)
+            .where(channelNotice.member.id.eq(creatorId))
+            .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 b0802c8..a517775 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
@@ -1,10 +1,20 @@
 package kr.co.vividnext.sodalive.explorer
 
 import kr.co.vividnext.sodalive.common.SodaException
+import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse
+import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponseItem
+import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice
+import kr.co.vividnext.sodalive.explorer.profile.ChannelNoticeRepository
+import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
+import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
+import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
+import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
 import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
 import kr.co.vividnext.sodalive.member.Member
+import kr.co.vividnext.sodalive.member.MemberRole
 import kr.co.vividnext.sodalive.member.MemberService
 import org.springframework.beans.factory.annotation.Value
+import org.springframework.data.domain.Pageable
 import org.springframework.stereotype.Service
 import org.springframework.transaction.annotation.Transactional
 
@@ -13,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional
 class ExplorerService(
     private val memberService: MemberService,
     private val queryRepository: ExplorerQueryRepository,
+    private val cheersRepository: CreatorCheersRepository,
+    private val noticeRepository: ChannelNoticeRepository,
 
     @Value("\${cloud.aws.cloud-front.host}")
     private val cloudFrontHost: String
@@ -142,4 +154,156 @@ class ExplorerService(
             .map { GetRoomDetailUser(it, cloudFrontHost) }
             .toList()
     }
+
+    fun getCreatorProfile(creatorId: Long, timezone: String, member: Member): GetCreatorProfileResponse {
+        // 크리에이터(유저) 정보
+        val creatorAccount = queryRepository.getAccount(creatorId) ?: throw SodaException("없는 사용자 입니다.")
+
+        // 차단된 사용자 체크
+        val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId)
+        if (isBlocked) throw SodaException("${creatorAccount.nickname}님의 요청으로 채널 접근이 제한됩니다.")
+
+        val notificationUserIds = queryRepository.getNotificationUserIds(creatorId)
+        val isNotification = notificationUserIds.contains(member.id)
+        val notificationRecipientCount = notificationUserIds.size
+
+        // 후원랭킹
+        val userDonationRanking = queryRepository.getUserDonationRanking(
+            creatorId,
+            10,
+            withDonationCan = creatorId == member.id!!
+        )
+
+        // 추천 크리에이터
+        val similarCreatorList = queryRepository.getSimilarCreatorList(creatorId)
+
+        // 라이브
+        val liveRoomList = queryRepository.getLiveRoomList(
+            creatorId,
+            userMember = member,
+            timezone = timezone,
+            limit = 4
+        )
+
+        // 공지사항
+        val notice = queryRepository.getNoticeString(creatorId)
+
+        // 응원
+        val cheers = queryRepository.getCheersList(creatorId, timezone = timezone, offset = 0, limit = 4)
+
+        // 차단한 크리에이터 인지 체크
+        val isBlock = memberService.isBlocked(blockedMemberId = creatorId, memberId = member.id!!)
+
+        // 활동요약 (라이브 횟수, 라이브 시간, 라이브 참여자, 콘텐츠 수)
+        val liveCount = queryRepository.getLiveCount(creatorId) ?: 0
+        val liveTime = queryRepository.getLiveTime(creatorId)
+        val liveContributorCount = queryRepository.getLiveContributorCount(creatorId) ?: 0
+        val contentCount = 0L
+
+        return GetCreatorProfileResponse(
+            creator = CreatorResponse(
+                creatorId = creatorAccount.id!!,
+                profileUrl = if (creatorAccount.profileImage != null) {
+                    "$cloudFrontHost/${creatorAccount.profileImage}"
+                } else {
+                    "$cloudFrontHost/profile/default-profile.png"
+                },
+                nickname = creatorAccount.nickname,
+                tags = creatorAccount.tags.asSequence().filter { it.tag.isActive }.map { it.tag.tag }.toList(),
+                introduce = creatorAccount.introduce,
+                instagramUrl = creatorAccount.instagramUrl,
+                youtubeUrl = creatorAccount.youtubeUrl,
+                websiteUrl = creatorAccount.websiteUrl,
+                blogUrl = creatorAccount.blogUrl,
+                isNotification = isNotification,
+                notificationRecipientCount = notificationRecipientCount
+            ),
+            userDonationRanking = userDonationRanking,
+            similarCreatorList = similarCreatorList,
+            liveRoomList = liveRoomList,
+            notice = notice,
+            cheers = cheers,
+            activitySummary = GetCreatorActivitySummary(
+                liveCount = liveCount,
+                liveTime = liveTime,
+                liveContributorCount = liveContributorCount,
+                contentCount = contentCount
+            ),
+            isBlock = isBlock
+        )
+    }
+
+    fun getFollowerList(
+        creatorId: Long,
+        member: Member,
+        pageable: Pageable
+    ): GetFollowerListResponse {
+        val totalCount = queryRepository
+            .getFollowerListTotalCount(creatorId)
+
+        val followerList = queryRepository.getFollowerList(creatorId, pageable.offset, pageable.pageSize.toLong())
+            .asSequence()
+            .map {
+                val isFollow = if (it.role == MemberRole.CREATOR) {
+                    queryRepository.isFollow(creatorId = it.userId, memberId = member.id!!)
+                } else {
+                    null
+                }
+
+                GetFollowerListResponseItem(
+                    userId = it.userId,
+                    profileImage = it.profileImage,
+                    nickname = it.nickname,
+                    isFollow = isFollow
+                )
+            }
+            .toList()
+
+        return GetFollowerListResponse(totalCount = totalCount, items = followerList)
+    }
+
+    @Transactional
+    fun writeCheers(request: PostWriteCheersRequest, member: Member) {
+        val creator = queryRepository.getAccount(request.creatorId) ?: throw SodaException("없는 사용자 입니다.")
+
+        val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId)
+        if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.")
+
+        val cheers = CreatorCheers(cheers = request.content)
+        cheers.member = member
+        cheers.creator = creator
+
+        val parent = if (request.parentId != null) {
+            queryRepository.getCreatorCheers(request.parentId)
+        } else {
+            null
+        }
+
+        if (parent != null) {
+            cheers.parent = parent
+        }
+
+        cheersRepository.save(cheers)
+    }
+
+    @Transactional
+    fun modifyCheers(request: PutWriteCheersRequest, member: Member) {
+        val cheers = queryRepository.getCheers(request.cheersId, member.id!!)
+            ?: throw SodaException("잘못된 요청입니다.")
+
+        cheers.cheers = request.content
+    }
+
+    @Transactional
+    fun saveNotice(member: Member, notice: String) {
+        var channelNotice = queryRepository.getNotice(creatorId = member.id!!)
+
+        if (channelNotice == null) {
+            channelNotice = ChannelNotice(notice)
+            channelNotice.member = member
+            noticeRepository.save(channelNotice)
+        } else {
+            channelNotice.notice = notice
+        }
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt
new file mode 100644
index 0000000..142f1b2
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt
@@ -0,0 +1,15 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class GetCheersResponse(
+    val totalCount: Int,
+    val cheers: List<GetCheersResponseItem>
+)
+
+data class GetCheersResponseItem(
+    val cheersId: Long,
+    val nickname: String,
+    val profileUrl: String,
+    val content: String,
+    val date: String,
+    val replyList: List<GetCheersResponseItem>
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt
new file mode 100644
index 0000000..3a1b5f6
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt
@@ -0,0 +1,19 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class GetCreatorProfileResponse(
+    val creator: CreatorResponse,
+    val userDonationRanking: List<UserDonationRankingResponse>,
+    val similarCreatorList: List<SimilarCreatorResponse>,
+    val liveRoomList: List<LiveRoomResponse>,
+    val notice: String,
+    val cheers: GetCheersResponse,
+    val activitySummary: GetCreatorActivitySummary,
+    val isBlock: Boolean
+)
+
+data class GetCreatorActivitySummary(
+    val liveCount: Long,
+    val liveTime: Long,
+    val liveContributorCount: Long,
+    val contentCount: Long
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt
new file mode 100644
index 0000000..d21bd6b
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/LiveRoomResponse.kt
@@ -0,0 +1,22 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class GetLiveRoomAllResponse(
+    val totalCount: Int,
+    val liveRoomList: List<LiveRoomResponse>
+)
+
+data class LiveRoomResponse(
+    val roomId: Long,
+    val title: String,
+    val content: String,
+    val isPaid: Boolean,
+    val beginDateTime: String,
+    val coverImageUrl: String,
+    val isAdult: Boolean,
+    val price: Int,
+    val channelName: String?,
+    val managerNickname: String,
+    val isReservation: Boolean,
+    val isActive: Boolean,
+    val isPrivateRoom: Boolean
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt
new file mode 100644
index 0000000..710baf7
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/SimilarCreatorResponse.kt
@@ -0,0 +1,8 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class SimilarCreatorResponse(
+    val userId: Long,
+    val nickname: String,
+    val profileImage: String,
+    val tags: List<String>
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt
new file mode 100644
index 0000000..d68947d
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/UserDonationRankingResponse.kt
@@ -0,0 +1,16 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class GetDonationAllResponse(
+    val accumulatedCansToday: Int,
+    val accumulatedCansLastWeek: Int,
+    val accumulatedCansThisMonth: Int,
+    val totalCount: Int,
+    val userDonationRanking: List<UserDonationRankingResponse>
+)
+
+data class UserDonationRankingResponse(
+    val userId: Long,
+    val nickname: String,
+    val profileImage: String,
+    val donationCan: Int
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt
new file mode 100644
index 0000000..9f16c0e
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListDto.kt
@@ -0,0 +1,11 @@
+package kr.co.vividnext.sodalive.explorer.follower
+
+import com.querydsl.core.annotations.QueryProjection
+import kr.co.vividnext.sodalive.member.MemberRole
+
+data class GetFollowerListDto @QueryProjection constructor(
+    val userId: Long,
+    val profileImage: String,
+    val nickname: String,
+    val role: MemberRole
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt
new file mode 100644
index 0000000..d7ef0ec
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/follower/GetFollowerListResponse.kt
@@ -0,0 +1,13 @@
+package kr.co.vividnext.sodalive.explorer.follower
+
+data class GetFollowerListResponse(
+    val totalCount: Int,
+    val items: List<GetFollowerListResponseItem>
+)
+
+data class GetFollowerListResponseItem(
+    val userId: Long,
+    val profileImage: String,
+    val nickname: String,
+    val isFollow: Boolean?
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt
new file mode 100644
index 0000000..33993b9
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNotice.kt
@@ -0,0 +1,19 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+import kr.co.vividnext.sodalive.common.BaseEntity
+import kr.co.vividnext.sodalive.member.Member
+import javax.persistence.Column
+import javax.persistence.Entity
+import javax.persistence.FetchType
+import javax.persistence.JoinColumn
+import javax.persistence.OneToOne
+
+@Entity
+data class ChannelNotice(
+    @Column(columnDefinition = "TEXT")
+    var notice: String
+) : BaseEntity() {
+    @OneToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id", nullable = false)
+    var member: Member? = null
+}
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt
new file mode 100644
index 0000000..d506bb4
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/ChannelNoticeRepository.kt
@@ -0,0 +1,7 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+@Repository
+interface ChannelNoticeRepository : JpaRepository<ChannelNotice, Long>
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt
new file mode 100644
index 0000000..56aee2e
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt
@@ -0,0 +1,36 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+import kr.co.vividnext.sodalive.common.BaseEntity
+import kr.co.vividnext.sodalive.member.Member
+import javax.persistence.Column
+import javax.persistence.Entity
+import javax.persistence.FetchType
+import javax.persistence.JoinColumn
+import javax.persistence.ManyToOne
+import javax.persistence.OneToMany
+
+@Entity
+data class CreatorCheers(
+    @Column(columnDefinition = "TEXT", nullable = false)
+    var cheers: String,
+    val isActive: Boolean = true
+) : BaseEntity() {
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "parent_id", nullable = true)
+    var parent: CreatorCheers? = null
+        set(value) {
+            value?.children?.add(this)
+            field = value
+        }
+
+    @OneToMany(mappedBy = "parent")
+    var children: MutableList<CreatorCheers> = mutableListOf()
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id", nullable = false)
+    var member: Member? = null
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "creator_id", nullable = false)
+    var creator: Member? = null
+}
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt
new file mode 100644
index 0000000..831264a
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheersRepository.kt
@@ -0,0 +1,7 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+@Repository
+interface CreatorCheersRepository : JpaRepository<CreatorCheers, Long>
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt
new file mode 100644
index 0000000..c937b9b
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostCreatorNoticeRequest.kt
@@ -0,0 +1,3 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+data class PostCreatorNoticeRequest(val notice: String)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt
new file mode 100644
index 0000000..9d75c7f
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt
@@ -0,0 +1,7 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+data class PostWriteCheersRequest(
+    val parentId: Long? = null,
+    val creatorId: Long,
+    val content: String
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt
new file mode 100644
index 0000000..ebb7209
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PutWriteCheersRequest.kt
@@ -0,0 +1,6 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+data class PutWriteCheersRequest(
+    val cheersId: Long,
+    val content: String
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt
new file mode 100644
index 0000000..c1e62f9
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/TimeDifferenceResult.kt
@@ -0,0 +1,8 @@
+package kr.co.vividnext.sodalive.explorer.profile
+
+import java.time.LocalDateTime
+
+data class TimeDifferenceResult(
+    val beginDateTime: LocalDateTime,
+    val updatedAt: LocalDateTime
+)