feat(character-image): 보유 이미지 전용 목록 API 추가 및 DB 페이징 적용
- /api/chat/character/image/my-list 엔드포인트 추가
  - 로그인/본인인증 체크
  - 캐릭터 프로필 이미지를 리스트 맨 앞에 포함
  - 보유 이미지(무료 또는 구매 이력 존재)만 노출
  - CloudFront 서명 URL 발급로 접근 제어
- 페이징 로직 개선
  - 기존: 전체 조회 후 메모리에서 필터링/슬라이싱
  - 변경: QueryDSL로 DB 레벨에서 보유 이미지만 오프셋/리밋 조회
  - 프로필 아이템(인덱스 0) 포함을 고려하여 owned offset/limit 계산
  - 빈 페이지 요청 시 즉시 빈 결과 반환
- Repository
  - CharacterImageQueryRepository + Impl 추가
  - findOwnedActiveImagesByCharacterPaged(...) 구현
    - 구매 이력: CHAT_MESSAGE_PURCHASE, CHARACTER_IMAGE_PURCHASE만 인정, 환불 제외
    - 활성 이미지, sortOrder asc, id asc 정렬 + offset/limit
- Service
  - getCharacterImagePath(characterId) 추가
  - pageOwnedActiveByCharacterForMember(...) 추가
- Controller
  - my-list 응답 스키마는 list와 동일하게 totalCount/ownedCount/items 유지
  - 페이지 사이즈 상한 20 적용, 5분 만료 서명 URL
			
			
This commit is contained in:
		| @@ -45,7 +45,6 @@ class CharacterImageController( | |||||||
|         val pageResult = imageService.pageActiveByCharacter(characterId, pageable) |         val pageResult = imageService.pageActiveByCharacter(characterId, pageable) | ||||||
|         val totalCount = pageResult.totalElements |         val totalCount = pageResult.totalElements | ||||||
|  |  | ||||||
|         // 현재 요구사항 기준 '무료=보유'로 계산 (구매 이력은 추후 확장) |  | ||||||
|         val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) |         val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) | ||||||
|  |  | ||||||
|         val expiration = 5L * 60L * 1000L // 5분 |         val expiration = 5L * 60L * 1000L // 5분 | ||||||
| @@ -74,6 +73,82 @@ class CharacterImageController( | |||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     @GetMapping("/my-list") | ||||||
|  |     fun myList( | ||||||
|  |         @RequestParam characterId: Long, | ||||||
|  |         @RequestParam(required = false, defaultValue = "0") page: Int, | ||||||
|  |         @RequestParam(required = false, defaultValue = "20") size: Int, | ||||||
|  |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||||
|  |     ) = run { | ||||||
|  |         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||||
|  |  | ||||||
|  |         val pageSize = if (size <= 0) 20 else minOf(size, 20) | ||||||
|  |         val expiration = 5L * 60L * 1000L // 5분 | ||||||
|  |  | ||||||
|  |         val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) | ||||||
|  |         val totalCount = ownedCount + 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 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         // 보유 이미지의 오프셋/리밋 계산 (결합 리스트 [프로필] + ownedImages) | ||||||
|  |         val ownedOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong() | ||||||
|  |         val ownedLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong() | ||||||
|  |  | ||||||
|  |         val ownedImagesPage = if (ownedLimit > 0) { | ||||||
|  |             imageService.pageOwnedActiveByCharacterForMember(characterId, member.id!!, ownedOffset, ownedLimit) | ||||||
|  |         } else { | ||||||
|  |             emptyList() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val items = buildList { | ||||||
|  |             if (startIndex == 0 && pageLength > 0) add(profileItem) | ||||||
|  |             ownedImagesPage.forEach { img -> | ||||||
|  |                 val url = imageCloudFront.generateSignedURL(img.imagePath, expiration) | ||||||
|  |                 add( | ||||||
|  |                     CharacterImageListItemResponse( | ||||||
|  |                         id = img.id!!, | ||||||
|  |                         imageUrl = url, | ||||||
|  |                         isOwned = true, | ||||||
|  |                         imagePriceCan = img.imagePriceCan, | ||||||
|  |                         sortOrder = img.sortOrder | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         ApiResponse.ok( | ||||||
|  |             CharacterImageListResponse( | ||||||
|  |                 totalCount = totalCount, | ||||||
|  |                 ownedCount = ownedCount, | ||||||
|  |                 items = items | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @PostMapping("/purchase") |     @PostMapping("/purchase") | ||||||
|     fun purchase( |     fun purchase( | ||||||
|         @RequestBody req: CharacterImagePurchaseRequest, |         @RequestBody req: CharacterImagePurchaseRequest, | ||||||
|   | |||||||
| @@ -1,5 +1,9 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.character.image | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import com.querydsl.jpa.JPAExpressions | ||||||
|  | import com.querydsl.jpa.impl.JPAQueryFactory | ||||||
|  | import kr.co.vividnext.sodalive.can.use.CanUsage | ||||||
|  | import kr.co.vividnext.sodalive.can.use.QUseCan.useCan | ||||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||||
| import org.springframework.data.jpa.repository.JpaRepository | import org.springframework.data.jpa.repository.JpaRepository | ||||||
| @@ -7,7 +11,7 @@ import org.springframework.data.jpa.repository.Query | |||||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
| @Repository | @Repository | ||||||
| interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository { | ||||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> |     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> | ||||||
|  |  | ||||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( |     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( | ||||||
| @@ -23,3 +27,50 @@ interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | |||||||
|     ) |     ) | ||||||
|     fun findMaxSortOrderByCharacterId(characterId: Long): Int |     fun findMaxSortOrderByCharacterId(characterId: Long): Int | ||||||
| } | } | ||||||
|  |  | ||||||
|  | interface CharacterImageQueryRepository { | ||||||
|  |     fun findOwnedActiveImagesByCharacterPaged( | ||||||
|  |         characterId: Long, | ||||||
|  |         memberId: Long, | ||||||
|  |         offset: Long, | ||||||
|  |         limit: Long | ||||||
|  |     ): List<CharacterImage> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class CharacterImageQueryRepositoryImpl( | ||||||
|  |     private val queryFactory: JPAQueryFactory | ||||||
|  | ) : CharacterImageQueryRepository { | ||||||
|  |     override fun findOwnedActiveImagesByCharacterPaged( | ||||||
|  |         characterId: Long, | ||||||
|  |         memberId: Long, | ||||||
|  |         offset: Long, | ||||||
|  |         limit: Long | ||||||
|  |     ): List<CharacterImage> { | ||||||
|  |         val usages = listOf(CanUsage.CHAT_MESSAGE_PURCHASE, CanUsage.CHARACTER_IMAGE_PURCHASE) | ||||||
|  |         val ci = QCharacterImage.characterImage | ||||||
|  |         return queryFactory | ||||||
|  |             .selectFrom(ci) | ||||||
|  |             .where( | ||||||
|  |                 ci.chatCharacter.id.eq(characterId) | ||||||
|  |                     .and(ci.isActive.isTrue) | ||||||
|  |                     .and( | ||||||
|  |                         ci.imagePriceCan.eq(0L).or( | ||||||
|  |                             JPAExpressions | ||||||
|  |                                 .selectOne() | ||||||
|  |                                 .from(useCan) | ||||||
|  |                                 .where( | ||||||
|  |                                     useCan.member.id.eq(memberId) | ||||||
|  |                                         .and(useCan.isRefund.isFalse) | ||||||
|  |                                         .and(useCan.characterImage.id.eq(ci.id)) | ||||||
|  |                                         .and(useCan.canUsage.`in`(usages)) | ||||||
|  |                                 ) | ||||||
|  |                                 .exists() | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |             ) | ||||||
|  |             .orderBy(ci.sortOrder.asc(), ci.id.asc()) | ||||||
|  |             .offset(offset) | ||||||
|  |             .limit(limit) | ||||||
|  |             .fetch() | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -56,6 +56,23 @@ class CharacterImageService( | |||||||
|     fun getById(id: Long): CharacterImage = |     fun getById(id: Long): CharacterImage = | ||||||
|         imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } |         imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } | ||||||
|  |  | ||||||
|  |     fun getCharacterImagePath(characterId: Long): String? { | ||||||
|  |         val character = characterRepository.findById(characterId) | ||||||
|  |             .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } | ||||||
|  |         return character.imagePath | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 보유한(무료+구매) 활성 이미지 페이징 조회 | ||||||
|  |     fun pageOwnedActiveByCharacterForMember( | ||||||
|  |         characterId: Long, | ||||||
|  |         memberId: Long, | ||||||
|  |         offset: Long, | ||||||
|  |         limit: Long | ||||||
|  |     ): List<CharacterImage> { | ||||||
|  |         if (limit <= 0L) return emptyList() | ||||||
|  |         return imageRepository.findOwnedActiveImagesByCharacterPaged(characterId, memberId, offset, limit) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @Transactional |     @Transactional | ||||||
|     fun registerImage( |     fun registerImage( | ||||||
|         characterId: Long, |         characterId: Long, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user