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 { + 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 { + 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 { + 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): List { + 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 { + 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() + + // 인기 급상승중 (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 { + 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) + +data class GetExplorerSectionResponse( + val title: String, + val coloredTitle: String?, + val color: String?, + val creators: List +) + +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 = 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 = mutableListOf() + @OneToMany(mappedBy = "member", cascade = [CascadeType.ALL], orphanRemoval = true) + var tags: MutableList = mutableListOf() + + @OneToMany(mappedBy = "creator") + var follower: MutableList = 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, 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()