From 7c8084bdd4834bfa89e3e958d4240094e6e2b3aa Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 29 Jul 2023 05:37:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EC=9D=B8=20=EC=B6=A9=EC=A0=84,=20?= =?UTF-8?q?=EC=BD=94=EC=9D=B8=20=EB=82=B4=EC=97=AD=20API=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 --- build.gradle.kts | 3 + .../kr/co/vividnext/sodalive/can/Can.kt | 20 ++ .../vividnext/sodalive/can/CanController.kt | 60 ++++++ .../vividnext/sodalive/can/CanRepository.kt | 96 +++++++++ .../co/vividnext/sodalive/can/CanResponse.kt | 11 + .../co/vividnext/sodalive/can/CanService.kt | 118 ++++++++++ .../can/GetCanChargeStatusResponseItem.kt | 7 + .../sodalive/can/GetCanStatusResponse.kt | 6 + .../can/GetCanUseStatusResponseItem.kt | 7 + .../vividnext/sodalive/can/charge/Charge.kt | 45 ++++ .../sodalive/can/charge/ChargeController.kt | 52 +++++ .../sodalive/can/charge/ChargeData.kt | 35 +++ .../sodalive/can/charge/ChargeRepository.kt | 7 + .../sodalive/can/charge/ChargeService.kt | 203 ++++++++++++++++++ .../sodalive/can/charge/ChargeStatus.kt | 8 + .../vividnext/sodalive/can/payment/Payment.kt | 41 ++++ .../sodalive/can/payment/PaymentGateway.kt | 5 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 8 + .../co/vividnext/sodalive/can/use/UseCan.kt | 40 ++++ .../sodalive/can/use/UseCanCalculate.kt | 37 ++++ .../sodalive/configs/OkHttpConfig.kt | 22 ++ .../vividnext/sodalive/live/room/LiveRoom.kt | 5 + .../kr/co/vividnext/sodalive/member/Member.kt | 4 +- src/main/resources/application.yml | 4 + src/test/resources/application.yml | 4 + 25 files changed, 846 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt diff --git a/build.gradle.kts b/build.gradle.kts index f55ad63..78ca8b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { // bootpay implementation("io.github.bootpay:backend:2.2.1") + implementation("com.squareup.okhttp3:okhttp:4.9.3") + implementation("org.json:json:20230227") + developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt new file mode 100644 index 0000000..1f0f4f6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/Can.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.can + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +data class Can( + var title: String, + var can: Int, + var rewardCan: Int, + var price: Int, + @Enumerated(value = EnumType.STRING) + var status: CanStatus +) : BaseEntity() + +enum class CanStatus { + SALE, END_OF_SALE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt new file mode 100644 index 0000000..af53b78 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt @@ -0,0 +1,60 @@ +package kr.co.vividnext.sodalive.can + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +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("/can") +class CanController(private val service: CanService) { + @GetMapping + fun getCans(): ApiResponse> { + return ApiResponse.ok(service.getCans()) + } + + @GetMapping("/status") + fun getCanStatus( + @RequestParam container: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getCanStatus(member, container)) + } + + @GetMapping("/status/use") + fun getCanUseStatus( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam("timezone") timezone: String, + @RequestParam("container") container: String, + pageable: Pageable + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getCanUseStatus(member, pageable, timezone, container)) + } + + @GetMapping("/status/charge") + fun getCanChargeStatus( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam("timezone") timezone: String, + @RequestParam("container") container: String, + pageable: Pageable + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.getCanChargeStatus(member, pageable, timezone, container)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt new file mode 100644 index 0000000..05bedff --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanRepository.kt @@ -0,0 +1,96 @@ +package kr.co.vividnext.sodalive.can + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.QCan.can1 +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.charge.QCharge.charge +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.payment.QPayment.payment +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.QMember +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CanRepository : JpaRepository, CanQueryRepository + +interface CanQueryRepository { + fun findAllByStatus(status: CanStatus): List + fun getCanUseStatus(member: Member, pageable: Pageable): List + fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List +} + +@Repository +class CanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : CanQueryRepository { + override fun findAllByStatus(status: CanStatus): List { + return queryFactory + .select( + QCanResponse( + can1.id, + can1.title, + can1.can, + can1.rewardCan, + can1.price + ) + ) + .from(can1) + .where(can1.status.eq(status)) + .orderBy(can1.can.asc()) + .fetch() + } + + override fun getCanUseStatus(member: Member, pageable: Pageable): List { + return queryFactory + .selectFrom(useCan) + .where(useCan.member.id.eq(member.id)) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy(useCan.id.desc()) + .fetch() + } + + override fun getCanChargeStatus(member: Member, pageable: Pageable, container: String): List { + val qMember = QMember.member + val chargeStatusCondition = when (container) { + "aos" -> { + charge.payment.paymentGateway.eq(PaymentGateway.PG) + .or(charge.payment.paymentGateway.eq(PaymentGateway.GOOGLE_IAP)) + } + + "ios" -> { + charge.payment.paymentGateway.eq(PaymentGateway.PG) + .or(charge.payment.paymentGateway.eq(PaymentGateway.APPLE_IAP)) + } + + else -> charge.payment.paymentGateway.eq(PaymentGateway.PG) + } + + return queryFactory + .selectFrom(charge) + .innerJoin(charge.member, qMember) + .innerJoin(charge.useCan, useCan) + .leftJoin(charge.payment, payment) + .where( + qMember.id.eq(member.id) + .and( + payment.status.eq(PaymentStatus.COMPLETE) + .or( + charge.status.eq(ChargeStatus.REFUND_CHARGE) + .and(useCan.isNotNull) + ) + .or(charge.status.eq(ChargeStatus.EVENT)) + .or(charge.status.eq(ChargeStatus.ADMIN)) + ) + .and(chargeStatusCondition) + ) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy(charge.id.desc()) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt new file mode 100644 index 0000000..e3c5d48 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanResponse.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.can + +import com.querydsl.core.annotations.QueryProjection + +data class CanResponse @QueryProjection constructor( + val id: Long, + val title: String, + val can: Int, + val rewardCan: Int, + val price: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt new file mode 100644 index 0000000..a8d99c8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -0,0 +1,118 @@ +package kr.co.vividnext.sodalive.can + +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class CanService(private val repository: CanRepository) { + fun getCans(): List { + return repository.findAllByStatus(status = CanStatus.SALE) + } + + fun getCanStatus(member: Member, container: String): GetCanStatusResponse { + return GetCanStatusResponse( + chargeCan = member.getChargeCan(container), + rewardCan = member.getRewardCan(container) + ) + } + + fun getCanUseStatus( + member: Member, + pageable: Pageable, + timezone: String, + container: String + ): List { + return repository.getCanUseStatus(member, pageable) + .filter { (it.can + it.rewardCan) > 0 } + .filter { + when (container) { + "aos" -> { + it.useCanCalculates.any { useCanCalculate -> + useCanCalculate.paymentGateway == PaymentGateway.PG || + useCanCalculate.paymentGateway == PaymentGateway.GOOGLE_IAP + } + } + + "ios" -> { + it.useCanCalculates.any { useCanCalculate -> + useCanCalculate.paymentGateway == PaymentGateway.PG || + useCanCalculate.paymentGateway == PaymentGateway.APPLE_IAP + } + } + + else -> it.useCanCalculates.any { useCanCalculate -> + useCanCalculate.paymentGateway == PaymentGateway.PG + } + } + } + .map { + val title: String = when (it.canUsage) { + CanUsage.DONATION -> { + "[후원] ${it.room!!.member!!.nickname}" + } + + CanUsage.LIVE -> { + "[라이브] ${it.room!!.title}" + } + + CanUsage.CHANGE_NICKNAME -> "닉네임 변경" + CanUsage.ORDER_CONTENT -> "콘텐츠 구매" + } + + val createdAt = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetCanUseStatusResponseItem( + title = title, + date = createdAt.format( + DateTimeFormatter.ofPattern("yyyy-MM-dd | HH:mm:ss") + ), + can = it.can + it.rewardCan + ) + } + } + + fun getCanChargeStatus( + member: Member, + pageable: Pageable, + timezone: String, + container: String + ): List { + return repository.getCanChargeStatus(member, pageable, container) + .map { + val canTitle = it.title ?: "" + val chargeMethod = when (it.status) { + ChargeStatus.CHARGE, ChargeStatus.EVENT -> { + it.payment!!.method ?: "" + } + + ChargeStatus.REFUND_CHARGE -> { + "환불" + } + + else -> { + "환불" + } + } + + val createdAt = it.createdAt!! + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(timezone)) + + GetCanChargeStatusResponseItem( + canTitle = canTitle, + date = createdAt.format( + DateTimeFormatter.ofPattern("yyyy-MM-dd | HH:mm:ss") + ), + chargeMethod = chargeMethod + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt new file mode 100644 index 0000000..5564e01 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanChargeStatusResponseItem.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can + +data class GetCanChargeStatusResponseItem( + val canTitle: String, + val date: String, + val chargeMethod: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt new file mode 100644 index 0000000..401a551 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanStatusResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.can + +data class GetCanStatusResponse( + val chargeCan: Int, + val rewardCan: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt new file mode 100644 index 0000000..bef3654 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/GetCanUseStatusResponseItem.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can + +data class GetCanUseStatusResponseItem( + val title: String, + val date: String, + val can: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt new file mode 100644 index 0000000..bb59e85 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/Charge.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.can.charge + +import kr.co.vividnext.sodalive.can.Can +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToOne + +@Entity +data class Charge( + var chargeCan: Int, + var rewardCan: Int, + @Enumerated(value = EnumType.STRING) + var status: ChargeStatus = ChargeStatus.CHARGE +) : BaseEntity() { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "can_id", nullable = true) + var can: Can? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "payment_id", nullable = true) + var payment: Payment? = null + set(value) { + value?.charge = this + field = value + } + + @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "use_can_id", nullable = true) + var useCan: UseCan? = null + + var title: String? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt new file mode 100644 index 0000000..6f6291b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.can.charge + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.core.userdetails.User +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.RestController + +@RestController +@RequestMapping("/charge") +class ChargeController(private val service: ChargeService) { + + @PostMapping + fun charge( + @RequestBody chargeRequest: ChargeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.charge(member, chargeRequest)) + } + + @PostMapping("/verify") + fun verify( + @RequestBody verifyRequest: VerifyRequest, + @AuthenticationPrincipal user: User + ) = ApiResponse.ok(service.verify(user, verifyRequest)) + + @PostMapping("/apple") + fun appleCharge( + @RequestBody chargeRequest: AppleChargeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException("로그인 정보를 확인해주세요.") + } + + ApiResponse.ok(service.appleCharge(member, chargeRequest)) + } + + @PostMapping("/apple/verify") + fun appleVerify( + @RequestBody verifyRequest: AppleVerifyRequest, + @AuthenticationPrincipal user: User + ) = ApiResponse.ok(service.appleVerify(user, verifyRequest)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt new file mode 100644 index 0000000..7e7374a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeData.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.can.charge + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.can.payment.PaymentGateway + +data class ChargeRequest(val canId: Long, val paymentGateway: PaymentGateway) + +data class ChargeResponse(val chargeId: Long) + +data class VerifyRequest( + @JsonProperty("receipt_id") + val receiptId: String, + @JsonProperty("order_id") + val orderId: String +) + +data class VerifyResult( + @JsonProperty("receipt_id") + val receiptId: String, + val method: String, + val status: Int, + val price: Int +) + +data class AppleChargeRequest( + val title: String, + val chargeCan: Int, + val paymentGateway: PaymentGateway, + var price: Double? = null, + var locale: String? = null +) + +data class AppleVerifyRequest(val receiptString: String, val chargeId: Long) + +data class AppleVerifyResponse(val status: Int) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt new file mode 100644 index 0000000..d9d02d6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.can.charge + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChargeRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt new file mode 100644 index 0000000..a9c7e21 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -0,0 +1,203 @@ +package kr.co.vividnext.sodalive.can.charge + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.bootpay.Bootpay +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpHeaders +import org.springframework.security.core.userdetails.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ChargeService( + private val chargeRepository: ChargeRepository, + private val canRepository: CanRepository, + private val memberRepository: MemberRepository, + private val objectMapper: ObjectMapper, + private val okHttpClient: OkHttpClient, + @Value("\${bootpay.application-id}") + private val bootpayApplicationId: String, + @Value("\${bootpay.private-key}") + private val bootpayPrivateKey: String, + @Value("\${apple.iap-verify-sandbox-url}") + private val appleInAppVerifySandBoxUrl: String, + @Value("\${apple.iap-verify-url}") + private val appleInAppVerifyUrl: String +) { + + @Transactional + fun charge(member: Member, request: ChargeRequest): ChargeResponse { + val can = canRepository.findByIdOrNull(request.canId) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val charge = Charge(can.can, can.rewardCan) + charge.title = can.title + charge.member = member + charge.can = can + + val payment = Payment(paymentGateway = request.paymentGateway) + payment.price = can.price.toDouble() + charge.payment = payment + + chargeRepository.save(charge) + + return ChargeResponse(chargeId = charge.id!!) + } + + @Transactional + fun verify(user: User, verifyRequest: VerifyRequest) { + val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) + ?: throw SodaException("결제정보에 오류가 있습니다.") + val member = memberRepository.findByEmail(user.username) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + if (charge.payment!!.paymentGateway == PaymentGateway.PG) { + val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) + + try { + bootpay.accessToken + val verifyResult = objectMapper.convertValue( + bootpay.getReceipt(verifyRequest.receiptId), + VerifyResult::class.java + ) + + if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) { + charge.payment?.receiptId = verifyResult.receiptId + charge.payment?.method = verifyResult.method + charge.payment?.status = PaymentStatus.COMPLETE + member.charge(charge.chargeCan, charge.rewardCan, "pg") + } else { + throw SodaException("결제정보에 오류가 있습니다.") + } + } catch (e: Exception) { + throw SodaException("결제정보에 오류가 있습니다.") + } + } else { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + + @Transactional + fun appleCharge(member: Member, request: AppleChargeRequest): ChargeResponse { + val charge = Charge(request.chargeCan, 0) + charge.title = request.title + charge.member = member + + val payment = Payment(paymentGateway = request.paymentGateway) + payment.price = if (request.price != null) { + request.price!! + } else { + 0.toDouble() + } + + payment.locale = request.locale + + charge.payment = payment + + chargeRepository.save(charge) + + return ChargeResponse(chargeId = charge.id!!) + } + + @Transactional + fun appleVerify(user: User, verifyRequest: AppleVerifyRequest) { + val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId) + ?: throw SodaException("결제정보에 오류가 있습니다.") + val member = memberRepository.findByEmail(user.username) + ?: throw SodaException("로그인 정보를 확인해주세요.") + + if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { + // 검증로직 + if (requestRealServerVerify(verifyRequest)) { + charge.payment?.receiptId = verifyRequest.receiptString + charge.payment?.method = "애플(인 앱 결제)" + charge.payment?.status = PaymentStatus.COMPLETE + member.charge(charge.chargeCan, charge.rewardCan, "ios") + } else { + throw SodaException("결제정보에 오류가 있습니다.") + } + } else { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + + private fun requestRealServerVerify(verifyRequest: AppleVerifyRequest): Boolean { + val body = JSONObject() + body.put("receipt-data", verifyRequest.receiptString) + val request = Request.Builder() + .url(appleInAppVerifyUrl) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .post(body.toString().toRequestBody("application/json".toMediaTypeOrNull())) + .build() + + val response = okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + val responseString = response.body?.string() + if (responseString != null) { + val verifyResult = objectMapper.readValue(responseString, AppleVerifyResponse::class.java) + return when (verifyResult.status) { + 0 -> { + true + } + + 21007 -> { + requestSandboxServerVerify(verifyRequest) + } + + else -> { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + } else { + throw SodaException("결제를 완료하지 못했습니다.") + } + } else { + throw SodaException("결제를 완료하지 못했습니다.") + } + } + + private fun requestSandboxServerVerify(verifyRequest: AppleVerifyRequest): Boolean { + val body = JSONObject() + body.put("receipt-data", verifyRequest.receiptString) + val request = Request.Builder() + .url(appleInAppVerifySandBoxUrl) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .post(body.toString().toRequestBody("application/json".toMediaTypeOrNull())) + .build() + + val response = okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + val responseString = response.body?.string() + if (responseString != null) { + val verifyResult = objectMapper.readValue(responseString, AppleVerifyResponse::class.java) + return when (verifyResult.status) { + 0 -> { + true + } + + else -> { + throw SodaException("결제정보에 오류가 있습니다.") + } + } + } else { + throw SodaException("결제를 완료하지 못했습니다.") + } + } else { + throw SodaException("결제를 완료하지 못했습니다.") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt new file mode 100644 index 0000000..07d5c0f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeStatus.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.can.charge + +enum class ChargeStatus { + CHARGE, REFUND_CHARGE, EVENT, CANCEL, + + // 관리자 지급 + ADMIN +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt new file mode 100644 index 0000000..7438bbd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/Payment.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.can.payment + +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.OneToOne + +@Entity +data class Payment( + @Enumerated(value = EnumType.STRING) + var status: PaymentStatus = PaymentStatus.REQUEST, + + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + val paymentGateway: PaymentGateway +) : BaseEntity() { + @OneToOne(mappedBy = "payment", fetch = FetchType.LAZY) + var charge: Charge? = null + + @Column(columnDefinition = "TEXT", nullable = true) + var receiptId: String? = null + var method: String? = null + + var price: Double = 0.toDouble() + var locale: String? = null +} + +enum class PaymentStatus { + // 결제요청 + REQUEST, + + // 결제완료 + COMPLETE, + + // 환불 + RETURN +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt new file mode 100644 index 0000000..8874c09 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/PaymentGateway.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.can.payment + +enum class PaymentGateway { + PG, GOOGLE_IAP, APPLE_IAP +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt new file mode 100644 index 0000000..5e53fdc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.can.use + +enum class CanUsage { + LIVE, + DONATION, + CHANGE_NICKNAME, + ORDER_CONTENT +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt new file mode 100644 index 0000000..9232846 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.can.use + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany + +@Entity +data class UseCan( + @Enumerated(value = EnumType.STRING) + val canUsage: CanUsage, + + val can: Int, + + val rewardCan: Int, + + var isRefund: Boolean = false +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = true) + var room: LiveRoom? = null + set(value) { + value?.useCan?.add(this) + field = value + } + + @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) + val useCanCalculates: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt new file mode 100644 index 0000000..0982736 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.can.use + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +data class UseCanCalculate( + val can: Int, + + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + val paymentGateway: PaymentGateway, + + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + var status: UseCanCalculateStatus +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "use_can_id", nullable = false) + var useCan: UseCan? = null + set(value) { + value?.useCanCalculates?.add(this) + field = value + } +} + +enum class UseCanCalculateStatus { + RECEIVED, CALCULATE_COMPLETE, REFUND +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt new file mode 100644 index 0000000..6a5523a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/OkHttpConfig.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.configs + +import okhttp3.OkHttpClient +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.concurrent.TimeUnit + +@Configuration +class OkHttpConfig { + @Bean("okHttpClient") + fun okHttpClient(): OkHttpClient { + return OkHttpClient() + .newBuilder().apply { + // 서버 연결을 최대 60초 수행 + connectTimeout(60, TimeUnit.SECONDS) + // 서버 요청을 최대 60초 수행 + writeTimeout(60, TimeUnit.SECONDS) + // 서버 응답을 최대 60초 기다림 + readTimeout(60, TimeUnit.SECONDS) + }.build() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt index 2578439..5137e5c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.live.room +import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.member.Member import java.time.LocalDateTime @@ -9,6 +10,7 @@ import javax.persistence.EnumType import javax.persistence.Enumerated import javax.persistence.FetchType import javax.persistence.JoinColumn +import javax.persistence.OneToMany import javax.persistence.OneToOne @Entity @@ -31,6 +33,9 @@ data class LiveRoom( @JoinColumn(name = "member_id", nullable = false) var member: Member? = null + @OneToMany(mappedBy = "room", fetch = FetchType.LAZY) + var useCan: MutableList = mutableListOf() + var channelName: String? = null var isActive: Boolean = true } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index e836a94..a500c93 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -56,8 +56,8 @@ data class Member( private var pgRewardCan: Int = 0 private var googleChargeCan: Int = 0 private var googleRewardCan: Int = 0 - private var appleChargeCan: Int = 0 - private var appleRewardCan: Int = 0 + var appleChargeCan: Int = 0 + var appleRewardCan: Int = 0 fun getChargeCan(container: String): Int { return when (container) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 180b5dd..75ee46f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,10 @@ bootpay: applicationId: ${BOOTPAY_APPLICATION_ID} privateKey: ${BOOTPAY_PRIVATE_KEY} +apple: + iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt + iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + cloud: aws: credentials: diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ac59f28..4cbecc3 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -5,6 +5,10 @@ logging: util: EC2MetadataUtils: error +apple: + iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt + iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + cloud: aws: credentials: