From 13fd262c94d3438d4526c87edbd14d83ed6a7a65 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 17:39:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B4=EC=9C=A0=20=ED=8C=90=EB=8B=A8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/can/CanService.kt | 2 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 4 +- .../co/vividnext/sodalive/can/use/UseCan.kt | 12 ++++ .../sodalive/can/use/UseCanRepository.kt | 34 ++++++++- .../image/CharacterImageController.kt | 71 +++++++++++++++++++ .../image/CharacterImageRepository.kt | 9 +++ .../character/image/CharacterImageService.kt | 39 +++++++++- .../image/dto/CharacterImageListDtos.kt | 18 +++++ 8 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 33466a4..c77366a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -72,6 +72,8 @@ class CanService(private val repository: CanRepository) { CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" + CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" + CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" } val createdAt = it.createdAt!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 0b26698..976845c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -9,5 +9,7 @@ enum class CanUsage { SPIN_ROULETTE, PAID_COMMUNITY_POST, ALARM_SLOT, - AUDITION_VOTE + AUDITION_VOTE, + CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) + CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt index 5a879f4..3d0fc46 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.can.use import kr.co.vividnext.sodalive.audition.AuditionApplicant +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage +import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.order.Order @@ -58,6 +60,16 @@ data class UseCan( @JoinColumn(name = "audition_applicant_id", nullable = true) var auditionApplicant: AuditionApplicant? = null + // 메시지를 통한 구매 연관 (옵션) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_message_id", nullable = true) + var chatMessage: ChatMessage? = null + + // 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_image_id", nullable = true) + var characterImage: CharacterImage? = null + @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) val useCanCalculates: MutableList = mutableListOf() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt index 07bdce1..fd8f1dd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt @@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface UseCanRepository : JpaRepository, UseCanQueryRepository +interface UseCanRepository : JpaRepository, UseCanQueryRepository { + // 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외) + fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn( + memberId: Long, + imageId: Long, + usages: Collection + ): Boolean +} interface UseCanQueryRepository { fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean + fun countPurchasedActiveImagesByCharacter( + memberId: Long, + characterId: Long, + usages: Collection + ): Long } class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository { @@ -26,4 +38,24 @@ class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Use return useCanId != null && useCanId > 0 } + + override fun countPurchasedActiveImagesByCharacter( + memberId: Long, + characterId: Long, + usages: Collection + ): Long { + val count = queryFactory + .selectDistinct(useCan.characterImage.id) + .from(useCan) + .where( + useCan.member.id.eq(memberId) + .and(useCan.isRefund.isFalse) + .and(useCan.characterImage.chatCharacter.id.eq(characterId)) + .and(useCan.characterImage.isActive.isTrue) + .and(useCan.canUsage.`in`(usages)) + ) + .fetch() + .size + return count.toLong() + } } 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 new file mode 100644 index 0000000..7f59c75 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -0,0 +1,71 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListItemResponse +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListResponse +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/character/image") +class CharacterImageController( + private val imageService: CharacterImageService, + private val imageCloudFront: ImageContentCloudFront, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + + @GetMapping("/list") + fun list( + @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 pageable = PageRequest.of(page, pageSize) + + val pageResult = imageService.pageActiveByCharacter(characterId, pageable) + val totalCount = pageResult.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, + isAdult = img.isAdult, + sortOrder = img.sortOrder + ) + } + + ApiResponse.ok( + CharacterImageListResponse( + totalCount = totalCount, + ownedCount = ownedCount, + items = items + ) + ) + } +} 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 3c6de00..8337da6 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,7 @@ package kr.co.vividnext.sodalive.chat.character.image +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @@ -8,6 +10,13 @@ import org.springframework.stereotype.Repository interface CharacterImageRepository : JpaRepository { fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List + fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( + characterId: Long, + pageable: Pageable + ): Page + + fun countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId: Long, imagePriceCan: Long): Long + @Query( "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" 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 8ea064e..a97c9df 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 @@ -1,7 +1,11 @@ package kr.co.vividnext.sodalive.chat.character.image +// ktlint-disable standard:max-line-length +import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -9,13 +13,46 @@ import org.springframework.transaction.annotation.Transactional class CharacterImageService( private val characterRepository: ChatCharacterRepository, private val imageRepository: CharacterImageRepository, - private val triggerTagRepository: CharacterImageTriggerRepository + private val triggerTagRepository: CharacterImageTriggerRepository, + private val useCanRepository: kr.co.vividnext.sodalive.can.use.UseCanRepository ) { fun listActiveByCharacter(characterId: Long): List { return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) } + // 페이징 조회(활성 이미지) + fun pageActiveByCharacter(characterId: Long, pageable: Pageable): Page { + return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable) + } + + // 구매 이력 + 무료로 계산된 보유 수 + fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long { + val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L) + val purchasedCount = useCanRepository.countPurchasedActiveImagesByCharacter( + memberId, + characterId, + listOf( + CanUsage.CHAT_MESSAGE_PURCHASE, + CanUsage.CHARACTER_IMAGE_PURCHASE + ) + ) + return freeCount + purchasedCount + } + + fun isOwnedImageByMember(imageId: Long, memberId: Long): Boolean { + // 무료이거나(컨트롤러에서 가격 확인) 구매 이력이 있으면 보유로 판단 + val purchased = useCanRepository.existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn( + memberId, + imageId, + listOf( + CanUsage.CHAT_MESSAGE_PURCHASE, + CanUsage.CHARACTER_IMAGE_PURCHASE + ) + ) + return purchased + } + fun getById(id: Long): CharacterImage = imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt new file mode 100644 index 0000000..ad19968 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.chat.character.image.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class CharacterImageListItemResponse( + @JsonProperty("id") val id: Long, + @JsonProperty("imageUrl") val imageUrl: String, + @JsonProperty("isOwned") val isOwned: Boolean, + @JsonProperty("imagePriceCan") val imagePriceCan: Long, + @JsonProperty("isAdult") val isAdult: Boolean, + @JsonProperty("sortOrder") val sortOrder: Int +) + +data class CharacterImageListResponse( + @JsonProperty("totalCount") val totalCount: Long, + @JsonProperty("ownedCount") val ownedCount: Long, + @JsonProperty("items") val items: List +)