From 48b0190242cc45a7bf9f359a4f965cabbe57ebd2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 23:52:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-image):=20=EB=B3=B4=EC=9C=A0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=84=EC=9A=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DB=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- .../image/CharacterImageController.kt | 77 ++++++++++++++++++- .../image/CharacterImageRepository.kt | 53 ++++++++++++- .../character/image/CharacterImageService.kt | 17 ++++ 3 files changed, 145 insertions(+), 2 deletions(-) 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 97337e0..c131b73 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 @@ -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, 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 8337da6..c8a4e0e 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 @@ -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 { +interface CharacterImageRepository : JpaRepository, CharacterImageQueryRepository { fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( @@ -23,3 +27,50 @@ interface CharacterImageRepository : JpaRepository { ) fun findMaxSortOrderByCharacterId(characterId: Long): Int } + +interface CharacterImageQueryRepository { + fun findOwnedActiveImagesByCharacterPaged( + characterId: Long, + memberId: Long, + offset: Long, + limit: Long + ): List +} + +class CharacterImageQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : CharacterImageQueryRepository { + override fun findOwnedActiveImagesByCharacterPaged( + characterId: Long, + memberId: Long, + offset: Long, + limit: Long + ): List { + 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() + } +} 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 a97c9df..751b814 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 @@ -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 { + if (limit <= 0L) return emptyList() + return imageRepository.findOwnedActiveImagesByCharacterPaged(characterId, memberId, offset, limit) + } + @Transactional fun registerImage( characterId: Long,