fix(charge): 충전 이벤트 보너스 지급을 안정화한다
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.admin.event.charge
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/charge/event-jobs")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminChargeEventJobController(
|
||||
private val service: AdminChargeEventJobService
|
||||
) {
|
||||
@GetMapping
|
||||
fun getJobs() = ApiResponse.ok(service.getJobs())
|
||||
|
||||
@PostMapping("/{jobId}/retry")
|
||||
fun retry(@PathVariable jobId: Long): ApiResponse<Unit> {
|
||||
service.retry(jobId)
|
||||
return ApiResponse.ok(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kr.co.vividnext.sodalive.admin.event.charge
|
||||
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJob
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobType
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class AdminChargeEventJobResponse(
|
||||
val id: Long,
|
||||
val sourceChargeId: Long,
|
||||
val resultChargeId: Long?,
|
||||
val memberId: Long,
|
||||
val chargeEventId: Long?,
|
||||
val jobType: ChargeEventJobType,
|
||||
val additionalCan: Int,
|
||||
val status: ChargeEventJobStatus,
|
||||
val retryCount: Int,
|
||||
val nextRetryAt: LocalDateTime?,
|
||||
val lastError: String?,
|
||||
val createdAt: LocalDateTime?,
|
||||
val updatedAt: LocalDateTime?
|
||||
) {
|
||||
companion object {
|
||||
fun from(job: ChargeEventJob): AdminChargeEventJobResponse {
|
||||
return AdminChargeEventJobResponse(
|
||||
id = job.id!!,
|
||||
sourceChargeId = job.sourceChargeId,
|
||||
resultChargeId = job.resultChargeId,
|
||||
memberId = job.memberId,
|
||||
chargeEventId = job.chargeEventId,
|
||||
jobType = job.jobType,
|
||||
additionalCan = job.additionalCan,
|
||||
status = job.status,
|
||||
retryCount = job.retryCount,
|
||||
nextRetryAt = job.nextRetryAt,
|
||||
lastError = job.lastError,
|
||||
createdAt = job.createdAt,
|
||||
updatedAt = job.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.vividnext.sodalive.admin.event.charge
|
||||
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class AdminChargeEventJobService(
|
||||
private val repository: ChargeEventJobRepository
|
||||
) {
|
||||
fun getJobs(): List<AdminChargeEventJobResponse> {
|
||||
return repository.findVisibleAdminJobs().map(AdminChargeEventJobResponse::from)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun retry(jobId: Long) {
|
||||
val job = repository.findByIdForUpdate(jobId) ?: return
|
||||
if (job.status != ChargeEventJobStatus.FAILED) return
|
||||
|
||||
job.status = ChargeEventJobStatus.PENDING
|
||||
job.retryCount = 0
|
||||
job.nextRetryAt = LocalDateTime.now()
|
||||
job.lastError = null
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,19 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Repository
|
||||
interface ChargeRepository : JpaRepository<Charge, Long>, ChargeQueryRepository
|
||||
interface ChargeRepository : JpaRepository<Charge, Long>, ChargeQueryRepository {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from Charge c where c.id = :chargeId")
|
||||
fun findByIdForUpdate(@Param("chargeId") chargeId: Long): Charge?
|
||||
}
|
||||
|
||||
interface ChargeQueryRepository {
|
||||
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
|
||||
|
||||
@@ -3,7 +3,7 @@ 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.charge.event.ChargeSpringEvent
|
||||
import kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobService
|
||||
import kr.co.vividnext.sodalive.can.coupon.CanCouponNumberRepository
|
||||
import kr.co.vividnext.sodalive.can.coupon.CouponType
|
||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||
@@ -27,7 +27,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.json.JSONObject
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.retry.annotation.Backoff
|
||||
@@ -52,7 +51,7 @@ class ChargeService(
|
||||
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val chargeEventJobService: ChargeEventJobService,
|
||||
|
||||
private val googlePlayService: GooglePlayService,
|
||||
private val messageSource: SodaMessageSource,
|
||||
@@ -102,7 +101,7 @@ class ChargeService(
|
||||
@Transactional
|
||||
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
|
||||
val chargeId = request.orderId.toLongOrNull() ?: return false
|
||||
val charge = chargeRepository.findByIdOrNull(chargeId) ?: return false
|
||||
val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false
|
||||
|
||||
// 결제수단 확인
|
||||
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||
@@ -154,15 +153,9 @@ class ChargeService(
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
charge.payment?.locale = request.requestCurrency
|
||||
|
||||
val member = charge.member!!
|
||||
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -307,9 +300,9 @@ class ChargeService(
|
||||
|
||||
@Transactional
|
||||
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
val member = memberRepository.findByIdForUpdate(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
val currency = charge.can?.currency
|
||||
@@ -372,13 +365,7 @@ class ChargeService(
|
||||
charge.payment?.locale = verifyResponse.requestCurrency
|
||||
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
@@ -429,12 +416,16 @@ class ChargeService(
|
||||
|
||||
@Transactional
|
||||
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
val member = memberRepository.findByIdForUpdate(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
||||
return completeResponse(charge, memberId)
|
||||
}
|
||||
|
||||
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
|
||||
|
||||
try {
|
||||
@@ -449,19 +440,9 @@ class ChargeService(
|
||||
charge.payment?.method = verifyResult.method
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
return completeResponse(charge, memberId)
|
||||
} else {
|
||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
}
|
||||
@@ -475,12 +456,16 @@ class ChargeService(
|
||||
|
||||
@Transactional
|
||||
fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
|
||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
val member = memberRepository.findByIdForUpdate(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
||||
return completeResponse(charge, memberId)
|
||||
}
|
||||
|
||||
val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
|
||||
|
||||
try {
|
||||
@@ -500,18 +485,8 @@ class ChargeService(
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||
return completeResponse(charge, memberId)
|
||||
} else {
|
||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
}
|
||||
@@ -547,12 +522,16 @@ class ChargeService(
|
||||
|
||||
@Transactional
|
||||
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId)
|
||||
val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId)
|
||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
val member = memberRepository.findByIdForUpdate(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
|
||||
if (charge.payment?.status == PaymentStatus.COMPLETE) {
|
||||
return completeResponse(charge, memberId)
|
||||
}
|
||||
|
||||
// 검증로직
|
||||
if (requestRealServerVerify(verifyRequest)) {
|
||||
charge.payment?.receiptId = verifyRequest.receiptString
|
||||
@@ -562,18 +541,8 @@ class ChargeService(
|
||||
charge.payment?.status = PaymentStatus.COMPLETE
|
||||
member.charge(charge.chargeCan, charge.rewardCan, "ios")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||
return completeResponse(charge, memberId)
|
||||
} else {
|
||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
}
|
||||
@@ -619,9 +588,9 @@ class ChargeService(
|
||||
productId: String,
|
||||
purchaseToken: String
|
||||
): ChargeCompleteResponse {
|
||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||
val charge = chargeRepository.findByIdForUpdate(chargeId)
|
||||
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
val member = memberRepository.findByIdForUpdate(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
|
||||
if (charge.payment!!.status == PaymentStatus.REQUEST) {
|
||||
@@ -631,21 +600,13 @@ class ChargeService(
|
||||
charge.payment!!.status = PaymentStatus.COMPLETE
|
||||
member.charge(charge.chargeCan, 0, "aos")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ChargeSpringEvent(
|
||||
chargeId = charge.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
)
|
||||
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
|
||||
return completeResponse(charge, memberId)
|
||||
} else {
|
||||
throw SodaException(messageKey = "can.charge.purchase_failed_contact")
|
||||
}
|
||||
} else if (charge.payment!!.status == PaymentStatus.COMPLETE) {
|
||||
return completeResponse(charge, memberId)
|
||||
} else {
|
||||
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||
}
|
||||
@@ -727,6 +688,14 @@ class ChargeService(
|
||||
return String.format(template, *args)
|
||||
}
|
||||
|
||||
private fun completeResponse(charge: Charge, memberId: Long): ChargeCompleteResponse {
|
||||
return ChargeCompleteResponse(
|
||||
price = charge.payment!!.price,
|
||||
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
|
||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||
)
|
||||
}
|
||||
|
||||
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
|
||||
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
|
||||
val cardCodes = setOf(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.event
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
enum class ChargeEventJobStatus {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
DONE,
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum class ChargeEventJobType {
|
||||
FIRST_CHARGE,
|
||||
ACTIVE_CHARGE_EVENT
|
||||
}
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "charge_event_job",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_charge_event_job_idempotency_key",
|
||||
columnNames = ["idempotency_key"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class ChargeEventJob(
|
||||
@Column(name = "source_charge_id", nullable = false)
|
||||
val sourceChargeId: Long,
|
||||
|
||||
@Column(name = "result_charge_id")
|
||||
var resultChargeId: Long? = null,
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
val memberId: Long,
|
||||
|
||||
@Column(name = "charge_event_id")
|
||||
val chargeEventId: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "job_type", nullable = false, length = 30)
|
||||
val jobType: ChargeEventJobType,
|
||||
|
||||
@Column(name = "idempotency_key", nullable = false, length = 100)
|
||||
val idempotencyKey: String,
|
||||
|
||||
@Column(name = "additional_can", nullable = false)
|
||||
val additionalCan: Int,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "payment_gateway", nullable = false, length = 30)
|
||||
val paymentGateway: PaymentGateway,
|
||||
|
||||
@Column(name = "container", nullable = false, length = 10)
|
||||
val container: String,
|
||||
|
||||
@Column(name = "method_snapshot", nullable = false, length = 100)
|
||||
val methodSnapshot: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: ChargeEventJobStatus = ChargeEventJobStatus.PENDING,
|
||||
|
||||
@Column(name = "retry_count", nullable = false)
|
||||
var retryCount: Int = 0,
|
||||
|
||||
@Column(name = "next_retry_at")
|
||||
var nextRetryAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "processing_started_at")
|
||||
var processingStartedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "processed_at")
|
||||
var processedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "last_error", columnDefinition = "text")
|
||||
var lastError: String? = null
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,56 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.event
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface ChargeEventJobRepository : JpaRepository<ChargeEventJob, Long> {
|
||||
fun findByIdempotencyKey(idempotencyKey: String): ChargeEventJob?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select j from ChargeEventJob j where j.id = :jobId")
|
||||
fun findByIdForUpdate(@Param("jobId") jobId: Long): ChargeEventJob?
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
select j.id
|
||||
from charge_event_job j
|
||||
where j.status = 'PENDING'
|
||||
and (j.next_retry_at is null or j.next_retry_at <= :now)
|
||||
order by j.created_at asc
|
||||
limit :limit
|
||||
for update skip locked
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findNextPendingJobIdsForUpdate(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("limit") limit: Int
|
||||
): List<Long>
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
select j.id
|
||||
from charge_event_job j
|
||||
where j.status = 'PENDING'
|
||||
and j.job_type = 'FIRST_CHARGE'
|
||||
and (j.next_retry_at is null or j.next_retry_at <= :now)
|
||||
order by j.created_at asc
|
||||
limit 1
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findPendingFirstChargeJobId(@Param("now") now: LocalDateTime): Long?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select j from ChargeEventJob j
|
||||
where j.status in (kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus.PENDING, kr.co.vividnext.sodalive.can.charge.event.ChargeEventJobStatus.FAILED)
|
||||
order by j.createdAt desc
|
||||
"""
|
||||
)
|
||||
fun findVisibleAdminJobs(): List<ChargeEventJob>
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.event
|
||||
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||
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.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.round
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class ChargeEventJobService(
|
||||
private val jobRepository: ChargeEventJobRepository,
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val chargeEventRepository: ChargeEventRepository? = null,
|
||||
private val authRepository: AuthRepository? = null,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher? = null,
|
||||
transactionManager: PlatformTransactionManager? = null
|
||||
) {
|
||||
private val transactionTemplate = transactionManager?.let { TransactionTemplate(it) }
|
||||
|
||||
@Transactional
|
||||
fun createAndProcessImmediate(sourceChargeId: Long, memberId: Long): ChargeEventJob? {
|
||||
val job = createProcessingJob(sourceChargeId, memberId) ?: return null
|
||||
if (transactionTemplate == null || !TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
processJob(job.id!!)
|
||||
return job
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() {
|
||||
processImmediateAfterCommit(job.id!!)
|
||||
}
|
||||
}
|
||||
)
|
||||
return job
|
||||
}
|
||||
|
||||
private fun processImmediateAfterCommit(jobId: Long) {
|
||||
try {
|
||||
transactionTemplate?.executeWithoutResult { processJob(jobId) }
|
||||
} catch (ex: Exception) {
|
||||
transactionTemplate?.executeWithoutResult { markImmediateFailure(jobId, ex) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun markImmediateFailure(jobId: Long, ex: Exception) {
|
||||
val job = jobRepository.findByIdForUpdate(jobId) ?: return
|
||||
if (job.status == ChargeEventJobStatus.DONE) return
|
||||
job.status = ChargeEventJobStatus.PENDING
|
||||
job.nextRetryAt = LocalDateTime.now().plusMinutes(5)
|
||||
job.lastError = ex.message?.take(MAX_ERROR_LENGTH)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createProcessingJob(sourceChargeId: Long, memberId: Long): ChargeEventJob? {
|
||||
val charge = chargeRepository.findByIdForUpdate(sourceChargeId)
|
||||
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||
val member = memberRepository.findByIdForUpdate(memberId)
|
||||
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||
val snapshot = buildSnapshot(charge, member) ?: return null
|
||||
val existing = jobRepository.findByIdempotencyKey(snapshot.idempotencyKey)
|
||||
if (existing != null) return existing
|
||||
|
||||
return try {
|
||||
jobRepository.saveAndFlush(snapshot)
|
||||
} catch (_: DataIntegrityViolationException) {
|
||||
jobRepository.findByIdempotencyKey(snapshot.idempotencyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun processJob(jobId: Long) {
|
||||
val job = jobRepository.findByIdForUpdate(jobId) ?: return
|
||||
if (job.status == ChargeEventJobStatus.DONE) return
|
||||
|
||||
val member = memberRepository.findByIdForUpdate(job.memberId)
|
||||
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||
val eventCharge = Charge(0, job.additionalCan, status = ChargeStatus.EVENT)
|
||||
eventCharge.title = "${job.additionalCan} 캔"
|
||||
eventCharge.member = member
|
||||
eventCharge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = job.paymentGateway).also {
|
||||
it.method = job.methodSnapshot
|
||||
}
|
||||
val savedCharge = chargeRepository.save(eventCharge)
|
||||
|
||||
member.charge(0, job.additionalCan, job.container)
|
||||
job.status = ChargeEventJobStatus.DONE
|
||||
job.resultChargeId = savedCharge.id
|
||||
job.processedAt = LocalDateTime.now()
|
||||
job.nextRetryAt = null
|
||||
job.lastError = null
|
||||
publishPaidNotification(job)
|
||||
}
|
||||
|
||||
private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? {
|
||||
val paymentGateway = charge.payment?.paymentGateway
|
||||
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||
return if (member.auth != null) {
|
||||
val authRepository = authRepository ?: return null
|
||||
val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di)
|
||||
val chargeCount = authRepository.getMemberIdsByDi(member.auth!!.di)
|
||||
.sumOf { id -> chargeRepository.getChargeCountAfterDate(memberId = id, authDate) }
|
||||
if (chargeCount > 1) {
|
||||
buildActiveEventSnapshot(charge, member, paymentGateway)
|
||||
} else {
|
||||
buildFirstChargeSnapshot(charge, member, paymentGateway)
|
||||
}
|
||||
} else {
|
||||
buildActiveEventSnapshot(charge, member, paymentGateway)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFirstChargeSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob {
|
||||
val additionalCan = ceil(charge.chargeCan * 0.15).toInt()
|
||||
return ChargeEventJob(
|
||||
sourceChargeId = charge.id!!,
|
||||
memberId = member.id!!,
|
||||
chargeEventId = null,
|
||||
jobType = ChargeEventJobType.FIRST_CHARGE,
|
||||
idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.FIRST_CHARGE, null),
|
||||
additionalCan = additionalCan,
|
||||
paymentGateway = paymentGateway,
|
||||
container = container(paymentGateway),
|
||||
methodSnapshot = FIRST_CHARGE_METHOD,
|
||||
status = ChargeEventJobStatus.PROCESSING,
|
||||
processingStartedAt = LocalDateTime.now()
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? {
|
||||
val repository = chargeEventRepository ?: return null
|
||||
val chargeEvent = repository.getChargeEvent() ?: return null
|
||||
val eventChargeCount = repository.getPaymentCount(
|
||||
member = member,
|
||||
method = chargeEvent.title,
|
||||
startDate = chargeEvent.startDate,
|
||||
endDate = chargeEvent.endDate
|
||||
)
|
||||
if (eventChargeCount >= chargeEvent.availableCount) return null
|
||||
|
||||
val additionalCan = round(charge.chargeCan * chargeEvent.addPercent).toInt()
|
||||
return ChargeEventJob(
|
||||
sourceChargeId = charge.id!!,
|
||||
memberId = member.id!!,
|
||||
chargeEventId = chargeEvent.id!!,
|
||||
jobType = ChargeEventJobType.ACTIVE_CHARGE_EVENT,
|
||||
idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.ACTIVE_CHARGE_EVENT, chargeEvent.id),
|
||||
additionalCan = additionalCan,
|
||||
paymentGateway = paymentGateway,
|
||||
container = container(paymentGateway),
|
||||
methodSnapshot = chargeEvent.title,
|
||||
status = ChargeEventJobStatus.PROCESSING,
|
||||
processingStartedAt = LocalDateTime.now()
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishPaidNotification(job: ChargeEventJob) {
|
||||
val publisher = applicationEventPublisher ?: return
|
||||
if (job.jobType == ChargeEventJobType.FIRST_CHARGE) {
|
||||
publisher.publishEvent(
|
||||
FcmEvent(
|
||||
type = FcmEventType.INDIVIDUAL,
|
||||
category = PushNotificationCategory.SYSTEM,
|
||||
titleKey = "can.charge.event.first_title",
|
||||
messageKey = "can.charge.event.additional_can_paid",
|
||||
args = listOf(job.additionalCan),
|
||||
recipients = listOf(job.memberId),
|
||||
isAuth = null
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
publisher.publishEvent(
|
||||
FcmEvent(
|
||||
type = FcmEventType.INDIVIDUAL,
|
||||
category = PushNotificationCategory.SYSTEM,
|
||||
title = job.methodSnapshot,
|
||||
messageKey = "can.charge.event.additional_can_paid",
|
||||
args = listOf(job.additionalCan),
|
||||
recipients = listOf(job.memberId),
|
||||
isAuth = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun container(paymentGateway: PaymentGateway): String {
|
||||
return when (paymentGateway) {
|
||||
PaymentGateway.GOOGLE_IAP -> "aos"
|
||||
PaymentGateway.APPLE_IAP -> "ios"
|
||||
else -> "pg"
|
||||
}
|
||||
}
|
||||
|
||||
private fun idempotencyKey(sourceChargeId: Long, jobType: ChargeEventJobType, chargeEventId: Long?): String {
|
||||
return "charge-event:$sourceChargeId:$jobType:${chargeEventId ?: "none"}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FIRST_CHARGE_METHOD = "첫 충전 이벤트"
|
||||
private const val MAX_ERROR_LENGTH = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.event
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Component
|
||||
class ChargeEventJobWorker(
|
||||
private val jobRepository: ChargeEventJobRepository,
|
||||
private val jobService: ChargeEventJobService,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@Scheduled(fixedDelayString = "\${sodalive.charge-event-job.fixed-delay-ms:300000}")
|
||||
fun runPendingJobs() {
|
||||
val now = LocalDateTime.now()
|
||||
claimPendingJobs(now).forEach { jobId ->
|
||||
try {
|
||||
jobService.processJob(jobId)
|
||||
} catch (ex: Exception) {
|
||||
failJob(jobId, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun claimPendingJobs(now: LocalDateTime): List<Long> {
|
||||
return transactionTemplate.execute {
|
||||
val jobIds = jobRepository.findNextPendingJobIdsForUpdate(now, BATCH_SIZE)
|
||||
jobIds.forEach { jobId ->
|
||||
val job = jobRepository.findById(jobId).orElse(null) ?: return@forEach
|
||||
job.status = ChargeEventJobStatus.PROCESSING
|
||||
job.processingStartedAt = LocalDateTime.now()
|
||||
jobRepository.save(job)
|
||||
}
|
||||
jobIds
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private fun failJob(jobId: Long, ex: Exception) {
|
||||
log.warn("Failed to process charge event job. jobId={}, error={}", jobId, ex.message)
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val job = jobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
|
||||
job.retryCount += 1
|
||||
job.lastError = ex.message?.take(MAX_ERROR_LENGTH)
|
||||
if (job.retryCount >= MAX_RETRY_COUNT) {
|
||||
job.status = ChargeEventJobStatus.FAILED
|
||||
return@executeWithoutResult
|
||||
}
|
||||
job.status = ChargeEventJobStatus.PENDING
|
||||
job.nextRetryAt = LocalDateTime.now().plusMinutes(backoffMinutes(job.retryCount))
|
||||
jobRepository.save(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoffMinutes(retryCount: Int): Long {
|
||||
return when (retryCount) {
|
||||
1 -> 5L
|
||||
2 -> 10L
|
||||
else -> 15L
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BATCH_SIZE = 30
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
private const val MAX_ERROR_LENGTH = 1000
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.can.charge.event
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.event.charge.ChargeEvent
|
||||
import kr.co.vividnext.sodalive.admin.event.charge.QChargeEvent.chargeEvent
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
|
||||
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
|
||||
@@ -14,6 +15,7 @@ interface ChargeEventRepository : JpaRepository<ChargeEvent, Long>, ChargeEventQ
|
||||
|
||||
interface ChargeEventQueryRepository {
|
||||
fun getChargeEvent(): ChargeEvent?
|
||||
fun existsActiveChargeEvent(): Boolean
|
||||
fun getPaymentCount(member: Member, method: String, startDate: LocalDateTime, endDate: LocalDateTime): Int
|
||||
}
|
||||
|
||||
@@ -31,6 +33,19 @@ class ChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun existsActiveChargeEvent(): Boolean {
|
||||
val now = LocalDateTime.now()
|
||||
return queryFactory
|
||||
.select(chargeEvent.id)
|
||||
.from(chargeEvent)
|
||||
.where(
|
||||
chargeEvent.isActive.isTrue
|
||||
.and(chargeEvent.startDate.loe(now))
|
||||
.and(chargeEvent.endDate.goe(now))
|
||||
)
|
||||
.fetchFirst() != null
|
||||
}
|
||||
|
||||
override fun getPaymentCount(
|
||||
member: Member,
|
||||
method: String,
|
||||
@@ -39,16 +54,19 @@ class ChargeEventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
||||
): Int {
|
||||
val where = charge.member.eq(member)
|
||||
.and(charge.payment.method.eq(method))
|
||||
.and(charge.status.eq(ChargeStatus.EVENT))
|
||||
.and(charge.createdAt.between(startDate, endDate))
|
||||
.and(
|
||||
charge.payment.status.eq(PaymentStatus.COMPLETE)
|
||||
.or(charge.payment.status.eq(PaymentStatus.RETURN))
|
||||
)
|
||||
|
||||
return queryFactory
|
||||
.selectFrom(charge)
|
||||
.select(charge.id.count())
|
||||
.from(charge)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.where(where)
|
||||
.fetch()
|
||||
.count()
|
||||
.fetchOne()
|
||||
?.toInt() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.can.charge.event
|
||||
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
|
||||
class ChargeSpringEvent(
|
||||
val chargeId: Long,
|
||||
val memberId: Long
|
||||
)
|
||||
|
||||
@Component
|
||||
class ChargeSpringEventListener(
|
||||
private val chargeEventService: ChargeEventService
|
||||
) {
|
||||
@Async
|
||||
@TransactionalEventListener
|
||||
fun applyChargeEvent(event: ChargeSpringEvent) {
|
||||
chargeEventService.applyChargeEvent(event.chargeId, event.memberId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user