feat(home): AI 캐릭터 부족분을 랜덤 보충한다
This commit is contained in:
@@ -75,7 +75,10 @@ class HomeRecommendationFacade(
|
||||
includeAdultContents = includeAdult
|
||||
)
|
||||
.map { it.toItem() },
|
||||
aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() },
|
||||
aiCharacters = queryService.findAiCharacterRecommendations(
|
||||
limit = HOME_AI_CHARACTER_LIMIT,
|
||||
backfillRandom = true
|
||||
).map { it.toItem() },
|
||||
genreCreators = queryService.findGenreCreatorRecommendations(
|
||||
memberId = member?.id,
|
||||
includeAdultGenres = includeAdult,
|
||||
|
||||
@@ -775,6 +775,29 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findRandomAiCharacterRecommendationIds(
|
||||
excludeCharacterIds: List<Long>,
|
||||
limit: Int
|
||||
): List<Long> {
|
||||
val creatorMember = QMember("creatorMember")
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
|
||||
return queryFactory
|
||||
.select(chatCharacter.id)
|
||||
.from(chatCharacter)
|
||||
.join(chatCharacter.creatorMember, creatorMember)
|
||||
.where(
|
||||
chatCharacter.isActive.isTrue,
|
||||
excludeAiCharacterCondition(excludeCharacterIds),
|
||||
creatorMember.isActive.isTrue,
|
||||
creatorMember.role.eq(MemberRole.CREATOR),
|
||||
creatorMember.memberKind.eq(MemberKind.AI_CHARACTER)
|
||||
)
|
||||
.orderBy(randomTieBreaker.asc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findCheerCreatorRecommendationDetails(
|
||||
creatorIds: List<Long>,
|
||||
memberId: Long?
|
||||
@@ -1235,6 +1258,10 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun excludeAiCharacterCondition(excludeCharacterIds: List<Long>): BooleanExpression? {
|
||||
return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds)
|
||||
}
|
||||
|
||||
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||
if (memberId == null) return null
|
||||
val blockMember = QBlockMember("recommendationBlockMember")
|
||||
|
||||
@@ -78,13 +78,23 @@ class HomeRecommendationQueryService(
|
||||
|
||||
fun findAiCharacterRecommendations(
|
||||
offset: Long = 0,
|
||||
limit: Int = DEFAULT_AI_CHARACTER_LIMIT
|
||||
limit: Int = DEFAULT_AI_CHARACTER_LIMIT,
|
||||
backfillRandom: Boolean = false
|
||||
): List<HomeAiCharacterRecommendationRecord> {
|
||||
val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
|
||||
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
|
||||
.associateBy { it.characterId }
|
||||
val snapshotDetails = snapshots.mapNotNull { detailsById[it.targetId] }
|
||||
|
||||
return snapshots.mapNotNull { detailsById[it.targetId] }
|
||||
if (!backfillRandom || snapshots.isEmpty() || snapshotDetails.size >= limit) return snapshotDetails
|
||||
|
||||
val randomIds = queryPort.findRandomAiCharacterRecommendationIds(
|
||||
excludeCharacterIds = snapshots.map { it.targetId },
|
||||
limit = limit - snapshotDetails.size
|
||||
)
|
||||
val randomDetailsById = queryPort.findAiCharacterRecommendationDetails(randomIds).associateBy { it.characterId }
|
||||
val randomDetails = randomIds.mapNotNull { randomDetailsById[it] }
|
||||
return (snapshotDetails + randomDetails).distinctBy { it.characterId }.take(limit)
|
||||
}
|
||||
|
||||
private fun findAiCharacterSnapshotsWithFallback(
|
||||
|
||||
@@ -58,6 +58,11 @@ interface HomeRecommendationQueryPort {
|
||||
|
||||
fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord>
|
||||
|
||||
fun findRandomAiCharacterRecommendationIds(
|
||||
excludeCharacterIds: List<Long>,
|
||||
limit: Int
|
||||
): List<Long>
|
||||
|
||||
fun findCheerCreatorRecommendationDetails(
|
||||
creatorIds: List<Long>,
|
||||
memberId: Long? = null
|
||||
|
||||
@@ -1619,6 +1619,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("랜덤 AI 캐릭터 id는 제외 id를 빼고 활성 AI 캐릭터 크리에이터 회원만 조회한다")
|
||||
fun shouldFindRandomAiCharacterRecommendationIdsExcludingExistingCharacters() {
|
||||
val excludedCharacter = saveCharacter("ai-random-excluded", isActive = true)
|
||||
val visibleCharacter = saveCharacter("ai-random-visible", isActive = true)
|
||||
val inactiveCharacter = saveCharacter("ai-random-inactive", isActive = false)
|
||||
val inactiveCreatorCharacter = saveCharacter("ai-random-inactive-creator", isActive = true).apply {
|
||||
creatorMember!!.isActive = false
|
||||
}
|
||||
val userCreatorCharacter = saveCharacter("ai-random-user-creator", isActive = true).apply {
|
||||
creatorMember!!.role = MemberRole.USER
|
||||
}
|
||||
val humanCreatorCharacter = saveCharacter("ai-random-human-creator", isActive = true).apply {
|
||||
creatorMember!!.memberKind = MemberKind.HUMAN
|
||||
}
|
||||
flushAndClear()
|
||||
|
||||
val characterIds = repository.findRandomAiCharacterRecommendationIds(
|
||||
excludeCharacterIds = listOf(excludedCharacter.id!!),
|
||||
limit = 10
|
||||
)
|
||||
|
||||
assertEquals(listOf(visibleCharacter.id), characterIds)
|
||||
assertEquals(false, characterIds.contains(inactiveCharacter.id))
|
||||
assertEquals(false, characterIds.contains(inactiveCreatorCharacter.id))
|
||||
assertEquals(false, characterIds.contains(userCreatorCharacter.id))
|
||||
assertEquals(false, characterIds.contains(humanCreatorCharacter.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다")
|
||||
fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() {
|
||||
|
||||
@@ -196,6 +196,53 @@ class HomeRecommendationQueryServiceTest {
|
||||
assertEquals(null, characters.last().originalWorkTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 AI 캐릭터 추천은 스냅샷 상세가 부족하면 랜덤 AI 캐릭터로 limit까지 채운다")
|
||||
fun shouldBackfillAiCharactersWithRandomDetailsWhenSnapshotDetailsAreUnderLimit() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
snapshotAt,
|
||||
(1L..3L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
|
||||
)
|
||||
port.aiCharacterDetailsByRequest = mapOf(
|
||||
(1L..3L).toList() to listOf(aiCharacterRecord(1L, 101L)),
|
||||
listOf(4L, 5L) to listOf(aiCharacterRecord(4L, 104L), aiCharacterRecord(5L, 105L))
|
||||
)
|
||||
port.randomAiCharacterIds = listOf(4L, 5L)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true)
|
||||
|
||||
assertEquals(listOf(4L, 5L), port.aiCharacterDetailIds)
|
||||
assertEquals((1L..3L).toList(), port.randomAiCharacterExcludeIds)
|
||||
assertEquals(2, port.randomAiCharacterLimit)
|
||||
assertEquals(listOf((1L..3L).toList(), listOf(4L, 5L)), port.aiCharacterDetailIdRequests)
|
||||
assertEquals(listOf(1L, 4L, 5L), characters.map { it.characterId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("홈 AI 캐릭터 추천은 랜덤 후보가 부족하면 가능한 캐릭터만 중복 없이 반환한다")
|
||||
fun shouldReturnAvailableAiCharactersWhenRandomBackfillIsUnderLimit() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
snapshotAt,
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L, 100.0, snapshotAt))
|
||||
)
|
||||
port.aiCharacterDetailsByRequest = mapOf(
|
||||
listOf(1L) to listOf(aiCharacterRecord(1L, 101L)),
|
||||
listOf(2L) to listOf(aiCharacterRecord(2L, 102L))
|
||||
)
|
||||
port.randomAiCharacterIds = listOf(2L)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true)
|
||||
|
||||
assertEquals(listOf(1L), port.randomAiCharacterExcludeIds)
|
||||
assertEquals(2, port.randomAiCharacterLimit)
|
||||
assertEquals(listOf(listOf(1L), listOf(2L)), port.aiCharacterDetailIdRequests)
|
||||
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
|
||||
fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() {
|
||||
@@ -254,10 +301,39 @@ class HomeRecommendationQueryServiceTest {
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals(null, port.randomAiCharacterExcludeIds)
|
||||
assertEquals(null, port.randomAiCharacterLimit)
|
||||
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
|
||||
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 전체보기는 첫 페이지여도 랜덤 보충을 적용하지 않는다")
|
||||
fun shouldNotBackfillAiCharacterPageWithRandomDetails() {
|
||||
val fallback = FakeAiCharacterSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.AI_CHARACTER,
|
||||
snapshotAt,
|
||||
(1L..20L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
|
||||
)
|
||||
|
||||
port.aiCharacterDetails = listOf(aiCharacterRecord(1L, 101L))
|
||||
port.randomAiCharacterIds = listOf(21L)
|
||||
|
||||
val characters = service.findAiCharacterRecommendations(offset = 0, limit = 20)
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals(null, port.randomAiCharacterExcludeIds)
|
||||
assertEquals(null, port.randomAiCharacterLimit)
|
||||
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
|
||||
assertEquals(listOf(1L), characters.map { it.characterId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다")
|
||||
fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() {
|
||||
@@ -902,6 +978,9 @@ class HomeRecommendationQueryServiceTest {
|
||||
var firstAudioMemberId: Long? = null
|
||||
var firstAudioIncludeAdultContents: Boolean? = null
|
||||
var aiCharacterDetailIds: List<Long> = emptyList()
|
||||
val aiCharacterDetailIdRequests = mutableListOf<List<Long>>()
|
||||
var randomAiCharacterExcludeIds: List<Long>? = null
|
||||
var randomAiCharacterLimit: Int? = null
|
||||
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||
@@ -962,6 +1041,8 @@ class HomeRecommendationQueryServiceTest {
|
||||
)
|
||||
)
|
||||
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||
var aiCharacterDetailsByRequest: Map<List<Long>, List<HomeAiCharacterRecommendationRecord>> = emptyMap()
|
||||
var randomAiCharacterIds: List<Long> = emptyList()
|
||||
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||
var cheerCreatorMemberId: Long? = null
|
||||
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||
@@ -1048,9 +1129,20 @@ class HomeRecommendationQueryServiceTest {
|
||||
|
||||
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
|
||||
aiCharacterDetailIds = characterIds
|
||||
aiCharacterDetailIdRequests.add(characterIds)
|
||||
aiCharacterDetailsByRequest[characterIds]?.let { return it }
|
||||
return aiCharacterDetails
|
||||
}
|
||||
|
||||
override fun findRandomAiCharacterRecommendationIds(
|
||||
excludeCharacterIds: List<Long>,
|
||||
limit: Int
|
||||
): List<Long> {
|
||||
randomAiCharacterExcludeIds = excludeCharacterIds
|
||||
randomAiCharacterLimit = limit
|
||||
return randomAiCharacterIds.take(limit)
|
||||
}
|
||||
|
||||
override fun findCheerCreatorRecommendationDetails(
|
||||
creatorIds: List<Long>,
|
||||
memberId: Long?
|
||||
@@ -1154,6 +1246,16 @@ private fun fixedClock(): Clock {
|
||||
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
private fun aiCharacterRecord(characterId: Long, creatorId: Long) = HomeAiCharacterRecommendationRecord(
|
||||
characterId = characterId,
|
||||
creatorId = creatorId,
|
||||
name = "character-$characterId",
|
||||
description = "description-$characterId",
|
||||
profileImage = null,
|
||||
totalChatCount = 0L,
|
||||
originalWorkTitle = null
|
||||
)
|
||||
|
||||
private class FakeCheerCreatorSnapshotFallbackPort(
|
||||
private val snapshots: List<RecommendationSnapshotRecord>
|
||||
) : CheerCreatorSnapshotFallbackPort {
|
||||
|
||||
Reference in New Issue
Block a user