diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index c131b73..057ca9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -40,30 +40,73 @@ class CharacterImageController( if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") 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 expiration = 5L * 60L * 1000L // 5분 - val items = pageResult.content.map { img -> - val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!) - val url = if (isOwned) { - imageCloudFront.generateSignedURL(img.imagePath, expiration) - } else { - "$imageHost/${img.blurImagePath}" - } - CharacterImageListItemResponse( - id = img.id!!, - imageUrl = url, - isOwned = isOwned, - imagePriceCan = img.imagePriceCan, - sortOrder = img.sortOrder + 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 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 url = if (isOwned) { + imageCloudFront.generateSignedURL(img.imagePath, expiration) + } else { + "$imageHost/${img.blurImagePath}" + } + add( + CharacterImageListItemResponse( + id = img.id!!, + imageUrl = url, + isOwned = isOwned, + imagePriceCan = img.imagePriceCan, + sortOrder = img.sortOrder + ) + ) + } + } + ApiResponse.ok( CharacterImageListResponse( totalCount = totalCount, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt index c8a4e0e..f23c7e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -35,6 +35,12 @@ interface CharacterImageQueryRepository { offset: Long, limit: Long ): List + + fun findActiveImagesByCharacterPaged( + characterId: Long, + offset: Long, + limit: Long + ): List } class CharacterImageQueryRepositoryImpl( @@ -73,4 +79,22 @@ class CharacterImageQueryRepositoryImpl( .limit(limit) .fetch() } + + override fun findActiveImagesByCharacterPaged( + characterId: Long, + offset: Long, + limit: Long + ): List { + 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() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index 751b814..b0bbe98 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -26,6 +26,16 @@ class CharacterImageService( return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable) } + // 오프셋/리밋 조회(활성 이미지) + fun pageActiveByCharacterOffset( + characterId: Long, + offset: Long, + limit: Long + ): List { + if (limit <= 0L) return emptyList() + return imageRepository.findActiveImagesByCharacterPaged(characterId, offset, limit) + } + // 구매 이력 + 무료로 계산된 보유 수 fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long { val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L)