diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/keyword/SeriesKeyword.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/keyword/SeriesKeyword.kt index d36e591..fe1d77c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/keyword/SeriesKeyword.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/keyword/SeriesKeyword.kt @@ -11,6 +11,7 @@ import javax.persistence.Id import javax.persistence.JoinColumn import javax.persistence.ManyToOne import javax.persistence.PrePersist +import javax.persistence.PreUpdate @Entity class SeriesKeyword { @@ -19,6 +20,7 @@ class SeriesKeyword { var id: Long? = null var createdAt: LocalDateTime? = null + var updatedAt: LocalDateTime? = null @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id", nullable = false) @@ -28,8 +30,16 @@ class SeriesKeyword { @JoinColumn(name = "keyword_id", nullable = false) var keyword: HashTag? = null + var isActive: Boolean = true + @PrePersist fun prePersist() { createdAt = LocalDateTime.now() + updatedAt = LocalDateTime.now() + } + + @PreUpdate + fun preUpdate() { + updatedAt = LocalDateTime.now() } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingController.kt index 704bb12..fcfaf9a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/marketing/AdTrackingController.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.marketing +import kr.co.vividnext.sodalive.common.ApiResponse import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -12,12 +13,14 @@ class AdTrackingController(private val service: AdTrackingService) { fun trackingAppLaunch( @RequestBody request: AdTrackingAppLaunchRequest ) = run { - service.saveTrackingHistory( - pid = request.pid, - type = AdTrackingHistoryType.APP_LAUNCH, - memberId = 0, - price = null, - locale = null + ApiResponse.ok( + service.saveTrackingHistory( + pid = request.pid, + type = AdTrackingHistoryType.APP_LAUNCH, + memberId = 0, + price = null, + locale = null + ) ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt new file mode 100644 index 0000000..4fb0ab4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt @@ -0,0 +1,97 @@ +package kr.co.vividnext.sodalive.search + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +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("/search") +class SearchController(private val service: SearchService) { + @GetMapping + fun searchUnified( + @RequestParam keyword: String, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok( + service.searchUnified( + keyword, + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member + ) + ) + } + + @GetMapping("/creators") + fun searchCreatorList( + @RequestParam keyword: String, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok( + service.searchCreatorList( + keyword, + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/contents") + fun searchContentList( + @RequestParam keyword: String, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok( + service.searchContentList( + keyword, + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/series") + fun searchSeriesList( + @RequestParam keyword: String, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok( + service.searchSeriesList( + keyword, + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt new file mode 100644 index 0000000..8231b27 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchRepository.kt @@ -0,0 +1,354 @@ +package kr.co.vividnext.sodalive.search + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.content.series.genre.QSeriesGenre.seriesGenre +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.hashtag.QAudioContentHashTag.audioContentHashTag +import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag +import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series +import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent +import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +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 + +@Repository +class SearchRepository( + private val queryFactory: JPAQueryFactory, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + fun searchCreatorTotalCount( + keyword: String, + memberId: Long + ): Int { + val blockMemberCondition = blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + + return queryFactory + .select(member.id) + .from(member) + .leftJoin(memberCreatorTag).on(memberCreatorTag.member.id.eq(member.id)) + .leftJoin(creatorTag).on(memberCreatorTag.tag.id.eq(creatorTag.id)) + .leftJoin(blockMember).on(blockMemberCondition) + .where( + member.isActive.isTrue + .and(member.role.eq(MemberRole.CREATOR)) + .and(creatorTag.isActive.isTrue.or(creatorTag.id.isNull)) + .and( + member.nickname.containsIgnoreCase(keyword) + .or(member.introduce.containsIgnoreCase(keyword)) + .or(creatorTag.tag.containsIgnoreCase(keyword)) + ) + .and(blockMember.id.isNull) + ) + .groupBy(member.id) + .fetch() + .size + } + + fun searchCreatorList( + keyword: String, + memberId: Long, + offset: Long, + limit: Long + ): List { + val blockMemberCondition = blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + + return queryFactory + .select( + QSearchResponseItem( + member.id, + member.profileImage.prepend("/").prepend(imageHost), + member.nickname, + member.nickname + ) + ) + .from(member) + .leftJoin(memberCreatorTag).on(memberCreatorTag.member.id.eq(member.id)) + .leftJoin(creatorTag).on(memberCreatorTag.tag.id.eq(creatorTag.id)) + .leftJoin(blockMember).on(blockMemberCondition) + .where( + member.isActive.isTrue + .and(member.role.eq(MemberRole.CREATOR)) + .and(creatorTag.isActive.isTrue.or(creatorTag.id.isNull)) + .and( + member.nickname.containsIgnoreCase(keyword) + .or(member.introduce.containsIgnoreCase(keyword)) + .or(creatorTag.tag.containsIgnoreCase(keyword)) + ) + .and(blockMember.id.isNull) + ) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + fun searchContentTotalCount( + keyword: String, + memberId: Long, + isAdult: Boolean, + contentType: ContentType + ): Int { + val blockMemberCondition = blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + + var where = audioContent.member.isActive.isTrue + .and(audioContent.member.role.eq(MemberRole.CREATOR)) + .and(audioContent.isActive.isTrue) + .and(audioContent.limited.isNull) + .and(audioContent.duration.isNotNull) + .and( + audioContent.title.containsIgnoreCase(keyword) + .or(audioContent.detail.containsIgnoreCase(keyword)) + .or(audioContent.theme.theme.containsIgnoreCase(keyword)) + .or(hashTag.tag.containsIgnoreCase(keyword).and(audioContentHashTag.isActive.isTrue)) + + ) + .and(blockMember.id.isNull) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } else { + if (contentType != ContentType.ALL) { + where = where.and( + audioContent.member.isNull.or( + audioContent.member.auth.gender.eq( + if (contentType == ContentType.MALE) { + 0 + } else { + 1 + } + ) + ) + ) + } + } + + return queryFactory + .select(audioContent.id) + .from(audioContent) + .innerJoin(audioContent.member, member) + .leftJoin(audioContentTheme).on(audioContentTheme.id.eq(audioContent.theme.id)) + .leftJoin(audioContentHashTag).on(audioContentHashTag.audioContent.id.eq(audioContent.id)) + .leftJoin(hashTag).on(audioContentHashTag.hashTag.id.eq(hashTag.id)) + .leftJoin(blockMember).on(blockMemberCondition) + .where(where) + .groupBy(audioContent.id) + .fetch() + .size + } + + fun searchContentList( + keyword: String, + memberId: Long, + isAdult: Boolean, + contentType: ContentType, + offset: Long, + limit: Long + ): List { + val blockMemberCondition = blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + + var where = audioContent.member.isActive.isTrue + .and(audioContent.member.role.eq(MemberRole.CREATOR)) + .and(audioContent.isActive.isTrue) + .and(audioContent.limited.isNull) + .and(audioContent.duration.isNotNull) + .and( + audioContent.title.containsIgnoreCase(keyword) + .or(audioContent.detail.containsIgnoreCase(keyword)) + .or(audioContent.theme.theme.containsIgnoreCase(keyword)) + .or(hashTag.tag.containsIgnoreCase(keyword).and(audioContentHashTag.isActive.isTrue)) + + ) + .and(blockMember.id.isNull) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } else { + if (contentType != ContentType.ALL) { + where = where.and( + audioContent.member.isNull.or( + audioContent.member.auth.gender.eq( + if (contentType == ContentType.MALE) { + 0 + } else { + 1 + } + ) + ) + ) + } + } + + return queryFactory + .select( + QSearchResponseItem( + audioContent.id, + audioContent.coverImage.prepend("/").prepend(imageHost), + audioContent.title, + audioContent.member.nickname + ) + ) + .from(audioContent) + .innerJoin(audioContent.member, member) + .leftJoin(audioContentTheme).on(audioContentTheme.id.eq(audioContent.theme.id)) + .leftJoin(audioContentHashTag).on(audioContentHashTag.audioContent.id.eq(audioContent.id)) + .leftJoin(hashTag).on(audioContentHashTag.hashTag.id.eq(hashTag.id)) + .leftJoin(blockMember).on(blockMemberCondition) + .where(where) + .groupBy(audioContent.id) + .orderBy(audioContent.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + fun searchSeriesTotalCount( + keyword: String, + memberId: Long, + isAdult: Boolean, + contentType: ContentType + ): Int { + val blockMemberCondition = blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + + var where = series.isActive.isTrue + .and(audioContent.isActive.isTrue) + .and(member.isActive.isTrue) + .and(member.isNotNull) + .and(member.role.eq(MemberRole.CREATOR)) + .and(audioContent.duration.isNotNull) + .and(audioContent.limited.isNull) + .and( + series.title.containsIgnoreCase(keyword) + .or(series.introduction.containsIgnoreCase(keyword)) + .or(seriesGenre.genre.containsIgnoreCase(keyword)) + .or(hashTag.tag.containsIgnoreCase(keyword).and(seriesKeyword.isActive.isTrue)) + ) + .and(blockMember.id.isNull) + + if (!isAdult) { + where = where + .and(series.isAdult.isFalse) + .and(audioContent.isAdult.isFalse) + } else { + if (contentType != ContentType.ALL) { + where = where.and( + audioContent.member.isNull.or( + audioContent.member.auth.gender.eq( + if (contentType == ContentType.MALE) { + 0 + } else { + 1 + } + ) + ) + ) + } + } + + return queryFactory + .select(series.id) + .from(seriesContent) + .innerJoin(seriesContent.series, series) + .innerJoin(seriesContent.content, audioContent) + .innerJoin(series.member, member) + .leftJoin(seriesGenre).on(seriesGenre.id.eq(series.genre.id)) + .leftJoin(seriesKeyword).on(seriesKeyword.series.id.eq(series.id)) + .leftJoin(hashTag).on(hashTag.id.eq(seriesKeyword.keyword.id)) + .leftJoin(blockMember).on(blockMemberCondition) + .where(where) + .groupBy(series.id, series.orders) + .fetch() + .size + } + + fun searchSeriesList( + keyword: String, + memberId: Long, + isAdult: Boolean, + contentType: ContentType, + offset: Long, + limit: Long + ): List { + val blockMemberCondition = blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + + var where = series.isActive.isTrue + .and(audioContent.isActive.isTrue) + .and(member.isActive.isTrue) + .and(member.isNotNull) + .and(member.role.eq(MemberRole.CREATOR)) + .and(audioContent.duration.isNotNull) + .and(audioContent.limited.isNull) + .and( + series.title.containsIgnoreCase(keyword) + .or(series.introduction.containsIgnoreCase(keyword)) + .or(seriesGenre.genre.containsIgnoreCase(keyword)) + .or(hashTag.tag.containsIgnoreCase(keyword).and(seriesKeyword.isActive.isTrue)) + ) + .and(blockMember.id.isNull) + + if (!isAdult) { + where = where + .and(series.isAdult.isFalse) + .and(audioContent.isAdult.isFalse) + } else { + if (contentType != ContentType.ALL) { + where = where.and( + audioContent.member.isNull.or( + audioContent.member.auth.gender.eq( + if (contentType == ContentType.MALE) { + 0 + } else { + 1 + } + ) + ) + ) + } + } + + return queryFactory + .select( + QSearchResponseItem( + series.id, + series.coverImage.prepend("/").prepend(imageHost), + series.title, + series.member.nickname + ) + ) + .from(seriesContent) + .innerJoin(seriesContent.series, series) + .innerJoin(seriesContent.content, audioContent) + .innerJoin(series.member, member) + .leftJoin(seriesGenre).on(seriesGenre.id.eq(series.genre.id)) + .leftJoin(seriesKeyword).on(seriesKeyword.series.id.eq(series.id)) + .leftJoin(hashTag).on(hashTag.id.eq(seriesKeyword.keyword.id)) + .leftJoin(blockMember).on(blockMemberCondition) + .where(where) + .groupBy(series.id, series.orders) + .orderBy(series.orders.asc()) + .offset(offset) + .limit(limit) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchResponse.kt new file mode 100644 index 0000000..575136e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchResponse.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.search + +import com.querydsl.core.annotations.QueryProjection + +data class SearchUnifiedResponse( + val creatorList: List, + val contentList: List, + val seriesList: List +) + +data class SearchResponse( + val totalCount: Int, + val items: List +) + +data class SearchResponseItem @QueryProjection constructor( + val id: Long, + val imageUrl: String, + val title: String, + val nickname: String +) { + var type: SearchResponseType = SearchResponseType.CREATOR +} + +enum class SearchResponseType { + CREATOR, CONTENT, SERIES +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt new file mode 100644 index 0000000..811ea59 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchService.kt @@ -0,0 +1,149 @@ +package kr.co.vividnext.sodalive.search + +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.member.Member +import org.springframework.stereotype.Service + +@Service +class SearchService(private val repository: SearchRepository) { + fun searchUnified( + keyword: String, + isAdultContentVisible: Boolean, + contentType: ContentType, + member: Member + ): SearchUnifiedResponse { + val isAdult = member.auth != null && isAdultContentVisible + + val creatorList = repository.searchCreatorList( + keyword = keyword, + memberId = member.id!!, + offset = 0, + limit = 3 + ) + .map { + it.type = SearchResponseType.CREATOR + it + } + + val contentList = repository.searchContentList( + keyword = keyword, + memberId = member.id!!, + isAdult = isAdult, + contentType = contentType, + offset = 0, + limit = 3 + ) + .map { + it.type = SearchResponseType.CONTENT + it + } + + val seriesList = repository.searchSeriesList( + keyword = keyword, + memberId = member.id!!, + isAdult = isAdult, + contentType = contentType, + offset = 0, + limit = 3 + ) + .map { + it.type = SearchResponseType.SERIES + it + } + + return SearchUnifiedResponse( + creatorList = creatorList, + contentList = contentList, + seriesList = seriesList + ) + } + + fun searchCreatorList( + keyword: String, + isAdultContentVisible: Boolean, + contentType: ContentType, + member: Member, + offset: Long, + limit: Long + ): SearchResponse { + val totalCount = repository.searchCreatorTotalCount(keyword, memberId = member.id!!) + val items = repository.searchCreatorList( + keyword = keyword, + memberId = member.id!!, + offset = offset, + limit = limit + ) + .map { + it.type = SearchResponseType.CREATOR + it + } + + return SearchResponse(totalCount, items) + } + + fun searchContentList( + keyword: String, + isAdultContentVisible: Boolean, + contentType: ContentType, + member: Member, + offset: Long, + limit: Long + ): SearchResponse { + val isAdult = member.auth != null && isAdultContentVisible + + val totalCount = repository.searchContentTotalCount( + keyword, + memberId = member.id!!, + isAdult = isAdult, + contentType = contentType + ) + + val items = repository.searchContentList( + keyword = keyword, + memberId = member.id!!, + isAdult = isAdult, + contentType = contentType, + offset = offset, + limit = limit + ) + .map { + it.type = SearchResponseType.CONTENT + it + } + + return SearchResponse(totalCount, items) + } + + fun searchSeriesList( + keyword: String, + isAdultContentVisible: Boolean, + contentType: ContentType, + member: Member, + offset: Long, + limit: Long + ): SearchResponse { + val isAdult = member.auth != null && isAdultContentVisible + + val totalCount = repository.searchSeriesTotalCount( + keyword, + memberId = member.id!!, + isAdult = isAdult, + contentType = contentType + ) + + val items = repository.searchSeriesList( + keyword = keyword, + memberId = member.id!!, + isAdult = isAdult, + contentType = contentType, + offset = offset, + limit = limit + ) + .map { + it.type = SearchResponseType.SERIES + it + } + + return SearchResponse(totalCount, items) + } +}