Compare commits

..

4 Commits

Author SHA1 Message Date
klaus 83028f7817 Merge pull request 'test' (#298) from test into main
Reviewed-on: #298
2025-03-26 21:08:29 +00:00
Klaus 5777d9700f 크리에이터, 콘텐츠, 시리즈 검색
- 콘텐츠, 시리즈 검색 결과에 크리에이터 닉네임 추가
2025-03-27 05:40:07 +09:00
Klaus e1e9f4588a 크리에이터, 콘텐츠, 시리즈 검색 2025-03-27 00:49:00 +09:00
Klaus be2f013b9a 마케팅 트래킹
- AppLaunch 트래킹에 빈 본문 추가
2025-03-26 16:51:00 +09:00
6 changed files with 646 additions and 6 deletions

View File

@ -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()
}
}

View File

@ -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
)
)
}
}

View File

@ -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()
)
)
}
}

View File

@ -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<SearchResponseItem> {
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<SearchResponseItem> {
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<SearchResponseItem> {
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()
}
}

View File

@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.search
import com.querydsl.core.annotations.QueryProjection
data class SearchUnifiedResponse(
val creatorList: List<SearchResponseItem>,
val contentList: List<SearchResponseItem>,
val seriesList: List<SearchResponseItem>
)
data class SearchResponse(
val totalCount: Int,
val items: List<SearchResponseItem>
)
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
}

View File

@ -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)
}
}