feat(home): AI 캐릭터 부족분을 랜덤 보충한다

This commit is contained in:
2026-07-22 22:14:26 +09:00
parent 075ca88f01
commit a0bf71be47
10 changed files with 230 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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