fix(home-following): 최근 대화를 팔로우 크리에이터로 제한한다

This commit is contained in:
2026-08-19 12:23:33 +09:00
parent b18e40d4fc
commit b1be87b46f
9 changed files with 570 additions and 20 deletions
@@ -87,6 +87,15 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
AND pc.isActive = true
AND r.isActive = true
AND m.isActive = true
AND (
:followedCreatorsOnly = false
OR EXISTS (
SELECT cf.id FROM CreatorFollowing cf
WHERE cf.member.id = :memberId
AND cf.creator.id = pc.character.creatorMember.id
AND cf.isActive = true
)
)
AND (
:cursorAt IS NULL
OR m.createdAt < :cursorAt
@@ -110,6 +119,7 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
@Param("cursorAt") cursorAt: LocalDateTime?,
@Param("cursorChatType") cursorChatType: String?,
@Param("cursorRoomId") cursorRoomId: Long?,
@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean,
pageable: Pageable
): List<V2ChatRoomListQueryDto>
@@ -17,7 +17,13 @@ class HomeFollowingFacade(
}
val home = homeFollowingQueryService.findHomeFollowing(member)
val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms
val recentChats = chatRoomListService.getRooms(
member,
filter = "ALL",
cursor = null,
limit = 10,
followedCreatorsOnly = true
).rooms
return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats))
}
@@ -28,7 +28,8 @@ class ChatRoomListService(
member: Member,
filter: String = ChatRoomListFilter.ALL.name,
cursor: String? = null,
limit: Int = DEFAULT_LIMIT
limit: Int = DEFAULT_LIMIT,
followedCreatorsOnly: Boolean = false
): ChatRoomListPageResponse {
val type = ChatRoomListFilter.from(filter)
val safeLimit = limit.coerceIn(1, DEFAULT_LIMIT)
@@ -41,12 +42,14 @@ class ChatRoomListService(
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
followedCreatorsOnly,
pageable
) + dmRoomRepository.findDmChatListRooms(
member.id!!,
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
followedCreatorsOnly,
pageable
)
}
@@ -56,6 +59,7 @@ class ChatRoomListService(
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
followedCreatorsOnly,
pageable
)
}
@@ -65,6 +69,7 @@ class ChatRoomListService(
parsedCursor?.lastMessageAt,
parsedCursor?.chatType,
parsedCursor?.roomId,
followedCreatorsOnly,
pageable
)
}
@@ -57,6 +57,15 @@ interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Lon
and opponent.isActive = true
and opponent.member.id <> :memberId
and m.isActive = true
and (
:followedCreatorsOnly = false
or exists (
select cf.id from CreatorFollowing cf
where cf.member.id = :memberId
and cf.creator.id = opponent.member.id
and cf.isActive = true
)
)
and (
:cursorAt is null
or m.createdAt < :cursorAt
@@ -80,6 +89,7 @@ interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Lon
@Param("cursorAt") cursorAt: LocalDateTime?,
@Param("cursorChatType") cursorChatType: String?,
@Param("cursorRoomId") cursorRoomId: Long?,
@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean,
pageable: Pageable
): List<ChatRoomListQueryDto>
}
@@ -1,5 +1,10 @@
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
@@ -9,6 +14,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
@@ -85,10 +91,19 @@ class HomeFollowingEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator"))
.andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId))
.andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live"))
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.chatRoomId))
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("DM"))
.andExpect(jsonPath("$.data.recentChats[0].targetName").value("home-following-creator"))
.andExpect(jsonPath("$.data.recentChats[0].lastMessage").value("recent dm"))
.andExpect(jsonPath("$.data.recentChats.length()").value(2))
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.aiChatRoomId))
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("AI"))
.andExpect(jsonPath("$.data.recentChats[1].roomId").value(fixture.dmChatRoomId))
.andExpect(jsonPath("$.data.recentChats[1].chatType").value("DM"))
.andExpect(jsonPath("$.data.recentChats[1].targetName").value("home-following-creator"))
.andExpect(jsonPath("$.data.recentChats[1].lastMessage").value("recent dm"))
.andExpect(
jsonPath("$.data.recentChats[?(@.roomId == ${fixture.unfollowedDmRoomIds.first()})]").isEmpty
)
.andExpect(
jsonPath("$.data.recentChats[?(@.roomId == ${fixture.inactiveFollowingDmRoomId})]").isEmpty
)
.andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}"))
.andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}"))
.andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString()))
@@ -172,7 +187,21 @@ class HomeFollowingEndToEndTest @Autowired constructor(
val rankedVisibleFromAtUtc = now.minusHours(1)
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
val dmChatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
val aiCreator = saveMember(
"home-following-ai-creator",
MemberRole.CREATOR,
memberKind = MemberKind.AI_CHARACTER
)
saveFollowing(viewer, aiCreator)
val aiChatRoom = saveAiChatRoom(viewer, aiCreator, now.minusMinutes(5))
val unfollowedDmRoomIds = (1..11).map { index ->
val unfollowedCreator = saveMember("home-following-unfollowed-$index", MemberRole.CREATOR)
saveDmChatRoom(viewer, unfollowedCreator, now.minusSeconds(index.toLong())).id!!
}
val inactiveFollowingCreator = saveMember("home-following-inactive", MemberRole.CREATOR)
saveFollowing(viewer, inactiveFollowingCreator, isActive = false)
val inactiveFollowingDmRoom = saveDmChatRoom(viewer, inactiveFollowingCreator, now.minusMinutes(1))
entityManager.flush()
entityManager.clear()
@@ -181,7 +210,10 @@ class HomeFollowingEndToEndTest @Autowired constructor(
creatorId = creator.id!!,
liveId = live.id!!,
audioId = audio.id!!,
chatRoomId = chatRoom.id!!,
dmChatRoomId = dmChatRoom.id!!,
aiChatRoomId = aiChatRoom.id!!,
unfollowedDmRoomIds = unfollowedDmRoomIds,
inactiveFollowingDmRoomId = inactiveFollowingDmRoom.id!!,
rankedNewsId = rankedNews.id!!,
audioNewsId = audioNews.id!!,
communityNewsId = communityNews.id!!,
@@ -191,21 +223,27 @@ class HomeFollowingEndToEndTest @Autowired constructor(
}!!
}
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null): Member {
private fun saveMember(
seed: String,
role: MemberRole,
profileImage: String? = null,
memberKind: MemberKind = MemberKind.HUMAN
): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
profileImage = profileImage,
role = role,
memberKind = memberKind,
countryCode = "US"
)
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
val following = CreatorFollowing(isActive = true).apply {
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean = true): CreatorFollowing {
val following = CreatorFollowing(isActive = isActive).apply {
this.member = member
this.creator = creator
}
@@ -354,12 +392,42 @@ class HomeFollowingEndToEndTest @Autowired constructor(
return room
}
private fun saveAiChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): ChatRoom {
val character = ChatCharacter(
characterUUID = "home-following-ai-character-uuid",
name = "home-following-ai-character",
description = "description",
systemPrompt = "system"
).apply { creatorMember = creator }
entityManager.persist(character)
val room = ChatRoom(sessionId = "home-following-ai-room", title = "home-following-ai-room")
entityManager.persist(room)
val viewerParticipant = ChatParticipant(room, ParticipantType.USER, member = viewer)
val characterParticipant = ChatParticipant(room, ParticipantType.CHARACTER, character = character)
entityManager.persist(viewerParticipant)
entityManager.persist(characterParticipant)
val message = ChatMessage(
message = "recent ai",
chatRoom = room,
participant = characterParticipant,
isActive = true
)
entityManager.persist(message)
entityManager.flush()
message.createdAt = messageCreatedAt
message.updatedAt = messageCreatedAt
return room
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val liveId: Long,
val audioId: Long,
val chatRoomId: Long,
val dmChatRoomId: Long,
val aiChatRoomId: Long,
val unfollowedDmRoomIds: List<Long>,
val inactiveFollowingDmRoomId: Long,
val rankedNewsId: Long,
val audioNewsId: Long,
val communityNewsId: Long,
@@ -60,7 +60,8 @@ class HomeFollowingFacadeTest {
)
Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member)
Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null))
.`when`(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
.`when`(chatRoomListService)
.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true)
val response = facade.getFollowingTab(member)
@@ -71,7 +72,8 @@ class HomeFollowingFacadeTest {
assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId)
assertEquals("news-5", response.recentNews.single().newsId)
Mockito.verify(queryService).findHomeFollowing(member)
Mockito.verify(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
Mockito.verify(chatRoomListService)
.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true)
}
private fun homeFollowing(): HomeFollowing {
@@ -39,7 +39,9 @@ class ChatRoomListServiceTest {
val member = member(1L)
val aiLastAt = LocalDateTime.of(2026, 5, 14, 11, 0)
val dmLastAt = LocalDateTime.of(2026, 5, 14, 12, 0)
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
Mockito.`when`(
aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 10L,
@@ -52,7 +54,9 @@ class ChatRoomListServiceTest {
)
)
)
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
Mockito.`when`(
dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 20L,
@@ -80,7 +84,9 @@ class ChatRoomListServiceTest {
@DisplayName("DM 필터는 DM 방만 조회하고 음성 메시지 요약 문구를 사용한다")
fun shouldReturnOnlyDmRoomsWithVoicePreview() {
val member = member(1L)
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
Mockito.`when`(
dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 20L,
@@ -118,7 +124,9 @@ class ChatRoomListServiceTest {
lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0).minusMinutes(index)
)
}
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(rows)
Mockito.`when`(
aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
).thenReturn(rows)
val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30)
@@ -133,7 +141,9 @@ class ChatRoomListServiceTest {
fun shouldKeepRoomsWithSameTimestampAfterCursor() {
val member = member(1L)
val cursorAt = LocalDateTime.of(2026, 5, 14, 12, 0)
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn(
Mockito.`when`(
aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31))
).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 30L,
@@ -146,7 +156,9 @@ class ChatRoomListServiceTest {
)
)
)
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn(
Mockito.`when`(
dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31))
).thenReturn(
listOf(
ChatRoomListQueryDto(
roomId = 20L,
@@ -175,5 +187,21 @@ class ChatRoomListServiceTest {
assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl)
}
@Test
@DisplayName("팔로우 크리에이터 필터는 AI와 DM 저장소에 전달한다")
fun shouldPassFollowedCreatorsOnlyToBothRepositories() {
val member = member(1L)
val pageable = PageRequest.of(0, 11)
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, true, pageable))
.thenReturn(emptyList())
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, true, pageable))
.thenReturn(emptyList())
service.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true)
Mockito.verify(aiRoomRepository).findAiChatListRooms(1L, null, null, null, true, pageable)
Mockito.verify(dmRoomRepository).findDmChatListRooms(1L, null, null, null, true, pageable)
}
private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id }
}