캐릭터 챗봇 #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>
|
||||
)
|
Loading…
Reference in New Issue