From 4f89b0189ef898f89757e2a94b6224bf32bfbbac Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 16:02:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(series-main):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=ED=99=88,=20=EC=9A=94=EC=9D=BC=EB=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88,=20=EC=9E=A5=EB=A5=B4=EB=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminContentSeriesBannerController.kt | 3 +- .../content/series/ContentSeriesRepository.kt | 94 +++++++++++ .../content/series/ContentSeriesService.kt | 31 +++- .../content/series/main/SeriesHomeResponse.kt | 10 ++ .../series/main/SeriesMainController.kt | 150 ++++++++++++++++++ .../banner/ContentSeriesBannerService.kt} | 15 +- 6 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesHomeResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt rename src/main/kotlin/kr/co/vividnext/sodalive/{admin/content/series/banner/AdminContentSeriesBannerService.kt => content/series/main/banner/ContentSeriesBannerService.kt} (85%) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt index 9dd67aa..5763fe6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpda import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest @@ -30,7 +31,7 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/admin/audio-content/series/banner") @PreAuthorize("hasRole('ADMIN')") class AdminContentSeriesBannerController( - private val bannerService: AdminContentSeriesBannerService, + private val bannerService: ContentSeriesBannerService, private val s3Uploader: S3Uploader, @Value("\${cloud.aws.s3.bucket}") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index e27eb56..25baba6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -43,6 +43,21 @@ interface ContentSeriesQueryRepository { limit: Long ): List + fun getSeriesByGenreTotalCount( + genreId: Long, + isAuth: Boolean, + contentType: ContentType + ): Int + + fun getSeriesByGenreList( + imageHost: String, + genreId: Long, + isAuth: Boolean, + contentType: ContentType, + offset: Long, + limit: Long + ): List + fun getSeriesDetail(seriesId: Long, isAuth: Boolean, contentType: ContentType): Series? fun getKeywordList(seriesId: Long): List fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse @@ -168,6 +183,85 @@ class ContentSeriesQueryRepositoryImpl( .fetch() } + override fun getSeriesByGenreTotalCount( + genreId: Long, + isAuth: Boolean, + contentType: ContentType + ): Int { + var where = series.isActive.isTrue + .and(series.genre.id.eq(genreId)) + + if (!isAuth) { + 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 + .select(series.id) + .from(series) + .innerJoin(series.member, member) + .innerJoin(series.genre, seriesGenre) + .where(where) + .fetch() + .size + } + + override fun getSeriesByGenreList( + imageHost: String, + genreId: Long, + isAuth: Boolean, + contentType: ContentType, + offset: Long, + limit: Long + ): List { + var where = series.isActive.isTrue + .and(series.genre.id.eq(genreId)) + + if (!isAuth) { + 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 + .select(series) + .from(seriesContent) + .innerJoin(seriesContent.series, series) + .innerJoin(seriesContent.content, audioContent) + .innerJoin(series.member, member) + .innerJoin(series.genre, seriesGenre) + .where(where) + .orderBy(audioContent.releaseDate.desc(), series.createdAt.asc()) + .offset(offset) + .limit(limit) + .fetch() + } + override fun getSeriesDetail(seriesId: Long, isAuth: Boolean, contentType: ContentType): Series? { var where = series.id.eq(seriesId) .and(series.isActive.isTrue) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 005fdcc..cfde3f0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -83,6 +83,35 @@ class ContentSeriesService( return GetSeriesListResponse(totalCount, items) } + fun getSeriesListByGenre( + genreId: Long, + isAdultContentVisible: Boolean, + contentType: ContentType, + member: Member, + offset: Long = 0, + limit: Long = 20 + ): GetSeriesListResponse { + val isAuth = member.auth != null && isAdultContentVisible + + val totalCount = repository.getSeriesByGenreTotalCount( + genreId = genreId, + isAuth = isAuth, + contentType = contentType + ) + + val rawItems = repository.getSeriesByGenreList( + imageHost = coverImageHost, + genreId = genreId, + isAuth = isAuth, + contentType = contentType, + offset = offset, + limit = limit + ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } + + val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) + return GetSeriesListResponse(totalCount, items) + } + fun getSeriesDetail( seriesId: Long, isAdultContentVisible: Boolean, @@ -208,7 +237,7 @@ class ContentSeriesService( val seriesList = repository.getRecommendSeriesList( isAuth = isAuth, contentType = contentType, - limit = 10 + limit = 20 ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesHomeResponse.kt new file mode 100644 index 0000000..8efbec0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesHomeResponse.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.content.series.main + +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse +import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse + +data class SeriesHomeResponse( + val banners: List, + val completedSeriesList: List, + val recommendSeriesList: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt new file mode 100644 index 0000000..384569a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt @@ -0,0 +1,150 @@ +package kr.co.vividnext.sodalive.content.series.main + +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse +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.content.series.ContentSeriesService +import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +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("/audio-content/series/main") +class SeriesMainController( + private val contentSeriesService: ContentSeriesService, + private val bannerService: ContentSeriesBannerService, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + @GetMapping + fun fetchData( + @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("로그인 정보를 확인해주세요.") + + val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) + .content + .map { + SeriesBannerResponse.from(it, imageHost) + } + + val completedSeriesList = contentSeriesService.getSeriesList( + creatorId = null, + isCompleted = true, + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member + ).items + + val recommendSeriesList = contentSeriesService.getRecommendSeriesList( + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member + ) + + ApiResponse.ok( + SeriesHomeResponse( + banners = banners, + completedSeriesList = completedSeriesList, + recommendSeriesList = recommendSeriesList + + ) + ) + } + + @GetMapping("/recommend") + fun getRecommendSeriesList( + @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( + contentSeriesService.getRecommendSeriesList( + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member + ) + ) + } + + @GetMapping("/day-of-week") + fun getDayOfWeekSeriesList( + @RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val pageable = PageRequest.of(page, size) + + ApiResponse.ok( + contentSeriesService.getDayOfWeekSeriesList( + memberId = member.id, + isAdult = member.auth != null && (isAdultContentVisible ?: true), + contentType = contentType ?: ContentType.ALL, + dayOfWeek = dayOfWeek, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/genre-list") + fun getGenreList( + @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("로그인 정보를 확인해주세요.") + + val memberId = member.id!! + val isAdult = member.auth != null && (isAdultContentVisible ?: true) + + ApiResponse.ok( + contentSeriesService.getGenreList( + memberId = memberId, + isAdult = isAdult, + contentType = contentType ?: ContentType.ALL + ) + ) + } + + @GetMapping("/list-by-genre") + fun getSeriesListByGenre( + @RequestParam("genreId") genreId: Long, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + val pageable = PageRequest.of(page, size) + + ApiResponse.ok( + contentSeriesService.getSeriesListByGenre( + genreId = genreId, + 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/admin/content/series/banner/AdminContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt similarity index 85% rename from src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt index 598dd95..90f876e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt @@ -1,15 +1,14 @@ -package kr.co.vividnext.sodalive.admin.content.series.banner +package kr.co.vividnext.sodalive.content.series.main.banner import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner -import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBannerRepository import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional @Service -class AdminContentSeriesBannerService( +class ContentSeriesBannerService( private val bannerRepository: SeriesBannerRepository, private val seriesRepository: AdminContentSeriesRepository ) { @@ -22,7 +21,7 @@ class AdminContentSeriesBannerService( .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } } - @org.springframework.transaction.annotation.Transactional + @Transactional fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner { val series = seriesRepository.findByIdAndActiveTrue(seriesId) ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") @@ -37,7 +36,7 @@ class AdminContentSeriesBannerService( return bannerRepository.save(banner) } - @org.springframework.transaction.annotation.Transactional + @Transactional fun updateBanner( bannerId: Long, imagePath: String? = null, @@ -58,7 +57,7 @@ class AdminContentSeriesBannerService( return bannerRepository.save(banner) } - @org.springframework.transaction.annotation.Transactional + @Transactional fun deleteBanner(bannerId: Long) { val banner = bannerRepository.findById(bannerId) .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } @@ -66,7 +65,7 @@ class AdminContentSeriesBannerService( bannerRepository.save(banner) } - @org.springframework.transaction.annotation.Transactional + @Transactional fun updateBannerOrders(ids: List): List { val updated = mutableListOf() for (index in ids.indices) {