feat(chat-character-image): 캐릭터 이미지 리스트 API 추가 및 보유 판단 로직 적용
This commit is contained in:
parent
8451cdfb80
commit
13fd262c94
|
@ -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>
|
||||||
|
)
|
Loading…
Reference in New Issue