feat(gift): 선물 배송 스케줄러를 추가한다

This commit is contained in:
2026-09-30 13:38:04 +09:00
parent 6bf4a682bf
commit 0f727c8b75
2 changed files with 388 additions and 0 deletions
@@ -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 = "배송지 미입력 기한 초과"
}
}
@@ -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)
}
}