캐릭터 챗봇 #338
| @@ -45,7 +45,6 @@ class CharacterImageController( | ||||
|         val pageResult = imageService.pageActiveByCharacter(characterId, pageable) | ||||
|         val totalCount = pageResult.totalElements | ||||
|  | ||||
|         // 현재 요구사항 기준 '무료=보유'로 계산 (구매 이력은 추후 확장) | ||||
|         val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) | ||||
|  | ||||
|         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") | ||||
|     fun purchase( | ||||
|         @RequestBody req: CharacterImagePurchaseRequest, | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| 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.Pageable | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| @@ -7,7 +11,7 @@ import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | ||||
| interface CharacterImageRepository : JpaRepository<CharacterImage, Long>, CharacterImageQueryRepository { | ||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> | ||||
|  | ||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( | ||||
| @@ -23,3 +27,50 @@ interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | ||||
|     ) | ||||
|     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 = | ||||
|         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 | ||||
|     fun registerImage( | ||||
|         characterId: Long, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user