test #327

Merged
klaus merged 13 commits from test into main 2025-07-14 11:07:58 +00:00
27 changed files with 847 additions and 36 deletions

View File

@ -70,6 +70,9 @@ dependencies {
implementation("org.apache.poi:poi-ooxml:5.2.3") implementation("org.apache.poi:poi-ooxml:5.2.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
// file mimetype check
implementation("org.apache.tika:tika-core:3.2.0")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j") runtimeOnly("com.mysql:mysql-connector-j")

View File

@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelResponse
data class GetHomeResponse(
val liveList: List<GetRoomListResponse>,
val creatorRanking: List<GetExplorerSectionCreatorResponse>,
val latestContentThemeList: List<String>,
val latestContentList: List<AudioContentMainItem>,
val eventBannerList: GetEventResponse,
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
val auditionList: List<GetAuditionListItem>,
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
val contentRanking: List<GetAudioContentRankingItem>,
val recommendChannelList: List<RecommendChannelResponse>,
val freeContentList: List<AudioContentMainItem>,
val curationList: List<GetContentCurationResponse>
)

View File

@ -0,0 +1,66 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
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("/api/home")
class HomeController(private val service: HomeService) {
@GetMapping
fun fetchData(
@RequestParam timezone: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.fetchData(
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
@GetMapping("/latest-content")
fun getLatestContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getLatestContentByTheme(
theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
@GetMapping("/day-of-week-series")
fun getDayOfWeekSeriesList(
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
)
)
}
}

View File

@ -0,0 +1,248 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.RankingRepository
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
@Service
class HomeService(
private val eventService: EventService,
private val memberService: MemberService,
private val liveRoomService: LiveRoomService,
private val auditionService: AuditionService,
private val seriesService: ContentSeriesService,
private val contentService: AudioContentService,
private val curationService: AudioContentCurationService,
private val contentThemeService: AudioContentThemeService,
private val recommendChannelService: RecommendChannelQueryService,
private val rankingService: RankingService,
private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun fetchData(
timezone: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): GetHomeResponse {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val liveList = liveRoomService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
)
val creatorRanking = rankingRepository
.getCreatorRankings()
.filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
} else {
true
}
}
.map {
val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size
it.toExplorerSectionCreator(imageHost, followerCount)
}
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
contentType = contentType
)
val latestContentList = contentService.getLatestContentByTheme(
theme = latestContentThemeList,
contentType = contentType,
isFree = false,
isAdult = isAdult
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val eventBannerList = eventService.getEventList(isAdult = isAdult)
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult,
contentType = contentType
)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
.withMinute(0)
.withSecond(0)
.minusWeeks(1)
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val endDate = startDate
.plusDays(6)
val contentRanking = rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
startDate = startDate.minusDays(1),
endDate = endDate,
sortType = "매출"
)
// TODO 오디오 북
val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId,
isAdult = isAdult,
contentType = contentType
)
val freeContentList = contentService.getLatestContentByTheme(
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = contentType
),
contentType = contentType,
isFree = true,
isAdult = isAdult
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val curationList = curationService.getContentCurationList(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult,
contentType = contentType,
memberId = memberId
)
return GetHomeResponse(
liveList = liveList,
creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList,
latestContentList = latestContentList,
eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList,
auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList,
contentRanking = contentRanking,
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
curationList = curationList
)
}
fun getLatestContentByTheme(
theme: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val themeList = if (theme.isBlank()) {
contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = contentType
)
} else {
listOf(theme)
}
return contentService.getLatestContentByTheme(
theme = themeList,
contentType = contentType,
isFree = false,
isAdult = isAdult
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
}
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
): List<GetSeriesListResponse.SeriesListItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
return seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
dayOfWeek = dayOfWeek
)
}
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
val systemTime = LocalDateTime.now()
val zoneId = ZoneId.of(timezone)
val zonedDateTime = systemTime.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId)
val dayToSeriesPublishedDaysOfWeek = mapOf(
DayOfWeek.MONDAY to SeriesPublishedDaysOfWeek.MON,
DayOfWeek.TUESDAY to SeriesPublishedDaysOfWeek.TUE,
DayOfWeek.WEDNESDAY to SeriesPublishedDaysOfWeek.WED,
DayOfWeek.THURSDAY to SeriesPublishedDaysOfWeek.THU,
DayOfWeek.FRIDAY to SeriesPublishedDaysOfWeek.FRI,
DayOfWeek.SATURDAY to SeriesPublishedDaysOfWeek.SAT,
DayOfWeek.SUNDAY to SeriesPublishedDaysOfWeek.SUN
)
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
}
}

View File

@ -12,6 +12,7 @@ interface AuditionQueryRepository {
fun getCompletedAuditionCount(isAdult: Boolean): Int fun getCompletedAuditionCount(isAdult: Boolean): Int
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem> fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
} }
class AuditionQueryRepositoryImpl( class AuditionQueryRepositoryImpl(
@ -94,4 +95,27 @@ class AuditionQueryRepositoryImpl(
.where(audition.id.eq(auditionId)) .where(audition.id.eq(auditionId))
.fetchFirst() .fetchFirst()
} }
override fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
var where = audition.isActive.isTrue
.and(audition.status.eq(AuditionStatus.IN_PROGRESS))
if (!isAdult) {
where = where.and(audition.isAdult.isFalse)
}
return queryFactory
.select(
QGetAuditionListItem(
audition.id,
audition.title,
audition.imagePath.prepend("/").prepend(cloudFrontHost),
audition.status.eq(AuditionStatus.COMPLETED)
)
)
.from(audition)
.where(where)
.orderBy(audition.status.desc())
.fetch()
}
} }

View File

@ -28,4 +28,8 @@ class AuditionService(
roleList = roleList roleList = roleList
) )
} }
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
return repository.getInProgressAuditionList(isAdult)
}
} }

View File

@ -80,6 +80,9 @@ class SecurityConfig(
.antMatchers("/v2/audio-content/main/home").permitAll() .antMatchers("/v2/audio-content/main/home").permitAll()
.antMatchers("/v2/audio-content/main/home/popular-content-by-creator").permitAll() .antMatchers("/v2/audio-content/main/home/popular-content-by-creator").permitAll()
.antMatchers("/v2/audio-content/main/home/content/ranking").permitAll() .antMatchers("/v2/audio-content/main/home/content/ranking").permitAll()
.antMatchers("/api/home").permitAll()
.antMatchers("/api/home/latest-content").permitAll()
.antMatchers("/api/home/day-of-week-series").permitAll()
.antMatchers(HttpMethod.GET, "/faq").permitAll() .antMatchers(HttpMethod.GET, "/faq").permitAll()
.antMatchers(HttpMethod.GET, "/faq/category").permitAll() .antMatchers(HttpMethod.GET, "/faq/category").permitAll()
.antMatchers("/audition").permitAll() .antMatchers("/audition").permitAll()

View File

@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.content
import com.fasterxml.jackson.annotation.JsonProperty
import com.querydsl.core.annotations.QueryProjection
data class AudioContentMainItem @QueryProjection constructor(
@JsonProperty("contentId") val contentId: Long,
@JsonProperty("creatorId") val creatorId: Long,
@JsonProperty("title") val title: String,
@JsonProperty("coverImageUrl") val coverImageUrl: String,
@JsonProperty("creatorNickname") val creatorNickname: String,
@JsonProperty("isPointAvailable") val isPointAvailable: Boolean
)

View File

@ -176,6 +176,23 @@ interface AudioContentQueryRepository {
fun findContentHashTagByContentIdAndIsActive(contentId: Long, isActive: Boolean): List<AudioContentHashTag> fun findContentHashTagByContentIdAndIsActive(contentId: Long, isActive: Boolean): List<AudioContentHashTag>
fun findContentIdAndHashTagId(contentId: Long, hashTagId: Int): AudioContentHashTag? fun findContentIdAndHashTagId(contentId: Long, hashTagId: Int): AudioContentHashTag?
fun getLatestContentByTheme(
theme: List<String>,
contentType: ContentType,
offset: Long,
limit: Long,
isFree: Boolean,
isAdult: Boolean
): List<AudioContentMainItem>
fun findContentByCurationId(
curationId: Long,
isAdult: Boolean,
contentType: ContentType,
offset: Long = 0,
limit: Long = 20
): List<GetAudioContentMainItem>
} }
@Repository @Repository
@ -1281,4 +1298,126 @@ class AudioContentQueryRepositoryImpl(
.orderBy(audioContentHashTag.id.asc()) .orderBy(audioContentHashTag.id.asc())
.fetchFirst() .fetchFirst()
} }
override fun getLatestContentByTheme(
theme: List<String>,
contentType: ContentType,
offset: Long,
limit: Long,
isFree: Boolean,
isAdult: Boolean
): List<AudioContentMainItem> {
var where = audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.and(
audioContent.releaseDate.isNull
.or(audioContent.releaseDate.loe(LocalDateTime.now()))
)
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
}
)
)
)
}
}
if (theme.isNotEmpty()) {
where = where.and(audioContentTheme.theme.`in`(theme))
}
where = if (isFree) {
where.and(audioContent.price.loe(0))
} else {
where.and(audioContent.price.gt(0))
}
return queryFactory
.select(
QAudioContentMainItem(
audioContent.id,
member.id,
audioContent.title,
audioContent.coverImage.prepend("/").prepend(imageHost),
member.nickname,
audioContent.isPointAvailable
)
)
.from(audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(audioContent.theme, audioContentTheme)
.where(where)
.offset(offset)
.limit(limit)
.orderBy(audioContent.id.desc())
.fetch()
}
override fun findContentByCurationId(
curationId: Long,
isAdult: Boolean,
contentType: ContentType,
offset: Long,
limit: Long
): List<GetAudioContentMainItem> {
var where = audioContentCuration.isActive.isTrue
.and(audioContentCurationItem.isActive.isTrue)
.and(audioContent.isActive.isTrue)
.and(audioContent.member.isNotNull)
.and(audioContent.duration.isNotNull)
.and(audioContent.member.isActive.isTrue)
.and(audioContentCuration.id.eq(curationId))
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(
QGetAudioContentMainItem(
audioContent.id,
audioContent.coverImage.prepend("/").prepend(imageHost),
audioContent.title,
member.id,
member.profileImage.prepend("/").prepend(imageHost),
member.nickname,
audioContent.price,
audioContent.duration,
audioContent.isPointAvailable
)
)
.from(audioContentCurationItem)
.innerJoin(audioContentCurationItem.content, audioContent)
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
.innerJoin(audioContent.member, member)
.where(where)
.orderBy(audioContentCurationItem.orders.asc())
.offset(offset)
.limit(limit)
.fetch()
}
} }

View File

@ -938,4 +938,22 @@ class AudioContentService(
return GenerateUrlResponse(contentUrl) return GenerateUrlResponse(contentUrl)
} }
fun getLatestContentByTheme(
theme: List<String>,
contentType: ContentType,
offset: Long = 0,
limit: Long = 20,
isFree: Boolean = false,
isAdult: Boolean = false
): List<AudioContentMainItem> {
return repository.getLatestContentByTheme(
theme = theme,
contentType = contentType,
offset = offset,
limit = limit,
isFree = isFree,
isAdult = isAdult
)
}
} }

View File

@ -12,5 +12,7 @@ data class GetAudioContentMainItem @QueryProjection constructor(
@JsonProperty("creatorNickname") val creatorNickname: String, @JsonProperty("creatorNickname") val creatorNickname: String,
@JsonProperty("price") val price: Int, @JsonProperty("price") val price: Int,
@JsonProperty("duration") val duration: String, @JsonProperty("duration") val duration: String,
@JsonProperty("isPointAvailable") val isPointAvailable: Boolean @get:JsonProperty("isPointAvailable")
@param:JsonProperty("isPointAvailable")
val isPointAvailable: Boolean
) )

View File

@ -1,16 +1,20 @@
package kr.co.vividnext.sodalive.content.main.curation package kr.co.vividnext.sodalive.content.main.curation
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service @Service
class AudioContentCurationService( class AudioContentCurationService(
private val repository: AudioContentCurationQueryRepository, private val repository: AudioContentCurationQueryRepository,
private val contentRepository: AudioContentRepository,
private val blockMemberRepository: BlockMemberRepository, private val blockMemberRepository: BlockMemberRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
@ -46,4 +50,31 @@ class AudioContentCurationService(
items = audioContentList items = audioContentList
) )
} }
@Transactional(readOnly = true)
fun getContentCurationList(
tabId: Long,
isAdult: Boolean,
contentType: ContentType,
memberId: Long?
): List<GetContentCurationResponse> {
return repository.findByContentMainTabId(tabId = tabId, isAdult = isAdult)
.map {
GetContentCurationResponse(
title = it.title,
items = contentRepository.findContentByCurationId(
curationId = it.id!!,
isAdult = isAdult,
contentType = contentType
).filter { item ->
if (memberId != null) {
!blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = item.creatorId)
} else {
true
}
}
)
}
.filter { it.items.isNotEmpty() }
}
} }

View File

@ -1,8 +1,9 @@
package kr.co.vividnext.sodalive.content.main.tab package kr.co.vividnext.sodalive.content.main.tab
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem import kr.co.vividnext.sodalive.content.main.GetAudioContentMainItem
data class GetContentCurationResponse( data class GetContentCurationResponse(
val title: String, @JsonProperty("title") val title: String,
val items: List<GetAudioContentMainItem> @JsonProperty("items") val items: List<GetAudioContentMainItem>
) )

View File

@ -42,7 +42,6 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
ApiResponse.ok( ApiResponse.ok(
service.getOriginalAudioDramaList( service.getOriginalAudioDramaList(
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true), isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL, contentType = contentType ?: ContentType.ALL,
offset = pageable.offset, offset = pageable.offset,

View File

@ -41,7 +41,6 @@ class AudioContentMainTabSeriesService(
) )
val originalAudioDrama = seriesService.getOriginalAudioDramaList( val originalAudioDrama = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
offset = 0, offset = 0,
@ -158,15 +157,13 @@ class AudioContentMainTabSeriesService(
} }
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
memberId: Long,
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
limit: Long limit: Long
): GetSeriesListResponse { ): GetSeriesListResponse {
val totalCount = seriesService.getOriginalAudioDramaTotalCount(memberId, isAdult, contentType) val totalCount = seriesService.getOriginalAudioDramaTotalCount(isAdult, contentType)
val items = seriesService.getOriginalAudioDramaList( val items = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
offset = offset, offset = offset,

View File

@ -19,7 +19,6 @@ class ContentSeriesController(private val service: ContentSeriesService) {
@GetMapping @GetMapping
fun getSeriesList( fun getSeriesList(
@RequestParam creatorId: Long, @RequestParam creatorId: Long,
@RequestParam("sortType", required = false) sortType: SeriesSortType? = SeriesSortType.NEWEST,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@ -30,7 +29,6 @@ class ContentSeriesController(private val service: ContentSeriesService) {
ApiResponse.ok( ApiResponse.ok(
service.getSeriesList( service.getSeriesList(
creatorId = creatorId, creatorId = creatorId,
sortType = sortType ?: SeriesSortType.NEWEST,
isAdultContentVisible = isAdultContentVisible ?: true, isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL, contentType = contentType ?: ContentType.ALL,
member = member, member = member,

View File

@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.content.series.content.QGetSeriesContentMinMaxPr
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series 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.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.QSeriesKeyword.seriesKeyword 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.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
@ -37,16 +38,22 @@ interface ContentSeriesQueryRepository {
fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse
fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List<Series> fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List<Series>
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
memberId: Long,
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<Series> ): List<Series>
fun getOriginalAudioDramaTotalCount(memberId: Long, isAdult: Boolean, contentType: ContentType): Int fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int
fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List<GetSeriesGenreListResponse> fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List<GetSeriesGenreListResponse>
fun findByCurationId(curationId: Long, memberId: Long, isAdult: Boolean, contentType: ContentType): List<Series> fun findByCurationId(curationId: Long, memberId: Long, isAdult: Boolean, contentType: ContentType): List<Series>
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
contentType: ContentType,
isAdult: Boolean,
offset: Long,
limit: Long
): List<Series>
} }
class ContentSeriesQueryRepositoryImpl( class ContentSeriesQueryRepositoryImpl(
@ -207,19 +214,13 @@ class ContentSeriesQueryRepositoryImpl(
} }
override fun getOriginalAudioDramaList( override fun getOriginalAudioDramaList(
memberId: Long,
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
limit: Long limit: Long
): List<Series> { ): List<Series> {
val blockMemberCondition = blockMember.member.id.eq(member.id)
.and(blockMember.isActive.isTrue)
.and(blockMember.blockedMember.id.eq(memberId))
var where = series.isOriginal.isTrue var where = series.isOriginal.isTrue
.and(series.isActive.isTrue) .and(series.isActive.isTrue)
.and(blockMember.id.isNull)
if (!isAdult) { if (!isAdult) {
where = where.and(series.isAdult.isFalse) where = where.and(series.isAdult.isFalse)
@ -242,7 +243,6 @@ class ContentSeriesQueryRepositoryImpl(
return queryFactory return queryFactory
.selectFrom(series) .selectFrom(series)
.innerJoin(series.member, member) .innerJoin(series.member, member)
.leftJoin(blockMember).on(blockMemberCondition)
.where(where) .where(where)
.orderBy(series.id.desc()) .orderBy(series.id.desc())
.offset(offset) .offset(offset)
@ -250,14 +250,9 @@ class ContentSeriesQueryRepositoryImpl(
.fetch() .fetch()
} }
override fun getOriginalAudioDramaTotalCount(memberId: Long, isAdult: Boolean, contentType: ContentType): Int { override fun getOriginalAudioDramaTotalCount(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.isOriginal.isTrue var where = series.isOriginal.isTrue
.and(series.isActive.isTrue) .and(series.isActive.isTrue)
.and(blockMember.id.isNull)
if (!isAdult) { if (!isAdult) {
where = where.and(series.isAdult.isFalse) where = where.and(series.isAdult.isFalse)
@ -281,7 +276,6 @@ class ContentSeriesQueryRepositoryImpl(
.select(series.id) .select(series.id)
.from(series) .from(series)
.innerJoin(series.member, member) .innerJoin(series.member, member)
.leftJoin(blockMember).on(blockMemberCondition)
.where(where) .where(where)
.fetch() .fetch()
.size .size
@ -385,4 +379,41 @@ class ContentSeriesQueryRepositoryImpl(
.orderBy(audioContentCurationItem.orders.asc()) .orderBy(audioContentCurationItem.orders.asc())
.fetch() .fetch()
} }
override fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
contentType: ContentType,
isAdult: Boolean,
offset: Long,
limit: Long
): List<Series> {
var where = series.isActive.isTrue
.and(series.publishedDaysOfWeek.contains(dayOfWeek))
if (!isAdult) {
where = where.and(series.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
series.member.isNull.or(
series.member.auth.gender.eq(
if (contentType == ContentType.MALE) {
0
} else {
1
}
)
)
)
}
}
return queryFactory
.selectFrom(series)
.where(where)
.groupBy(series.id)
.offset(offset)
.limit(limit)
.fetch()
}
} }

View File

@ -30,18 +30,17 @@ class ContentSeriesService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String private val coverImageHost: String
) { ) {
fun getOriginalAudioDramaTotalCount(memberId: Long, isAdult: Boolean, contentType: ContentType): Int { fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int {
return repository.getOriginalAudioDramaTotalCount(memberId, isAdult, contentType) return repository.getOriginalAudioDramaTotalCount(isAdult, contentType)
} }
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
memberId: Long,
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
val originalAudioDramaList = repository.getOriginalAudioDramaList(memberId, isAdult, contentType, offset, limit) val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, offset, limit)
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType) return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)
} }
@ -54,7 +53,6 @@ class ContentSeriesService(
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
contentType: ContentType, contentType: ContentType,
member: Member, member: Member,
sortType: SeriesSortType = SeriesSortType.NEWEST,
offset: Long = 0, offset: Long = 0,
limit: Long = 10 limit: Long = 10
): GetSeriesListResponse { ): GetSeriesListResponse {
@ -224,6 +222,36 @@ class ContentSeriesService(
return seriesToSeriesListItem(seriesList, isAdult, contentType) return seriesToSeriesListItem(seriesList, isAdult, contentType)
} }
fun getDayOfWeekSeriesList(
memberId: Long?,
isAdult: Boolean,
contentType: ContentType,
dayOfWeek: SeriesPublishedDaysOfWeek,
offset: Long = 0,
limit: Long = 10
): List<GetSeriesListResponse.SeriesListItem> {
var seriesList = repository.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
contentType = contentType,
isAdult = isAdult,
offset = offset,
limit = limit
)
seriesList = if (memberId != null) {
seriesList.filter {
!blockMemberRepository.isBlocked(
blockedMemberId = memberId,
memberId = it.member!!.id!!
)
}
} else {
seriesList
}
return seriesToSeriesListItem(seriesList, isAdult, contentType)
}
private fun seriesToSeriesListItem( private fun seriesToSeriesListItem(
seriesList: List<Series>, seriesList: List<Series>,
isAdult: Boolean, isAdult: Boolean,

View File

@ -19,6 +19,19 @@ class AudioContentThemeService(
return queryRepository.getActiveThemes() return queryRepository.getActiveThemes()
} }
@Transactional(readOnly = true)
fun getActiveThemeOfContent(
isAdult: Boolean = false,
isFree: Boolean = false,
contentType: ContentType
): List<String> {
return queryRepository.getActiveThemeOfContent(
isAdult = isAdult,
isFree = isFree,
contentType = contentType
)
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getContentByTheme( fun getContentByTheme(
themeId: Long, themeId: Long,

View File

@ -26,7 +26,6 @@ class EventService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getEventList(isAdult: Boolean? = null): GetEventResponse { fun getEventList(isAdult: Boolean? = null): GetEventResponse {
val eventList = repository.getEventList(isAdult) val eventList = repository.getEventList(isAdult)
.asSequence()
.map { .map {
if (!it.thumbnailImageUrl.startsWith("https://")) { if (!it.thumbnailImageUrl.startsWith("https://")) {
it.thumbnailImageUrl = "$cloudFrontHost/${it.thumbnailImageUrl}" it.thumbnailImageUrl = "$cloudFrontHost/${it.thumbnailImageUrl}"
@ -42,7 +41,6 @@ class EventService(
it it
} }
.toList()
return GetEventResponse(0, eventList) return GetEventResponse(0, eventList)
} }

View File

@ -14,5 +14,6 @@ data class GetExplorerSectionCreatorResponse(
val id: Long, val id: Long,
val nickname: String, val nickname: String,
val tags: String, val tags: String,
val profileImageUrl: String val profileImageUrl: String,
val followerCount: Int
) )

View File

@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@ -71,6 +72,8 @@ class CreatorCommunityService(
throw SodaException("오디오 등록을 위해서는 이미지가 필요합니다.") throw SodaException("오디오 등록을 위해서는 이미지가 필요합니다.")
} }
postImage?.let { validateImage(it, request.price > 0) }
val post = CreatorCommunity( val post = CreatorCommunity(
content = request.content, content = request.content,
price = request.price, price = request.price,
@ -129,6 +132,8 @@ class CreatorCommunityService(
val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!) val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!)
?: throw SodaException("잘못된 요청입니다.") ?: throw SodaException("잘못된 요청입니다.")
postImage?.let { validateImage(it, post.price > 0) }
if (request.content != null) { if (request.content != null) {
post.content = request.content post.content = request.content
} }

View File

@ -123,7 +123,7 @@ data class Member(
} }
} }
fun toExplorerSectionCreator(imageHost: String): GetExplorerSectionCreatorResponse { fun toExplorerSectionCreator(imageHost: String, followerCount: Int = 0): GetExplorerSectionCreatorResponse {
return GetExplorerSectionCreatorResponse( return GetExplorerSectionCreatorResponse(
id = id!!, id = id!!,
nickname = nickname, nickname = nickname,
@ -135,7 +135,8 @@ data class Member(
"$imageHost/$profileImage" "$imageHost/$profileImage"
} else { } else {
"$imageHost/profile/default-profile.png" "$imageHost/profile/default-profile.png"
} },
followerCount = followerCount
) )
} }
} }

View File

@ -0,0 +1,91 @@
package kr.co.vividnext.sodalive.query.recommend
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment
import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
@Repository
class RecommendChannelQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun getRecommendChannelList(isAdult: Boolean, contentType: ContentType): List<RecommendChannelResponse> {
var where = member.role.eq(MemberRole.CREATOR)
.and(audioContent.isActive.isTrue)
if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
member.auth.gender.eq(
if (contentType == ContentType.MALE) {
0
} else {
1
}
)
)
}
}
return queryFactory
.select(
QRecommendChannelResponse(
member.id,
member.nickname,
member.profileImage.prepend("/").prepend(imageHost),
audioContent.id.count(),
Expressions.constant(emptyList<RecommendChannelContentItem>())
)
)
.from(member)
.innerJoin(auth).on(auth.member.id.eq(member.id))
.innerJoin(audioContent).on(audioContent.member.id.eq(member.id))
.where(where)
.groupBy(member.id)
.having(audioContent.id.count().goe(3))
.orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc())
.limit(6)
.fetch()
}
fun getContentsByCreatorIdLikeDesc(creatorId: Long): List<RecommendChannelContentItem> {
return queryFactory
.select(
QRecommendChannelContentItem(
audioContent.id,
audioContent.title,
audioContent.coverImage.prepend("/").prepend(imageHost),
audioContentLike.id.countDistinct(),
audioContentComment.id.countDistinct()
)
)
.from(audioContent)
.leftJoin(audioContentLike)
.on(
audioContentLike.audioContent.id.eq(audioContent.id)
.and(audioContentLike.isActive.isTrue)
)
.leftJoin(audioContentComment)
.on(
audioContentComment.audioContent.id.eq(audioContent.id)
.and(audioContentComment.isActive.isTrue)
)
.where(audioContent.member.id.eq(creatorId))
.groupBy(audioContent.id)
.orderBy(audioContentLike.id.countDistinct().desc())
.limit(3)
.fetch()
}
}

View File

@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.query.recommend
import kr.co.vividnext.sodalive.content.ContentType
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class RecommendChannelQueryService(private val repository: RecommendChannelQueryRepository) {
@Cacheable(
cacheNames = ["default"],
key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType"
)
fun getRecommendChannel(
memberId: Long?,
isAdult: Boolean,
contentType: ContentType
): List<RecommendChannelResponse> {
val recommendChannelList = repository.getRecommendChannelList(
isAdult = isAdult,
contentType = contentType
)
return recommendChannelList.map {
it.contentList = repository.getContentsByCreatorIdLikeDesc(it.channelId)
it
}
}
}

View File

@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.query.recommend
import com.fasterxml.jackson.annotation.JsonProperty
import com.querydsl.core.annotations.QueryProjection
data class RecommendChannelResponse @QueryProjection constructor(
@JsonProperty("channelId") val channelId: Long,
@JsonProperty("creatorNickname") val creatorNickname: String,
@JsonProperty("creatorProfileImageUrl") val creatorProfileImageUrl: String,
@JsonProperty("contentCount") val contentCount: Long,
@JsonProperty("contentList") var contentList: List<RecommendChannelContentItem>
)
data class RecommendChannelContentItem @QueryProjection constructor(
@JsonProperty("contentId") val contentId: Long,
@JsonProperty("title") val title: String,
@JsonProperty("thumbnailImageUrl") val thumbnailImageUrl: String,
@JsonProperty("likeCount") val likeCount: Long,
@JsonProperty("commentCount") val commentCount: Long
)

View File

@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.utils
import kr.co.vividnext.sodalive.common.SodaException
import org.apache.tika.Tika
import org.springframework.web.multipart.MultipartFile
/**
* 이미지 파일인지 확인하고,
* GIF의 경우 외부 조건(gifAllowed) 따라 허용 여부 판단
*/
fun validateImage(file: MultipartFile, gifAllowed: Boolean) {
val mimeType = Tika().detect(file.bytes)
if (!mimeType.startsWith("image/")) {
throw SodaException("이미지 파일만 업로드할 수 있습니다.")
}
if (mimeType == "image/gif" && !gifAllowed) {
throw SodaException("GIF 파일은 유료 게시물만 업로드 할 수 있습니다.")
}
}