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
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(

View File

@@ -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>
)

View File

@@ -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
)
}
}

View File

@@ -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(