feat(recommend): 홈 추천 응답 필드를 정리한다
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<HomeLiveItem>,
|
||||
val banners: List<HomeBannerItem>,
|
||||
@@ -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<HomeCreatorItem>
|
||||
)
|
||||
|
||||
@@ -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<RecentlyActiveCreatorRecord> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||
|
||||
Reference in New Issue
Block a user