feat(character-image): 캐릭터 이미지 리스트 첫 칸에 프로필 이미지 포함 및 페이징 보정

사용자 경험 향상을 위해 캐릭터 프로필 이미지를 이미지 리스트의 맨 앞에 노출하도록 변경.
This commit is contained in:
Klaus 2025-08-27 14:22:07 +09:00
parent 48b0190242
commit 0347d767f0
3 changed files with 95 additions and 18 deletions

View File

@ -40,21 +40,62 @@ class CharacterImageController(
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
val pageSize = if (size <= 0) 20 else minOf(size, 20) val pageSize = if (size <= 0) 20 else minOf(size, 20)
val pageable = PageRequest.of(page, pageSize)
val pageResult = imageService.pageActiveByCharacter(characterId, pageable)
val totalCount = pageResult.totalElements
// 전체 활성 이미지 수(프로필 제외) 파악을 위해 최소 페이지 조회
val totalActiveElements = imageService.pageActiveByCharacter(characterId, PageRequest.of(0, 1)).totalElements
val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!)
val totalCount = totalActiveElements + 1 // 프로필 포함
val startIndex = page * pageSize
if (startIndex >= totalCount) {
return@run ApiResponse.ok(
CharacterImageListResponse(
totalCount = totalCount,
ownedCount = ownedCount,
items = emptyList()
)
)
}
val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt())
val pageLength = endExclusive - startIndex
// 프로필 이미지 구성(맨 앞)
val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png"
val profileItem = CharacterImageListItemResponse(
id = 0L,
imageUrl = "$imageHost/$profilePath",
isOwned = true,
imagePriceCan = 0L,
sortOrder = 0
)
// 활성 이미지 offset/limit 계산 (결합 리스트 [프로필] + activeImages)
val activeOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong()
val activeLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong()
val expiration = 5L * 60L * 1000L // 5분 val expiration = 5L * 60L * 1000L // 5분
val items = pageResult.content.map { img -> val activeImages = if (activeLimit > 0) {
imageService.pageActiveByCharacterOffset(
characterId,
activeOffset,
activeLimit
)
} else {
emptyList()
}
val items = buildList {
if (startIndex == 0 && pageLength > 0) add(profileItem)
activeImages.forEach { img ->
val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!) val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!)
val url = if (isOwned) { val url = if (isOwned) {
imageCloudFront.generateSignedURL(img.imagePath, expiration) imageCloudFront.generateSignedURL(img.imagePath, expiration)
} else { } else {
"$imageHost/${img.blurImagePath}" "$imageHost/${img.blurImagePath}"
} }
add(
CharacterImageListItemResponse( CharacterImageListItemResponse(
id = img.id!!, id = img.id!!,
imageUrl = url, imageUrl = url,
@ -62,6 +103,8 @@ class CharacterImageController(
imagePriceCan = img.imagePriceCan, imagePriceCan = img.imagePriceCan,
sortOrder = img.sortOrder sortOrder = img.sortOrder
) )
)
}
} }
ApiResponse.ok( ApiResponse.ok(

View File

@ -35,6 +35,12 @@ interface CharacterImageQueryRepository {
offset: Long, offset: Long,
limit: Long limit: Long
): List<CharacterImage> ): List<CharacterImage>
fun findActiveImagesByCharacterPaged(
characterId: Long,
offset: Long,
limit: Long
): List<CharacterImage>
} }
class CharacterImageQueryRepositoryImpl( class CharacterImageQueryRepositoryImpl(
@ -73,4 +79,22 @@ class CharacterImageQueryRepositoryImpl(
.limit(limit) .limit(limit)
.fetch() .fetch()
} }
override fun findActiveImagesByCharacterPaged(
characterId: Long,
offset: Long,
limit: Long
): List<CharacterImage> {
val ci = QCharacterImage.characterImage
return queryFactory
.selectFrom(ci)
.where(
ci.chatCharacter.id.eq(characterId)
.and(ci.isActive.isTrue)
)
.orderBy(ci.sortOrder.asc(), ci.id.asc())
.offset(offset)
.limit(limit)
.fetch()
}
} }

View File

@ -26,6 +26,16 @@ class CharacterImageService(
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable) return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable)
} }
// 오프셋/리밋 조회(활성 이미지)
fun pageActiveByCharacterOffset(
characterId: Long,
offset: Long,
limit: Long
): List<CharacterImage> {
if (limit <= 0L) return emptyList()
return imageRepository.findActiveImagesByCharacterPaged(characterId, offset, limit)
}
// 구매 이력 + 무료로 계산된 보유 수 // 구매 이력 + 무료로 계산된 보유 수
fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long { fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long {
val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L) val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L)