feat(recommend): 홈 추천 응답 필드를 정리한다

This commit is contained in:
2026-06-05 18:15:19 +09:00
parent 7606796fe3
commit 6b469c1fad
8 changed files with 164 additions and 183 deletions

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.home.application 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.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy 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.HomeRecommendationPageResponse
import kr.co.vividnext.sodalive.v2.api.home.dto.HomeRecommendationResponse 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.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.api.home.dto.toUtcIso
import kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
@@ -228,30 +230,38 @@ class HomeRecommendationFacade(
} }
private fun HomeLiveRecommendationRecord.toItem() = HomeLiveItem( private fun HomeLiveRecommendationRecord.toItem() = HomeLiveItem(
liveRoomId = liveRoomId, roomId = liveRoomId,
creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage), creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage)
title = title,
coverImage = imageUrl(cloudFrontHost, coverImage),
beginDateTime = beginDateTime.toUtcIso(),
channelName = channelName
) )
private fun HomeBannerRecommendationRecord.toItem() = HomeBannerItem( private fun HomeBannerRecommendationRecord.toItem() = HomeBannerItem(
bannerId = bannerId, imageUrl = imageUrl(cloudFrontHost, thumbnailImage) ?: "",
type = type, eventItem = eventItem(),
thumbnailImage = imageUrl(cloudFrontHost, thumbnailImage),
eventId = eventId,
creatorId = creatorId, creatorId = creatorId,
seriesId = seriesId, seriesId = seriesId,
link = link 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( private fun RecentlyActiveCreatorRecord.toItem() = HomeActiveCreatorItem(
creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage), creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage),
activityType = activityType.name, activityType = activityType.name,
activityAt = activityAt.toUtcIso(), activityAt = activityAt.toUtcIso(),
targetId = targetId targetId = targetId
@@ -260,18 +270,17 @@ class HomeRecommendationFacade(
private fun RecentDebutCreatorRecord.toItem() = HomeCreatorItem( private fun RecentDebutCreatorRecord.toItem() = HomeCreatorItem(
creatorId = creatorId, creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage) creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage)
) )
private fun HomeFirstAudioContentRecord.toItem() = HomeFirstAudioContentItem( private fun HomeFirstAudioContentRecord.toItem() = HomeFirstAudioContentItem(
contentId = contentId, contentId = contentId,
creatorId = creatorId, creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage), creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage),
title = title, title = title,
price = price, price = price,
coverImage = imageUrl(cloudFrontHost, coverImage), coverImage = imageUrl(cloudFrontHost, coverImage),
releaseDate = releaseDate.toUtcIso(),
isPointAvailable = isPointAvailable isPointAvailable = isPointAvailable
) )
@@ -285,13 +294,12 @@ class HomeRecommendationFacade(
) )
private fun HomeGenreCreatorRecommendationGroup.toItem() = HomeGenreCreatorGroupItem( private fun HomeGenreCreatorRecommendationGroup.toItem() = HomeGenreCreatorGroupItem(
genreId = genreId,
genreName = genreName, genreName = genreName,
creators = creators.map { creators = creators.map {
HomeCreatorItem( HomeCreatorItem(
creatorId = it.creatorId, creatorId = it.creatorId,
creatorNickname = it.creatorNickname, creatorNickname = it.creatorNickname,
creatorProfileImage = imageUrl(cloudFrontHost, it.creatorProfileImage) creatorProfileImage = profileImageUrl(cloudFrontHost, it.creatorProfileImage)
) )
} }
) )
@@ -299,7 +307,7 @@ class HomeRecommendationFacade(
private fun HomeCheerCreatorRecommendationRecord.toCreatorItem() = HomeCreatorItem( private fun HomeCheerCreatorRecommendationRecord.toCreatorItem() = HomeCreatorItem(
creatorId = creatorId, creatorId = creatorId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
creatorProfileImage = imageUrl(cloudFrontHost, creatorProfileImage) creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage)
) )
private fun HomePopularCommunityRecommendationRecord.toItem() = HomePopularCommunityPostItem( private fun HomePopularCommunityRecommendationRecord.toItem() = HomePopularCommunityPostItem(

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.home.dto package kr.co.vividnext.sodalive.v2.api.home.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.event.EventItem
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -12,6 +13,10 @@ internal fun imageUrl(cloudFrontHost: String, path: String?): String? {
return if (path.isNullOrBlank()) null else "$cloudFrontHost/$path" 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( data class HomeRecommendationResponse(
val lives: List<HomeLiveItem>, val lives: List<HomeLiveItem>,
val banners: List<HomeBannerItem>, val banners: List<HomeBannerItem>,
@@ -25,30 +30,22 @@ data class HomeRecommendationResponse(
) )
data class HomeLiveItem( data class HomeLiveItem(
val liveRoomId: Long, val roomId: Long,
val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String?, val creatorProfileImage: String
val title: String,
val coverImage: String?,
val beginDateTime: String,
val channelName: String
) )
data class HomeBannerItem( data class HomeBannerItem(
val bannerId: Long, val imageUrl: String,
val type: String, val eventItem: EventItem?,
val thumbnailImage: String?,
val eventId: Long?,
val creatorId: Long?, val creatorId: Long?,
val seriesId: Long?, val seriesId: Long?,
val link: String? val link: String?
) )
data class HomeActiveCreatorItem( data class HomeActiveCreatorItem(
val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String?, val creatorProfileImage: String,
val activityType: String, val activityType: String,
val activityAt: String, val activityAt: String,
val targetId: Long? val targetId: Long?
@@ -57,18 +54,17 @@ data class HomeActiveCreatorItem(
data class HomeCreatorItem( data class HomeCreatorItem(
val creatorId: Long, val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String? val creatorProfileImage: String
) )
data class HomeFirstAudioContentItem( data class HomeFirstAudioContentItem(
val contentId: Long, val contentId: Long,
val creatorId: Long, val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String?, val creatorProfileImage: String,
val title: String, val title: String,
val price: Int, val price: Int,
val coverImage: String?, val coverImage: String?,
val releaseDate: String,
@JsonProperty("isPointAvailable") @JsonProperty("isPointAvailable")
val isPointAvailable: Boolean val isPointAvailable: Boolean
) )
@@ -83,7 +79,6 @@ data class HomeAiCharacterItem(
) )
data class HomeGenreCreatorGroupItem( data class HomeGenreCreatorGroupItem(
val genreId: Long,
val genreName: String, val genreName: String,
val creators: List<HomeCreatorItem> val creators: List<HomeCreatorItem>
) )

View File

@@ -59,13 +59,8 @@ class DefaultHomeRecommendationQueryRepository(
Projections.constructor( Projections.constructor(
HomeLiveRecommendationRecord::class.java, HomeLiveRecommendationRecord::class.java,
liveRoom.id, liveRoom.id,
member.id,
member.nickname, member.nickname,
member.profileImage, member.profileImage
liveRoom.title,
liveRoom.coverImage,
liveRoom.beginDateTime,
liveRoom.channelName
) )
) )
.from(liveRoom) .from(liveRoom)
@@ -96,15 +91,14 @@ class DefaultHomeRecommendationQueryRepository(
.select( .select(
Projections.constructor( Projections.constructor(
HomeBannerRecommendationRecord::class.java, HomeBannerRecommendationRecord::class.java,
audioContentBanner.id,
audioContentBanner.type.stringValue(),
audioContentBanner.thumbnailImage, audioContentBanner.thumbnailImage,
event.id, event.id,
event.thumbnailImage,
event.detailImage,
event.link,
bannerCreator.id, bannerCreator.id,
series.id, series.id,
audioContentBanner.link, audioContentBanner.link
audioContentBanner.orders,
randomTieBreaker
) )
) )
.from(audioContentBanner) .from(audioContentBanner)
@@ -128,8 +122,7 @@ class DefaultHomeRecommendationQueryRepository(
includeAdultActivities: Boolean includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> { ): List<RecentlyActiveCreatorRecord> {
val sql = """ val sql = """
select ranked.creator_id, select ranked.creator_nickname,
ranked.creator_nickname,
ranked.creator_profile_image, ranked.creator_profile_image,
ranked.activity_type, ranked.activity_type,
ranked.activity_at, ranked.activity_at,
@@ -202,12 +195,11 @@ class DefaultHomeRecommendationQueryRepository(
return rows.map { row -> return rows.map { row ->
RecentlyActiveCreatorRecord( RecentlyActiveCreatorRecord(
creatorId = (row[0] as Number).toLong(), creatorNickname = row[0] as String,
creatorNickname = row[1] as String, creatorProfileImage = row[1] as String?,
creatorProfileImage = row[2] as String?, activityType = RecommendedActivityType.valueOf(row[2] as String),
activityType = RecommendedActivityType.valueOf(row[3] as String), activityAt = toLocalDateTime(row[3]),
activityAt = toLocalDateTime(row[4]), targetId = (row[4] as Number?)?.toLong()
targetId = (row[5] as Number?)?.toLong()
) )
} }
} }
@@ -315,18 +307,7 @@ class DefaultHomeRecommendationQueryRepository(
) )
select m.id as creator_id, select m.id as creator_id,
m.nickname as creator_nickname, m.nickname as creator_nickname,
m.profile_image as creator_profile_image, 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
from member m from member m
join creator_debut cd on cd.creator_id = m.id join creator_debut cd on cd.creator_id = m.id
left join follow_stats fs on fs.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 >= :boost30Start
and cd.debut_at <= :now and cd.debut_at <= :now
and ${notBlockedCreatorSql("m.id")} 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 limit :limit
offset :offset offset :offset
""".trimIndent() """.trimIndent()
@@ -354,10 +343,7 @@ class DefaultHomeRecommendationQueryRepository(
RecentDebutCreatorRecord( RecentDebutCreatorRecord(
creatorId = (row[0] as Number).toLong(), creatorId = (row[0] as Number).toLong(),
creatorNickname = row[1] as String, creatorNickname = row[1] as String,
creatorProfileImage = row[2] as String?, creatorProfileImage = row[2] as String?
debutAt = toLocalDateTime(row[3]),
score = (row[4] as Number).toDouble(),
randomTieBreaker = (row[5] as Number).toDouble()
) )
} }
} }
@@ -425,17 +411,7 @@ class DefaultHomeRecommendationQueryRepository(
ec.title as title, ec.title as title,
ec.price as price, ec.price as price,
ec.cover_image as cover_image, ec.cover_image as cover_image,
ec.release_date as release_date, ec.is_point_available as is_point_available
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
from eligible_contents ec from eligible_contents ec
join member m on m.id = ec.creator_id join member m on m.id = ec.creator_id
join creator_debut cd on cd.creator_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 cd.debut_at <= :now
and ec.release_date >= :boost30Start and ec.release_date >= :boost30Start
and ${notBlockedCreatorSql("m.id")} 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 limit :limit
offset :offset offset :offset
""".trimIndent() """.trimIndent()
@@ -477,10 +460,7 @@ class DefaultHomeRecommendationQueryRepository(
title = row[4] as String, title = row[4] as String,
price = (row[5] as Number).toInt(), price = (row[5] as Number).toInt(),
coverImage = row[6] as String?, coverImage = row[6] as String?,
releaseDate = toLocalDateTime(row[7]), isPointAvailable = row[7] as Boolean
isPointAvailable = row[8] as Boolean,
recencyScore = (row[9] as Number).toInt(),
randomTieBreaker = (row[10] as Number).toDouble()
) )
} }
} }

View File

@@ -79,29 +79,22 @@ interface HomeRecommendationQueryPort {
data class HomeLiveRecommendationRecord( data class HomeLiveRecommendationRecord(
val liveRoomId: Long, val liveRoomId: Long,
val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String?, val creatorProfileImage: String?
val title: String,
val coverImage: String?,
val beginDateTime: LocalDateTime,
val channelName: String
) )
data class HomeBannerRecommendationRecord( data class HomeBannerRecommendationRecord(
val bannerId: Long,
val type: String,
val thumbnailImage: String, val thumbnailImage: String,
val eventId: Long?, val eventId: Long?,
val eventThumbnailImage: String?,
val eventDetailImage: String?,
val eventLink: String?,
val creatorId: Long?, val creatorId: Long?,
val seriesId: Long?, val seriesId: Long?,
val link: String?, val link: String?
val orders: Int,
val randomTieBreaker: Double
) )
data class RecentlyActiveCreatorRecord( data class RecentlyActiveCreatorRecord(
val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String?, val creatorProfileImage: String?,
val activityType: RecommendedActivityType, val activityType: RecommendedActivityType,
@@ -112,10 +105,7 @@ data class RecentlyActiveCreatorRecord(
data class RecentDebutCreatorRecord( data class RecentDebutCreatorRecord(
val creatorId: Long, val creatorId: Long,
val creatorNickname: String, val creatorNickname: String,
val creatorProfileImage: String?, val creatorProfileImage: String?
val debutAt: LocalDateTime,
val score: Double,
val randomTieBreaker: Double
) )
data class HomeFirstAudioContentRecord( data class HomeFirstAudioContentRecord(
@@ -126,10 +116,7 @@ data class HomeFirstAudioContentRecord(
val title: String, val title: String,
val price: Int, val price: Int,
val coverImage: String?, val coverImage: String?,
val releaseDate: LocalDateTime, val isPointAvailable: Boolean
val isPointAvailable: Boolean,
val recencyScore: Int,
val randomTieBreaker: Double
) )
data class HomeAiCharacterRecommendationRecord( data class HomeAiCharacterRecommendationRecord(

View File

@@ -438,7 +438,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
.param("size", "1") .param("size", "1")
) )
.andExpect(status().isOk) .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)) .andExpect(jsonPath("$.data.hasNext").value(true))
} }
@@ -461,7 +461,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
.param("size", "1") .param("size", "1")
) )
.andExpect(status().isOk) .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)) .andExpect(jsonPath("$.data.hasNext").value(false))
} }

View File

@@ -24,7 +24,6 @@ class HomeRecommendationResponseTest {
title = "first audio", title = "first audio",
price = 9, price = 9,
coverImage = "https://cdn.test/cover/audio.png", coverImage = "https://cdn.test/cover/audio.png",
releaseDate = "2026-06-01T00:00:00Z",
isPointAvailable = true isPointAvailable = true
) )
), ),
@@ -36,6 +35,14 @@ class HomeRecommendationResponseTest {
profileImage = "https://cdn.test/profile/character.png", profileImage = "https://cdn.test/profile/character.png",
totalChatCount = 4L, totalChatCount = 4L,
originalWorkTitle = "original" originalWorkTitle = "original"
),
HomeAiCharacterItem(
characterId = 4L,
name = "character-without-image",
description = "description",
profileImage = null,
totalChatCount = 5L,
originalWorkTitle = null
) )
), ),
genreCreators = emptyList(), genreCreators = emptyList(),
@@ -54,6 +61,20 @@ class HomeRecommendationResponseTest {
likeCount = 7L, likeCount = 7L,
commentCount = 8L, commentCount = 8L,
existOrdered = true 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(9, json["firstAudioContents"][0]["price"].asInt())
assertEquals(true, json["firstAudioContents"][0]["isPointAvailable"].asBoolean()) assertEquals(true, json["firstAudioContents"][0]["isPointAvailable"].asBoolean())
assertFalse(json["firstAudioContents"][0].has("pointAvailable")) 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("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(5L, json["popularCommunityPosts"][0]["postId"].asLong())
assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText()) 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("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText())
assertEquals(9, json["popularCommunityPosts"][0]["price"].asInt()) assertEquals(9, json["popularCommunityPosts"][0]["price"].asInt())
assertEquals(true, json["popularCommunityPosts"][0]["existOrdered"].asBoolean()) 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)
} }
} }

View File

@@ -81,11 +81,10 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false) val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false)
assertEquals(20, lives.size) assertEquals(20, lives.size)
assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime) assertEquals(true, lives.zipWithNext().all { it.first.liveRoomId > it.second.liveRoomId })
assertEquals(baseAt.plusDays(1).plusMinutes(1), lives.last().beginDateTime)
assertEquals(false, lives.any { it.liveRoomId == oldLive.id }) assertEquals(false, lives.any { it.liveRoomId == oldLive.id })
assertEquals(false, lives.any { it.liveRoomId == latestLive.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 @Test
@@ -132,7 +131,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val creator = saveMember("banner-creator", MemberRole.CREATOR) val creator = saveMember("banner-creator", MemberRole.CREATOR)
val event = saveEvent("event-banner") val event = saveEvent("event-banner")
val laterBanner = saveBanner("later.png", AudioContentBannerType.CREATOR, orders = 2, isActive = true, creator = creator) val laterBanner = saveBanner("later.png", AudioContentBannerType.CREATOR, orders = 2, isActive = true, creator = creator)
val sameOrderBanner1 = saveBanner( saveBanner(
"same-1.png", "same-1.png",
AudioContentBannerType.LINK, AudioContentBannerType.LINK,
orders = 1, orders = 1,
@@ -161,12 +160,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val banners = repository.findHomeBanners(limit = 20) val banners = repository.findHomeBanners(limit = 20)
assertEquals(20, banners.size) assertEquals(20, banners.size)
assertEquals(listOf(1, 1, 2), banners.take(3).map { it.orders }) assertEquals(setOf("same-1.png", "same-2.png"), banners.take(2).map { it.thumbnailImage }.toSet())
assertEquals(setOf(sameOrderBanner1.id, sameOrderBanner2.id), banners.take(2).map { it.bannerId }.toSet()) assertEquals(laterBanner.thumbnailImage, banners[2].thumbnailImage)
assertEquals(true, banners.take(2).zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker })
assertEquals(laterBanner.id, banners[2].bannerId)
assertEquals(creator.id, banners[2].creatorId) 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" }) assertEquals(false, banners.any { it.thumbnailImage == "inactive.png" })
} }
@@ -193,7 +194,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val banners = repository.findHomeBanners(limit = 20) val banners = repository.findHomeBanners(limit = 20)
assertEquals(listOf(homeBanner.id), banners.map { it.bannerId }) assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
} }
@Test @Test
@@ -250,8 +251,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val banners = repository.findHomeBanners(limit = 20) val banners = repository.findHomeBanners(limit = 20)
assertEquals( assertEquals(
listOf(activeEventBanner.id, activeCreatorBanner.id, activeSeriesBanner.id, linkBanner.id), listOf(
banners.map { it.bannerId } 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) val banners = repository.findHomeBanners(limit = 20, memberId = viewer.id)
assertEquals( assertEquals(
listOf(visibleEventBanner.id, visibleCreatorBanner.id, visibleSeriesBanner.id, visibleLinkBanner.id), listOf(
banners.map { it.bannerId } visibleEventBanner.thumbnailImage,
visibleCreatorBanner.thumbnailImage,
visibleSeriesBanner.thumbnailImage,
visibleLinkBanner.thumbnailImage
),
banners.map { it.thumbnailImage }
) )
} }
@@ -343,22 +354,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
flushAndClear() flushAndClear()
val creators = repository.findRecentlyActiveCreators(limit = 10) val creators = repository.findRecentlyActiveCreators(limit = 10)
val byCreatorId = creators.associateBy { it.creatorId } val byCreatorNickname = creators.associateBy { it.creatorNickname }
assertEquals(4, creators.size) assertEquals(4, creators.size)
assertEquals( assertEquals(
listOf(liveCreator.id, audioCreator.id, replayCreator.id, communityCreator.id), listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname),
creators.map { it.creatorId } creators.map { it.creatorNickname }
) )
assertEquals(RecommendedActivityType.LIVE, byCreatorId[liveCreator.id]!!.activityType) assertEquals(RecommendedActivityType.LIVE, byCreatorNickname[liveCreator.nickname]!!.activityType)
assertEquals(null, byCreatorId[liveCreator.id]!!.targetId) assertEquals(null, byCreatorNickname[liveCreator.nickname]!!.targetId)
assertEquals(baseAt, byCreatorId[liveCreator.id]!!.activityAt) assertEquals(baseAt, byCreatorNickname[liveCreator.nickname]!!.activityAt)
assertEquals(RecommendedActivityType.AUDIO, byCreatorId[audioCreator.id]!!.activityType) assertEquals(RecommendedActivityType.AUDIO, byCreatorNickname[audioCreator.nickname]!!.activityType)
assertEquals(audio.id, byCreatorId[audioCreator.id]!!.targetId) assertEquals(audio.id, byCreatorNickname[audioCreator.nickname]!!.targetId)
assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorId[replayCreator.id]!!.activityType) assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
assertEquals(replay.id, byCreatorId[replayCreator.id]!!.targetId) assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId)
assertEquals(RecommendedActivityType.COMMUNITY, byCreatorId[communityCreator.id]!!.activityType) assertEquals(RecommendedActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType)
assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId) assertEquals(community.id, byCreatorNickname[communityCreator.nickname]!!.targetId)
} }
@Test @Test
@@ -379,10 +390,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false) val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false)
val visibleCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = true) 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( assertEquals(
listOf(normalLiveCreator.id, adultLiveCreator.id, adultAudioCreator.id, adultCommunityCreator.id), listOf(
visibleCreators.map { it.creatorId } normalLiveCreator.nickname,
adultLiveCreator.nickname,
adultAudioCreator.nickname,
adultCommunityCreator.nickname
),
visibleCreators.map { it.creatorNickname }
) )
assertEquals(null, visibleCreators[0].targetId) assertEquals(null, visibleCreators[0].targetId)
assertEquals(null, visibleCreators[1].targetId) assertEquals(null, visibleCreators[1].targetId)
@@ -412,7 +428,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id) 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) assertEquals(RecommendedActivityType.COMMUNITY, creators.single().activityType)
} }
@@ -933,22 +949,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val creators = repository.findRecentDebutCreators(now, limit = 10) 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(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 @Test
@@ -1014,12 +1015,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false) val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false)
val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId } 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()) assertEquals(pagedCreatorIds, pagedCreatorIds.distinct())
} }
@Test @Test
@DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한") @DisplayName("최근 데뷔 크리에이터 동점은 DB 랜덤 tie-breaker로 정렬하고 조회 필드에는 노출하지 않는")
fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() { fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0) val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator1 = saveMember("tie-debut-1", MemberRole.CREATOR) val creator1 = saveMember("tie-debut-1", MemberRole.CREATOR)
@@ -1031,7 +1032,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val creators = repository.findRecentDebutCreators(now, limit = 10) val creators = repository.findRecentDebutCreators(now, limit = 10)
assertEquals(2, creators.size) 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 @Test
@@ -1061,7 +1062,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val contents = repository.findFirstAudioContents(now, limit = 10) val contents = repository.findFirstAudioContents(now, limit = 10)
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId }) assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
assertEquals(100, contents.single().recencyScore)
assertEquals(12, contents.single().price) assertEquals(12, contents.single().price)
} }
@@ -1082,8 +1082,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val contents = repository.findFirstAudioContents(now, limit = 10) val contents = repository.findFirstAudioContents(now, limit = 10)
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId }) 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 @Test
@@ -1142,7 +1140,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false) val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false)
val pagedContentIds = (page0 + page1 + page2).map { it.contentId } 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()) assertEquals(pagedContentIds, pagedContentIds.distinct())
} }

View File

@@ -604,31 +604,24 @@ class HomeRecommendationQueryServiceTest {
val liveRecommendations = listOf( val liveRecommendations = listOf(
HomeLiveRecommendationRecord( HomeLiveRecommendationRecord(
liveRoomId = 1L, liveRoomId = 1L,
creatorId = 10L,
creatorNickname = "creator", creatorNickname = "creator",
creatorProfileImage = "profile.png", creatorProfileImage = "profile.png"
title = "live",
coverImage = "cover.png",
beginDateTime = LocalDateTime.of(2026, 5, 31, 10, 0),
channelName = "channel"
) )
) )
val banners = listOf( val banners = listOf(
HomeBannerRecommendationRecord( HomeBannerRecommendationRecord(
bannerId = 2L,
type = "LINK",
thumbnailImage = "banner.png", thumbnailImage = "banner.png",
eventId = null, eventId = null,
eventThumbnailImage = null,
eventDetailImage = null,
eventLink = null,
creatorId = null, creatorId = null,
seriesId = null, seriesId = null,
link = "https://example.com", link = "https://example.com"
orders = 1,
randomTieBreaker = 0.1
) )
) )
val activeCreators = listOf( val activeCreators = listOf(
RecentlyActiveCreatorRecord( RecentlyActiveCreatorRecord(
creatorId = 10L,
creatorNickname = "creator", creatorNickname = "creator",
creatorProfileImage = "profile.png", creatorProfileImage = "profile.png",
activityType = RecommendedActivityType.LIVE, activityType = RecommendedActivityType.LIVE,
@@ -640,10 +633,7 @@ class HomeRecommendationQueryServiceTest {
RecentDebutCreatorRecord( RecentDebutCreatorRecord(
creatorId = 11L, creatorId = 11L,
creatorNickname = "debut-creator", creatorNickname = "debut-creator",
creatorProfileImage = "debut-profile.png", creatorProfileImage = "debut-profile.png"
debutAt = LocalDateTime.of(2026, 5, 20, 10, 0),
score = 1.2,
randomTieBreaker = 0.2
) )
) )
val firstAudioContents = listOf( val firstAudioContents = listOf(
@@ -655,10 +645,7 @@ class HomeRecommendationQueryServiceTest {
title = "first-audio", title = "first-audio",
price = 10, price = 10,
coverImage = "first-audio.png", coverImage = "first-audio.png",
releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0), isPointAvailable = true
isPointAvailable = true,
recencyScore = 100,
randomTieBreaker = 0.3
) )
) )
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList() var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()