feat(chat-character): Character DTO에 isNew 매핑 적용(N+1 제거)

- 내용: 서비스 매핑에서 보조 쿼리 결과를 이용해 `isNew` 채움
This commit is contained in:
2025-11-13 22:44:13 +09:00
parent e4c1cf5a9a
commit 597bd8f8ae
5 changed files with 102 additions and 24 deletions

View File

@@ -92,7 +92,8 @@ class ChatCharacterController(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
isNew = false
) )
} }
) )

View File

@@ -21,7 +21,8 @@ data class Character(
@JsonProperty("characterId") val characterId: Long, @JsonProperty("characterId") val characterId: Long,
@JsonProperty("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("description") val description: String, @JsonProperty("description") val description: String,
@JsonProperty("imageUrl") val imageUrl: String @JsonProperty("imageUrl") val imageUrl: String,
@JsonProperty("isNew") val isNew: Boolean
) )
data class RecentCharacter( data class RecentCharacter(

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag
import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
@@ -34,6 +35,7 @@ class ChatCharacterService(
private val hobbyRepository: ChatCharacterHobbyRepository, private val hobbyRepository: ChatCharacterHobbyRepository,
private val goalRepository: ChatCharacterGoalRepository, private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery, private val popularCharacterQuery: PopularCharacterQuery,
private val imageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
@@ -46,12 +48,25 @@ class ChatCharacterService(
} else { } else {
chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit)) chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit))
} }
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
return chars.map { return chars.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
isNew = recentSet.contains(it.id)
) )
} }
} }
@@ -69,12 +84,25 @@ class ChatCharacterService(
val window = RankingWindowCalculator.now("popular-character") val window = RankingWindowCalculator.now("popular-character")
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
val list = loadCharactersInOrder(topIds) val list = loadCharactersInOrder(topIds)
val recentSet = if (list.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
list.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
return list.map { return list.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
isNew = recentSet.contains(it.id)
) )
} }
} }
@@ -109,15 +137,28 @@ class ChatCharacterService(
content = emptyList() content = emptyList()
) )
} }
val fallback = chatCharacterRepository.findByIsActiveTrue( val chars = chatCharacterRepository.findByIsActiveTrue(
PageRequest.of(0, 20, Sort.by("createdAt").descending()) PageRequest.of(0, 20, Sort.by("createdAt").descending())
) ).content
val content = fallback.content.map {
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
val content = chars.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
isNew = recentSet.contains(it.id)
) )
} }
return RecentCharactersResponse( return RecentCharactersResponse(
@@ -126,16 +167,29 @@ class ChatCharacterService(
) )
} }
val pageResult = chatCharacterRepository.findRecentSince( val chars = chatCharacterRepository.findRecentSince(
since, since,
PageRequest.of(safePage, safeSize) PageRequest.of(safePage, safeSize)
) ).content
val content = pageResult.content.map {
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
val content = chars.map {
Character( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = it.name,
description = it.description, description = it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
isNew = recentSet.contains(it.id)
) )
} }

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.chat.original.controller package kr.co.vividnext.sodalive.chat.original.controller
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
@@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
/** /**
* 앱용 원작(오리지널 작품) 공개 API * 앱용 원작(오리지널 작품) 공개 API
@@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/chat/original") @RequestMapping("/api/chat/original")
class OriginalWorkController( class OriginalWorkController(
private val queryService: OriginalWorkQueryService, private val queryService: OriginalWorkQueryService,
private val characterImageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
@@ -65,17 +70,34 @@ class OriginalWorkController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val ow = queryService.getOriginalWork(id) val ow = queryService.getOriginalWork(id)
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20) val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
val characters = pageRes.content.map {
val path = it.imagePath ?: "profile/default-profile.png" val recentSet = if (chars.isNotEmpty()) {
Character( characterImageRepository
characterId = it.id!!, .findCharacterIdsWithRecentImages(
name = it.name, chars.map { it.id!! },
description = it.description, LocalDateTime.now().minusDays(3)
imageUrl = "$imageHost/$path" )
) .toSet()
} else {
emptySet()
} }
val response = OriginalWorkDetailResponse.from(ow, imageHost, characters)
ApiResponse.ok(response) ApiResponse.ok(
OriginalWorkDetailResponse.from(
ow,
imageHost,
chars.map<ChatCharacter, Character> {
val path = it.imagePath ?: "profile/default-profile.png"
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/$path",
isNew = recentSet.contains(it.id)
)
}
)
)
} }
} }

View File

@@ -59,7 +59,7 @@ class OriginalWorkQueryService(
val safePage = if (page < 0) 0 else page val safePage = if (page < 0) 0 else page
val safeSize = when { val safeSize = when {
size <= 0 -> 20 size <= 0 -> 20
size > 50 -> 50 size > 20 -> 20
else -> size else -> size
} }
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())