From 0f727c8b759f31b691f68a89a4e103f5e1dc9f2b Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:38:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20=EB=B0=B0?= =?UTF-8?q?=EC=86=A1=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/scheduler/GiftScheduler.kt | 101 ++++++ .../out/scheduler/GiftSchedulerTest.kt | 287 ++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt new file mode 100644 index 00000000..5ede6cbf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt @@ -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 = "배송지 미입력 기한 초과" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt new file mode 100644 index 00000000..8cef5474 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt @@ -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 { + 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) + } +}