diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesController.kt index bc2867c..77f9add 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesController.kt @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -19,4 +21,9 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic fun searchSeriesList( @RequestParam(value = "search_word") searchWord: String ) = ApiResponse.ok(service.searchSeriesList(searchWord)) + + @PutMapping + fun modifySeries( + @RequestBody request: AdminModifySeriesRequest + ) = ApiResponse.ok(service.modifySeries(request), "시리즈가 수정되었습니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt index 1a949d2..31505cf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt @@ -1,10 +1,17 @@ package kr.co.vividnext.sodalive.admin.content.series +import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional @Service -class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) { +class AdminContentSeriesService( + private val repository: AdminContentSeriesRepository, + private val genreRepository: AdminContentSeriesGenreRepository +) { fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse { val totalCount = repository.getSeriesTotalCount() val items = repository.getSeriesList( @@ -12,10 +19,53 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi limit = pageable.pageSize.toLong() ) + if (items.isNotEmpty()) { + val ids = items.map { it.id } + val seriesList = repository.findAllById(ids) + val seriesMap = seriesList.associateBy { it.id } + + items.forEach { item -> + val s = seriesMap[item.id] + if (s != null) { + item.publishedDaysOfWeek = s.publishedDaysOfWeek.toList().sortedBy { it.ordinal } + item.isOriginal = s.isOriginal + } + } + } + return GetAdminSeriesListResponse(totalCount, items) } fun searchSeriesList(searchWord: String): List { return repository.searchSeriesList(searchWord) } + + @Transactional + fun modifySeries(request: AdminModifySeriesRequest) { + val series = repository.findByIdAndActiveTrue(request.seriesId) + ?: throw SodaException("잘못된 요청입니다.") + + if (request.publishedDaysOfWeek != null) { + val days = request.publishedDaysOfWeek + if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) { + throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.") + } + series.publishedDaysOfWeek.clear() + series.publishedDaysOfWeek.addAll(days) + } + + if (request.genreId != null) { + val genre = genreRepository.findActiveSeriesGenreById(request.genreId) + ?: throw SodaException("잘못된 요청입니다.") + series.genre = genre + } + + if (request.isOriginal != null) { + series.isOriginal = request.isOriginal + } + + if (request.isAdult != null) { + series.isAdult = request.isAdult + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminModifySeriesRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminModifySeriesRequest.kt new file mode 100644 index 0000000..065cc6e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminModifySeriesRequest.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.admin.content.series + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek + +data class AdminModifySeriesRequest( + val seriesId: Long, + val publishedDaysOfWeek: Set?, + val genreId: Long?, + val isOriginal: Boolean?, + val isAdult: Boolean? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/GetAdminSeriesListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/GetAdminSeriesListResponse.kt index ec03b71..139c7ef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/GetAdminSeriesListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/GetAdminSeriesListResponse.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.content.series import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek data class GetAdminSeriesListResponse( val totalCount: Int, @@ -17,7 +18,10 @@ data class GetAdminSeriesListItem @QueryProjection constructor( val numberOfWorks: Long, val state: String, val isAdult: Boolean -) +) { + var publishedDaysOfWeek: List = emptyList() + var isOriginal: Boolean = false +} data class GetAdminSearchSeriesListItem @QueryProjection constructor( val id: Long, 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 new file mode 100644 index 0000000..5763fe6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -0,0 +1,145 @@ +package kr.co.vividnext.sodalive.admin.content.series.banner + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse +import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest +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 +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/admin/audio-content/series/banner") +@PreAuthorize("hasRole('ADMIN')") +class AdminContentSeriesBannerController( + private val bannerService: ContentSeriesBannerService, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val s3Bucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + /** + * 활성화된 배너 목록 조회 API + */ + @GetMapping("/list") + fun getBannerList( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = run { + val pageable = PageRequest.of(page, size) + val banners = bannerService.getActiveBanners(pageable) + val response = SeriesBannerListPageResponse( + totalCount = banners.totalElements, + content = banners.content.map { SeriesBannerResponse.from(it, imageHost) } + ) + ApiResponse.ok(response) + } + + /** + * 배너 상세 조회 API + */ + @GetMapping("/{bannerId}") + fun getBannerDetail(@PathVariable bannerId: Long) = run { + val banner = bannerService.getBannerById(bannerId) + val response = SeriesBannerResponse.from(banner, imageHost) + ApiResponse.ok(response) + } + + /** + * 배너 등록 API + */ + @PostMapping("/register") + fun registerBanner( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java) + + val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "") + val imagePath = saveImage(banner.id!!, image) + val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) + val response = SeriesBannerResponse.from(updatedBanner, imageHost) + ApiResponse.ok(response) + } + + /** + * 배너 수정 API + */ + @PutMapping("/update") + fun updateBanner( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java) + // 배너 존재 확인 + bannerService.getBannerById(request.bannerId) + val imagePath = saveImage(request.bannerId, image) + val updated = bannerService.updateBanner( + bannerId = request.bannerId, + imagePath = imagePath, + seriesId = request.seriesId + ) + val response = SeriesBannerResponse.from(updated, imageHost) + ApiResponse.ok(response) + } + + /** + * 배너 삭제 API (소프트 삭제) + */ + @DeleteMapping("/{bannerId}") + fun deleteBanner(@PathVariable bannerId: Long) = run { + bannerService.deleteBanner(bannerId) + ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") + } + + /** + * 배너 정렬 순서 일괄 변경 API + */ + @PutMapping("/orders") + fun updateBannerOrders( + @RequestBody request: UpdateBannerOrdersRequest + ) = run { + bannerService.updateBannerOrders(request.ids) + ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.") + } + + private fun saveImage(bannerId: Long, image: MultipartFile): String { + try { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + val fileName = generateFileName("series-banner") + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = "series_banner/$bannerId/$fileName", + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt new file mode 100644 index 0000000..d054ca9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.admin.content.series.banner.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner + +// 시리즈 배너 등록 요청 DTO +data class SeriesBannerRegisterRequest( + @JsonProperty("seriesId") val seriesId: Long +) + +// 시리즈 배너 수정 요청 DTO +data class SeriesBannerUpdateRequest( + @JsonProperty("bannerId") val bannerId: Long, + @JsonProperty("seriesId") val seriesId: Long? = null +) + +// 시리즈 배너 응답 DTO +data class SeriesBannerResponse( + val id: Long, + val imagePath: String, + val seriesId: Long, + val seriesTitle: String +) { + companion object { + fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse { + return SeriesBannerResponse( + id = banner.id!!, + imagePath = "$imageHost/${banner.imagePath}", + seriesId = banner.series.id!!, + seriesTitle = banner.series.title + ) + } + } +} + +// 시리즈 배너 목록 페이지 응답 DTO +data class SeriesBannerListPageResponse( + val totalCount: Long, + val content: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreRepository.kt index 3587baa..42140d1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreRepository.kt @@ -8,6 +8,7 @@ interface AdminContentSeriesGenreRepository : JpaRepository, interface AdminContentSeriesGenreQueryRepository { fun getSeriesGenreList(): List + fun findActiveSeriesGenreById(id: Long): SeriesGenre? } class AdminContentSeriesGenreQueryRepositoryImpl( @@ -21,4 +22,14 @@ class AdminContentSeriesGenreQueryRepositoryImpl( .orderBy(seriesGenre.orders.asc()) .fetch() } + + override fun findActiveSeriesGenreById(id: Long): SeriesGenre? { + return queryFactory + .selectFrom(seriesGenre) + .where( + seriesGenre.id.eq(id) + .and(seriesGenre.isActive.isTrue) + ) + .fetchFirst() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt index efbd497..131034e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt @@ -27,5 +27,6 @@ data class GetHomeResponse( val recommendChannelList: List, val freeContentList: List, val pointAvailableContentList: List, + val recommendContentList: List, val curationList: List ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt index 6941ac3..986a35b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt @@ -4,6 +4,7 @@ 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 kr.co.vividnext.sodalive.rank.ContentRankingSortType import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -63,4 +64,44 @@ class HomeController(private val service: HomeService) { ) ) } + + // 추천 콘텐츠만 새로고침하기 위한 엔드포인트 + @GetMapping("/recommend-contents") + fun getRecommendContents( + @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.getRecommendContentList( + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + member = member + ) + ) + } + + // 콘텐츠 랭킹 엔드포인트 + @GetMapping("/content-ranking") + fun getContentRanking( + @RequestParam("sort", required = false) sort: ContentRankingSortType? = null, + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @RequestParam("offset", required = false) offset: Long? = null, + @RequestParam("limit", required = false) limit: Long? = null, + @RequestParam("theme", required = false) theme: String? = null, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + service.getContentRankingBySort( + sort = sort ?: ContentRankingSortType.REVENUE, + isAdultContentVisible = isAdultContentVisible ?: true, + contentType = contentType ?: ContentType.ALL, + offset = offset, + limit = limit, + theme = theme, + member = member + ) + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index b45db6e..6fde4af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService 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.GetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService import kr.co.vividnext.sodalive.content.series.ContentSeriesService @@ -18,6 +19,7 @@ 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.ContentRankingSortType import kr.co.vividnext.sodalive.rank.RankingRepository import kr.co.vividnext.sodalive.rank.RankingService import org.springframework.beans.factory.annotation.Value @@ -48,6 +50,11 @@ class HomeService( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { + companion object { + private const val RECOMMEND_TARGET_SIZE = 20 + private const val RECOMMEND_MAX_ATTEMPTS = 3 + } + fun fetchData( timezone: String, isAdultContentVisible: Boolean, @@ -148,7 +155,7 @@ class HomeService( contentType = contentType, startDate = startDate.minusDays(1), endDate = endDate, - sortType = "매출" + sort = ContentRankingSortType.REVENUE ) val recommendChannelList = recommendChannelService.getRecommendChannel( @@ -213,6 +220,11 @@ class HomeService( recommendChannelList = recommendChannelList, freeContentList = freeContentList, pointAvailableContentList = pointAvailableContentList, + recommendContentList = getRecommendContentList( + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, + member = member + ), curationList = curationList ) } @@ -267,6 +279,40 @@ class HomeService( ) } + fun getContentRankingBySort( + sort: ContentRankingSortType, + isAdultContentVisible: Boolean, + contentType: ContentType, + offset: Long?, + limit: Long?, + theme: String?, + member: Member? + ): List { + val memberId = member?.id + val isAdult = member?.auth != null && isAdultContentVisible + + 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) + + return rankingService.getContentRanking( + memberId = memberId, + isAdult = isAdult, + contentType = contentType, + startDate = startDate.minusDays(1), + endDate = endDate, + offset = offset ?: 0, + limit = limit ?: 12, + sort = sort, + theme = theme ?: "" + ) + } + private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek { val systemTime = LocalDateTime.now() val zoneId = ZoneId.of(timezone) @@ -284,4 +330,46 @@ class HomeService( return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM } + + // 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다. + fun getRecommendContentList( + isAdultContentVisible: Boolean, + contentType: ContentType, + member: Member? + ): List { + val memberId = member?.id + val isAdult = member?.auth != null && isAdultContentVisible + + // Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회 + val seen = HashSet(RECOMMEND_TARGET_SIZE * 2) + val result = ArrayList(RECOMMEND_TARGET_SIZE) + var attempt = 0 + while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) { + attempt += 1 + val batch = contentService.getLatestContentByTheme( + theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회 + contentType = contentType, + offset = 0, + limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회 + isFree = false, + isAdult = isAdult, + orderByRandom = true + ).filter { + if (memberId != null) { + !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) + } else { + true + } + } + + for (item in batch) { + if (result.size >= RECOMMEND_TARGET_SIZE) break + if (seen.add(item.contentId)) { + result.add(item) + } + } + } + + return result + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index d86ac8d..055f2f3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -74,6 +74,13 @@ class ChatCharacterController( size = 50 ).content + // 추천 캐릭터 조회 + // 최근 대화한 캐릭터를 제외한 랜덤 20개 조회 + // Controller에서는 호출만 + // 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성 + val excludeIds = recentCharacters.map { it.characterId } + val recommendCharacters = service.getRecommendCharacters(excludeIds, 20) + // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) val curationSections = curationQueryService.getActiveCurationsWithCharacters() .map { agg -> @@ -85,7 +92,8 @@ class ChatCharacterController( characterId = it.id!!, name = it.name, description = it.description, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", + isNew = false ) } ) @@ -98,6 +106,7 @@ class ChatCharacterController( recentCharacters = recentCharacters, popularCharacters = popularCharacters, newCharacters = newCharacters, + recommendCharacters = recommendCharacters, curationSections = curationSections ) ) @@ -193,4 +202,23 @@ class ChatCharacterController( ) ) } + + /** + * 추천 캐릭터 새로고침 API + * - 최근 대화한 캐릭터를 제외하고 랜덤 20개 반환 + * - 비회원 또는 본인인증되지 않은 경우: 최근 대화 목록 없음 → 전체 활성 캐릭터 중 랜덤 20개 + */ + @GetMapping("/recommend") + fun getRecommendCharacters( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + val recent = if (member == null || member.auth == null) { + emptyList() + } else { + chatRoomService + .listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려 + .map { it.characterId } + } + ApiResponse.ok(service.getRecommendCharacters(recent, 20)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt index e54ba93..824be47 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt @@ -7,6 +7,7 @@ data class CharacterMainResponse( val recentCharacters: List, val popularCharacters: List, val newCharacters: List, + val recommendCharacters: List, val curationSections: List ) @@ -20,7 +21,8 @@ data class Character( @JsonProperty("characterId") val characterId: Long, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String, - @JsonProperty("imageUrl") val imageUrl: String + @JsonProperty("imageUrl") val imageUrl: String, + @JsonProperty("isNew") val isNew: Boolean ) data class RecentCharacter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt index f23c7e8..cb4a5d8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -8,7 +8,9 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository +import java.time.LocalDateTime @Repository interface CharacterImageRepository : JpaRepository, CharacterImageQueryRepository { @@ -26,6 +28,21 @@ interface CharacterImageRepository : JpaRepository, Charac "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" ) fun findMaxSortOrderByCharacterId(characterId: Long): Int + + @Query( + """ + select distinct c.id + from CharacterImage ci + join ci.chatCharacter c + where ci.isActive = true + and ci.createdAt >= :since + and c.id in :characterIds + """ + ) + fun findCharacterIdsWithRecentImages( + @Param("characterIds") characterIds: List, + @Param("since") since: LocalDateTime + ): List } interface CharacterImageQueryRepository { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index eb9bc4d..e35bf6f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -74,5 +74,29 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): List + /** + * 활성 캐릭터 무작위 조회 + */ + @Query( + """ + SELECT c FROM ChatCharacter c + WHERE c.isActive = true + ORDER BY function('RAND') + """ + ) + fun findRandomActive(pageable: Pageable): List + + /** + * 제외할 캐릭터를 뺀 활성 캐릭터 무작위 조회 + */ + @Query( + """ + SELECT c FROM ChatCharacter c + WHERE c.isActive = true AND c.id NOT IN :excludeIds + ORDER BY function('RAND') + """ + ) + fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List, pageable: Pageable): List + fun findByIdInAndIsActiveTrue(ids: List): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 1c23587..79eeacb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository @@ -34,10 +35,42 @@ class ChatCharacterService( private val hobbyRepository: ChatCharacterHobbyRepository, private val goalRepository: ChatCharacterGoalRepository, private val popularCharacterQuery: PopularCharacterQuery, + private val imageRepository: CharacterImageRepository, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { + @Transactional(readOnly = true) + fun getRecommendCharacters(excludeCharacterIds: List = emptyList(), limit: Int = 20): List { + val safeLimit = if (limit <= 0) 20 else if (limit > 50) 50 else limit + val chars = if (excludeCharacterIds.isNotEmpty()) { + chatCharacterRepository.findRandomActiveExcluding(excludeCharacterIds, PageRequest.of(0, safeLimit)) + } else { + chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit)) + } + + val recentSet = if (chars.isNotEmpty()) { + imageRepository + .findCharacterIdsWithRecentImages( + chars.map { it.id!! }, + LocalDateTime.now().minusDays(3) + ) + .toSet() + } else { + emptySet() + } + + return chars.map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", + isNew = recentSet.contains(it.id) + ) + } + } + /** * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 @@ -51,12 +84,25 @@ class ChatCharacterService( val window = RankingWindowCalculator.now("popular-character") val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) val list = loadCharactersInOrder(topIds) + + val recentSet = if (list.isNotEmpty()) { + imageRepository + .findCharacterIdsWithRecentImages( + list.map { it.id!! }, + LocalDateTime.now().minusDays(3) + ) + .toSet() + } else { + emptySet() + } + return list.map { Character( characterId = it.id!!, name = it.name, description = it.description, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", + isNew = recentSet.contains(it.id) ) } } @@ -91,15 +137,28 @@ class ChatCharacterService( content = emptyList() ) } - val fallback = chatCharacterRepository.findByIsActiveTrue( + val chars = chatCharacterRepository.findByIsActiveTrue( PageRequest.of(0, 20, Sort.by("createdAt").descending()) - ) - val content = fallback.content.map { + ).content + + val recentSet = if (chars.isNotEmpty()) { + imageRepository + .findCharacterIdsWithRecentImages( + chars.map { it.id!! }, + LocalDateTime.now().minusDays(3) + ) + .toSet() + } else { + emptySet() + } + + val content = chars.map { Character( characterId = it.id!!, name = it.name, description = it.description, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", + isNew = recentSet.contains(it.id) ) } return RecentCharactersResponse( @@ -108,16 +167,29 @@ class ChatCharacterService( ) } - val pageResult = chatCharacterRepository.findRecentSince( + val chars = chatCharacterRepository.findRecentSince( since, PageRequest.of(safePage, safeSize) - ) - val content = pageResult.content.map { + ).content + + val recentSet = if (chars.isNotEmpty()) { + imageRepository + .findCharacterIdsWithRecentImages( + chars.map { it.id!! }, + LocalDateTime.now().minusDays(3) + ) + .toSet() + } else { + emptySet() + } + + val content = chars.map { Character( characterId = it.id!!, name = it.name, description = it.description, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", + isNew = recentSet.contains(it.id) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index 43ad355..fab48a6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.chat.original.controller +import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.dto.Character +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime /** * 앱용 원작(오리지널 작품) 공개 API @@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/chat/original") class OriginalWorkController( private val queryService: OriginalWorkQueryService, + private val characterImageRepository: CharacterImageRepository, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -65,17 +70,34 @@ class OriginalWorkController( if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") val ow = queryService.getOriginalWork(id) - val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20) - val characters = pageRes.content.map { - val path = it.imagePath ?: "profile/default-profile.png" - Character( - characterId = it.id!!, - name = it.name, - description = it.description, - imageUrl = "$imageHost/$path" - ) + val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content + + val recentSet = if (chars.isNotEmpty()) { + characterImageRepository + .findCharacterIdsWithRecentImages( + chars.map { it.id!! }, + LocalDateTime.now().minusDays(3) + ) + .toSet() + } else { + emptySet() } - val response = OriginalWorkDetailResponse.from(ow, imageHost, characters) - ApiResponse.ok(response) + + ApiResponse.ok( + OriginalWorkDetailResponse.from( + ow, + imageHost, + chars.map { + val path = it.imagePath ?: "profile/default-profile.png" + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/$path", + isNew = recentSet.contains(it.id) + ) + } + ) + ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt index b6b88f5..c32f3d3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -59,7 +59,7 @@ class OriginalWorkQueryService( val safePage = if (page < 0) 0 else page val safeSize = when { size <= 0 -> 20 - size > 50 -> 50 + size > 20 -> 20 else -> size } val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt index c9510ab..ff15997 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -237,6 +237,30 @@ class AudioContentController(private val service: AudioContentService) { ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member)) } + @GetMapping("/all") + fun getAllContents( + @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, + @RequestParam("contentType", required = false) contentType: ContentType? = null, + @RequestParam("isFree", required = false) isFree: Boolean? = null, + @RequestParam("isPointAvailableOnly", required = false) isPointAvailableOnly: Boolean? = null, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + + ApiResponse.ok( + service.getLatestContentByTheme( + theme = emptyList(), + contentType = contentType ?: ContentType.ALL, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + isFree = isFree ?: false, + isAdult = (isAdultContentVisible ?: true) && member.auth != null, + isPointAvailableOnly = isPointAvailableOnly ?: false + ) + ) + } + @GetMapping("/replay-live") fun replayLive( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt index 998bbc7..e9f69fd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt @@ -18,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController class ContentSeriesController(private val service: ContentSeriesService) { @GetMapping fun getSeriesList( - @RequestParam creatorId: Long, + @RequestParam(required = false) creatorId: Long?, + @RequestParam(name = "isOriginal", required = false) isOriginal: Boolean? = null, + @RequestParam(name = "isCompleted", required = false) isCompleted: Boolean? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @@ -29,6 +31,8 @@ class ContentSeriesController(private val service: ContentSeriesService) { ApiResponse.ok( service.getSeriesList( creatorId = creatorId, + isOriginal = isOriginal ?: false, + isCompleted = isCompleted ?: false, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, member = member, 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 8cf50e7..2b97ea5 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 @@ -14,6 +14,7 @@ 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.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState 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 @@ -23,10 +24,34 @@ import org.springframework.data.jpa.repository.JpaRepository interface ContentSeriesRepository : JpaRepository, ContentSeriesQueryRepository interface ContentSeriesQueryRepository { - fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int + fun getSeriesTotalCount( + creatorId: Long?, + isAuth: Boolean, + contentType: ContentType, + isOriginal: Boolean, + isCompleted: Boolean + ): Int + fun getSeriesList( imageHost: String, - creatorId: Long, + creatorId: Long?, + isAuth: Boolean, + contentType: ContentType, + isOriginal: Boolean, + isCompleted: Boolean, + offset: Long, + limit: Long + ): List + + fun getSeriesByGenreTotalCount( + genreId: Long, + isAuth: Boolean, + contentType: ContentType + ): Int + + fun getSeriesByGenreList( + imageHost: String, + genreId: Long, isAuth: Boolean, contentType: ContentType, offset: Long, @@ -59,9 +84,26 @@ interface ContentSeriesQueryRepository { class ContentSeriesQueryRepositoryImpl( private val queryFactory: JPAQueryFactory ) : ContentSeriesQueryRepository { - override fun getSeriesTotalCount(creatorId: Long, isAuth: Boolean, contentType: ContentType): Int { - var where = series.member.id.eq(creatorId) - .and(series.isActive.isTrue) + override fun getSeriesTotalCount( + creatorId: Long?, + isAuth: Boolean, + contentType: ContentType, + isOriginal: Boolean, + isCompleted: Boolean + ): Int { + var where = series.isActive.isTrue + + if (creatorId != null) { + where = where.and(series.member.id.eq(creatorId)) + } + + if (isOriginal) { + where = where.and(series.isOriginal.isTrue) + } + + if (isCompleted) { + where = where.and(series.state.eq(SeriesState.COMPLETE)) + } if (!isAuth) { where = where.and(series.isAdult.isFalse) @@ -92,14 +134,26 @@ class ContentSeriesQueryRepositoryImpl( override fun getSeriesList( imageHost: String, - creatorId: Long, + creatorId: Long?, isAuth: Boolean, contentType: ContentType, + isOriginal: Boolean, + isCompleted: Boolean, offset: Long, limit: Long ): List { - var where = series.member.id.eq(creatorId) - .and(series.isActive.isTrue) + var where = series.isActive.isTrue + + if (creatorId != null) { + where = where.and(series.member.id.eq(creatorId)) + } + if (isOriginal) { + where = where.and(series.isOriginal.isTrue) + } + + if (isCompleted) { + where = where.and(series.state.eq(SeriesState.COMPLETE)) + } if (!isAuth) { where = where.and(series.isAdult.isFalse) @@ -129,6 +183,89 @@ 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(seriesContent) + .innerJoin(seriesContent.series, series) + .innerJoin(seriesContent.content, audioContent) + .innerJoin(series.member, member) + .innerJoin(series.genre, seriesGenre) + .where(where) + .groupBy(series.id) + .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) + .groupBy(series.id) + .orderBy(audioContent.releaseDate.max().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 20404de..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 @@ -49,25 +49,61 @@ class ContentSeriesService( } fun getSeriesList( - creatorId: Long, + creatorId: Long?, + isOriginal: Boolean = false, + isCompleted: Boolean = false, isAdultContentVisible: Boolean, contentType: ContentType, member: Member, offset: Long = 0, - limit: Long = 10 + limit: Long = 20 ): GetSeriesListResponse { val isAuth = member.auth != null && isAdultContentVisible val totalCount = repository.getSeriesTotalCount( creatorId = creatorId, isAuth = isAuth, - contentType = contentType + contentType = contentType, + isOriginal = isOriginal, + isCompleted = isCompleted ) + val rawItems = repository.getSeriesList( imageHost = coverImageHost, creatorId = creatorId, isAuth = isAuth, contentType = contentType, + isOriginal = isOriginal, + isCompleted = isCompleted, + 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 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!!) } @@ -201,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/content/series/main/banner/ContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt new file mode 100644 index 0000000..90f876e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt @@ -0,0 +1,80 @@ +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 org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ContentSeriesBannerService( + private val bannerRepository: SeriesBannerRepository, + private val seriesRepository: AdminContentSeriesRepository +) { + fun getActiveBanners(pageable: Pageable): Page { + return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) + } + + fun getBannerById(bannerId: Long): SeriesBanner { + return bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + } + + @Transactional + fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner { + val series = seriesRepository.findByIdAndActiveTrue(seriesId) + ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") + + val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1 + + val banner = SeriesBanner( + imagePath = imagePath, + series = series, + sortOrder = finalSortOrder + ) + return bannerRepository.save(banner) + } + + @Transactional + fun updateBanner( + bannerId: Long, + imagePath: String? = null, + seriesId: Long? = null + ): SeriesBanner { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") + + if (imagePath != null) banner.imagePath = imagePath + + if (seriesId != null) { + val series = seriesRepository.findByIdAndActiveTrue(seriesId) + ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") + banner.series = series + } + + return bannerRepository.save(banner) + } + + @Transactional + fun deleteBanner(bannerId: Long) { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + banner.isActive = false + bannerRepository.save(banner) + } + + @Transactional + fun updateBannerOrders(ids: List): List { + val updated = mutableListOf() + for (index in ids.indices) { + val banner = bannerRepository.findById(ids[index]) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") } + if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}") + banner.sortOrder = index + 1 + updated.add(bannerRepository.save(banner)) + } + return updated + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt new file mode 100644 index 0000000..2b4cc81 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.content.series.main.banner + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 시리즈 배너 엔티티 + * 이미지와 시리즈 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다. + * 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다. + */ +@Entity +class SeriesBanner( + // 배너 이미지 경로 + var imagePath: String? = null, + + // 연관된 캐릭터 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "series_id") + var series: Series, + + // 정렬 순서 (낮을수록 먼저 표시) + var sortOrder: Int = 0, + + // 활성화 여부 (소프트 삭제용) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt new file mode 100644 index 0000000..c2c2052 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.content.series.main.banner + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface SeriesBannerRepository : JpaRepository { + fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page + + @Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true") + fun findMaxSortOrder(): Int? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/ContentRankingSortType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/ContentRankingSortType.kt new file mode 100644 index 0000000..312b44c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/ContentRankingSortType.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.rank + +/** + * 콘텐츠 랭킹 정렬 기준 + */ +enum class ContentRankingSortType { + // 매출: order.can.sum.desc + REVENUE, + + // 판매량: order.id.count.desc + SALES_COUNT, + + // 댓글 수: audioContentComment.id.count.desc + COMMENT_COUNT, + + // 좋아요 수: audioContentLike.id.count.desc + LIKE_COUNT, + + // 후원: audioContentComment.donationCan.sum.desc + DONATION +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index ddf2288..7e81aa5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -132,6 +132,14 @@ class RankingRepository( .innerJoin(audioContent.theme, audioContentTheme) } + "판매량" -> { + select + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(audioContent.theme, audioContentTheme) + } + else -> { select .from(order) @@ -184,6 +192,18 @@ class RankingRepository( .orderBy(audioContentLike.id.count().desc(), audioContent.createdAt.asc()) } + "판매량" -> { + select + .where( + where + .and(order.isActive.isTrue) + .and(order.createdAt.goe(startDate)) + .and(order.createdAt.lt(endDate)) + ) + .groupBy(audioContent.id) + .orderBy(order.id.count().desc(), audioContent.createdAt.asc()) + } + else -> { select .where( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt index 7b5fdeb..3ae4d09 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt @@ -76,6 +76,38 @@ class RankingService( return contentList } + private fun toSortString(sort: ContentRankingSortType): String = when (sort) { + ContentRankingSortType.REVENUE -> "매출" + ContentRankingSortType.SALES_COUNT -> "판매량" + ContentRankingSortType.COMMENT_COUNT -> "댓글" + ContentRankingSortType.LIKE_COUNT -> "좋아요" + ContentRankingSortType.DONATION -> "후원" + } + + fun getContentRanking( + memberId: Long?, + isAdult: Boolean, + contentType: ContentType, + startDate: LocalDateTime, + endDate: LocalDateTime, + offset: Long = 0, + limit: Long = 12, + sort: ContentRankingSortType, + theme: String = "" + ): List { + return getContentRanking( + memberId = memberId, + isAdult = isAdult, + contentType = contentType, + startDate = startDate, + endDate = endDate, + offset = offset, + limit = limit, + sortType = toSortString(sort), + theme = theme + ) + } + fun getSeriesRanking( memberId: Long?, isAdult: Boolean, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesServiceTest.kt new file mode 100644 index 0000000..ddf0b09 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesServiceTest.kt @@ -0,0 +1,120 @@ +package kr.co.vividnext.sodalive.admin.content.series + +import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.common.SodaException +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.SeriesState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class AdminContentSeriesServiceTest { + private lateinit var seriesRepository: AdminContentSeriesRepository + private lateinit var genreRepository: AdminContentSeriesGenreRepository + private lateinit var service: AdminContentSeriesService + + @BeforeEach + fun setup() { + seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java) + genreRepository = Mockito.mock(AdminContentSeriesGenreRepository::class.java) + service = AdminContentSeriesService(seriesRepository, genreRepository) + } + + @Test + fun shouldModifySeriesFieldsByAdmin() { + // given + val series = Series( + title = "title", + introduction = "intro", + state = SeriesState.PROCEEDING + ) + series.id = 1L + series.genre = SeriesGenre(genre = "Old", isAdult = false) + series.publishedDaysOfWeek.add(SeriesPublishedDaysOfWeek.MON) + series.isAdult = false + series.isOriginal = false + + Mockito.`when`(seriesRepository.findByIdAndActiveTrue(1L)).thenReturn(series) + + val newGenre = SeriesGenre(genre = "New", isAdult = false) + newGenre.id = 10L + Mockito.`when`(genreRepository.findActiveSeriesGenreById(10L)).thenReturn(newGenre) + + val request = AdminModifySeriesRequest( + seriesId = 1L, + publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.WED), + genreId = 10L, + isOriginal = true, + isAdult = true + ) + + // when + service.modifySeries(request) + + // then + assertEquals(setOf(SeriesPublishedDaysOfWeek.WED), series.publishedDaysOfWeek) + assertEquals(newGenre, series.genre) + assertEquals(true, series.isOriginal) + assertEquals(true, series.isAdult) + } + + @Test + fun shouldThrowWhenRandomAndOtherDaysSelectedTogether() { + // given + val series = Series( + title = "title", + introduction = "intro", + state = SeriesState.PROCEEDING + ) + series.id = 2L + series.genre = SeriesGenre(genre = "Old", isAdult = false) + + Mockito.`when`(seriesRepository.findByIdAndActiveTrue(2L)).thenReturn(series) + + val request = AdminModifySeriesRequest( + seriesId = 2L, + publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.RANDOM, SeriesPublishedDaysOfWeek.MON), + genreId = null, + isOriginal = null, + isAdult = null + ) + + // expect + assertThrows(SodaException::class.java) { + service.modifySeries(request) + } + } + + @Test + fun shouldThrowWhenGenreNotFound() { + // given + val series = Series( + title = "title", + introduction = "intro", + state = SeriesState.PROCEEDING + ) + series.id = 3L + series.genre = SeriesGenre(genre = "Old", isAdult = false) + Mockito.`when`(seriesRepository.findByIdAndActiveTrue(3L)).thenReturn(series) + + // genre not found + Mockito.`when`(genreRepository.findActiveSeriesGenreById(999L)).thenReturn(null) + + val request = AdminModifySeriesRequest( + seriesId = 3L, + publishedDaysOfWeek = null, + genreId = 999L, + isOriginal = null, + isAdult = null + ) + + // expect + assertThrows(SodaException::class.java) { + service.modifySeries(request) + } + } +}