feat(recommend): 장르 기반 크리에이터 추천 조회를 추가한다
This commit is contained in:
@@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
import kr.co.vividnext.sodalive.event.Event
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
@@ -983,6 +984,223 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 조회 이력 콘텐츠 테마와 랜덤 보충 테마를 고르고 팔로우 크리에이터를 제외한다")
|
||||
fun shouldFindGenreCreatorRecommendationsFromViewHistoryThemeWithFallbackAndFollowExclusion() {
|
||||
val viewer = saveMember("genre-viewer", MemberRole.USER)
|
||||
val followedCreator = saveMember("genre-followed", MemberRole.CREATOR)
|
||||
val viewedCreator = saveMember("genre-viewed", MemberRole.CREATOR)
|
||||
val fallbackCreator = saveMember("genre-fallback", MemberRole.CREATOR)
|
||||
val viewedTheme = saveTheme("viewed-theme")
|
||||
val fallbackTheme = saveTheme("fallback-theme")
|
||||
val viewedContent = saveAudioContent(
|
||||
viewedCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||
isActive = true,
|
||||
theme = viewedTheme
|
||||
)
|
||||
saveAudioContent(
|
||||
followedCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 11, 0),
|
||||
isActive = true,
|
||||
theme = viewedTheme
|
||||
)
|
||||
saveAudioContent(
|
||||
fallbackCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 12, 0),
|
||||
isActive = true,
|
||||
theme = fallbackTheme
|
||||
)
|
||||
saveFollowing(viewer, followedCreator, isActive = true)
|
||||
entityManager.persist(
|
||||
CreatorContentViewHistory(
|
||||
memberId = viewer.id!!,
|
||||
contentId = viewedContent.id!!,
|
||||
genreId = 999L,
|
||||
viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
)
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val recommendations = repository.findGenreCreatorRecommendations(
|
||||
memberId = viewer.id!!,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 2,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(2, recommendations.size)
|
||||
assertEquals(viewedTheme.id, recommendations.first().genreId)
|
||||
assertEquals(viewedTheme.theme, recommendations.first().genreName)
|
||||
assertEquals(false, recommendations.flatMap { it.creators }.any { it.creatorId == followedCreator.id })
|
||||
assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == viewedCreator.id })
|
||||
assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == fallbackCreator.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 series_genre가 아니라 content_theme 기준으로 그룹을 만든다")
|
||||
fun shouldGroupGenreCreatorRecommendationsByContentThemeNotSeriesGenre() {
|
||||
val creator = saveMember("theme-source-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("theme-source")
|
||||
val unrelatedGenre = saveSeriesGenre("unrelated-genre", isAdult = false)
|
||||
val content = saveAudioContent(creator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = theme)
|
||||
saveSeriesContent(saveSeries("unrelated-series", creator, isActive = true, genre = unrelatedGenre), content)
|
||||
flushAndClear()
|
||||
|
||||
val recommendations = repository.findGenreCreatorRecommendations(
|
||||
memberId = null,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 1,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(theme.id, recommendations.single().genreId)
|
||||
assertEquals(theme.theme, recommendations.single().genreName)
|
||||
assertEquals(listOf(creator.id), recommendations.single().creators.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 빈 테마 그룹을 제외하고 다른 테마로 보충한다")
|
||||
fun shouldSkipEmptyThemeGroupsAndBackfillOtherThemes() {
|
||||
val viewer = saveMember("empty-group-viewer", MemberRole.USER)
|
||||
val followedCreator = saveMember("empty-group-followed", MemberRole.CREATOR)
|
||||
val inactiveCreator = saveMember("empty-group-inactive", MemberRole.CREATOR, isActive = false)
|
||||
val firstCreator = saveMember("empty-group-first", MemberRole.CREATOR)
|
||||
val secondCreator = saveMember("empty-group-second", MemberRole.CREATOR)
|
||||
val followedTheme = saveTheme("empty-followed-theme")
|
||||
val inactiveTheme = saveTheme("empty-inactive-theme")
|
||||
val firstTheme = saveTheme("empty-first-theme")
|
||||
val secondTheme = saveTheme("empty-second-theme")
|
||||
val followedContent = saveAudioContent(
|
||||
followedCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||
isActive = true,
|
||||
theme = followedTheme
|
||||
)
|
||||
val inactiveContent = saveAudioContent(
|
||||
inactiveCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 11, 0),
|
||||
isActive = true,
|
||||
theme = inactiveTheme
|
||||
)
|
||||
saveAudioContent(firstCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = firstTheme)
|
||||
saveAudioContent(secondCreator, LocalDateTime.of(2026, 5, 30, 13, 0), isActive = true, theme = secondTheme)
|
||||
saveFollowing(viewer, followedCreator, isActive = true)
|
||||
listOf(followedContent, inactiveContent).forEach { content ->
|
||||
entityManager.persist(
|
||||
CreatorContentViewHistory(
|
||||
memberId = viewer.id!!,
|
||||
contentId = content.id!!,
|
||||
genreId = 999L,
|
||||
viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
flushAndClear()
|
||||
|
||||
val recommendations = repository.findGenreCreatorRecommendations(
|
||||
memberId = viewer.id!!,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 2,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(2, recommendations.size)
|
||||
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
||||
assertEquals(false, recommendations.any { it.genreId == followedTheme.id || it.genreId == inactiveTheme.id })
|
||||
assertEquals(setOf(firstTheme.id, secondTheme.id), recommendations.map { it.genreId }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다")
|
||||
fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() {
|
||||
val sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR)
|
||||
val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR)
|
||||
val firstTheme = saveTheme("candidate-first-theme")
|
||||
val duplicateTheme = saveTheme("candidate-duplicate-theme")
|
||||
val backfillTheme = saveTheme("candidate-backfill-theme")
|
||||
saveAudioContent(sharedCreator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = firstTheme)
|
||||
saveAudioContent(sharedCreator, LocalDateTime.of(2026, 5, 30, 11, 0), isActive = true, theme = duplicateTheme)
|
||||
saveAudioContent(backfillCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = backfillTheme)
|
||||
flushAndClear()
|
||||
|
||||
val recommendations = repository.findGenreCreatorRecommendations(
|
||||
memberId = null,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 2,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(3, recommendations.size)
|
||||
assertEquals(
|
||||
setOf(firstTheme.id, duplicateTheme.id, backfillTheme.id),
|
||||
recommendations.map { it.genreId }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 같은 크리에이터의 여러 콘텐츠가 장르별 limit을 중복 소모하지 않는다")
|
||||
fun shouldDeduplicateCreatorsBeforeApplyingPerGenreLimit() {
|
||||
val theme = saveTheme("duplicate-theme")
|
||||
val duplicateCreator = saveMember("duplicate-creator", MemberRole.CREATOR)
|
||||
val otherCreator = saveMember("other-creator", MemberRole.CREATOR)
|
||||
repeat(3) { index ->
|
||||
saveAudioContent(
|
||||
duplicateCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 10, 0).plusMinutes(index.toLong()),
|
||||
isActive = true,
|
||||
theme = theme
|
||||
)
|
||||
}
|
||||
saveAudioContent(otherCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = theme)
|
||||
flushAndClear()
|
||||
|
||||
val recommendations = repository.findGenreCreatorRecommendations(
|
||||
memberId = null,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 1,
|
||||
creatorLimit = 2
|
||||
)
|
||||
|
||||
assertEquals(1, recommendations.size)
|
||||
assertEquals(2, recommendations.single().creators.size)
|
||||
assertEquals(
|
||||
setOf(duplicateCreator.id, otherCreator.id),
|
||||
recommendations.single().creators.map { it.creatorId }.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 성인 장르를 성인 노출 허용 회원에게만 포함한다")
|
||||
fun shouldIncludeAdultGenreCreatorsOnlyWhenAdultGenresVisible() {
|
||||
val adultCreator = saveMember("adult-genre-creator", MemberRole.CREATOR)
|
||||
val adultTheme = saveTheme("adult-theme")
|
||||
saveAudioContent(
|
||||
adultCreator,
|
||||
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||
isActive = true,
|
||||
theme = adultTheme,
|
||||
isAdult = true
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val hidden = repository.findGenreCreatorRecommendations(
|
||||
memberId = null,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 5,
|
||||
creatorLimit = 8
|
||||
)
|
||||
val visible = repository.findGenreCreatorRecommendations(
|
||||
memberId = null,
|
||||
includeAdultGenres = true,
|
||||
genreLimit = 5,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(false, hidden.any { it.genreId == adultTheme.id })
|
||||
assertEquals(true, visible.any { it.genreId == adultTheme.id })
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
@@ -1096,19 +1314,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
creator: Member,
|
||||
releaseDate: LocalDateTime,
|
||||
isActive: Boolean,
|
||||
themeName: String = "theme-${creator.nickname}-$releaseDate"
|
||||
themeName: String = "theme-${creator.nickname}-$releaseDate",
|
||||
theme: AudioContentTheme = saveTheme(themeName),
|
||||
isAdult: Boolean = false
|
||||
): AudioContent {
|
||||
val theme = AudioContentTheme(
|
||||
theme = themeName,
|
||||
image = "theme-${creator.nickname}-$releaseDate.png"
|
||||
)
|
||||
entityManager.persist(theme)
|
||||
|
||||
val content = AudioContent(
|
||||
title = "content-${creator.nickname}-$releaseDate",
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
releaseDate = releaseDate
|
||||
releaseDate = releaseDate,
|
||||
isAdult = isAdult
|
||||
)
|
||||
content.member = creator
|
||||
content.theme = theme
|
||||
@@ -1117,6 +1332,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
return content
|
||||
}
|
||||
|
||||
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
|
||||
val theme = AudioContentTheme(
|
||||
theme = name,
|
||||
image = "$name.png",
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveAudioContentComment(member: Member, content: AudioContent, isActive: Boolean): AudioContentComment {
|
||||
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
|
||||
comment.member = member
|
||||
@@ -1148,9 +1373,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
return event
|
||||
}
|
||||
|
||||
private fun saveSeries(title: String, owner: Member, isActive: Boolean): Series {
|
||||
val genre = SeriesGenre(genre = "genre-$title")
|
||||
entityManager.persist(genre)
|
||||
private fun saveSeries(
|
||||
title: String,
|
||||
owner: Member,
|
||||
isActive: Boolean,
|
||||
genre: SeriesGenre = saveSeriesGenre("genre-$title", isAdult = false)
|
||||
): Series {
|
||||
val series = Series(
|
||||
title = title,
|
||||
introduction = "introduction",
|
||||
@@ -1163,6 +1391,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveSeriesGenre(name: String, isAdult: Boolean): SeriesGenre {
|
||||
val genre = SeriesGenre(genre = name, isAdult = isAdult)
|
||||
entityManager.persist(genre)
|
||||
return genre
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = content
|
||||
entityManager.persist(seriesContent)
|
||||
return seriesContent
|
||||
}
|
||||
|
||||
private fun saveMainTab(title: String): AudioContentMainTab {
|
||||
val tab = AudioContentMainTab(title = title, isActive = true)
|
||||
entityManager.persist(tab)
|
||||
|
||||
@@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendat
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||
@@ -279,6 +281,112 @@ class HomeRecommendationQueryServiceTest {
|
||||
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), service.findPopularCommunityRecommendations())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 기본 5개 장르와 장르별 8명을 조회하고 한 응답 안에서 크리에이터 중복을 제거한다")
|
||||
fun shouldFindGenreCreatorRecommendationsWithDefaultLimitsAndCreatorUniqueness() {
|
||||
port.genreCreatorRecommendations = listOf(
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 1L,
|
||||
genreName = "romance",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
),
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 11L,
|
||||
creatorNickname = "creator-11",
|
||||
creatorProfileImage = "11.png"
|
||||
)
|
||||
)
|
||||
),
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 2L,
|
||||
genreName = "fantasy",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
),
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 12L,
|
||||
creatorNickname = "creator-12",
|
||||
creatorProfileImage = "12.png"
|
||||
),
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 13L,
|
||||
creatorNickname = "creator-13",
|
||||
creatorProfileImage = "13.png"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val recommendations = service.findGenreCreatorRecommendations(
|
||||
memberId = 100L,
|
||||
includeAdultGenres = true
|
||||
)
|
||||
|
||||
assertEquals(100L, port.genreCreatorMemberId)
|
||||
assertEquals(true, port.genreCreatorIncludeAdultGenres)
|
||||
assertEquals(5, port.genreCreatorGenreLimit)
|
||||
assertEquals(40, port.genreCreatorCreatorLimit)
|
||||
assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId })
|
||||
assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 빈 그룹을 제외하고 뒤 후보로 보충한다")
|
||||
fun shouldSkipEmptyGenreCreatorGroupsAfterCreatorDeduplication() {
|
||||
port.genreCreatorRecommendations = listOf(
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 1L,
|
||||
genreName = "theme-1",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
),
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 2L,
|
||||
genreName = "theme-2",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "creator-10",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
),
|
||||
HomeGenreCreatorRecommendationGroup(
|
||||
genreId = 3L,
|
||||
genreName = "theme-3",
|
||||
creators = listOf(
|
||||
HomeGenreCreatorRecommendationRecord(
|
||||
creatorId = 11L,
|
||||
creatorNickname = "creator-11",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val recommendations = service.findGenreCreatorRecommendations(
|
||||
memberId = 100L,
|
||||
includeAdultGenres = false,
|
||||
genreLimit = 2,
|
||||
creatorLimit = 8
|
||||
)
|
||||
|
||||
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
|
||||
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
||||
}
|
||||
|
||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||
var liveLimit: Int? = null
|
||||
var bannerLimit: Int? = null
|
||||
@@ -291,6 +399,10 @@ class HomeRecommendationQueryServiceTest {
|
||||
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||
var genreCreatorMemberId: Long? = null
|
||||
var genreCreatorIncludeAdultGenres: Boolean? = null
|
||||
var genreCreatorGenreLimit: Int? = null
|
||||
var genreCreatorCreatorLimit: Int? = null
|
||||
val liveRecommendations = listOf(
|
||||
HomeLiveRecommendationRecord(
|
||||
liveRoomId = 1L,
|
||||
@@ -352,6 +464,7 @@ class HomeRecommendationQueryServiceTest {
|
||||
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
|
||||
|
||||
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
||||
liveLimit = limit
|
||||
@@ -416,6 +529,19 @@ class HomeRecommendationQueryServiceTest {
|
||||
popularCommunityIncludeAdultCommunities = includeAdultCommunities
|
||||
return popularCommunityDetails
|
||||
}
|
||||
|
||||
override fun findGenreCreatorRecommendations(
|
||||
memberId: Long?,
|
||||
includeAdultGenres: Boolean,
|
||||
genreLimit: Int,
|
||||
creatorLimit: Int
|
||||
): List<HomeGenreCreatorRecommendationGroup> {
|
||||
genreCreatorMemberId = memberId
|
||||
genreCreatorIncludeAdultGenres = includeAdultGenres
|
||||
genreCreatorGenreLimit = genreLimit
|
||||
genreCreatorCreatorLimit = creatorLimit
|
||||
return genreCreatorRecommendations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user