From a538bb766d6aca0d1b8d563349516ea577bac6ef Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 11 Nov 2025 14:21:37 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(home):=20=ED=99=88=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=EC=9A=A9=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeService: getRecommendContentList 추가 및 fetchData에 recommendContentList 주입 - HomeController: GET /api/home/recommend-contents 엔드포인트 추가 - 추천 로직은 랜덤 20개, 성인/타입/차단 필터 반영 --- .../sodalive/api/home/GetHomeResponse.kt | 1 + .../sodalive/api/home/HomeController.kt | 16 ++++++ .../sodalive/api/home/HomeService.kt | 49 +++++++++++++++++++ 3 files changed, 66 insertions(+) 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..3b44d1e 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 @@ -63,4 +63,20 @@ 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 + ) + ) + } } 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..7501745 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 @@ -48,6 +48,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, @@ -213,6 +218,11 @@ class HomeService( recommendChannelList = recommendChannelList, freeContentList = freeContentList, pointAvailableContentList = pointAvailableContentList, + recommendContentList = getRecommendContentList( + isAdultContentVisible = isAdultContentVisible, + contentType = contentType, + member = member + ), curationList = curationList ) } @@ -284,4 +294,43 @@ 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 + + // 최대 3회까지 동일 로직으로 추가 조회하며, 중복을 제거하고 20개가 되면 조기 반환한다. + val unique = LinkedHashMap() // contentId 기준 중복 제거 + 순서 보존 + var attempt = 0 + while (attempt < RECOMMEND_MAX_ATTEMPTS && unique.size < RECOMMEND_TARGET_SIZE) { + attempt += 1 + val batch = contentService.getLatestContentByTheme( + theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회 + contentType = contentType, + isFree = false, + isAdult = isAdult, + orderByRandom = true + ).filter { + if (memberId != null) { + !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) + } else { + true + } + } + + for (item in batch) { + if (unique.size >= RECOMMEND_TARGET_SIZE) break + if (!unique.containsKey(item.contentId)) { + unique[item.contentId] = item + } + } + } + + return unique.values.take(RECOMMEND_TARGET_SIZE) + } } From 80c44373c744aa58eaa9fef97a9255595b096877 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 11 Nov 2025 14:46:36 +0900 Subject: [PATCH 02/15] =?UTF-8?q?refactor(home):=20=EC=B6=94=EC=B2=9C=20de?= =?UTF-8?q?dup=20=EC=9E=90=EB=A3=8C=EA=B5=AC=EC=A1=B0=EB=A5=BC=20LinkedHas?= =?UTF-8?q?hMap=EC=97=90=EC=84=9C=20Set+List=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/api/home/HomeService.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 7501745..7283f5c 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 @@ -304,14 +304,17 @@ class HomeService( val memberId = member?.id val isAdult = member?.auth != null && isAdultContentVisible - // 최대 3회까지 동일 로직으로 추가 조회하며, 중복을 제거하고 20개가 되면 조기 반환한다. - val unique = LinkedHashMap() // contentId 기준 중복 제거 + 순서 보존 + // 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 && unique.size < RECOMMEND_TARGET_SIZE) { + 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 @@ -324,13 +327,13 @@ class HomeService( } for (item in batch) { - if (unique.size >= RECOMMEND_TARGET_SIZE) break - if (!unique.containsKey(item.contentId)) { - unique[item.contentId] = item + if (result.size >= RECOMMEND_TARGET_SIZE) break + if (seen.add(item.contentId)) { + result.add(item) } } } - return unique.values.take(RECOMMEND_TARGET_SIZE) + return result } } From 16b6c13309112a5948f6291e760553518f81310a Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 11 Nov 2025 17:01:50 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(chat-character):=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=9D=B8/=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20API=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatCharacterController.kt | 27 +++++++++++++++++++ .../character/dto/CharacterHomeResponse.kt | 1 + .../repository/ChatCharacterRepository.kt | 24 +++++++++++++++++ .../character/service/ChatCharacterService.kt | 18 +++++++++++++ 4 files changed, 70 insertions(+) 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..3957e08 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 -> @@ -98,6 +105,7 @@ class ChatCharacterController( recentCharacters = recentCharacters, popularCharacters = popularCharacters, newCharacters = newCharacters, + recommendCharacters = recommendCharacters, curationSections = curationSections ) ) @@ -193,4 +201,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..6b848d9 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 ) 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..68d06aa 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 @@ -38,6 +38,24 @@ class ChatCharacterService( @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)) + } + return chars.map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + } + /** * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용 From fe76ecdfa90c27bc575f6a4d60f4dbeea4297376 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 11 Nov 2025 23:02:58 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(chat-character):=20=EB=B3=B4?= =?UTF-8?q?=EC=98=A8=20=EC=A3=BC=EA=B0=84=20=EC=B0=A8=ED=8A=B8=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매출, 판매량, 댓글 수, 좋아요 수, 후원 --- .../sodalive/api/home/HomeController.kt | 25 ++++++++++++ .../sodalive/api/home/HomeService.kt | 38 ++++++++++++++++++- .../sodalive/rank/ContentRankingSortType.kt | 21 ++++++++++ .../sodalive/rank/RankingRepository.kt | 20 ++++++++++ .../vividnext/sodalive/rank/RankingService.kt | 32 ++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/rank/ContentRankingSortType.kt 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 3b44d1e..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 @@ -79,4 +80,28 @@ class HomeController(private val service: HomeService) { ) ) } + + // 콘텐츠 랭킹 엔드포인트 + @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 7283f5c..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 @@ -153,7 +155,7 @@ class HomeService( contentType = contentType, startDate = startDate.minusDays(1), endDate = endDate, - sortType = "매출" + sort = ContentRankingSortType.REVENUE ) val recommendChannelList = recommendChannelService.getRecommendChannel( @@ -277,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) 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, From ee35244296be81f1f1a4637936b66e8a737322de Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 12 Nov 2025 13:47:30 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat(content):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/AudioContentController.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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..0572d23 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,32 @@ 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 { + ApiResponse.ok( + service.getLatestContentByTheme( + theme = emptyList(), + contentType = contentType ?: ContentType.ALL, + offset = pageable.offset, + limit = pageable.pageSize.toLong(), + isFree = isFree ?: false, + isAdult = if (member != null) { + (isAdultContentVisible ?: true) && member.auth != null + } else { + false + }, + isPointAvailableOnly = isPointAvailableOnly ?: false + ) + ) + } + @GetMapping("/replay-live") fun replayLive( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, From 3ed306ae8c8cef63854c1abbcf26f6144de248da Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 12 Nov 2025 13:56:37 +0900 Subject: [PATCH 06/15] =?UTF-8?q?fix(content):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20-=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=90=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EB=A7=8C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/content/AudioContentController.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 0572d23..ff15997 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -246,6 +246,8 @@ class AudioContentController(private val service: AudioContentService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + ApiResponse.ok( service.getLatestContentByTheme( theme = emptyList(), @@ -253,11 +255,7 @@ class AudioContentController(private val service: AudioContentService) { offset = pageable.offset, limit = pageable.pageSize.toLong(), isFree = isFree ?: false, - isAdult = if (member != null) { - (isAdultContentVisible ?: true) && member.auth != null - } else { - false - }, + isAdult = (isAdultContentVisible ?: true) && member.auth != null, isPointAvailableOnly = isPointAvailableOnly ?: false ) ) From 4f52ec06631710114a5e95b292a6d21019790236 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 12 Nov 2025 14:58:48 +0900 Subject: [PATCH 07/15] =?UTF-8?q?fix(admin-series):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../series/AdminContentSeriesController.kt | 7 + .../series/AdminContentSeriesService.kt | 38 +++++- .../series/AdminModifySeriesRequest.kt | 11 ++ .../AdminContentSeriesGenreRepository.kt | 11 ++ .../series/AdminContentSeriesServiceTest.kt | 120 ++++++++++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminModifySeriesRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesServiceTest.kt 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..6b757ec 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( @@ -18,4 +25,33 @@ class AdminContentSeriesService(private val repository: AdminContentSeriesReposi 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/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/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) + } + } +} From bf149c45adf184bd4860914bb6aa24247340a5fe Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 12 Nov 2025 16:37:28 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat(admin-series):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=91=EB=8B=B5=EC=97=90=20publishedDaysOfWeek(?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8)=EC=99=80=20isOriginal(Boolean)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/AdminContentSeriesService.kt | 14 ++++++++++++++ .../content/series/GetAdminSeriesListResponse.kt | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) 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 6b757ec..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 @@ -19,6 +19,20 @@ class AdminContentSeriesService( 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) } 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, From 39760e16ffc1080472e148309c09a9dbe80bf043 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 12 Nov 2025 17:25:38 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat(series):=20=EC=98=A4=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=8A=A4=EC=98=A8=EC=97=90=EC=84=9C=EB=A7=8C?= =?UTF-8?q?(=EC=98=A4=EB=A6=AC=EC=A7=80=EB=84=90)=20=EC=A0=9C=EA=B3=B5?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=BD=98=ED=85=90=EC=B8=A0=EB=8F=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20isOriginal=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/ContentSeriesController.kt | 4 ++- .../content/series/ContentSeriesRepository.kt | 31 ++++++++++++++----- .../content/series/ContentSeriesService.kt | 8 +++-- 3 files changed, 32 insertions(+), 11 deletions(-) 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..5eab4b9 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,8 @@ 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("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @@ -29,6 +30,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { ApiResponse.ok( service.getSeriesList( creatorId = creatorId, + isOriginal = isOriginal ?: 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..93a7666 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 @@ -23,12 +23,13 @@ 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): Int fun getSeriesList( imageHost: String, - creatorId: Long, + creatorId: Long?, isAuth: Boolean, contentType: ContentType, + isOriginal: Boolean, offset: Long, limit: Long ): List @@ -59,9 +60,16 @@ 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): 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 (!isAuth) { where = where.and(series.isAdult.isFalse) @@ -92,14 +100,21 @@ class ContentSeriesQueryRepositoryImpl( override fun getSeriesList( imageHost: String, - creatorId: Long, + creatorId: Long?, isAuth: Boolean, contentType: ContentType, + isOriginal: 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 (!isAuth) { where = where.and(series.isAdult.isFalse) 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..c75b665 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,7 +49,8 @@ class ContentSeriesService( } fun getSeriesList( - creatorId: Long, + creatorId: Long?, + isOriginal: Boolean = false, isAdultContentVisible: Boolean, contentType: ContentType, member: Member, @@ -61,13 +62,16 @@ class ContentSeriesService( val totalCount = repository.getSeriesTotalCount( creatorId = creatorId, isAuth = isAuth, - contentType = contentType + contentType = contentType, + isOriginal = isOriginal ) + val rawItems = repository.getSeriesList( imageHost = coverImageHost, creatorId = creatorId, isAuth = isAuth, contentType = contentType, + isOriginal = isOriginal, offset = offset, limit = limit ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } From 9464cc5ed447166f3a3f71a860156bf50885edf5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 10:22:55 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat(series):=20=EC=99=84=EA=B2=B0?= =?UTF-8?q?=EB=90=9C=20=EC=8B=9C=EB=A6=AC=EC=A6=88=EB=A5=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?isCompleted=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/ContentSeriesController.kt | 2 ++ .../content/series/ContentSeriesRepository.kt | 28 +++++++++++++++++-- .../content/series/ContentSeriesService.kt | 7 +++-- 3 files changed, 33 insertions(+), 4 deletions(-) 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 5eab4b9..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 @@ -20,6 +20,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { fun getSeriesList( @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?, @@ -31,6 +32,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { 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 93a7666..e27eb56 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,13 +24,21 @@ import org.springframework.data.jpa.repository.JpaRepository interface ContentSeriesRepository : JpaRepository, ContentSeriesQueryRepository interface ContentSeriesQueryRepository { - fun getSeriesTotalCount(creatorId: Long?, isAuth: Boolean, contentType: ContentType, isOriginal: Boolean): Int + fun getSeriesTotalCount( + creatorId: Long?, + isAuth: Boolean, + contentType: ContentType, + isOriginal: Boolean, + isCompleted: Boolean + ): Int + fun getSeriesList( imageHost: String, creatorId: Long?, isAuth: Boolean, contentType: ContentType, isOriginal: Boolean, + isCompleted: Boolean, offset: Long, limit: Long ): List @@ -60,7 +69,13 @@ interface ContentSeriesQueryRepository { class ContentSeriesQueryRepositoryImpl( private val queryFactory: JPAQueryFactory ) : ContentSeriesQueryRepository { - override fun getSeriesTotalCount(creatorId: Long?, isAuth: Boolean, contentType: ContentType, isOriginal: Boolean): Int { + override fun getSeriesTotalCount( + creatorId: Long?, + isAuth: Boolean, + contentType: ContentType, + isOriginal: Boolean, + isCompleted: Boolean + ): Int { var where = series.isActive.isTrue if (creatorId != null) { @@ -71,6 +86,10 @@ class ContentSeriesQueryRepositoryImpl( where = where.and(series.isOriginal.isTrue) } + if (isCompleted) { + where = where.and(series.state.eq(SeriesState.COMPLETE)) + } + if (!isAuth) { where = where.and(series.isAdult.isFalse) } else { @@ -104,6 +123,7 @@ class ContentSeriesQueryRepositoryImpl( isAuth: Boolean, contentType: ContentType, isOriginal: Boolean, + isCompleted: Boolean, offset: Long, limit: Long ): List { @@ -116,6 +136,10 @@ class ContentSeriesQueryRepositoryImpl( where = where.and(series.isOriginal.isTrue) } + if (isCompleted) { + where = where.and(series.state.eq(SeriesState.COMPLETE)) + } + if (!isAuth) { where = where.and(series.isAdult.isFalse) } else { 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 c75b665..005fdcc 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 @@ -51,11 +51,12 @@ class ContentSeriesService( fun getSeriesList( 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 @@ -63,7 +64,8 @@ class ContentSeriesService( creatorId = creatorId, isAuth = isAuth, contentType = contentType, - isOriginal = isOriginal + isOriginal = isOriginal, + isCompleted = isCompleted ) val rawItems = repository.getSeriesList( @@ -72,6 +74,7 @@ class ContentSeriesService( isAuth = isAuth, contentType = contentType, isOriginal = isOriginal, + isCompleted = isCompleted, offset = offset, limit = limit ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } From 27be9a4fc2ff9a6294e136f32795a54eebc48637 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 11:37:46 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat(series-banner):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EB=B0=B0=EB=84=88=EC=9D=98=20=EB=93=B1=EB=A1=9D,?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C,=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EC=A0=95=EB=A0=AC=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=9D=BC=EA=B4=84=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9D=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminContentSeriesBannerController.kt | 144 ++++++++++++++++++ .../banner/AdminContentSeriesBannerService.kt | 81 ++++++++++ .../series/banner/dto/SeriesBannerDtos.kt | 40 +++++ .../series/main/banner/SeriesBanner.kt | 30 ++++ .../main/banner/SeriesBannerRepository.kt | 15 ++ 5 files changed, 310 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/dto/SeriesBannerDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBanner.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/SeriesBannerRepository.kt 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..9dd67aa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -0,0 +1,144 @@ +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.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: AdminContentSeriesBannerService, + 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/AdminContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt new file mode 100644 index 0000000..598dd95 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerService.kt @@ -0,0 +1,81 @@ +package kr.co.vividnext.sodalive.admin.content.series.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 + +@Service +class AdminContentSeriesBannerService( + 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") } + } + + @org.springframework.transaction.annotation.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) + } + + @org.springframework.transaction.annotation.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) + } + + @org.springframework.transaction.annotation.Transactional + fun deleteBanner(bannerId: Long) { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + banner.isActive = false + bannerRepository.save(banner) + } + + @org.springframework.transaction.annotation.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/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/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? +} From 4f89b0189ef898f89757e2a94b6224bf32bfbbac Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 16:02:11 +0900 Subject: [PATCH 12/15] =?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) { From 9f6bdf6ed8e3dca6adbe2ad9215819019c6e6bfc Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 19:57:48 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat(series-main):=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=EB=B3=84=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20group=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audioContent.id를 그룹 조건에서 제거 --- .../sodalive/content/series/ContentSeriesRepository.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 25baba6..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 @@ -211,10 +211,13 @@ class ContentSeriesQueryRepositoryImpl( return queryFactory .select(series.id) - .from(series) + .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 } @@ -256,7 +259,8 @@ class ContentSeriesQueryRepositoryImpl( .innerJoin(series.member, member) .innerJoin(series.genre, seriesGenre) .where(where) - .orderBy(audioContent.releaseDate.desc(), series.createdAt.asc()) + .groupBy(series.id) + .orderBy(audioContent.releaseDate.max().desc(), series.createdAt.asc()) .offset(offset) .limit(limit) .fetch() From e4c1cf5a9a1b6ea4bb8f7b1c04a605c7ff9ddaf0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 22:41:20 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat(repo):=20=EC=B5=9C=EA=B7=BC=203?= =?UTF-8?q?=EC=9D=BC=20=EB=82=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=20=EC=BA=90=EB=A6=AD=ED=84=B0=20id=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EB=82=B4=EC=9A=A9:=20`findCharacterIdsWithRece?= =?UTF-8?q?ntImages(characterIds,=20since)`=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8:=20=EC=99=9C(=EC=9D=B4=EC=9C=A0)=20=E2=80=93?= =?UTF-8?q?=20N+1=20=EC=A0=9C=EA=B1=B0,=20=EB=AC=B4=EC=97=87=20=E2=80=93?= =?UTF-8?q?=20IN=20=EA=B8=B0=EB=B0=98=20=EB=B2=8C=ED=81=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character/image/CharacterImageRepository.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 { From 597bd8f8ae0ddad86cf158247596a51c6c5feb84 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 13 Nov 2025 22:44:13 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat(chat-character):=20Character=20DTO?= =?UTF-8?q?=EC=97=90=20isNew=20=EB=A7=A4=ED=95=91=20=EC=A0=81=EC=9A=A9(N+1?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0)=20-=20=EB=82=B4=EC=9A=A9:=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A7=A4=ED=95=91=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=EC=BF=BC=EB=A6=AC=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=B4=20`isNew`=20=EC=B1=84?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatCharacterController.kt | 3 +- .../character/dto/CharacterHomeResponse.kt | 3 +- .../character/service/ChatCharacterService.kt | 74 ++++++++++++++++--- .../controller/OriginalWorkController.kt | 44 ++++++++--- .../service/OriginalWorkQueryService.kt | 2 +- 5 files changed, 102 insertions(+), 24 deletions(-) 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 3957e08..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 @@ -92,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 ) } ) 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 6b848d9..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 @@ -21,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/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 68d06aa..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,6 +35,7 @@ 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 @@ -46,12 +48,25 @@ class ChatCharacterService( } 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"}" + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", + isNew = recentSet.contains(it.id) ) } } @@ -69,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) ) } } @@ -109,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( @@ -126,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())