From 049e1c41dee81c6c6f50d0d460eb64dcb6d2d909 Mon Sep 17 00:00:00 2001
From: Klaus <klaus@vividnext.co.kr>
Date: Tue, 1 Aug 2023 10:23:49 +0900
Subject: [PATCH] =?UTF-8?q?=ED=83=90=EC=83=89=20=EB=A9=94=EC=9D=B8=20-=20A?=
 =?UTF-8?q?PI=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../sodalive/explorer/ExplorerController.kt   |  32 ++++
 .../explorer/ExplorerQueryRepository.kt       |  73 +++++++++
 .../sodalive/explorer/ExplorerService.kt      | 145 ++++++++++++++++++
 .../sodalive/explorer/GetExplorerResponse.kt  |  17 ++
 .../explorer/section/ExplorerSection.kt       |  43 ++++++
 .../sodalive/live/room/LiveRoomService.kt     |   6 +-
 .../kr/co/vividnext/sodalive/member/Member.kt |   8 +
 .../sodalive/member/MemberService.kt          |   2 +
 .../member/block/BlockMemberRepository.kt     |  15 ++
 .../sodalive/member/tag/CreatorTag.kt         |  17 ++
 .../sodalive/member/tag/MemberCreatorTag.kt   |  19 +++
 11 files changed, 376 insertions(+), 1 deletion(-)
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt
 create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt

diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt
new file mode 100644
index 0000000..574e2ca
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt
@@ -0,0 +1,32 @@
+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.member.Member
+import org.springframework.security.core.annotation.AuthenticationPrincipal
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/explorer")
+class ExplorerController(private val service: ExplorerService) {
+    @GetMapping
+    fun getExplorer(
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+
+        ApiResponse.ok(service.getExplorer(member))
+    }
+
+    @GetMapping("/search/channel")
+    fun getSearchChannel(
+        @RequestParam channel: String,
+        @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
+    ) = run {
+        if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
+        ApiResponse.ok(service.getSearchChannel(channel, member))
+    }
+}
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 19a869f..6a13f67 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt
@@ -1,13 +1,22 @@
 package kr.co.vividnext.sodalive.explorer
 
+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.explorer.section.ExplorerSection
+import kr.co.vividnext.sodalive.explorer.section.QExplorerSection.explorerSection
 import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
+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.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.LocalDateTime
 
 @Repository
 class ExplorerQueryRepository(
@@ -68,4 +77,68 @@ class ExplorerQueryRepository(
                 )
             }
     }
+
+    fun getSubscriberGrowthRankingCreators(limit: Long): List<Member> {
+        return queryFactory
+            .selectFrom(member)
+            .join(member.follower, creatorFollowing)
+            .where(
+                member.role.eq(MemberRole.CREATOR)
+                    .and(creatorFollowing.createdAt.goe(LocalDateTime.now().minusMonths(1)))
+                    .and(creatorFollowing.isActive.isTrue)
+            )
+            .groupBy(member.id)
+            .orderBy(member.follower.size().desc())
+            .limit(limit)
+            .fetch()
+    }
+
+    fun getNewCreators(): List<Member> {
+        return queryFactory
+            .selectFrom(member)
+            .where(
+                member.role.eq(MemberRole.CREATOR)
+                    .and(member.createdAt.goe(LocalDateTime.now().minusDays(30)))
+            )
+            .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc())
+            .limit(20)
+            .fetch()
+    }
+
+    fun getExplorerSectionData(isAdult: Boolean): List<ExplorerSection> {
+        var explorerSectionCondition = explorerSection.isActive.eq(true)
+        if (!isAdult) {
+            explorerSectionCondition = explorerSectionCondition.and(explorerSection.isAdult.isFalse)
+        }
+
+        return queryFactory
+            .selectFrom(explorerSection)
+            .where(explorerSectionCondition)
+            .orderBy(explorerSection.orders.asc())
+            .fetch()
+    }
+
+    fun findMemberByTag(tags: List<String>): List<Member> {
+        return queryFactory
+            .selectFrom(member)
+            .leftJoin(member.tags, memberCreatorTag)
+            .join(memberCreatorTag.tag, creatorTag)
+            .where(
+                member.role.eq(MemberRole.CREATOR)
+                    .and(creatorTag.tag.`in`(tags))
+            )
+            .fetch()
+            .distinct()
+    }
+
+    fun getSearchChannel(channel: String, accountId: Long): List<Member> {
+        return queryFactory.selectFrom(member)
+            .where(
+                member.nickname.containsIgnoreCase(channel)
+                    .and(member.isActive.isTrue)
+                    .and(member.id.ne(accountId))
+                    .and(member.role.eq(MemberRole.CREATOR))
+            )
+            .fetch()
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
new file mode 100644
index 0000000..b0802c8
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt
@@ -0,0 +1,145 @@
+package kr.co.vividnext.sodalive.explorer
+
+import kr.co.vividnext.sodalive.common.SodaException
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
+import kr.co.vividnext.sodalive.member.Member
+import kr.co.vividnext.sodalive.member.MemberService
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+@Transactional(readOnly = true)
+class ExplorerService(
+    private val memberService: MemberService,
+    private val queryRepository: ExplorerQueryRepository,
+
+    @Value("\${cloud.aws.cloud-front.host}")
+    private val cloudFrontHost: String
+) {
+    fun getExplorer(member: Member, growthRankingCreatorsLimit: Long = 20): GetExplorerResponse {
+        val sections = mutableListOf<GetExplorerSectionResponse>()
+
+        // 인기 급상승중 (subscriberGrowthRankingCreators)
+        val growthRankingCreators = queryRepository
+            .getSubscriberGrowthRankingCreators(limit = growthRankingCreatorsLimit)
+            .asSequence()
+            .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) }
+            .map {
+                GetExplorerSectionCreatorResponse(
+                    id = it.id!!,
+                    nickname = it.nickname,
+                    tags = it.tags
+                        .asSequence()
+                        .filter { tag -> tag.tag.isActive }
+                        .map { tag -> tag.tag.tag }
+                        .joinToString(" ") { tag -> "#$tag" },
+                    profileImageUrl = if (it.profileImage != null) {
+                        "$cloudFrontHost/${it.profileImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    }
+                )
+            }
+            .toList()
+
+        val growthRankingSection = GetExplorerSectionResponse(
+            title = "인기 급상승중",
+            coloredTitle = "인기",
+            color = "FF5C49",
+            creators = growthRankingCreators
+        )
+        sections.add(growthRankingSection)
+
+        // 새로 시작 (newCreators)
+        val newCreators = queryRepository
+            .getNewCreators()
+            .asSequence()
+            .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) }
+            .map {
+                GetExplorerSectionCreatorResponse(
+                    id = it.id!!,
+                    nickname = it.nickname,
+                    tags = it.tags
+                        .asSequence()
+                        .filter { tag -> tag.tag.isActive }
+                        .map { tag -> tag.tag.tag }
+                        .joinToString(" ") { tag -> "#$tag" },
+                    profileImageUrl = if (it.profileImage != null) {
+                        "$cloudFrontHost/${it.profileImage}"
+                    } else {
+                        "$cloudFrontHost/profile/default-profile.png"
+                    }
+                )
+            }
+            .toList()
+
+        val newCreatorsSection = GetExplorerSectionResponse(
+            title = "새로 시작",
+            coloredTitle = "새로",
+            color = "5FD28F",
+            creators = newCreators
+        )
+        sections.add(newCreatorsSection)
+
+        // 관리자에서 설정한 타이틀과 크리에이터
+        sections.addAll(
+            queryRepository
+                .getExplorerSectionData(isAdult = member.auth != null)
+                .asSequence()
+                .map {
+                    val tags = it.tags.asSequence().map { explorerSectionTag -> explorerSectionTag.tag!!.tag }.toList()
+                    val creators = queryRepository.findMemberByTag(tags)
+                        .asSequence()
+                        .filter { creator ->
+                            !memberService.isBlocked(
+                                blockedMemberId = member.id!!,
+                                memberId = creator.id!!
+                            )
+                        }
+                        .toList()
+
+                    GetExplorerSectionResponse(
+                        it.title,
+                        it.coloredTitle,
+                        it.color,
+                        creators = creators
+                            .asSequence()
+                            .map { account ->
+                                GetExplorerSectionCreatorResponse(
+                                    id = account.id!!,
+                                    nickname = account.nickname,
+                                    tags = account.tags
+                                        .asSequence()
+                                        .filter { counselorTag -> counselorTag.tag.isActive }
+                                        .toList()
+                                        .joinToString(" ") { counselorTag ->
+                                            "#${counselorTag.tag.tag}"
+                                        },
+                                    profileImageUrl = if (account.profileImage != null) {
+                                        "$cloudFrontHost/${account.profileImage}"
+                                    } else {
+                                        "$cloudFrontHost/profile/default-profile.png"
+                                    }
+                                )
+                            }.toList()
+                    )
+                }
+                .toList()
+        )
+
+        return GetExplorerResponse(sections = sections)
+    }
+
+    fun getSearchChannel(channel: String, member: Member): List<GetRoomDetailUser> {
+        if (channel.length < 2) {
+            throw SodaException("두 글자 이상 입력 하셔야 합니다.")
+        }
+
+        return queryRepository.getSearchChannel(channel, member.id!!)
+            .asSequence()
+            .filter { !memberService.isBlocked(blockedMemberId = member.id!!, memberId = it.id!!) }
+            .map { GetRoomDetailUser(it, cloudFrontHost) }
+            .toList()
+    }
+}
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt
new file mode 100644
index 0000000..2d7f4e8
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt
@@ -0,0 +1,17 @@
+package kr.co.vividnext.sodalive.explorer
+
+data class GetExplorerResponse(val sections: List<GetExplorerSectionResponse>)
+
+data class GetExplorerSectionResponse(
+    val title: String,
+    val coloredTitle: String?,
+    val color: String?,
+    val creators: List<GetExplorerSectionCreatorResponse>
+)
+
+data class GetExplorerSectionCreatorResponse(
+    val id: Long,
+    val nickname: String,
+    val tags: String,
+    val profileImageUrl: String
+)
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt
new file mode 100644
index 0000000..503d78f
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/section/ExplorerSection.kt
@@ -0,0 +1,43 @@
+package kr.co.vividnext.sodalive.explorer.section
+
+import kr.co.vividnext.sodalive.common.BaseEntity
+import kr.co.vividnext.sodalive.member.tag.CreatorTag
+import javax.persistence.CascadeType
+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 ExplorerSection(
+    var title: String,
+    var isAdult: Boolean,
+    @Column(nullable = false)
+    var orders: Int = 1
+) : BaseEntity() {
+
+    @Column(nullable = true)
+    var coloredTitle: String? = null
+
+    @Column(nullable = true)
+    var color: String? = null
+
+    @Column(nullable = false)
+    var isActive: Boolean = true
+
+    @OneToMany(mappedBy = "explorerSection", cascade = [CascadeType.ALL], orphanRemoval = true)
+    var tags: MutableList<ExplorerSectionCreatorTag> = mutableListOf()
+}
+
+@Entity
+class ExplorerSectionCreatorTag : BaseEntity() {
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "explorer_section_id", nullable = false)
+    var explorerSection: ExplorerSection? = null
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "creator_tag_id", nullable = false)
+    var tag: CreatorTag? = null
+}
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt
index 3938006..711371b 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt
@@ -696,7 +696,11 @@ class LiveRoomService(
             websiteUrl = user.websiteUrl,
             blogUrl = user.blogUrl,
             introduce = user.introduce,
-            tags = "",
+            tags = user.tags
+                .asSequence()
+                .filter { it.tag.isActive }
+                .map { it.tag.tag }
+                .joinToString(" ") { tag -> "#$tag" },
             isSpeaker = isSpeaker,
             isManager = isManager,
             isFollowing = isFollowing,
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt
index b8badb2..9179ddf 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt
@@ -2,8 +2,10 @@ package kr.co.vividnext.sodalive.member
 
 import kr.co.vividnext.sodalive.common.BaseEntity
 import kr.co.vividnext.sodalive.member.auth.Auth
+import kr.co.vividnext.sodalive.member.following.CreatorFollowing
 import kr.co.vividnext.sodalive.member.notification.MemberNotification
 import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
+import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag
 import javax.persistence.CascadeType
 import javax.persistence.Column
 import javax.persistence.Entity
@@ -33,6 +35,12 @@ data class Member(
     @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL])
     val stipulationAgrees: MutableList<StipulationAgree> = mutableListOf()
 
+    @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL], orphanRemoval = true)
+    var tags: MutableList<MemberCreatorTag> = mutableListOf()
+
+    @OneToMany(mappedBy = "creator")
+    var follower: MutableList<CreatorFollowing> = mutableListOf()
+
     @OneToOne(mappedBy = "member", fetch = FetchType.LAZY)
     var notification: MemberNotification? = null
 
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
index 42f34b5..d2cd91e 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt
@@ -332,4 +332,6 @@ class MemberService(
             blockMember.isActive = true
         }
     }
+
+    fun isBlocked(blockedMemberId: Long, memberId: Long) = blockMemberRepository.isBlocked(blockedMemberId, memberId)
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt
index c9a42d9..29eb50e 100644
--- a/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMemberRepository.kt
@@ -10,6 +10,7 @@ interface BlockMemberRepository : JpaRepository<BlockMember, Long>, BlockMemberQ
 
 interface BlockMemberQueryRepository {
     fun getBlockAccount(blockedMemberId: Long, memberId: Long): BlockMember?
+    fun isBlocked(blockedMemberId: Long, memberId: Long): Boolean
 }
 
 @Repository
@@ -24,4 +25,18 @@ class BlockMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
             .orderBy(blockMember.id.desc())
             .fetchFirst()
     }
+
+    override fun isBlocked(blockedMemberId: Long, memberId: Long): Boolean {
+        val blockedAccount = queryFactory
+            .select(blockMember.id)
+            .from(blockMember)
+            .where(
+                blockMember.memberId.eq(memberId)
+                    .and(blockMember.blockedMemberId.eq(blockedMemberId))
+                    .and(blockMember.isActive.isTrue)
+            )
+            .fetchOne()
+
+        return blockedAccount != null
+    }
 }
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt
new file mode 100644
index 0000000..99e3dfc
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/CreatorTag.kt
@@ -0,0 +1,17 @@
+package kr.co.vividnext.sodalive.member.tag
+
+import kr.co.vividnext.sodalive.common.BaseEntity
+import javax.persistence.Column
+import javax.persistence.Entity
+
+@Entity
+data class CreatorTag(
+    @Column(unique = true, nullable = false)
+    var tag: String,
+    @Column(nullable = true)
+    var image: String? = null,
+    @Column(nullable = false)
+    var isActive: Boolean = true,
+    @Column(nullable = false)
+    var orders: Int = 1
+) : BaseEntity()
diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt
new file mode 100644
index 0000000..ea0ecd3
--- /dev/null
+++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberCreatorTag.kt
@@ -0,0 +1,19 @@
+package kr.co.vividnext.sodalive.member.tag
+
+import kr.co.vividnext.sodalive.common.BaseEntity
+import kr.co.vividnext.sodalive.member.Member
+import javax.persistence.Entity
+import javax.persistence.FetchType
+import javax.persistence.JoinColumn
+import javax.persistence.ManyToOne
+
+@Entity
+data class MemberCreatorTag(
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "member_id", nullable = false)
+    var member: Member,
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "creator_tag_id", nullable = false)
+    var tag: CreatorTag
+) : BaseEntity()