From 2a0d2a385a050829cc8c8e455bf5f7b3ca6007f8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 03:20:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A7=91=EA=B3=84=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 120 +++++++----- .../port/out/HomeRecommendationQueryPort.kt | 2 +- ...ltHomeRecommendationQueryRepositoryTest.kt | 180 ++++++++++++++++-- 3 files changed, 236 insertions(+), 66 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 1ce83ea9..442d69ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -483,74 +483,89 @@ class DefaultHomeRecommendationQueryRepository( override fun findAiCharacterSnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List { val sql = """ - select cc.id as target_id, - (((select count(cm.id) - from chat_message cm - join chat_participant cp on cp.id = cm.participant_id - where cp.character_id = cc.id - and cm.created_at >= :windowStart - and cm.created_at <= :snapshotAt - and cm.is_active = true - and cp.is_active = true - and cp.participant_type = 'CHARACTER') * ${RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT} + - (select count(distinct up.member_id) - from chat_message cm - join chat_participant up on up.id = cm.participant_id - join chat_participant cp on cp.chat_room_id = cm.chat_room_id - join member m on m.id = up.member_id - where cp.character_id = cc.id - and cm.created_at >= :windowStart - and cm.created_at <= :snapshotAt - and cm.is_active = true - and up.is_active = true - and cp.is_active = true - and up.participant_type = 'USER' - and cp.participant_type = 'CHARACTER' - and m.is_active = true) * ${RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT}) * - case - when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} - when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} - when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} - else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} - end) as score, - rand() as random_tie_breaker - from chat_character cc - where cc.is_active = true - and (exists ( - select 1 + select ranked.target_id, + ranked.score, + ranked.snapshot_rank as random_tie_breaker + from ( + select scored.target_id, + scored.score, + row_number() over (order by scored.score desc, scored.character_created_at desc, scored.target_id desc) as snapshot_rank + from ( + select cc.id as target_id, + cc.created_at as character_created_at, + ((coalesce(acs.ai_chat_count, 0) * ${RecommendationScoreSpec.AI_CHAT_WEIGHT} + + coalesce(aus.active_user_count, 0) * ${RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT} + + coalesce(fs.follow_increase_count, 0) * ${RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT}) * + case + when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS} + when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS} + when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.AI_NEW_BOOST_30_DAYS} + else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} + end) as score + from chat_character cc + join member creator_member on creator_member.id = cc.creator_member_id + left join ( + select cp.character_id as character_id, count(cm.id) as ai_chat_count from chat_message cm join chat_participant cp on cp.id = cm.participant_id - where cp.character_id = cc.id - and cm.created_at >= :windowStart - and cm.created_at <= :snapshotAt + where cm.created_at >= :windowStart + and cm.created_at < :windowEndExclusive and cm.is_active = true and cp.is_active = true and cp.participant_type = 'CHARACTER' - ) or exists ( - select 1 + group by cp.character_id + ) acs on acs.character_id = cc.id + left join ( + select cp.character_id as character_id, count(distinct up.member_id) as active_user_count from chat_message cm join chat_participant up on up.id = cm.participant_id join chat_participant cp on cp.chat_room_id = cm.chat_room_id join member m on m.id = up.member_id - where cp.character_id = cc.id - and cm.created_at >= :windowStart - and cm.created_at <= :snapshotAt + where cm.created_at >= :windowStart + and cm.created_at < :windowEndExclusive and cm.is_active = true and up.is_active = true and cp.is_active = true and up.participant_type = 'USER' and cp.participant_type = 'CHARACTER' and m.is_active = true - )) - order by score desc, random_tie_breaker asc + group by cp.character_id + ) aus on aus.character_id = cc.id + left join ( + select cf.creator_id as creator_id, count(cf.id) as follow_increase_count + from creator_following cf + join member follower_member on follower_member.id = cf.member_id + where cf.created_at >= :windowStart + and cf.created_at < :windowEndExclusive + and cf.is_active = true + and follower_member.is_active = true + group by cf.creator_id + ) fs on fs.creator_id = cc.creator_member_id + where cc.is_active = true + and cc.created_at is not null + and cc.created_at < :windowEndExclusive + and creator_member.is_active = true + and creator_member.role = 'CREATOR' + and creator_member.member_kind = 'AI_CHARACTER' + ) scored + where scored.score > 0 + ) ranked + order by ranked.score desc, ranked.snapshot_rank asc limit :limit """.trimIndent() - return executeSnapshotQuery(sql, RecommendedSectionType.AI_CHARACTER, windowStart, snapshotAt, limit) + return executeSnapshotQuery( + sql, + RecommendedSectionType.AI_CHARACTER, + windowStart, + windowEndExclusive.minusSeconds(1), + limit, + windowEndExclusive + ) } override fun findCheerCreatorSnapshots( @@ -1129,12 +1144,19 @@ class DefaultHomeRecommendationQueryRepository( sectionType: RecommendedSectionType, windowStart: LocalDateTime, snapshotAt: LocalDateTime, - limit: Int + limit: Int, + windowEndExclusive: LocalDateTime? = null ): List { val query = entityManager.createNativeQuery(sql) .setParameter("windowStart", windowStart) - .setParameter("snapshotAt", snapshotAt) .setParameter("limit", limit) + if (sql.contains(":snapshotAt")) { + query.setParameter("snapshotAt", snapshotAt) + } + if (windowEndExclusive != null) { + query.setParameter("windowEndExclusive", windowEndExclusive) + } + query .setParameter( "boost10Start", snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index 16b3e094..b1640f33 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -40,7 +40,7 @@ interface HomeRecommendationQueryPort { fun findAiCharacterSnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 73587785..37172326 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -523,12 +523,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( } @Test - @DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다") - fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() { - val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) - val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + @DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다") + fun shouldFindAiCharacterSnapshotsWithFollowIncrease() { + val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) val user1 = saveMember("ai-user-1", MemberRole.USER) val user2 = saveMember("ai-user-2", MemberRole.USER) + val inactiveFollower = saveMember("ai-inactive-follower", MemberRole.USER) + val inactiveMemberFollower = saveMember("ai-inactive-member-follower", MemberRole.USER, isActive = false) val character = saveCharacter("character-1", isActive = true) val inactiveCharacter = saveCharacter("character-2", isActive = false) val room = saveChatRoom("room-1") @@ -547,23 +550,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false) val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true) val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true) - updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusDays(1)) - updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusDays(2)) + updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusHours(1)) + updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusHours(2)) updateCreatedAt("ChatMessage", message3.id!!, snapshotAt) - updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusDays(1)) + updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusHours(1)) updateCreatedAt("ChatMessage", characterMessage2.id!!, snapshotAt) - updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusDays(3)) + updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusHours(3)) updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1)) - updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusDays(1)) - updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 5, 20, 12, 0)) + updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusHours(1)) + val follow1 = saveFollowing(user1, character.creatorMember!!, isActive = true) + val follow2 = saveFollowing(user2, character.creatorMember!!, isActive = true) + val inactiveFollow = saveFollowing(inactiveFollower, character.creatorMember!!, isActive = false) + val inactiveMemberFollow = saveFollowing(inactiveMemberFollower, character.creatorMember!!, isActive = true) + updateCreatedAt("CreatorFollowing", follow1.id!!, windowStart.plusHours(1)) + updateCreatedAt("CreatorFollowing", follow2.id!!, snapshotAt) + updateCreatedAt("CreatorFollowing", inactiveFollow.id!!, windowStart.plusHours(1)) + updateCreatedAt("CreatorFollowing", inactiveMemberFollow.id!!, windowStart.plusHours(1)) + updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 7, 1, 12, 0)) flushAndClear() - val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 10) + val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10) val expectedScore = scorePolicy.calculateAiChatScore( recentChatCount = 2, recentActiveUserCount = 2, - newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 12, 0), snapshotAt) + followIncreaseCount = 2, + newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 7, 1, 12, 0), snapshotAt) ) assertEquals(1, snapshots.size) assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType) @@ -572,11 +584,75 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(snapshotAt, snapshots.single().snapshotAt) } + @Test + @DisplayName("AI 캐릭터 스냅샷은 종료 시각을 exclusive 경계로 집계한다") + fun shouldFindAiCharacterSnapshotsWithExclusiveWindowEnd() { + val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0) + val windowEndExclusive = LocalDateTime.of(2026, 7, 9, 15, 0) + val snapshotAt = windowEndExclusive.minusSeconds(1) + val includedUser = saveMember("ai-window-included-user", MemberRole.USER) + val excludedUser = saveMember("ai-window-excluded-user", MemberRole.USER) + val character = saveCharacter("ai-window-character", isActive = true) + val excludedCharacter = saveCharacter("ai-window-excluded-character", isActive = true) + val room = saveChatRoom("ai-window-room") + val excludedRoom = saveChatRoom("ai-window-excluded-room") + val includedUserParticipant = saveParticipant(room, ParticipantType.USER, member = includedUser) + val includedCharacterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character) + val excludedUserParticipant = saveParticipant(excludedRoom, ParticipantType.USER, member = excludedUser) + val excludedCharacterParticipant = saveParticipant(excludedRoom, ParticipantType.CHARACTER, character = excludedCharacter) + updateCreatedAt( + "ChatMessage", + saveMessage(room, includedUserParticipant, "included-user", true).id!!, + windowEndExclusive.minusSeconds(1) + ) + updateCreatedAt( + "ChatMessage", + saveMessage(room, includedCharacterParticipant, "included-character", true).id!!, + windowEndExclusive.minusSeconds(1) + ) + updateCreatedAt( + "ChatMessage", + saveMessage(excludedRoom, excludedUserParticipant, "excluded-user", true).id!!, + windowEndExclusive + ) + updateCreatedAt( + "ChatMessage", + saveMessage(excludedRoom, excludedCharacterParticipant, "excluded-character", true).id!!, + windowEndExclusive + ) + updateCreatedAt( + "CreatorFollowing", + saveFollowing(includedUser, character.creatorMember!!, isActive = true).id!!, + windowEndExclusive.minusSeconds(1) + ) + updateCreatedAt( + "CreatorFollowing", + saveFollowing(excludedUser, excludedCharacter.creatorMember!!, isActive = true).id!!, + windowEndExclusive + ) + updateCreatedAt("ChatCharacter", character.id!!, windowStart) + updateCreatedAt("ChatCharacter", excludedCharacter.id!!, windowEndExclusive) + flushAndClear() + + val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10) + + val expectedScore = scorePolicy.calculateAiChatScore( + recentChatCount = 1, + recentActiveUserCount = 1, + followIncreaseCount = 1, + newBoost = scorePolicy.calculateAiCharacterNewBoost(windowStart, snapshotAt) + ) + assertEquals(listOf(character.id), snapshots.map { it.targetId }) + assertEquals(expectedScore, snapshots.single().score, 0.0001) + assertEquals(snapshotAt, snapshots.single().snapshotAt) + } + @Test @DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다") fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() { val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) val user = saveMember("ai-score-user", MemberRole.USER) val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true) val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true) @@ -610,20 +686,92 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0)) flushAndClear() - val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 1) + val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 1) val expectedScore = scorePolicy.calculateAiChatScore( - recentChatCount = 2, + recentChatCount = 3, recentActiveUserCount = 1, - newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) + followIncreaseCount = 0, + newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 4, 1, 0, 0), snapshotAt) ) assertEquals(1, snapshots.size) assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType) - assertEquals(newLowerActivityCharacter.id, snapshots.single().targetId) + assertEquals(oldHighActivityCharacter.id, snapshots.single().targetId) assertEquals(expectedScore, snapshots.single().score, 0.0001) assertEquals(snapshotAt, snapshots.single().snapshotAt) } + @Test + @DisplayName("AI 캐릭터 스냅샷은 생성일 누락/미래와 유효하지 않은 AI 크리에이터 회원을 제외한다") + fun shouldExcludeInvalidAiCharacterSnapshotCandidates() { + val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val user = saveMember("ai-exclude-user", MemberRole.USER) + val visibleCharacter = saveCharacter("ai-exclude-visible", isActive = true) + val futureCharacter = saveCharacter("ai-exclude-future", isActive = true) + val inactiveCreatorCharacter = saveCharacter("ai-exclude-inactive-creator", isActive = true).apply { + creatorMember!!.isActive = false + } + val userCreatorCharacter = saveCharacter("ai-exclude-user-creator", isActive = true).apply { + creatorMember!!.role = MemberRole.USER + } + val humanCreatorCharacter = saveCharacter("ai-exclude-human-creator", isActive = true).apply { + creatorMember!!.memberKind = MemberKind.HUMAN + } + listOf(visibleCharacter, futureCharacter, inactiveCreatorCharacter, userCreatorCharacter, humanCreatorCharacter) + .forEachIndexed { index, character -> + val room = saveChatRoom("ai-exclude-room-$index") + val userParticipant = saveParticipant(room, ParticipantType.USER, member = user) + val characterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character) + updateCreatedAt( + "ChatMessage", + saveMessage(room, userParticipant, "user-$index", true).id!!, + windowStart.plusHours(1) + ) + updateCreatedAt( + "ChatMessage", + saveMessage(room, characterParticipant, "character-$index", true).id!!, + windowStart.plusHours(1) + ) + } + updateCreatedAt("ChatCharacter", visibleCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0)) + updateCreatedAt("ChatCharacter", futureCharacter.id!!, snapshotAt.plusSeconds(1)) + updateCreatedAt("ChatCharacter", inactiveCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0)) + updateCreatedAt("ChatCharacter", userCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0)) + updateCreatedAt("ChatCharacter", humanCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0)) + flushAndClear() + + val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10) + + assertEquals(listOf(visibleCharacter.id), snapshots.map { it.targetId }) + } + + @Test + @DisplayName("AI 캐릭터 스냅샷은 점수 동점이면 더 늦게 생성된 캐릭터를 먼저 반환하고 상위 20개만 저장 후보로 반환한다") + fun shouldOrderAiCharacterSnapshotsByScoreThenCreatedAtDescWithTopTwentyLimit() { + val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val user = saveMember("ai-top-user", MemberRole.USER) + val characters = (0..20).map { index -> + val character = saveCharacter("ai-top-$index", isActive = true) + val room = saveChatRoom("ai-top-room-$index") + val participant = saveParticipant(room, ParticipantType.USER, member = user) + saveParticipant(room, ParticipantType.CHARACTER, character = character) + updateCreatedAt("ChatMessage", saveMessage(room, participant, "message-$index", true).id!!, windowStart.plusHours(1)) + updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 6, 1, 0, 0).plusMinutes(index.toLong())) + character + } + flushAndClear() + + val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 20) + + assertEquals(20, snapshots.size) + assertEquals(characters.drop(1).reversed().map { it.id }, snapshots.map { it.targetId }) + assertEquals(false, snapshots.any { it.targetId == characters.first().id }) + } + @Test @DisplayName("최근 응원 스냅샷은 CHANNEL_DONATION 후원 금액과 후원 수만 집계한다") fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() {