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!!,
name = it.name,
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("name") val name: String,
@JsonProperty("description") val description: String,
@JsonProperty("imageUrl") val imageUrl: String
@JsonProperty("imageUrl") val imageUrl: String,
@JsonProperty("isNew") val isNew: Boolean
)
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.dto.Character
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.ChatCharacterHobbyRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
@@ -34,6 +35,7 @@ class ChatCharacterService(
private val hobbyRepository: ChatCharacterHobbyRepository,
private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery,
private val imageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -46,12 +48,25 @@ class ChatCharacterService(
} else {
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 {
Character(
characterId = it.id!!,
name = it.name,
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 topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
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 {
Character(
characterId = it.id!!,
name = it.name,
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()
)
}
val fallback = chatCharacterRepository.findByIsActiveTrue(
val chars = chatCharacterRepository.findByIsActiveTrue(
PageRequest.of(0, 20, Sort.by("createdAt").descending())
)
val content = fallback.content.map {
).content
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
val content = chars.map {
Character(
characterId = it.id!!,
name = it.name,
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(
@@ -126,16 +167,29 @@ class ChatCharacterService(
)
}
val pageResult = chatCharacterRepository.findRecentSince(
val chars = chatCharacterRepository.findRecentSince(
since,
PageRequest.of(safePage, safeSize)
)
val content = pageResult.content.map {
).content
val recentSet = if (chars.isNotEmpty()) {
imageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.toSet()
} else {
emptySet()
}
val content = chars.map {
Character(
characterId = it.id!!,
name = it.name,
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
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
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.OriginalWorkListItemResponse
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.time.LocalDateTime
/**
* 앱용 원작(오리지널 작품) 공개 API
@@ -25,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/chat/original")
class OriginalWorkController(
private val queryService: OriginalWorkQueryService,
private val characterImageRepository: CharacterImageRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -65,17 +70,34 @@ class OriginalWorkController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val ow = queryService.getOriginalWork(id)
val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20)
val characters = pageRes.content.map {
val path = it.imagePath ?: "profile/default-profile.png"
Character(
characterId = it.id!!,
name = it.name,
description = it.description,
imageUrl = "$imageHost/$path"
)
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content
val recentSet = if (chars.isNotEmpty()) {
characterImageRepository
.findCharacterIdsWithRecentImages(
chars.map { it.id!! },
LocalDateTime.now().minusDays(3)
)
.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 safeSize = when {
size <= 0 -> 20
size > 50 -> 50
size > 20 -> 20
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())