feat(character-image): 이미지 단독 구매 API 및 결제 연동 추가

- 구매 요청/응답 DTO 추가
- 미보유 시 캔 차감 및 구매 이력 저장
- 서명 URL(5분) 반환
This commit is contained in:
Klaus 2025-08-22 21:37:18 +09:00
parent 2ac0a5f896
commit 692e060f6d
3 changed files with 95 additions and 0 deletions

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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
)