From 692e060f6d16a67b0a3bfa9cd06784d6b1a54c8f Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 22 Aug 2025 21:37:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-image):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A8=EB=8F=85=20=EA=B5=AC=EB=A7=A4=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EC=A0=9C=20=EC=97=B0=EB=8F=99=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구매 요청/응답 DTO 추가 - 미보유 시 캔 차감 및 구매 이력 저장 - 서명 URL(5분) 반환 --- .../sodalive/can/payment/CanPaymentService.kt | 46 +++++++++++++++++++ .../image/CharacterImageController.kt | 37 +++++++++++++++ .../image/dto/CharacterImagePurchaseDtos.kt | 12 +++++ 3 files changed, 95 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 5f60109..092a6e4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.order.Order @@ -327,4 +328,49 @@ class CanPaymentService( chargeRepository.save(charge) } } + + @Transactional + fun spendCanForCharacterImage( + memberId: Long, + needCan: Int, + image: CharacterImage, + container: String + ) { + val member = memberRepository.findByIdOrNull(id = memberId) + ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + + val useRewardCan = spendRewardCan(member, needCan, container) + val useChargeCan = if (needCan - useRewardCan.total > 0) { + spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container) + } else { + null + } + + if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) { + throw SodaException( + "${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " + + "캔이 부족합니다. 충전 후 이용해 주세요." + ) + } + + if (!useRewardCan.verify() || useChargeCan?.verify() == false) { + throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + } + + val useCan = UseCan( + canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE, + can = useChargeCan?.total ?: 0, + rewardCan = useRewardCan.total, + isSecret = false + ) + useCan.member = member + useCan.characterImage = image + + useCanRepository.save(useCan) + + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) + } } 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 index c2866fe..97337e0 100644 --- 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 @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.chat.character.image import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront +import kr.co.vividnext.sodalive.can.payment.CanPaymentService 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.chat.character.image.dto.CharacterImagePurchaseRequest +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseResponse import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member @@ -10,6 +13,8 @@ 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.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -19,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController class CharacterImageController( private val imageService: CharacterImageService, private val imageCloudFront: ImageContentCloudFront, + private val canPaymentService: CanPaymentService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -67,4 +73,35 @@ class CharacterImageController( ) ) } + + @PostMapping("/purchase") + fun purchase( + @RequestBody req: CharacterImagePurchaseRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val image = imageService.getById(req.imageId) + if (!image.isActive) throw SodaException("비활성화된 이미지입니다.") + + val isOwned = (image.imagePriceCan == 0L) || + imageService.isOwnedImageByMember(image.id!!, member.id!!) + + if (!isOwned) { + val needCan = image.imagePriceCan.toInt() + if (needCan <= 0) throw SodaException("구매 가격이 잘못되었습니다.") + + canPaymentService.spendCanForCharacterImage( + memberId = member.id!!, + needCan = needCan, + image = image, + container = req.container + ) + } + + val expiration = 5L * 60L * 1000L // 5분 + val signedUrl = imageCloudFront.generateSignedURL(image.imagePath, expiration) + ApiResponse.ok(CharacterImagePurchaseResponse(imageUrl = signedUrl)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt new file mode 100644 index 0000000..83cedf2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.chat.character.image.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class CharacterImagePurchaseRequest( + @JsonProperty("imageId") val imageId: Long, + @JsonProperty("container") val container: String +) + +data class CharacterImagePurchaseResponse( + @JsonProperty("imageUrl") val imageUrl: String +)