feat(gift): 선물 배송 스케줄러를 추가한다
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Component
|
||||
class GiftScheduler(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val giftPushService: GiftPushService,
|
||||
private val redissonClient: RedissonClient
|
||||
) {
|
||||
@Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul")
|
||||
@Transactional
|
||||
fun runExpiredTrackingRegistrationGiftCancellation() {
|
||||
val lock = redissonClient.getLock(EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY)
|
||||
|
||||
try {
|
||||
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
||||
cancelExpiredTrackingRegistrationGifts(LocalDateTime.now())
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.CANCELED
|
||||
requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now
|
||||
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
|
||||
giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId)
|
||||
}
|
||||
|
||||
return expiredGifts.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.UNDELIVERABLE
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.undeliverableAt = now
|
||||
delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON
|
||||
}
|
||||
|
||||
return expiredGifts.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun sendDeadlineReminderGifts(now: LocalDateTime): Int {
|
||||
val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS)
|
||||
val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil)
|
||||
val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil)
|
||||
|
||||
return trackingReminderCount + recipientAddressReminderCount
|
||||
}
|
||||
|
||||
private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
|
||||
val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil)
|
||||
gifts.forEach { gift ->
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.trackingDeadlineReminderSentAt = now
|
||||
giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId)
|
||||
}
|
||||
|
||||
return gifts.size
|
||||
}
|
||||
|
||||
private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
|
||||
val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil)
|
||||
gifts.forEach { gift ->
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.recipientAddressDeadlineReminderSentAt = now
|
||||
giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId)
|
||||
}
|
||||
|
||||
return gifts.size
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEADLINE_REMINDER_HOURS = 24L
|
||||
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
|
||||
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 자동처리 스케줄러 테스트")
|
||||
class GiftSchedulerTest @Autowired constructor(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository
|
||||
) {
|
||||
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val scheduler = GiftScheduler(
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
canPaymentService,
|
||||
giftPushService,
|
||||
Mockito.mock(RedissonClient::class.java)
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한이 지난 접수 선물을 취소하고 한 번 환불한다")
|
||||
fun shouldCancelOverdueReceivedGiftAndRefundOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(1, canceledCount)
|
||||
assertEquals(GiftStatus.CANCELED, gift.status)
|
||||
assertEquals(now, delivery.canceledAt)
|
||||
Mockito.verify(canPaymentService).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
|
||||
Mockito.verify(giftPushService).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한과 같은 접수 선물은 취소하지 않는다")
|
||||
fun shouldIgnoreReceivedGiftWhoseTrackingDeadlineIsNotPast() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now)
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(0, canceledCount)
|
||||
assertEquals(GiftStatus.RECEIVED, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한이 지나도 접수 상태가 아닌 선물은 취소하지 않는다")
|
||||
fun shouldIgnoreOverdueGiftThatIsNotReceived() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED, now.minusSeconds(1))
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(0, canceledCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("자동취소를 다시 실행해도 이미 취소된 선물은 환불하지 않는다")
|
||||
fun shouldNotRefundAlreadyCanceledGiftOnSecondRun() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
|
||||
|
||||
val firstCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
val secondCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(1, firstCanceledCount)
|
||||
assertEquals(0, secondCanceledCount)
|
||||
Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
|
||||
Mockito.verify(giftPushService, Mockito.times(1)).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한이 지난 발송 확인 선물을 전달 불가 처리한다")
|
||||
fun shouldMarkOverdueTrackingRegisteredGiftWithoutRecipientAddressUndeliverable() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1)
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(1, updatedCount)
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한이 지나도 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreGiftWithRecipientAddress() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1),
|
||||
recipientAddress = "서울시 송파구"
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다")
|
||||
fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (_, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1)
|
||||
)
|
||||
|
||||
val firstUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
val secondUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstUpdatedCount)
|
||||
assertEquals(0, secondUpdatedCount)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한 24시간 전 접수 선물에 한 번만 안내한다")
|
||||
fun shouldSendTrackingDeadlineReminderOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
|
||||
val (_, delivery) = saveGift(GiftStatus.RECEIVED, now.plusHours(24))
|
||||
|
||||
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstSentCount)
|
||||
assertEquals(0, secondSentCount)
|
||||
assertEquals(now, delivery.trackingDeadlineReminderSentAt)
|
||||
Mockito.verify(giftPushService, Mockito.times(1)).sendTrackingDeadlineReminder(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한 24시간 초과 전 접수 선물은 안내하지 않는다")
|
||||
fun shouldIgnoreTrackingDeadlineReminderBeforeWindow() {
|
||||
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
|
||||
saveGift(GiftStatus.RECEIVED, now.plusHours(24).plusSeconds(1))
|
||||
|
||||
val sentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
|
||||
assertEquals(0, sentCount)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한 24시간 전 선물에 한 번만 안내한다")
|
||||
fun shouldSendRecipientAddressDeadlineReminderOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
|
||||
val (_, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(6),
|
||||
recipientAddressDeadlineAt = now.plusHours(24)
|
||||
)
|
||||
|
||||
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstSentCount)
|
||||
assertEquals(0, secondSentCount)
|
||||
assertEquals(now, delivery.recipientAddressDeadlineReminderSentAt)
|
||||
Mockito.verify(giftPushService, Mockito.times(1))
|
||||
.sendRecipientAddressDeadlineReminder(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한 안내를 보내지 않는다")
|
||||
fun shouldIgnoreRecipientAddressDeadlineReminderWhenAddressExists() {
|
||||
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
|
||||
saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(6),
|
||||
recipientAddressDeadlineAt = now.plusHours(24),
|
||||
recipientAddress = "서울시 송파구"
|
||||
)
|
||||
|
||||
val sentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
|
||||
assertEquals(0, sentCount)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
private fun saveGift(
|
||||
status: GiftStatus,
|
||||
trackingDeadlineAt: LocalDateTime,
|
||||
recipientAddressDeadlineAt: LocalDateTime? = null,
|
||||
recipientAddress: String? = null
|
||||
): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = CAN_USAGE_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = APPLIED_AT,
|
||||
senderPrivacyAgreedAt = APPLIED_AT,
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
val delivery = giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
recipientAddress = recipientAddress,
|
||||
trackingDeadlineAt = trackingDeadlineAt,
|
||||
recipientAddressDeadlineAt = recipientAddressDeadlineAt
|
||||
)
|
||||
)
|
||||
|
||||
return gift to delivery
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
const val CATEGORY_ID = 3L
|
||||
const val CAN_USAGE_ID = 4L
|
||||
val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user