캐릭터 챗봇 #338
| @@ -72,6 +72,8 @@ class CanService(private val repository: CanRepository) { | |||||||
|                     CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" |                     CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" | ||||||
|                     CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" |                     CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" | ||||||
|                     CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" |                     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!! |                 val createdAt = it.createdAt!! | ||||||
|   | |||||||
| @@ -9,5 +9,7 @@ enum class CanUsage { | |||||||
|     SPIN_ROULETTE, |     SPIN_ROULETTE, | ||||||
|     PAID_COMMUNITY_POST, |     PAID_COMMUNITY_POST, | ||||||
|     ALARM_SLOT, |     ALARM_SLOT, | ||||||
|     AUDITION_VOTE |     AUDITION_VOTE, | ||||||
|  |     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) | ||||||
|  |     CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| package kr.co.vividnext.sodalive.can.use | package kr.co.vividnext.sodalive.can.use | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.audition.AuditionApplicant | 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.common.BaseEntity | ||||||
| import kr.co.vividnext.sodalive.content.AudioContent | import kr.co.vividnext.sodalive.content.AudioContent | ||||||
| import kr.co.vividnext.sodalive.content.order.Order | import kr.co.vividnext.sodalive.content.order.Order | ||||||
| @@ -58,6 +60,16 @@ data class UseCan( | |||||||
|     @JoinColumn(name = "audition_applicant_id", nullable = true) |     @JoinColumn(name = "audition_applicant_id", nullable = true) | ||||||
|     var auditionApplicant: AuditionApplicant? = null |     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]) |     @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) | ||||||
|     val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf() |     val useCanCalculates: MutableList<UseCanCalculate> = mutableListOf() | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository | |||||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
| @Repository | @Repository | ||||||
| interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository | interface UseCanRepository : JpaRepository<UseCan, Long>, UseCanQueryRepository { | ||||||
|  |     // 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외) | ||||||
|  |     fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn( | ||||||
|  |         memberId: Long, | ||||||
|  |         imageId: Long, | ||||||
|  |         usages: Collection<CanUsage> | ||||||
|  |     ): Boolean | ||||||
|  | } | ||||||
|  |  | ||||||
| interface UseCanQueryRepository { | interface UseCanQueryRepository { | ||||||
|     fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean |     fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean | ||||||
|  |     fun countPurchasedActiveImagesByCharacter( | ||||||
|  |         memberId: Long, | ||||||
|  |         characterId: Long, | ||||||
|  |         usages: Collection<CanUsage> | ||||||
|  |     ): Long | ||||||
| } | } | ||||||
|  |  | ||||||
| class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository { | class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository { | ||||||
| @@ -26,4 +38,24 @@ class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Use | |||||||
|  |  | ||||||
|         return useCanId != null && useCanId > 0 |         return useCanId != null && useCanId > 0 | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     override fun countPurchasedActiveImagesByCharacter( | ||||||
|  |         memberId: Long, | ||||||
|  |         characterId: Long, | ||||||
|  |         usages: Collection<CanUsage> | ||||||
|  |     ): 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() | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.character.image | 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.JpaRepository | ||||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||||
| @@ -8,6 +10,13 @@ import org.springframework.stereotype.Repository | |||||||
| interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | ||||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> |     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> | ||||||
|  |  | ||||||
|  |     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( | ||||||
|  |         characterId: Long, | ||||||
|  |         pageable: Pageable | ||||||
|  |     ): Page<CharacterImage> | ||||||
|  |  | ||||||
|  |     fun countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId: Long, imagePriceCan: Long): Long | ||||||
|  |  | ||||||
|     @Query( |     @Query( | ||||||
|         "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + |         "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + | ||||||
|             "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" |             "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" | ||||||
|   | |||||||
| @@ -1,7 +1,11 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.character.image | 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.chat.character.repository.ChatCharacterRepository | ||||||
| import kr.co.vividnext.sodalive.common.SodaException | 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.stereotype.Service | ||||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||||
|  |  | ||||||
| @@ -9,13 +13,46 @@ import org.springframework.transaction.annotation.Transactional | |||||||
| class CharacterImageService( | class CharacterImageService( | ||||||
|     private val characterRepository: ChatCharacterRepository, |     private val characterRepository: ChatCharacterRepository, | ||||||
|     private val imageRepository: CharacterImageRepository, |     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<CharacterImage> { |     fun listActiveByCharacter(characterId: Long): List<CharacterImage> { | ||||||
|         return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) |         return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // 페이징 조회(활성 이미지) | ||||||
|  |     fun pageActiveByCharacter(characterId: Long, pageable: Pageable): Page<CharacterImage> { | ||||||
|  |         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 = |     fun getById(id: Long): CharacterImage = | ||||||
|         imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } |         imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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<CharacterImageListItemResponse> | ||||||
|  | ) | ||||||
		Reference in New Issue
	
	Block a user