From 6b469c1fade8b213517b1f2887dc38ab91192ec6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 5 Jun 2026 18:15:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=ED=99=88=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=9D=91=EB=8B=B5=20=ED=95=84=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/HomeRecommendationFacade.kt | 46 ++++---- .../home/dto/HomeRecommendationResponse.kt | 29 +++-- ...efaultHomeRecommendationQueryRepository.kt | 84 ++++++--------- .../port/out/HomeRecommendationQueryPort.kt | 27 ++--- .../home/HomeRecommendationControllerTest.kt | 4 +- .../dto/HomeRecommendationResponseTest.kt | 28 ++++- ...ltHomeRecommendationQueryRepositoryTest.kt | 102 +++++++++--------- .../HomeRecommendationQueryServiceTest.kt | 27 ++--- 8 files changed, 164 insertions(+), 183 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index 2bdea0f8..4708f23c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.application +import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy @@ -14,6 +15,7 @@ import kr.co.vividnext.sodalive.v2.api.home.dto.HomePopularCommunityPostItem import kr.co.vividnext.sodalive.v2.api.home.dto.HomeRecommendationPageResponse import kr.co.vividnext.sodalive.v2.api.home.dto.HomeRecommendationResponse import kr.co.vividnext.sodalive.v2.api.home.dto.imageUrl +import kr.co.vividnext.sodalive.v2.api.home.dto.profileImageUrl import kr.co.vividnext.sodalive.v2.api.home.dto.toUtcIso import kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord @@ -228,30 +230,38 @@ class HomeRecommendationFacade( } private fun HomeLiveRecommendationRecord.toItem() = HomeLiveItem( - liveRoomId = liveRoomId, - creatorId = creatorId, + roomId = liveRoomId, creatorNickname = creatorNickname, - creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage), - title = title, - coverImage = imageUrl(cloudFrontHost, coverImage), - beginDateTime = beginDateTime.toUtcIso(), - channelName = channelName + creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage) ) private fun HomeBannerRecommendationRecord.toItem() = HomeBannerItem( - bannerId = bannerId, - type = type, - thumbnailImage = imageUrl(cloudFrontHost, thumbnailImage), - eventId = eventId, + imageUrl = imageUrl(cloudFrontHost, thumbnailImage) ?: "", + eventItem = eventItem(), creatorId = creatorId, seriesId = seriesId, link = link ) + private fun HomeBannerRecommendationRecord.eventItem(): EventItem? { + if (eventId == null || eventThumbnailImage == null) return null + return EventItem( + id = eventId, + thumbnailImageUrl = eventImageUrl(eventThumbnailImage) ?: eventThumbnailImage, + detailImageUrl = eventImageUrl(eventDetailImage), + popupImageUrl = null, + link = eventLink + ) + } + + private fun eventImageUrl(path: String?): String? { + if (path.isNullOrBlank()) return null + return if (path.startsWith("https://")) path else imageUrl(cloudFrontHost, path) + } + private fun RecentlyActiveCreatorRecord.toItem() = HomeActiveCreatorItem( - creatorId = creatorId, creatorNickname = creatorNickname, - creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage), + creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage), activityType = activityType.name, activityAt = activityAt.toUtcIso(), targetId = targetId @@ -260,18 +270,17 @@ class HomeRecommendationFacade( private fun RecentDebutCreatorRecord.toItem() = HomeCreatorItem( creatorId = creatorId, creatorNickname = creatorNickname, - creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage) + creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage) ) private fun HomeFirstAudioContentRecord.toItem() = HomeFirstAudioContentItem( contentId = contentId, creatorId = creatorId, creatorNickname = creatorNickname, - creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage), + creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage), title = title, price = price, coverImage = imageUrl(cloudFrontHost, coverImage), - releaseDate = releaseDate.toUtcIso(), isPointAvailable = isPointAvailable ) @@ -285,13 +294,12 @@ class HomeRecommendationFacade( ) private fun HomeGenreCreatorRecommendationGroup.toItem() = HomeGenreCreatorGroupItem( - genreId = genreId, genreName = genreName, creators = creators.map { HomeCreatorItem( creatorId = it.creatorId, creatorNickname = it.creatorNickname, - creatorProfileImage = imageUrl(cloudFrontHost, it.creatorProfileImage) + creatorProfileImage = profileImageUrl(cloudFrontHost, it.creatorProfileImage) ) } ) @@ -299,7 +307,7 @@ class HomeRecommendationFacade( private fun HomeCheerCreatorRecommendationRecord.toCreatorItem() = HomeCreatorItem( creatorId = creatorId, creatorNickname = creatorNickname, - creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage) + creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage) ) private fun HomePopularCommunityRecommendationRecord.toItem() = HomePopularCommunityPostItem( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponse.kt index 544ebe7d..6aeda5b6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponse.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.home.dto import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.event.EventItem import java.time.LocalDateTime import java.time.ZoneOffset @@ -12,6 +13,10 @@ internal fun imageUrl(cloudFrontHost: String, path: String?): String? { return if (path.isNullOrBlank()) null else "$cloudFrontHost/$path" } +internal fun profileImageUrl(cloudFrontHost: String, path: String?): String { + return imageUrl(cloudFrontHost, path) ?: "$cloudFrontHost/profile/default-profile.png" +} + data class HomeRecommendationResponse( val lives: List, val banners: List, @@ -25,30 +30,22 @@ data class HomeRecommendationResponse( ) data class HomeLiveItem( - val liveRoomId: Long, - val creatorId: Long, + val roomId: Long, val creatorNickname: String, - val creatorProfileImage: String?, - val title: String, - val coverImage: String?, - val beginDateTime: String, - val channelName: String + val creatorProfileImage: String ) data class HomeBannerItem( - val bannerId: Long, - val type: String, - val thumbnailImage: String?, - val eventId: Long?, + val imageUrl: String, + val eventItem: EventItem?, val creatorId: Long?, val seriesId: Long?, val link: String? ) data class HomeActiveCreatorItem( - val creatorId: Long, val creatorNickname: String, - val creatorProfileImage: String?, + val creatorProfileImage: String, val activityType: String, val activityAt: String, val targetId: Long? @@ -57,18 +54,17 @@ data class HomeActiveCreatorItem( data class HomeCreatorItem( val creatorId: Long, val creatorNickname: String, - val creatorProfileImage: String? + val creatorProfileImage: String ) data class HomeFirstAudioContentItem( val contentId: Long, val creatorId: Long, val creatorNickname: String, - val creatorProfileImage: String?, + val creatorProfileImage: String, val title: String, val price: Int, val coverImage: String?, - val releaseDate: String, @JsonProperty("isPointAvailable") val isPointAvailable: Boolean ) @@ -83,7 +79,6 @@ data class HomeAiCharacterItem( ) data class HomeGenreCreatorGroupItem( - val genreId: Long, val genreName: String, val creators: List ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 8dcc693b..c4a1d4a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -59,13 +59,8 @@ class DefaultHomeRecommendationQueryRepository( Projections.constructor( HomeLiveRecommendationRecord::class.java, liveRoom.id, - member.id, member.nickname, - member.profileImage, - liveRoom.title, - liveRoom.coverImage, - liveRoom.beginDateTime, - liveRoom.channelName + member.profileImage ) ) .from(liveRoom) @@ -96,15 +91,14 @@ class DefaultHomeRecommendationQueryRepository( .select( Projections.constructor( HomeBannerRecommendationRecord::class.java, - audioContentBanner.id, - audioContentBanner.type.stringValue(), audioContentBanner.thumbnailImage, event.id, + event.thumbnailImage, + event.detailImage, + event.link, bannerCreator.id, series.id, - audioContentBanner.link, - audioContentBanner.orders, - randomTieBreaker + audioContentBanner.link ) ) .from(audioContentBanner) @@ -128,8 +122,7 @@ class DefaultHomeRecommendationQueryRepository( includeAdultActivities: Boolean ): List { val sql = """ - select ranked.creator_id, - ranked.creator_nickname, + select ranked.creator_nickname, ranked.creator_profile_image, ranked.activity_type, ranked.activity_at, @@ -202,12 +195,11 @@ class DefaultHomeRecommendationQueryRepository( return rows.map { row -> RecentlyActiveCreatorRecord( - creatorId = (row[0] as Number).toLong(), - creatorNickname = row[1] as String, - creatorProfileImage = row[2] as String?, - activityType = RecommendedActivityType.valueOf(row[3] as String), - activityAt = toLocalDateTime(row[4]), - targetId = (row[5] as Number?)?.toLong() + creatorNickname = row[0] as String, + creatorProfileImage = row[1] as String?, + activityType = RecommendedActivityType.valueOf(row[2] as String), + activityAt = toLocalDateTime(row[3]), + targetId = (row[4] as Number?)?.toLong() ) } } @@ -315,18 +307,7 @@ class DefaultHomeRecommendationQueryRepository( ) select m.id as creator_id, m.nickname as creator_nickname, - m.profile_image as creator_profile_image, - cd.debut_at as debut_at, - ((coalesce(fs.follow_increase, 0) * ${RecommendationScoreSpec.DEBUT_FOLLOW_INCREASE_WEIGHT} + - coalesce(cs.content_activity_score, 0) * ${RecommendationScoreSpec.DEBUT_CONTENT_ACTIVITY_WEIGHT} + - coalesce(cms.communication_score, 0) * ${RecommendationScoreSpec.DEBUT_COMMUNICATION_WEIGHT}) * - case - when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} - when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} - when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} - else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} - end) as score, - m.id as random_tie_breaker + m.profile_image as creator_profile_image from member m join creator_debut cd on cd.creator_id = m.id left join follow_stats fs on fs.creator_id = m.id @@ -336,7 +317,15 @@ class DefaultHomeRecommendationQueryRepository( and cd.debut_at >= :boost30Start and cd.debut_at <= :now and ${notBlockedCreatorSql("m.id")} - order by score desc, random_tie_breaker asc + order by ((coalesce(fs.follow_increase, 0) * ${RecommendationScoreSpec.DEBUT_FOLLOW_INCREASE_WEIGHT} + + coalesce(cs.content_activity_score, 0) * ${RecommendationScoreSpec.DEBUT_CONTENT_ACTIVITY_WEIGHT} + + coalesce(cms.communication_score, 0) * ${RecommendationScoreSpec.DEBUT_COMMUNICATION_WEIGHT}) * + case + when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} + when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} + when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} + else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} + end) desc, rand(m.id) asc limit :limit offset :offset """.trimIndent() @@ -354,10 +343,7 @@ class DefaultHomeRecommendationQueryRepository( RecentDebutCreatorRecord( creatorId = (row[0] as Number).toLong(), creatorNickname = row[1] as String, - creatorProfileImage = row[2] as String?, - debutAt = toLocalDateTime(row[3]), - score = (row[4] as Number).toDouble(), - randomTieBreaker = (row[5] as Number).toDouble() + creatorProfileImage = row[2] as String? ) } } @@ -425,17 +411,7 @@ class DefaultHomeRecommendationQueryRepository( ec.title as title, ec.price as price, ec.cover_image as cover_image, - ec.release_date as release_date, - ec.is_point_available as is_point_available, - case - when ec.release_date >= :recency3Start then 100 - when ec.release_date >= :recency7Start then 80 - when ec.release_date >= :recency14Start then 60 - when ec.release_date >= :recency21Start then 40 - when ec.release_date >= :boost30Start then 20 - else 0 - end as recency_score, - ec.content_id as random_tie_breaker + ec.is_point_available as is_point_available from eligible_contents ec join member m on m.id = ec.creator_id join creator_debut cd on cd.creator_id = ec.creator_id @@ -445,7 +421,14 @@ class DefaultHomeRecommendationQueryRepository( and cd.debut_at <= :now and ec.release_date >= :boost30Start and ${notBlockedCreatorSql("m.id")} - order by recency_score desc, random_tie_breaker asc + order by case + when ec.release_date >= :recency3Start then 100 + when ec.release_date >= :recency7Start then 80 + when ec.release_date >= :recency14Start then 60 + when ec.release_date >= :recency21Start then 40 + when ec.release_date >= :boost30Start then 20 + else 0 + end desc, rand(ec.content_id) asc limit :limit offset :offset """.trimIndent() @@ -477,10 +460,7 @@ class DefaultHomeRecommendationQueryRepository( title = row[4] as String, price = (row[5] as Number).toInt(), coverImage = row[6] as String?, - releaseDate = toLocalDateTime(row[7]), - isPointAvailable = row[8] as Boolean, - recencyScore = (row[9] as Number).toInt(), - randomTieBreaker = (row[10] as Number).toDouble() + isPointAvailable = row[7] as Boolean ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt index 06460c47..b432b521 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt @@ -79,29 +79,22 @@ interface HomeRecommendationQueryPort { data class HomeLiveRecommendationRecord( val liveRoomId: Long, - val creatorId: Long, val creatorNickname: String, - val creatorProfileImage: String?, - val title: String, - val coverImage: String?, - val beginDateTime: LocalDateTime, - val channelName: String + val creatorProfileImage: String? ) data class HomeBannerRecommendationRecord( - val bannerId: Long, - val type: String, val thumbnailImage: String, val eventId: Long?, + val eventThumbnailImage: String?, + val eventDetailImage: String?, + val eventLink: String?, val creatorId: Long?, val seriesId: Long?, - val link: String?, - val orders: Int, - val randomTieBreaker: Double + val link: String? ) data class RecentlyActiveCreatorRecord( - val creatorId: Long, val creatorNickname: String, val creatorProfileImage: String?, val activityType: RecommendedActivityType, @@ -112,10 +105,7 @@ data class RecentlyActiveCreatorRecord( data class RecentDebutCreatorRecord( val creatorId: Long, val creatorNickname: String, - val creatorProfileImage: String?, - val debutAt: LocalDateTime, - val score: Double, - val randomTieBreaker: Double + val creatorProfileImage: String? ) data class HomeFirstAudioContentRecord( @@ -126,10 +116,7 @@ data class HomeFirstAudioContentRecord( val title: String, val price: Int, val coverImage: String?, - val releaseDate: LocalDateTime, - val isPointAvailable: Boolean, - val recencyScore: Int, - val randomTieBreaker: Double + val isPointAvailable: Boolean ) data class HomeAiCharacterRecommendationRecord( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 4959efa7..dd3bd47c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -438,7 +438,7 @@ class HomeRecommendationControllerTest @Autowired constructor( .param("size", "1") ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.items[0].liveRoomId").value(newest.id)) + .andExpect(jsonPath("$.data.items[0].roomId").value(newest.id)) .andExpect(jsonPath("$.data.hasNext").value(true)) } @@ -461,7 +461,7 @@ class HomeRecommendationControllerTest @Autowired constructor( .param("size", "1") ) .andExpect(status().isOk) - .andExpect(jsonPath("$.data.items[0].liveRoomId").value(oldest.id)) + .andExpect(jsonPath("$.data.items[0].roomId").value(oldest.id)) .andExpect(jsonPath("$.data.hasNext").value(false)) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponseTest.kt index 6646d164..872bbf2b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/HomeRecommendationResponseTest.kt @@ -24,7 +24,6 @@ class HomeRecommendationResponseTest { title = "first audio", price = 9, coverImage = "https://cdn.test/cover/audio.png", - releaseDate = "2026-06-01T00:00:00Z", isPointAvailable = true ) ), @@ -36,6 +35,14 @@ class HomeRecommendationResponseTest { profileImage = "https://cdn.test/profile/character.png", totalChatCount = 4L, originalWorkTitle = "original" + ), + HomeAiCharacterItem( + characterId = 4L, + name = "character-without-image", + description = "description", + profileImage = null, + totalChatCount = 5L, + originalWorkTitle = null ) ), genreCreators = emptyList(), @@ -54,6 +61,20 @@ class HomeRecommendationResponseTest { likeCount = 7L, commentCount = 8L, existOrdered = true + ), + HomePopularCommunityPostItem( + postId = 9L, + creatorId = 10L, + creatorNickname = "community-creator-without-media", + creatorProfileImage = null, + imageUrl = null, + audioUrl = null, + content = "community content without media", + price = 0, + createdAt = "2026-06-01T00:00:00Z", + likeCount = 0L, + commentCount = 0L, + existOrdered = false ) ) ) @@ -63,11 +84,16 @@ class HomeRecommendationResponseTest { assertEquals(9, json["firstAudioContents"][0]["price"].asInt()) assertEquals(true, json["firstAudioContents"][0]["isPointAvailable"].asBoolean()) assertFalse(json["firstAudioContents"][0].has("pointAvailable")) + assertFalse(json["firstAudioContents"][0].has("releaseDate")) assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText()) + assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull) assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong()) assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText()) assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText()) assertEquals(9, json["popularCommunityPosts"][0]["price"].asInt()) assertEquals(true, json["popularCommunityPosts"][0]["existOrdered"].asBoolean()) + assertEquals(true, json["popularCommunityPosts"][1]["creatorProfileImage"].isNull) + assertEquals(true, json["popularCommunityPosts"][1]["imageUrl"].isNull) + assertEquals(true, json["popularCommunityPosts"][1]["audioUrl"].isNull) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 00a63994..1ca95adc 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -81,11 +81,10 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false) assertEquals(20, lives.size) - assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime) - assertEquals(baseAt.plusDays(1).plusMinutes(1), lives.last().beginDateTime) + assertEquals(true, lives.zipWithNext().all { it.first.liveRoomId > it.second.liveRoomId }) assertEquals(false, lives.any { it.liveRoomId == oldLive.id }) assertEquals(false, lives.any { it.liveRoomId == latestLive.id }) - assertEquals(false, lives.any { it.creatorId == inactiveCreator.id }) + assertEquals(false, lives.any { it.creatorNickname == inactiveCreator.nickname }) } @Test @@ -132,7 +131,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val creator = saveMember("banner-creator", MemberRole.CREATOR) val event = saveEvent("event-banner") val laterBanner = saveBanner("later.png", AudioContentBannerType.CREATOR, orders = 2, isActive = true, creator = creator) - val sameOrderBanner1 = saveBanner( + saveBanner( "same-1.png", AudioContentBannerType.LINK, orders = 1, @@ -161,12 +160,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val banners = repository.findHomeBanners(limit = 20) assertEquals(20, banners.size) - assertEquals(listOf(1, 1, 2), banners.take(3).map { it.orders }) - assertEquals(setOf(sameOrderBanner1.id, sameOrderBanner2.id), banners.take(2).map { it.bannerId }.toSet()) - assertEquals(true, banners.take(2).zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker }) - assertEquals(laterBanner.id, banners[2].bannerId) + assertEquals(setOf("same-1.png", "same-2.png"), banners.take(2).map { it.thumbnailImage }.toSet()) + assertEquals(laterBanner.thumbnailImage, banners[2].thumbnailImage) assertEquals(creator.id, banners[2].creatorId) - assertEquals(event.id, banners.take(2).first { it.type == AudioContentBannerType.EVENT.name }.eventId) + val eventBanner = banners.take(2).single { it.thumbnailImage == sameOrderBanner2.thumbnailImage } + assertEquals(event.id, eventBanner.eventId) + assertEquals(event.thumbnailImage, eventBanner.eventThumbnailImage) + assertEquals(event.detailImage, eventBanner.eventDetailImage) + assertEquals(event.link, eventBanner.eventLink) assertEquals(false, banners.any { it.thumbnailImage == "inactive.png" }) } @@ -193,7 +194,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val banners = repository.findHomeBanners(limit = 20) - assertEquals(listOf(homeBanner.id), banners.map { it.bannerId }) + assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage }) } @Test @@ -250,8 +251,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val banners = repository.findHomeBanners(limit = 20) assertEquals( - listOf(activeEventBanner.id, activeCreatorBanner.id, activeSeriesBanner.id, linkBanner.id), - banners.map { it.bannerId } + listOf( + activeEventBanner.thumbnailImage, + activeCreatorBanner.thumbnailImage, + activeSeriesBanner.thumbnailImage, + linkBanner.thumbnailImage + ), + banners.map { it.thumbnailImage } ) } @@ -321,8 +327,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val banners = repository.findHomeBanners(limit = 20, memberId = viewer.id) assertEquals( - listOf(visibleEventBanner.id, visibleCreatorBanner.id, visibleSeriesBanner.id, visibleLinkBanner.id), - banners.map { it.bannerId } + listOf( + visibleEventBanner.thumbnailImage, + visibleCreatorBanner.thumbnailImage, + visibleSeriesBanner.thumbnailImage, + visibleLinkBanner.thumbnailImage + ), + banners.map { it.thumbnailImage } ) } @@ -343,22 +354,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( flushAndClear() val creators = repository.findRecentlyActiveCreators(limit = 10) - val byCreatorId = creators.associateBy { it.creatorId } + val byCreatorNickname = creators.associateBy { it.creatorNickname } assertEquals(4, creators.size) assertEquals( - listOf(liveCreator.id, audioCreator.id, replayCreator.id, communityCreator.id), - creators.map { it.creatorId } + listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname), + creators.map { it.creatorNickname } ) - assertEquals(RecommendedActivityType.LIVE, byCreatorId[liveCreator.id]!!.activityType) - assertEquals(null, byCreatorId[liveCreator.id]!!.targetId) - assertEquals(baseAt, byCreatorId[liveCreator.id]!!.activityAt) - assertEquals(RecommendedActivityType.AUDIO, byCreatorId[audioCreator.id]!!.activityType) - assertEquals(audio.id, byCreatorId[audioCreator.id]!!.targetId) - assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorId[replayCreator.id]!!.activityType) - assertEquals(replay.id, byCreatorId[replayCreator.id]!!.targetId) - assertEquals(RecommendedActivityType.COMMUNITY, byCreatorId[communityCreator.id]!!.activityType) - assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId) + assertEquals(RecommendedActivityType.LIVE, byCreatorNickname[liveCreator.nickname]!!.activityType) + assertEquals(null, byCreatorNickname[liveCreator.nickname]!!.targetId) + assertEquals(baseAt, byCreatorNickname[liveCreator.nickname]!!.activityAt) + assertEquals(RecommendedActivityType.AUDIO, byCreatorNickname[audioCreator.nickname]!!.activityType) + assertEquals(audio.id, byCreatorNickname[audioCreator.nickname]!!.targetId) + assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType) + assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId) + assertEquals(RecommendedActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType) + assertEquals(community.id, byCreatorNickname[communityCreator.nickname]!!.targetId) } @Test @@ -379,10 +390,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false) val visibleCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = true) - assertEquals(listOf(normalLiveCreator.id), hiddenCreators.map { it.creatorId }) + assertEquals(listOf(normalLiveCreator.nickname), hiddenCreators.map { it.creatorNickname }) assertEquals( - listOf(normalLiveCreator.id, adultLiveCreator.id, adultAudioCreator.id, adultCommunityCreator.id), - visibleCreators.map { it.creatorId } + listOf( + normalLiveCreator.nickname, + adultLiveCreator.nickname, + adultAudioCreator.nickname, + adultCommunityCreator.nickname + ), + visibleCreators.map { it.creatorNickname } ) assertEquals(null, visibleCreators[0].targetId) assertEquals(null, visibleCreators[1].targetId) @@ -412,7 +428,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id) - assertEquals(listOf(visibleCreator.id), creators.map { it.creatorId }) + assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname }) assertEquals(RecommendedActivityType.COMMUNITY, creators.single().activityType) } @@ -933,22 +949,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val creators = repository.findRecentDebutCreators(now, limit = 10) - val expectedHighScore = scorePolicy.calculateDebutCreatorScore( - followIncrease = 1, - contentActivityScore = 1, - communicationScore = 2, - newBoost = scorePolicy.calculateCreatorNewBoost(now.minusDays(20), now) - ) - val expectedLowScore = scorePolicy.calculateDebutCreatorScore( - followIncrease = 0, - contentActivityScore = 1, - communicationScore = 0, - newBoost = scorePolicy.calculateCreatorNewBoost(now.minusDays(5), now) - ) assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId }) - assertEquals(now.minusDays(20), creators.first().debutAt) - assertEquals(expectedHighScore, creators.first().score, 0.0001) - assertEquals(expectedLowScore, creators.last().score, 0.0001) } @Test @@ -1014,12 +1015,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false) val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId } - assertEquals(listOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds) + assertEquals(setOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds.toSet()) assertEquals(pagedCreatorIds, pagedCreatorIds.distinct()) } @Test - @DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다") + @DisplayName("최근 데뷔 크리에이터 동점은 DB 랜덤 tie-breaker로 정렬하고 조회 필드에는 노출하지 않는다") fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() { val now = LocalDateTime.of(2026, 5, 31, 10, 0) val creator1 = saveMember("tie-debut-1", MemberRole.CREATOR) @@ -1031,7 +1032,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val creators = repository.findRecentDebutCreators(now, limit = 10) assertEquals(2, creators.size) - assertEquals(true, creators.zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker }) + assertEquals(setOf(creator1.id, creator2.id), creators.map { it.creatorId }.toSet()) } @Test @@ -1061,7 +1062,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val contents = repository.findFirstAudioContents(now, limit = 10) assertEquals(listOf(eligibleActive.id), contents.map { it.contentId }) - assertEquals(100, contents.single().recencyScore) assertEquals(12, contents.single().price) } @@ -1082,8 +1082,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val contents = repository.findFirstAudioContents(now, limit = 10) assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId }) - assertEquals(listOf(100, 40), contents.map { it.recencyScore }) - assertEquals(true, contents.zipWithNext().all { it.first.recencyScore >= it.second.recencyScore }) } @Test @@ -1142,7 +1140,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false) val pagedContentIds = (page0 + page1 + page2).map { it.contentId } - assertEquals(listOf(content1.id, content2.id, content3.id), pagedContentIds) + assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet()) assertEquals(pagedContentIds, pagedContentIds.distinct()) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt index aac8f053..5a2cb42b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt @@ -604,31 +604,24 @@ class HomeRecommendationQueryServiceTest { val liveRecommendations = listOf( HomeLiveRecommendationRecord( liveRoomId = 1L, - creatorId = 10L, creatorNickname = "creator", - creatorProfileImage = "profile.png", - title = "live", - coverImage = "cover.png", - beginDateTime = LocalDateTime.of(2026, 5, 31, 10, 0), - channelName = "channel" + creatorProfileImage = "profile.png" ) ) val banners = listOf( HomeBannerRecommendationRecord( - bannerId = 2L, - type = "LINK", thumbnailImage = "banner.png", eventId = null, + eventThumbnailImage = null, + eventDetailImage = null, + eventLink = null, creatorId = null, seriesId = null, - link = "https://example.com", - orders = 1, - randomTieBreaker = 0.1 + link = "https://example.com" ) ) val activeCreators = listOf( RecentlyActiveCreatorRecord( - creatorId = 10L, creatorNickname = "creator", creatorProfileImage = "profile.png", activityType = RecommendedActivityType.LIVE, @@ -640,10 +633,7 @@ class HomeRecommendationQueryServiceTest { RecentDebutCreatorRecord( creatorId = 11L, creatorNickname = "debut-creator", - creatorProfileImage = "debut-profile.png", - debutAt = LocalDateTime.of(2026, 5, 20, 10, 0), - score = 1.2, - randomTieBreaker = 0.2 + creatorProfileImage = "debut-profile.png" ) ) val firstAudioContents = listOf( @@ -655,10 +645,7 @@ class HomeRecommendationQueryServiceTest { title = "first-audio", price = 10, coverImage = "first-audio.png", - releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0), - isPointAvailable = true, - recencyScore = 100, - randomTieBreaker = 0.3 + isPointAvailable = true ) ) var aiCharacterDetails: List = emptyList()