캐릭터 챗봇 #338
| @@ -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!! | ||||
|   | ||||
| @@ -9,5 +9,7 @@ enum class CanUsage { | ||||
|     SPIN_ROULETTE, | ||||
|     PAID_COMMUNITY_POST, | ||||
|     ALARM_SLOT, | ||||
|     AUDITION_VOTE | ||||
|     AUDITION_VOTE, | ||||
|     CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) | ||||
|     CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 | ||||
| } | ||||
|   | ||||
| @@ -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<UseCanCalculate> = mutableListOf() | ||||
| } | ||||
|   | ||||
| @@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.stereotype.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 { | ||||
|     fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean | ||||
|     fun countPurchasedActiveImagesByCharacter( | ||||
|         memberId: Long, | ||||
|         characterId: Long, | ||||
|         usages: Collection<CanUsage> | ||||
|     ): 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<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 | ||||
|  | ||||
| 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<CharacterImage, Long> { | ||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> | ||||
|  | ||||
|     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( | ||||
|         characterId: Long, | ||||
|         pageable: Pageable | ||||
|     ): Page<CharacterImage> | ||||
|  | ||||
|     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" | ||||
|   | ||||
| @@ -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<CharacterImage> { | ||||
|         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 = | ||||
|         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