From 8b2ac38b836600826b511224e2502d44741051e9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 08:44:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EB=B0=B0=EC=86=A1=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=EC=A0=95=EB=B3=B4=20=EC=A0=95=EB=A6=AC=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EA=B3=BC=20=EB=8F=99=EC=8B=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=B4=ED=98=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/GiftDeliveryRepository.kt | 19 +- .../adapter/out/persistence/GiftRepository.kt | 42 +- .../adapter/out/scheduler/GiftScheduler.kt | 179 ++++- .../v2/gift/application/GiftAdminService.kt | 18 +- .../v2/gift/application/GiftCommandService.kt | 12 +- .../adapter/in/web/AdminGiftControllerTest.kt | 523 ++++++++++++++- .../v2/gift/GiftCommandServiceTest.kt | 12 +- .../out/scheduler/GiftSchedulerTest.kt | 621 +++++++++++++++++- 8 files changed, 1360 insertions(+), 66 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt index 2a4c3487..d885410d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt @@ -1,9 +1,26 @@ package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import javax.persistence.EntityManager +import javax.persistence.LockModeType -interface GiftDeliveryRepository : JpaRepository { +interface GiftDeliveryRepository : JpaRepository, GiftDeliveryLockRepository { fun findByGiftId(giftId: Long): GiftDelivery? fun findByGiftIdIn(giftIds: Collection): List } + +interface GiftDeliveryLockRepository { + fun findByGiftIdForUpdate(giftId: Long): GiftDelivery? +} + +@Repository +class GiftDeliveryLockRepositoryImpl(private val entityManager: EntityManager) : GiftDeliveryLockRepository { + override fun findByGiftIdForUpdate(giftId: Long): GiftDelivery? = entityManager + .createQuery("select d from GiftDelivery d where d.giftId = :giftId", GiftDelivery::class.java) + .setParameter("giftId", giftId) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .resultList.singleOrNull() + ?.also { entityManager.refresh(it, LockModeType.PESSIMISTIC_WRITE) } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt index 8048ff04..e18c12cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt @@ -7,16 +7,14 @@ 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.EntityManager import javax.persistence.LockModeType -interface GiftRepository : JpaRepository { +interface GiftRepository : JpaRepository, GiftLockRepository { fun findByApplicationNo(applicationNo: String): Gift? - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select g from Gift g where g.applicationNo = :applicationNo") - fun findByApplicationNoForUpdate(@Param("applicationNo") applicationNo: String): Gift? - fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page fun findByRecipientMemberIdAndStatusIn( @@ -67,6 +65,7 @@ interface GiftRepository : JpaRepository { pageable: Pageable ): Page + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query( """ select g @@ -78,9 +77,9 @@ interface GiftRepository : JpaRepository { where d.giftId = g.id and d.trackingDeadlineAt < :now ) + order by g.id """ ) - @Lock(LockModeType.PESSIMISTIC_WRITE) fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List @Query( @@ -105,6 +104,22 @@ interface GiftRepository : JpaRepository { @Param("now") now: LocalDateTime ): List + @Query( + """ + select g + from Gift g + where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED + and exists ( + select d.id + from GiftDelivery d + where d.giftId = g.id + and d.deliveredAt is not null + and d.deliveredAt <= :cutoff + ) + """ + ) + fun findDeliveredGiftsWithDeliveryBeforeOrAt(@Param("cutoff") cutoff: LocalDateTime): List + @Query( """ select g @@ -120,7 +135,6 @@ interface GiftRepository : JpaRepository { ) """ ) - @Lock(LockModeType.PESSIMISTIC_WRITE) fun findReceivedGiftsWithTrackingDeadlineReminderDue( @Param("now") now: LocalDateTime, @Param("reminderUntil") reminderUntil: LocalDateTime @@ -151,3 +165,17 @@ interface GiftRepository : JpaRepository { @Param("reminderUntil") reminderUntil: LocalDateTime ): List } + +interface GiftLockRepository { + fun findByApplicationNoForUpdate(applicationNo: String): Gift? +} + +@Repository +class GiftLockRepositoryImpl(private val entityManager: EntityManager) : GiftLockRepository { + override fun findByApplicationNoForUpdate(applicationNo: String): Gift? = entityManager + .createQuery("select g from Gift g where g.applicationNo = :applicationNo", Gift::class.java) + .setParameter("applicationNo", applicationNo) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .resultList.singleOrNull() + ?.also { entityManager.refresh(it, LockModeType.PESSIMISTIC_WRITE) } +} 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 index 3d7b57c5..1c64c951 100644 --- 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 @@ -5,10 +5,13 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepo 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.RLock import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime import java.util.concurrent.TimeUnit @@ -39,63 +42,183 @@ class GiftScheduler( @Transactional fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int { val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now) - expiredGifts.forEach { gift -> + return expiredGifts.sortedBy { it.id }.count { candidate -> + val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo)) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id))) + if (gift.status != GiftStatus.RECEIVED || !delivery.trackingDeadlineAt.isBefore(now)) { + return@count false + } gift.status = GiftStatus.CANCELED - requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now + delivery.canceledAt = now canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId) giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId) + true + } + } + + @Scheduled(cron = "0 10 * * * *", zone = "Asia/Seoul") + @Transactional + fun runExpiredRecipientAddressGiftExpiration() { + val lock = redissonClient.getLock(EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY) + if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { + return } - return expiredGifts.size + try { + markExpiredRecipientAddressGiftsUndeliverable(LocalDateTime.now()) + } finally { + unlockAfterTransaction(lock) + } } @Transactional fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int { val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now) - expiredGifts.forEach { gift -> + return expiredGifts.sortedBy { it.id }.count { candidate -> + val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo)) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id))) + if (gift.status !in RECIPIENT_ADDRESS_STATUSES || delivery.recipientAddress != null || + delivery.recipientAddressDeadlineAt?.isBefore(now) != true + ) { + return@count false + } gift.status = GiftStatus.UNDELIVERABLE - val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) delivery.undeliverableAt = now delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON + true + } + } + + @Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul") + @Transactional + fun runDeliveredGiftPersonalDataErasure() { + val lock = redissonClient.getLock(DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY) + if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { + return } - return expiredGifts.size + try { + eraseDeliveredGiftPersonalData(LocalDateTime.now()) + } finally { + unlockAfterTransaction(lock) + } + } + + @Transactional + fun eraseDeliveredGiftPersonalData(now: LocalDateTime): Int { + val cutoff = now.minusDays(7) + val expiredGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff) + + return expiredGifts.count { gift -> + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + if ( + listOf( + delivery.senderName, + delivery.senderPhoneNumber, + delivery.senderZipCode, + delivery.senderAddress, + delivery.senderAddressDetail, + delivery.recipientName, + delivery.recipientPhoneNumber, + delivery.recipientZipCode, + delivery.recipientAddress, + delivery.recipientAddressDetail, + delivery.courierCompanyName, + delivery.trackingNumber + ).all { it == null } + ) { + false + } else { + delivery.senderName = null + delivery.senderPhoneNumber = null + delivery.senderZipCode = null + delivery.senderAddress = null + delivery.senderAddressDetail = null + delivery.recipientName = null + delivery.recipientPhoneNumber = null + delivery.recipientZipCode = null + delivery.recipientAddress = null + delivery.recipientAddressDetail = null + delivery.courierCompanyName = null + delivery.trackingNumber = null + true + } + } + } + + @Scheduled(cron = "0 40 * * * *", zone = "Asia/Seoul") + @Transactional + fun runDeadlineReminderGifts() { + val lock = redissonClient.getLock(DEADLINE_REMINDER_GIFTS_LOCK_KEY) + if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { + return + } + + try { + sendDeadlineReminderGifts(LocalDateTime.now()) + } finally { + unlockAfterTransaction(lock) + } } @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 + val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil) + + giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil) + return gifts.distinctBy { it.id }.sortedBy { it.id }.count { candidate -> + val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo)) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id))) + val recipientDeadline = delivery.recipientAddressDeadlineAt + when { + gift.status == GiftStatus.RECEIVED && delivery.trackingDeadlineAt.isAfter(now) && + !delivery.trackingDeadlineAt.isAfter(reminderUntil) && delivery.trackingDeadlineReminderSentAt == null -> { + delivery.trackingDeadlineReminderSentAt = now + giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId) + true + } + gift.status in RECIPIENT_ADDRESS_STATUSES && delivery.recipientAddress == null && + recipientDeadline != null && recipientDeadline.isAfter(now) && !recipientDeadline.isAfter(reminderUntil) && + delivery.recipientAddressDeadlineReminderSentAt == null -> { + delivery.recipientAddressDeadlineReminderSentAt = now + giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId) + true + } + else -> false + } + } } - 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) + private fun unlockAfterTransaction(lock: RLock) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + return } - 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 + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCompletion(status: Int) { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + ) } private companion object { + val RECIPIENT_ADDRESS_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) 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_GIFTS_LOCK_KEY = "lock:gift-expired-recipient-address-undeliverable" + const val DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY = "lock:gift-delivered-personal-data-erasure" + const val DEADLINE_REMINDER_GIFTS_LOCK_KEY = "lock:gift-deadline-reminders" const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과" } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt index f25d3f11..2fe893dc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -157,7 +157,7 @@ class GiftAdminService( fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.RECEIVED) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() val canceledAt = now.truncatedTo(ChronoUnit.SECONDS) gift.status = GiftStatus.CANCELED @@ -169,9 +169,9 @@ class GiftAdminService( @Transactional fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() gift.status = GiftStatus.ARRIVED_AT_MAILBOX delivery.arrivedAtMailboxAt = now @@ -183,9 +183,9 @@ class GiftAdminService( @Transactional fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() gift.status = GiftStatus.INSPECTION_COMPLETED delivery.inspectionCompletedAt = now @@ -200,11 +200,11 @@ class GiftAdminService( reason: String, now: LocalDateTime = LocalDateTime.now() ): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) { throw invalidRequest() } - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() gift.status = GiftStatus.UNDELIVERABLE delivery.undeliverableAt = now @@ -217,9 +217,9 @@ class GiftAdminService( @Transactional fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest() gift.status = GiftStatus.DELIVERED diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt index 86f1c82e..47755809 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt @@ -212,7 +212,7 @@ class GiftCommandService( val canceledAt = now() gift.status = GiftStatus.CANCELED - deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt + deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId) return GiftCancellationResult( @@ -234,7 +234,7 @@ class GiftCommandService( val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() validate(command) - val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val trackingRegisteredAt = now() val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7) @@ -263,12 +263,12 @@ class GiftCommandService( command: GiftRecipientAddressRegistrationCommand ): GiftRecipientAddressRegistrationResult { val recipientMemberId = member.id ?: throw invalidRequest() - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) { throw invalidRequest() } validate(command) - val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val registeredAt = now() delivery.recipientName = command.name @@ -292,11 +292,11 @@ class GiftCommandService( @Transactional fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult { val recipientMemberId = member.id ?: throw invalidRequest() - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) { throw invalidRequest() } - val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val deliveredAt = now() gift.status = GiftStatus.DELIVERED diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index ae27c462..13a343c0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -28,15 +28,19 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepo 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.adapter.out.persistence.GiftReview import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftScheduler +import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.hamcrest.Matchers.endsWith +import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -50,6 +54,8 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito +import org.springframework.aop.framework.Advised +import org.springframework.aop.support.AopUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -63,6 +69,9 @@ import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor +import org.springframework.scheduling.config.CronTask +import org.springframework.scheduling.support.ScheduledMethodRunnable import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication @@ -78,6 +87,8 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.interceptor.TransactionInterceptor +import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.context.request.ServletRequestAttributes @@ -113,12 +124,14 @@ class AdminGiftControllerTest @Autowired constructor( private val useCanRepository: UseCanRepository, private val calculateRepository: UseCanCalculateRepository, private val commandService: GiftCommandService, + private val adminService: GiftAdminService, private val scheduler: GiftScheduler, private val transactionManager: PlatformTransactionManager, private val entityManager: EntityManager, private val objectMapper: ObjectMapper, private val restTemplate: TestRestTemplate, - private val tokenProvider: TokenProvider + private val tokenProvider: TokenProvider, + private val scheduledProcessor: ScheduledAnnotationBeanPostProcessor ) { @LocalServerPort private var port: Int = 0 @@ -131,6 +144,25 @@ class AdminGiftControllerTest @Autowired constructor( @BeforeEach fun deleteGiftFixtures() { + val scheduledJobs = scheduledProcessor.scheduledTasks.filter { + (it.task.runnable as? ScheduledMethodRunnable)?.target === scheduler + } + assertTrue(AopUtils.isAopProxy(scheduler)) + assertTrue((scheduler as Advised).advisors.any { it.advice is TransactionInterceptor }) + assertEquals(4, scheduledJobs.size) + assertEquals( + mapOf( + "runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *", + "runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *", + "runDeliveredGiftPersonalDataErasure" to "0 20 * * * *", + "runDeadlineReminderGifts" to "0 40 * * * *" + ), + scheduledJobs.associate { + (it.task.runnable as ScheduledMethodRunnable).method.name to (it.task as CronTask).expression + } + ) + println("P15_SPRING transactionalProxy=true registeredMinutes=00,10,20,40 cancelingTestJobs=${scheduledJobs.size}") + scheduledJobs.forEach { it.cancel() } RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) giftDeliveryRepository.deleteAll() giftRepository.deleteAll() @@ -270,6 +302,248 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE")) } + @Test + @DisplayName("관리자는 개인정보가 지워진 전달 완료 선물을 기존 빈 문자열과 null 응답으로 조회한다") + fun shouldReturnCompatibleErasedAdminGiftResponses() { + val (gift, delivery) = saveAdminGift( + applicationNo = "A-ERASED-CONTROLLER", + status = GiftStatus.DELIVERED, + senderNickname = "erased-admin-fan", + recipientNickname = "erased-admin-creator", + withTracking = false + ) + delivery.senderName = null + delivery.senderPhoneNumber = null + delivery.senderZipCode = null + delivery.senderAddress = null + delivery.senderAddressDetail = null + giftDeliveryRepository.saveAndFlush(delivery) + + mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.status").value("DELIVERED")) + .andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan")) + .andExpect(jsonPath("$.data.senderInfo.name").value("")) + .andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("")) + .andExpect(jsonPath("$.data.senderInfo.address").value("")) + .andExpect(jsonPath("$.data.recipientInfo.nickname").value("erased-admin-creator")) + .andExpect(jsonPath("$.data.recipientInfo.name").value("")) + .andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value("")) + .andExpect(jsonPath("$.data.recipientInfo.address").value("")) + .andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value(nullValue())) + .andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value(nullValue())) + .andExpect(jsonPath("$.data.availableActions").isEmpty) + + mockMvc.perform( + get("/api/v2/admin/gifts") + .with(user("admin").roles("ADMIN")) + .param("applicationNo", gift.applicationNo) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].senderNickname").value("erased-admin-fan")) + .andExpect(jsonPath("$.data.items[0].recipientNickname").value("erased-admin-creator")) + .andExpect(jsonPath("$.data.items[0].courierCompanyName").value(nullValue())) + .andExpect(jsonPath("$.data.items[0].trackingNumber").value(nullValue())) + .andExpect(jsonPath("$.data.items[0].availableActions").isEmpty) + } + + @Test + @DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다") + fun shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts() { + val now = NOW.plusDays(14) + val recipient = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient") + ) + val unrelated = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-unrelated") + ) + val fixture = saveFundedGift(recipient) + assertEquals(4, setOf(fixture.sender.id, recipient.id, fixture.admin.id, unrelated.id).size) + val giftId = requireNotNull(fixture.gift.id) + val transaction = TransactionTemplate(transactionManager) + val review = requireNotNull( + transaction.execute { + val gift = giftRepository.findById(giftId).orElseThrow() + gift.status = GiftStatus.DELIVERED + gift.recipientTermsAgreed = true + gift.recipientPrivacyAgreed = true + gift.recipientTermsAgreedAt = NOW + gift.recipientPrivacyAgreedAt = NOW + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)) + delivery.senderAddressDetail = "101동 1001호" + delivery.recipientName = "김소다" + delivery.recipientPhoneNumber = "01098765432" + delivery.recipientZipCode = "04524" + delivery.recipientAddress = "서울시 중구" + delivery.recipientAddressDetail = "202호" + delivery.courierCompanyName = "CJ대한통운" + delivery.trackingNumber = "1234567890" + delivery.trackingRegisteredAt = NOW.minusDays(2) + delivery.arrivedAtMailboxAt = NOW.minusDays(1) + delivery.inspectionCompletedAt = NOW.plusDays(1) + delivery.deliveredAt = now.minusDays(7) + delivery.recipientAddressDeadlineAt = NOW.plusDays(5) + delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3) + delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4) + GiftReview(giftId, requireNotNull(fixture.sender.id), 5, "SAFE", "retained review").also { + entityManager.persist(it) + } + } + ) + val personalFields = listOf( + "senderName", "senderPhoneNumber", "senderZipCode", "senderAddress", "senderAddressDetail", + "recipientName", "recipientPhoneNumber", "recipientZipCode", "recipientAddress", "recipientAddressDetail", + "courierCompanyName", "trackingNumber" + ) + val originalGift = objectMapper.valueToTree(giftRepository.findById(giftId).orElseThrow()) + val originalDelivery = objectMapper.valueToTree(giftDeliveryRepository.findByGiftId(giftId)) + personalFields.forEach { + assertTrue(originalDelivery.path(it).isTextual && originalDelivery.path(it).textValue().isNotBlank()) + } + originalDelivery.remove(personalFields + "updatedAt") + val originalReview = transaction.execute { + objectMapper.valueToTree(requireNotNull(entityManager.find(GiftReview::class.java, review.id))) + } + assertRefundState(fixture, refunded = false) + + val senderToken = tokenFor(fixture.sender) + val recipientToken = tokenFor(recipient) + val adminToken = tokenFor(fixture.admin) + val unrelatedToken = tokenFor(unrelated) + val detailPath = "/api/v2/gifts/$APPLICATION_NO" + val adminPath = "/api/v2/admin/gifts/$APPLICATION_NO" + val adminListPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO" + val senderAddress = objectMapper.readTree( + """{"name":"홍길동","phoneNumber":"01012345678","address":"(06234) 서울시 강남구, 101동 1001호"}""" + ) + val recipientAddress = objectMapper.readTree( + """{"name":"김소다","phoneNumber":"01098765432","address":"(04524) 서울시 중구, 202호"}""" + ) + val emptyAddress = objectMapper.readTree("""{"name":"","phoneNumber":"","address":""}""") + val json = objectMapper.nodeFactory + var originalTimeline: JsonNode? = null + listOf(false, true).forEach { erased -> + if (erased) { + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(now)) + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(now)) + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) + } + listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach { + giftHttp(HttpMethod.GET, it, expectedStatus = HttpStatus.UNAUTHORIZED) + } + listOf(detailPath, adminPath, adminListPath).forEach { path -> + val denied = giftHttp(HttpMethod.GET, path, unrelatedToken) + assertEquals(json.booleanNode(false), denied.path("success")) + assertEquals(json.nullNode(), denied.path("data")) + assertEquals( + json.textNode(if (path == detailPath) "Invalid request." else "You do not have permission."), + denied.path("message") + ) + } + val sender = giftHttp(HttpMethod.GET, detailPath, senderToken) + val received = giftHttp(HttpMethod.GET, detailPath, recipientToken) + val admin = giftHttp(HttpMethod.GET, adminPath, adminToken) + listOf(sender, received, admin).forEach { assertEquals(json.booleanNode(true), it.path("success")) } + val sentData = sender.path("data") + val receivedData = received.path("data") + val adminData = admin.path("data") + assertEquals(if (erased) emptyAddress else senderAddress, sentData.path("senderInfo")) + assertEquals(json.nullNode(), sentData.path("recipientAddress")) + assertEquals(json.nullNode(), receivedData.path("senderInfo")) + assertEquals(if (erased) json.nullNode() else recipientAddress, receivedData.path("recipientAddress")) + assertEquals(json.nullNode(), receivedData.path("giftInfo").path("tracking")) + assertEquals(json.textNode("SENT"), sentData.path("direction")) + assertEquals(json.textNode("RECEIVED"), receivedData.path("direction")) + assertEquals(json.textNode(recipient.nickname), sentData.path("giftInfo").path("recipientCreatorNickname")) + assertEquals(json.textNode(fixture.sender.nickname), receivedData.path("giftInfo").path("senderNickname")) + assertEquals( + if (erased) json.nullNode() else json.textNode("CJ대한통운_1234567890"), + sentData.path("giftInfo").path("tracking") + ) + originalTimeline = originalTimeline ?: sentData.path("statusTimeline") + assertTrue(requireNotNull(originalTimeline).isArray) + assertEquals(5, requireNotNull(originalTimeline).size()) + assertEquals(json.textNode("2026-10-10T12:00:00Z"), requireNotNull(originalTimeline).last().path("occurredAt")) + listOf(sentData, receivedData).forEach { data -> + assertEquals(originalTimeline, data.path("statusTimeline")) + assertEquals(json.booleanNode(false), data.path("trackingRequired")) + assertEquals(json.booleanNode(false), data.path("recipientAddressRequired")) + assertEquals(json.nullNode(), data.path("mailbox")) + } + listOf(sentData, receivedData, adminData).forEach { data -> + assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo")) + assertEquals(json.textNode("DELIVERED"), data.path("status")) + } + listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) -> + val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname + assertEquals( + (if (erased) emptyAddress else address).deepCopy().put("nickname", nickname), + adminData.path(field) + ) + } + val adminList = giftHttp(HttpMethod.GET, adminListPath, adminToken) + assertEquals(json.booleanNode(true), adminList.path("success")) + assertEquals(json.numberNode(1), adminList.path("data").path("totalCount")) + val adminItem = adminList.path("data").path("items").single() + assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo")) + assertEquals(json.textNode("DELIVERED"), adminItem.path("status")) + assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname")) + assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname")) + listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data -> + assertEquals(if (erased) json.nullNode() else json.textNode("CJ대한통운"), data.path("courierCompanyName")) + assertEquals(if (erased) json.nullNode() else json.textNode("1234567890"), data.path("trackingNumber")) + } + listOf(adminData, adminItem).forEach { assertEquals(json.arrayNode(), it.path("availableActions")) } + listOf(Triple("SENT", senderToken, recipient.nickname), Triple("RECEIVED", recipientToken, fixture.sender.nickname)) + .forEach { (direction, token, nickname) -> + val list = giftHttp(HttpMethod.GET, "/api/v2/gifts?type=$direction", token) + assertEquals(json.booleanNode(true), list.path("success")) + assertEquals(json.numberNode(1), list.path("data").path("totalCount")) + val item = list.path("data").path("items").single() + assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo")) + assertEquals(json.textNode("DELIVERED"), item.path("status")) + assertEquals(json.textNode(direction), item.path("direction")) + assertEquals(json.textNode(nickname), item.path("counterpartNickname")) + assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered")) + } + println( + "P15_HTTP erased=$erased status=200 success=true " + + "senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " + + "recipientAddressNull=${receivedData.path("recipientAddress").isNull} " + + "trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " + + "requiredFlags=false nicknamePreserved=true timelinePreserved=true lists=1/1/1 auth=401/denied" + ) + } + transaction.executeWithoutResult { + entityManager.clear() + assertEquals(1L, giftRepository.count()) + assertEquals(1L, giftDeliveryRepository.count()) + assertEquals(originalGift, objectMapper.valueToTree(giftRepository.findById(giftId).orElseThrow())) + val reloadedDelivery = objectMapper.valueToTree(giftDeliveryRepository.findByGiftId(giftId)) + personalFields.forEach { assertEquals(json.nullNode(), reloadedDelivery.path(it), it) } + reloadedDelivery.remove(personalFields + "updatedAt") + assertEquals(originalDelivery, reloadedDelivery) + assertEquals( + originalReview, + objectMapper.valueToTree(entityManager.find(GiftReview::class.java, review.id)) + ) + val funding = chargeRepository.findById(fixture.fundingId).orElseThrow() + assertEquals(PaymentStatus.COMPLETE, funding.payment?.status) + assertEquals(PaymentGateway.PG, funding.payment?.paymentGateway) + } + assertRefundState(fixture, refunded = false) + Mockito.verify(canPaymentService, Mockito.never()) + .refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId) + Mockito.verifyNoInteractions(giftPushService) + println( + "P15_DB erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 paymentConsentReviewHistoryPreserved=true" + ) + } + @Test @DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다") fun shouldManageGiftCategories() { @@ -614,6 +888,66 @@ class AdminGiftControllerTest @Autowired constructor( Mockito.verifyNoInteractions(giftPushService) } + @Test + @DisplayName("자동 취소 배치 조회는 ID 순서로 모든 후보를 잠그고 두 번째 선물 취소도 커밋까지 대기시킨다") + fun shouldLockExpiredTrackingBatchUntilTransactionCommits() { + val (firstGift, _) = saveAdminGift( + applicationNo = "A-BATCH-FIRST", + status = GiftStatus.RECEIVED, + senderNickname = "batch-first-sender", + recipientNickname = "batch-first-recipient", + withTracking = false + ) + val firstCategory = categoryRepository.findById(firstGift.categoryId).orElseThrow() + firstCategory.classificationNumber = "101" + categoryRepository.saveAndFlush(firstCategory) + val fixture = saveFundedGift() + val winnerPaused = CountDownLatch(1) + val releaseWinner = CountDownLatch(1) + val contenderStarted = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val winner = executor.submit> { + requireNotNull( + TransactionTemplate(transactionManager).execute { + val ids = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(NOW).map { requireNotNull(it.id) } + assertEquals(listOf(firstGift.id, fixture.gift.id), ids) + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + ids + } + ) + } + var contender: java.util.concurrent.Future? = null + try { + assertTrue(winnerPaused.await(10, TimeUnit.SECONDS)) + contender = executor.submit { + contenderStarted.countDown() + executeRaceOperation(RaceOperation.USER, fixture) + } + assertTrue(contenderStarted.await(10, TimeUnit.SECONDS)) + val waitingContender = contender + assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) } + releaseWinner.countDown() + assertEquals(listOf(firstGift.id, fixture.gift.id), winner.get(10, TimeUnit.SECONDS)) + assertEquals("CANCELED", contender.get(10, TimeUnit.SECONDS)) + assertEquals(GiftStatus.CANCELED, giftRepository.findById(requireNotNull(fixture.gift.id)).orElseThrow().status) + assertTrue(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt != null) + assertRefundState(fixture, refunded = true) + assertEquals("REJECTED", executeRaceOperation(RaceOperation.USER, fixture)) + assertRefundState(fixture, refunded = true) + println("P15_BATCH_LOCK orderedCandidates=2 contenderBlockedUntilCommit=true refundCan=80 refundCount=1") + } finally { + releaseWinner.countDown() + executor.shutdown() + if (!executor.awaitTermination(15, TimeUnit.SECONDS)) { + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS) + contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) } + } + } + @ParameterizedTest @MethodSource("cancellationRaces") @DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다") @@ -695,6 +1029,167 @@ class AdminGiftControllerTest @Autowired constructor( } } + @ParameterizedTest + @MethodSource("recipientMutationRaces") + @DisplayName("배송지 등록과 예약·운영 변경은 오래된 영속 엔티티가 있어도 커밋까지 직렬화된다") + fun shouldSerializeRecipientMutations(operations: Pair) { + val fixture = saveFundedGift() + val giftId = requireNotNull(fixture.gift.id) + val expiring = RecipientMutation.EXPIRY in listOf(operations.first, operations.second) + val initialStatus = when { + RecipientMutation.INSPECT in listOf(operations.first, operations.second) -> GiftStatus.ARRIVED_AT_MAILBOX + listOf(operations.first, operations.second).any { + it in listOf(RecipientMutation.ADMIN_DELIVERY, RecipientMutation.CONFIRM) + } -> GiftStatus.INSPECTION_COMPLETED + else -> GiftStatus.TRACKING_REGISTERED + } + TransactionTemplate(transactionManager).executeWithoutResult { + giftRepository.findById(giftId).orElseThrow().status = initialStatus + requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddressDeadlineAt = + if (expiring) NOW.minusSeconds(1) else NOW.plusHours(1) + } + val winnerPaused = CountDownLatch(1) + val releaseWinner = CountDownLatch(1) + val contenderStarted = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val winner = executor.submit { + requireNotNull( + TransactionTemplate(transactionManager).execute { + val result = executeRecipientMutation(operations.first, fixture) + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + result + } + ) + } + var contender: java.util.concurrent.Future? = null + try { + assertTrue(winnerPaused.await(10, TimeUnit.SECONDS)) + contender = executor.submit { + try { + requireNotNull( + TransactionTemplate(transactionManager).execute { + assertEquals(initialStatus, giftRepository.findById(giftId).orElseThrow().status) + assertEquals(null, requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddress) + contenderStarted.countDown() + executeRecipientMutation(operations.second, fixture) + } + ) + } catch (exception: SodaException) { + assertEquals("common.error.invalid_request", exception.messageKey) + "REJECTED" + } + } + assertTrue(contenderStarted.await(10, TimeUnit.SECONDS)) + val waitingContender = contender + val blockedUntilCommit = try { + waitingContender.get(300, TimeUnit.MILLISECONDS) + false + } catch (exception: TimeoutException) { + true + } + releaseWinner.countDown() + val firstResult = winner.get(10, TimeUnit.SECONDS) + val secondResult = contender.get(10, TimeUnit.SECONDS) + val addressRegistered = operations.first == RecipientMutation.ADDRESS || + (operations.second == RecipientMutation.ADDRESS && operations.first != RecipientMutation.EXPIRY) + TransactionTemplate(transactionManager).executeWithoutResult { + entityManager.clear() + val gift = giftRepository.findById(giftId).orElseThrow() + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)) + assertEquals( + if (addressRegistered) { + listOf("recipient", "01098765432", "04524", "registered address", "202") + } else { + List(5) { null } + }, + listOf( + delivery.recipientName, + delivery.recipientPhoneNumber, + delivery.recipientZipCode, + delivery.recipientAddress, + delivery.recipientAddressDetail + ), + "committed recipient fields: $operations" + ) + assertEquals(addressRegistered, gift.recipientTermsAgreed) + assertEquals(addressRegistered, gift.recipientPrivacyAgreed) + assertEquals(addressRegistered, gift.recipientTermsAgreedAt != null) + assertEquals(addressRegistered, gift.recipientPrivacyAgreedAt != null) + assertEquals(gift.recipientTermsAgreedAt, gift.recipientPrivacyAgreedAt) + val expectedStatus = when { + operations.first == RecipientMutation.EXPIRY -> GiftStatus.UNDELIVERABLE + RecipientMutation.ARRIVE in listOf(operations.first, operations.second) -> GiftStatus.ARRIVED_AT_MAILBOX + RecipientMutation.INSPECT in listOf(operations.first, operations.second) -> GiftStatus.INSPECTION_COMPLETED + RecipientMutation.UNDELIVERABLE in listOf(operations.first, operations.second) -> GiftStatus.UNDELIVERABLE + listOf(operations.first, operations.second).any { + it in listOf(RecipientMutation.ADMIN_DELIVERY, RecipientMutation.CONFIRM) + } -> GiftStatus.DELIVERED + else -> initialStatus + } + assertEquals(expectedStatus, gift.status) + assertEquals(operations.first == RecipientMutation.EXPIRY, delivery.undeliverableAt != null && expiring) + assertEquals( + operations.first == RecipientMutation.REMINDER, + delivery.recipientAddressDeadlineReminderSentAt != null + ) + assertEquals(expectedStatus == GiftStatus.DELIVERED, delivery.deliveredAt != null) + assertEquals( + RecipientMutation.ARRIVE in listOf(operations.first, operations.second), + delivery.arrivedAtMailboxAt != null + ) + assertEquals( + RecipientMutation.INSPECT in listOf(operations.first, operations.second), + delivery.inspectionCompletedAt != null + ) + } + assertTrue(blockedUntilCommit, "contender must wait for winner commit: $operations") + if (operations.first == RecipientMutation.ADDRESS && operations.second in listOf( + RecipientMutation.REMINDER, + RecipientMutation.EXPIRY + ) + ) { + assertEquals("0", secondResult) + } + if (operations.first == RecipientMutation.EXPIRY) assertEquals("REJECTED", secondResult) + if (operations.first == RecipientMutation.REMINDER) assertEquals("1", firstResult) + assertEquals(0, scheduler.sendDeadlineReminderGifts(NOW)) + assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW)) + Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.REMINDER) 1 else 0)) + .sendRecipientAddressDeadlineReminder(APPLICATION_NO, fixture.gift.recipientMemberId) + assertRefundState(fixture, refunded = false) + println( + "P15_R1_RACE $operations first=$firstResult second=$secondResult serialized=$blockedUntilCommit persisted=true" + ) + } finally { + releaseWinner.countDown() + executor.shutdown() + if (!executor.awaitTermination(15, TimeUnit.SECONDS)) { + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS) + contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) } + } + } + + private fun executeRecipientMutation(operation: RecipientMutation, fixture: FundedGift): String = when (operation) { + RecipientMutation.ADDRESS -> commandService.registerRecipientAddress( + fixture.admin, + APPLICATION_NO, + GiftRecipientAddressRegistrationCommand("recipient", "01098765432", "04524", "registered address", "202", true, true) + ).status.name + RecipientMutation.REMINDER -> scheduler.sendDeadlineReminderGifts(NOW).toString() + RecipientMutation.EXPIRY -> scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW).toString() + RecipientMutation.ARRIVE -> adminService.arriveMailbox(APPLICATION_NO, NOW).status.name + RecipientMutation.INSPECT -> adminService.completeInspection(APPLICATION_NO, NOW).status.name + RecipientMutation.UNDELIVERABLE -> adminService.markUndeliverable(APPLICATION_NO, "operator reason", NOW).status.name + RecipientMutation.ADMIN_DELIVERY -> adminService.completeDelivery(APPLICATION_NO, NOW).status.name + RecipientMutation.CONFIRM -> commandService.confirmDelivery(fixture.admin, APPLICATION_NO).status.name + } + + enum class RecipientMutation { ADDRESS, REMINDER, EXPIRY, ARRIVE, INSPECT, UNDELIVERABLE, ADMIN_DELIVERY, CONFIRM } + private fun executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String { val requestAttributes = ServletRequestAttributes(MockHttpServletRequest()) RequestContextHolder.setRequestAttributes(requestAttributes) @@ -772,7 +1267,7 @@ class AdminGiftControllerTest @Autowired constructor( return body } - private fun saveFundedGift(): FundedGift = requireNotNull( + private fun saveFundedGift(recipient: Member? = null): FundedGift = requireNotNull( TransactionTemplate(transactionManager).execute { val sender = memberRepository.save( Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply { @@ -811,7 +1306,7 @@ class AdminGiftControllerTest @Autowired constructor( Gift( applicationNo = APPLICATION_NO, senderMemberId = requireNotNull(sender.id), - recipientMemberId = requireNotNull(admin.id), + recipientMemberId = requireNotNull((recipient ?: admin).id), status = GiftStatus.RECEIVED, sizeCode = GiftSize.SMALL, categoryId = requireNotNull(category.id), @@ -971,6 +1466,28 @@ class AdminGiftControllerTest @Autowired constructor( } private companion object { + @JvmStatic + fun recipientMutationRaces() = listOf( + RecipientMutation.REMINDER to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.REMINDER, + RecipientMutation.EXPIRY to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.EXPIRY, + RecipientMutation.ARRIVE to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.ARRIVE, + RecipientMutation.INSPECT to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.INSPECT, + RecipientMutation.ADDRESS to RecipientMutation.UNDELIVERABLE, + RecipientMutation.ADDRESS to RecipientMutation.ADMIN_DELIVERY, + RecipientMutation.ADDRESS to RecipientMutation.CONFIRM, + RecipientMutation.REMINDER to RecipientMutation.CONFIRM, + RecipientMutation.CONFIRM to RecipientMutation.REMINDER, + RecipientMutation.EXPIRY to RecipientMutation.CONFIRM, + RecipientMutation.CONFIRM to RecipientMutation.EXPIRY, + RecipientMutation.REMINDER to RecipientMutation.UNDELIVERABLE, + RecipientMutation.UNDELIVERABLE to RecipientMutation.REMINDER, + RecipientMutation.REMINDER to RecipientMutation.REMINDER + ) + @JvmStatic fun cancellationRaces() = listOf( RaceOperation.ADMIN to RaceOperation.ADMIN, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt index a7cf2efb..7c94c356 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt @@ -547,7 +547,7 @@ class GiftCommandServiceTest { trackingDeadlineAt = NOW.plusDays(3) ) Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(cancellationGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery) + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn(cancellationDelivery) } fun prepareGiftForTracking( @@ -580,7 +580,7 @@ class GiftCommandServiceTest { trackingDeadlineAt = NOW.plusDays(3) ) Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(trackingGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn( trackingDelivery.takeIf { deliveryExists } ) } @@ -614,8 +614,8 @@ class GiftCommandServiceTest { senderAddress = "서울시 강남구", trackingDeadlineAt = NOW.plusDays(3) ) - Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(recipientAddressGift) + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn( recipientAddressDelivery.takeIf { deliveryExists } ) } @@ -649,8 +649,8 @@ class GiftCommandServiceTest { senderAddress = "서울시 강남구", trackingDeadlineAt = NOW.plusDays(3) ) - Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(deliveryConfirmationGift) + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn( deliveryConfirmationDelivery.takeIf { deliveryExists } ) } 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 index 72cf16ad..90b17aab 100644 --- 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 @@ -1,23 +1,43 @@ package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member 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.adapter.out.persistence.GiftReview 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.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito +import org.redisson.api.RLock import org.redisson.api.RedissonClient import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager import org.springframework.context.annotation.Import +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.test.context.transaction.TestTransaction +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @DataJpaTest( properties = [ @@ -29,16 +49,296 @@ import java.time.LocalDateTime @DisplayName("선물 자동처리 스케줄러 테스트") class GiftSchedulerTest @Autowired constructor( private val giftRepository: GiftRepository, - private val giftDeliveryRepository: GiftDeliveryRepository + private val giftDeliveryRepository: GiftDeliveryRepository, + private val entityManager: TestEntityManager ) { private val canPaymentService = Mockito.mock(CanPaymentService::class.java) private val giftPushService = Mockito.mock(GiftPushService::class.java) + private val redissonClient = Mockito.mock(RedissonClient::class.java) private val scheduler = GiftScheduler( giftRepository, giftDeliveryRepository, canPaymentService, giftPushService, - Mockito.mock(RedissonClient::class.java) + redissonClient + ) + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("신규 진입점은 각 KST 분의 parameterless transactional 스케줄로 등록된다") + fun shouldScheduleEachEntryPointAtItsKstMinute(entryPoint: ScheduledEntryPoint) { + val method = GiftScheduler::class.java.getDeclaredMethod(entryPoint.methodName) + val scheduled = requireNotNull(method.getAnnotation(Scheduled::class.java)) + + assertEquals(entryPoint.cron, scheduled.cron) + assertEquals("Asia/Seoul", scheduled.zone) + assertNotNull(method.getAnnotation(Transactional::class.java)) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 독립 lock을 획득하면 현재 시각으로 한 번 위임하고 해제한다") + fun shouldAcquireDistinctLockAndDelegateWithWallClockNow(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + val schedulerSpy = Mockito.spy(scheduler) + val before = LocalDateTime.now() + + entryPoint.run(schedulerSpy) + + assertWallClockDelegation(schedulerSpy, entryPoint, before, LocalDateTime.now()) + TestTransaction.end() + Mockito.verify(redissonClient).getLock(entryPoint.lockKey) + Mockito.verifyNoMoreInteractions(redissonClient) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock).unlock() + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 lock 획득 실패 시 업무 위임과 unlock을 하지 않는다") + fun shouldSkipEachDelegateWhenItsLockIsNotAcquired(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = false, owned = false) + val schedulerSpy = Mockito.spy(scheduler) + + entryPoint.run(schedulerSpy) + + assertEquals(listOf(entryPoint.methodName), Mockito.mockingDetails(schedulerSpy).invocations.map { it.method.name }) + Mockito.verify(redissonClient).getLock(entryPoint.lockKey) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 업무 메서드 예외를 전파하면서 소유한 lock을 해제한다") + fun shouldUnlockEachOwnedLockWhenDelegateThrows(entryPoint: ScheduledEntryPoint) { + TestTransaction.end() + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + val failure = IllegalStateException("repository unavailable") + val failingScheduler = GiftScheduler( + Mockito.mock(GiftRepository::class.java) { throw failure }, + giftDeliveryRepository, + canPaymentService, + giftPushService, + redissonClient + ) + + val exception = assertThrows(IllegalStateException::class.java) { entryPoint.run(failingScheduler) } + + assertSame(failure, exception) + Mockito.verify(redissonClient).getLock(entryPoint.lockKey) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock).unlock() + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 업무 실행 후 현재 스레드가 소유하지 않은 lock을 해제하지 않는다") + fun shouldNotUnlockEachLockWhenCurrentThreadDoesNotOwnIt(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = true, owned = false) + val schedulerSpy = Mockito.spy(scheduler) + val before = LocalDateTime.now() + + entryPoint.run(schedulerSpy) + + assertWallClockDelegation(schedulerSpy, entryPoint, before, LocalDateTime.now()) + TestTransaction.end() + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 lock은 실제 commit 직전과 직후에도 재진입을 막고 완료 후 해제된다") + fun shouldHoldEachLockThroughCommitAndAllowReentryAfterCompletion(entryPoint: ScheduledEntryPoint) { + val (lock, contender) = contendingLocks(entryPoint) + var beforeCommitCalled = false + var afterCommitCalled = false + var completionStatus: Int? = null + + entryPoint.run(scheduler) + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + override fun beforeCommit(readOnly: Boolean) { + beforeCommitCalled = true + Mockito.verify(lock, Mockito.never()).unlock() + assertFalse(contender.tryLock(0, -1, TimeUnit.SECONDS)) + } + + override fun afterCommit() { + afterCommitCalled = true + Mockito.verify(lock, Mockito.never()).unlock() + assertFalse(contender.tryLock(0, -1, TimeUnit.SECONDS)) + } + + override fun afterCompletion(status: Int) { + completionStatus = status + } + }) + TestTransaction.flagForCommit() + + TestTransaction.end() + + assertTrue(beforeCommitCalled) + assertTrue(afterCommitCalled) + assertEquals(TransactionSynchronization.STATUS_COMMITTED, completionStatus) + Mockito.verify(lock).unlock() + assertTrue(contender.tryLock(0, -1, TimeUnit.SECONDS)) + contender.unlock() + assertEquals(0L, giftRepository.count()) + assertEquals(0L, giftDeliveryRepository.count()) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 lock은 업무 예외 후 실제 rollback 직전까지 재진입을 막고 완료 후 해제된다") + fun shouldHoldEachLockOnDelegateFailureUntilRollback(entryPoint: ScheduledEntryPoint) { + val (lock, contender) = contendingLocks(entryPoint) + val failure = IllegalStateException("repository unavailable") + val failingScheduler = GiftScheduler( + Mockito.mock(GiftRepository::class.java) { throw failure }, + giftDeliveryRepository, + canPaymentService, + giftPushService, + redissonClient + ) + var heldBeforeRollback = false + var reenteredBeforeRollback = true + var completionStatus: Int? = null + val exception = assertThrows(IllegalStateException::class.java) { entryPoint.run(failingScheduler) } + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + override fun beforeCompletion() { + heldBeforeRollback = lock.isHeldByCurrentThread + reenteredBeforeRollback = contender.tryLock(0, -1, TimeUnit.SECONDS) + } + + override fun afterCompletion(status: Int) { + completionStatus = status + } + }) + TestTransaction.flagForRollback() + + TestTransaction.end() + + assertSame(failure, exception) + assertTrue(heldBeforeRollback) + assertFalse(reenteredBeforeRollback) + assertEquals(TransactionSynchronization.STATUS_ROLLED_BACK, completionStatus) + Mockito.verify(lock).unlock() + assertTrue(contender.tryLock(0, -1, TimeUnit.SECONDS)) + contender.unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("신규 lock 소유권은 등록 시점이 아닌 transaction 완료 시점에 확인한다") + fun shouldCheckEachLockOwnershipAtTransactionCompletion(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + + entryPoint.run(scheduler) + + Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + TestTransaction.end() + + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("lock 획득 실패는 소유 여부와 무관하게 해제 callback을 등록하지 않는다") + fun shouldNotRegisterReleaseForEachUnacquiredLock(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = false, owned = true) + val synchronizations = TransactionSynchronizationManager.getSynchronizations() + + entryPoint.run(scheduler) + + assertEquals(synchronizations, TransactionSynchronizationManager.getSynchronizations()) + TestTransaction.end() + Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("transaction 없는 직접 호출은 업무 실행을 마친 뒤에만 lock을 즉시 해제한다") + fun shouldReleaseEachLockAfterDirectInvocationWithoutTransaction(entryPoint: ScheduledEntryPoint) { + TestTransaction.end() + assertFalse(TransactionSynchronizationManager.isSynchronizationActive()) + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + val directScheduler = Mockito.spy( + GiftScheduler( + Mockito.mock(GiftRepository::class.java), + Mockito.mock(GiftDeliveryRepository::class.java), + canPaymentService, + giftPushService, + redissonClient + ) + ) + val before = LocalDateTime.now() + + entryPoint.run(directScheduler) + + assertWallClockDelegation(directScheduler, entryPoint, before, LocalDateTime.now()) + val delegateCall = Mockito.mockingDetails(directScheduler).invocations.single { + it.method.name == entryPoint.delegateName + } + val unlockCall = Mockito.mockingDetails(lock).invocations.single { it.method.name == "unlock" } + assertTrue(delegateCall.sequenceNumber < unlockCall.sequenceNumber) + } + + private fun contendingLocks(entryPoint: ScheduledEntryPoint): Pair { + val lock = Mockito.mock(RLock::class.java) + val contender = Mockito.mock(RLock::class.java) + val heldBy = AtomicReference() + listOf(lock, contender).forEach { candidate -> + Mockito.`when`(candidate.tryLock(0, -1, TimeUnit.SECONDS)).thenAnswer { heldBy.compareAndSet(null, candidate) } + Mockito.`when`(candidate.isHeldByCurrentThread).thenAnswer { heldBy.get() === candidate } + Mockito.doAnswer { + assertTrue(heldBy.compareAndSet(candidate, null)) + null + }.`when`(candidate).unlock() + } + Mockito.`when`(redissonClient.getLock(entryPoint.lockKey)).thenReturn(lock) + return lock to contender + } + + private fun schedulerLock(entryPoint: ScheduledEntryPoint, acquired: Boolean, owned: Boolean): RLock { + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(entryPoint.lockKey)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(acquired) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(owned) + return lock + } + + private fun assertWallClockDelegation( + schedulerSpy: GiftScheduler, + entryPoint: ScheduledEntryPoint, + before: LocalDateTime, + after: LocalDateTime + ) { + val calls = Mockito.mockingDetails(schedulerSpy).invocations.filter { + it.method.parameterTypes.contentEquals(arrayOf(LocalDateTime::class.java)) + } + assertEquals(listOf(entryPoint.delegateName), calls.map { it.method.name }) + val now = calls.single().arguments.single() as LocalDateTime + assertTrue(!now.isBefore(before) && !now.isAfter(after)) + } + + data class ScheduledEntryPoint( + val methodName: String, + val delegateName: String, + val cron: String, + val lockKey: String, + val run: (GiftScheduler) -> Unit ) @Test @@ -299,24 +599,292 @@ class GiftSchedulerTest @Autowired constructor( Mockito.verifyNoInteractions(giftPushService) } + @Test + @DisplayName("전달 완료 후 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다") + fun shouldEraseDeliveredGiftWhenDeliveredAtEqualsSevenDayCutoff() { + val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF) + flushAndClear() + + val eligibleGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(ERASURE_CUTOFF) + assertEquals(listOf(gift.id), eligibleGifts.map { it.id }) + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + } + + @Test + @DisplayName("전달 완료 후 7일보다 1초 덜 지난 선물의 개인정보는 보존한다") + fun shouldIgnoreDeliveredGiftOneSecondNewerThanSevenDayCutoff() { + val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1)) + val originalPersonalData = personalData(delivery) + flushAndClear() + + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(originalPersonalData, personalData(reloadDelivery(gift))) + } + + @Test + @DisplayName("도입 시점보다 오래전에 생성되고 전달 완료된 과거 누적 선물도 지운다") + fun shouldEraseOlderHistoricalDeliveredGifts() { + val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0) + val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10)) + gift.createdAt = historicalAt + delivery.createdAt = historicalAt + flushAndClear() + + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + assertEquals(historicalAt, reloadDelivery(gift).createdAt) + assertEquals(historicalAt, entityManager.find(Gift::class.java, gift.id).createdAt) + } + + @Test + @DisplayName("완료 시각이 없거나 전달 완료가 아닌 모든 상태는 개인정보를 보존한다") + fun shouldIgnoreNullDeliveredAtAndNonDeliveredGifts() { + val gifts = listOf(saveGiftForErasure(null)) + GiftStatus.values() + .filter { it != GiftStatus.DELIVERED } + .mapIndexed { index, status -> + saveGiftForErasure(ERASURE_CUTOFF.minusDays(30), status, "IGNORE-$index") + } + val originals = gifts.map { (_, delivery) -> personalData(delivery) } + flushAndClear() + + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + gifts.forEachIndexed { index, (gift, _) -> + assertEquals(gift.status, entityManager.find(Gift::class.java, gift.id).status) + assertEquals(originals[index], personalData(reloadDelivery(gift))) + } + } + + @Test + @DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다") + fun shouldEraseRemainingFieldsFromPartiallyClearedGift() { + val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField -> + saveGiftForErasure(ERASURE_CUTOFF, applicationNo = "PARTIAL-$index").also { (_, delivery) -> + PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) } + } + } + flushAndClear() + + assertEquals(12, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) } + } + + @Test + @DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다") + fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull() { + val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF) + PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) } + flushAndClear() + val updatedAt = reloadDelivery(gift).updatedAt + + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + assertEquals(updatedAt, reloadDelivery(gift).updatedAt) + } + + @Test + @DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다") + fun shouldReturnZeroWhenErasureRunsAgain() { + val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF) + flushAndClear() + + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + val updatedAt = reloadDelivery(gift).updatedAt + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW.plusDays(1))) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + assertEquals(updatedAt, reloadDelivery(gift).updatedAt) + } + + @Test + @DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다") + fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps() { + val sender = entityManager.persist( + Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png") + .apply { pgChargeCan = 120 } + ) + val recipient = entityManager.persist( + Member(password = "test-password", nickname = "recipient", profileImage = "recipient-profile.png") + ) + val payment = entityManager.persist( + UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender } + ) + val (gift, delivery) = saveGift( + GiftStatus.DELIVERED, + APPLIED_AT.plusDays(3), + senderMemberId = requireNotNull(sender.id), + recipientMemberId = requireNotNull(recipient.id), + canUsageId = requireNotNull(payment.id) + ) + fillDeliveryPersonalData(delivery) + gift.recipientTermsAgreed = true + gift.recipientPrivacyAgreed = true + gift.recipientTermsAgreedAt = APPLIED_AT.plusDays(2) + gift.recipientPrivacyAgreedAt = APPLIED_AT.plusDays(2) + delivery.recipientAddressDeadlineAt = APPLIED_AT.plusDays(8) + delivery.trackingDeadlineReminderSentAt = APPLIED_AT.plusDays(1) + delivery.recipientAddressDeadlineReminderSentAt = APPLIED_AT.plusDays(7) + delivery.trackingRegisteredAt = APPLIED_AT.plusDays(2) + delivery.arrivedAtMailboxAt = APPLIED_AT.plusDays(3) + delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4) + delivery.deliveredAt = ERASURE_CUTOFF + delivery.canceledAt = APPLIED_AT.plusHours(1) + delivery.undeliverableAt = APPLIED_AT.plusHours(2) + delivery.undeliverableReason = "보존할 업무 사유" + val review = entityManager.persist( + GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다") + ) + flushAndClear() + val originalGift = entityManager.find(Gift::class.java, gift.id) + val originalDelivery = reloadDelivery(gift) + val originalPayment = entityManager.find(UseCan::class.java, payment.id) + val originalReview = entityManager.find(GiftReview::class.java, review.id) + flushAndClear() + + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + val reloadedGift = entityManager.find(Gift::class.java, gift.id) + assertEquals(originalGift.id, reloadedGift.id) + assertEquals(originalGift.applicationNo, reloadedGift.applicationNo) + assertEquals(originalGift.status, reloadedGift.status) + assertEquals(originalGift.senderMemberId, reloadedGift.senderMemberId) + assertEquals(originalGift.recipientMemberId, reloadedGift.recipientMemberId) + assertEquals(originalGift.sizeCode, reloadedGift.sizeCode) + assertEquals(originalGift.categoryId, reloadedGift.categoryId) + assertEquals(originalGift.categoryNameSnapshot, reloadedGift.categoryNameSnapshot) + assertEquals(originalGift.salePriceCan, reloadedGift.salePriceCan) + assertEquals(originalGift.canUsageId, reloadedGift.canUsageId) + assertEquals(originalGift.senderTermsAgreed, reloadedGift.senderTermsAgreed) + assertEquals(originalGift.senderPrivacyAgreed, reloadedGift.senderPrivacyAgreed) + assertEquals(originalGift.senderTermsAgreedAt, reloadedGift.senderTermsAgreedAt) + assertEquals(originalGift.senderPrivacyAgreedAt, reloadedGift.senderPrivacyAgreedAt) + assertEquals(originalGift.recipientTermsAgreed, reloadedGift.recipientTermsAgreed) + assertEquals(originalGift.recipientPrivacyAgreed, reloadedGift.recipientPrivacyAgreed) + assertEquals(originalGift.recipientTermsAgreedAt, reloadedGift.recipientTermsAgreedAt) + assertEquals(originalGift.recipientPrivacyAgreedAt, reloadedGift.recipientPrivacyAgreedAt) + assertEquals(originalGift.damageWaiverAgreed, reloadedGift.damageWaiverAgreed) + assertEquals(originalGift.createdAt, reloadedGift.createdAt) + + val reloadedDelivery = reloadDelivery(gift) + assertEquals(List(12) { null }, personalData(reloadedDelivery)) + assertEquals(originalDelivery.id, reloadedDelivery.id) + assertEquals(originalDelivery.giftId, reloadedDelivery.giftId) + assertEquals(originalDelivery.createdAt, reloadedDelivery.createdAt) + assertEquals(originalDelivery.trackingDeadlineAt, reloadedDelivery.trackingDeadlineAt) + assertEquals(originalDelivery.recipientAddressDeadlineAt, reloadedDelivery.recipientAddressDeadlineAt) + assertEquals(originalDelivery.trackingDeadlineReminderSentAt, reloadedDelivery.trackingDeadlineReminderSentAt) + assertEquals( + originalDelivery.recipientAddressDeadlineReminderSentAt, + reloadedDelivery.recipientAddressDeadlineReminderSentAt + ) + assertEquals(originalDelivery.trackingRegisteredAt, reloadedDelivery.trackingRegisteredAt) + assertEquals(originalDelivery.arrivedAtMailboxAt, reloadedDelivery.arrivedAtMailboxAt) + assertEquals(originalDelivery.inspectionCompletedAt, reloadedDelivery.inspectionCompletedAt) + assertEquals(originalDelivery.deliveredAt, reloadedDelivery.deliveredAt) + assertEquals(originalDelivery.canceledAt, reloadedDelivery.canceledAt) + assertEquals(originalDelivery.undeliverableAt, reloadedDelivery.undeliverableAt) + assertEquals(originalDelivery.undeliverableReason, reloadedDelivery.undeliverableReason) + + val reloadedPayment = entityManager.find(UseCan::class.java, payment.id) + assertEquals(payment.id, reloadedPayment.id) + assertEquals(sender.id, reloadedPayment.member?.id) + assertEquals(CanUsage.GIFT, reloadedPayment.canUsage) + assertEquals(60, reloadedPayment.can) + assertEquals(20, reloadedPayment.rewardCan) + assertEquals(false, reloadedPayment.isRefund) + assertEquals(originalPayment.createdAt, reloadedPayment.createdAt) + val reloadedSender = entityManager.find(Member::class.java, sender.id) + val reloadedRecipient = entityManager.find(Member::class.java, recipient.id) + assertEquals("sender", reloadedSender.nickname) + assertEquals("sender-profile.png", reloadedSender.profileImage) + assertEquals(120, reloadedSender.pgChargeCan) + assertEquals(0, reloadedSender.pgRewardCan) + assertEquals("recipient", reloadedRecipient.nickname) + assertEquals("recipient-profile.png", reloadedRecipient.profileImage) + val reloadedReview = entityManager.find(GiftReview::class.java, review.id) + assertEquals(originalReview.id, reloadedReview.id) + assertEquals(originalReview.giftId, reloadedReview.giftId) + assertEquals(originalReview.senderMemberId, reloadedReview.senderMemberId) + assertEquals(originalReview.rating, reloadedReview.rating) + assertEquals(originalReview.keywords, reloadedReview.keywords) + assertEquals(originalReview.comment, reloadedReview.comment) + assertEquals(originalReview.createdAt, reloadedReview.createdAt) + assertEquals(1L, giftRepository.count()) + assertEquals(1L, giftDeliveryRepository.count()) + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } + + private fun reloadDelivery(gift: Gift): GiftDelivery = + requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + + private fun personalData(delivery: GiftDelivery): List = PERSONAL_DATA_FIELDS.map { it.get(delivery) } + + private fun saveGiftForErasure( + deliveredAt: LocalDateTime?, + status: GiftStatus = GiftStatus.DELIVERED, + applicationNo: String = APPLICATION_NO + ): Pair = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo) + .also { (_, delivery) -> + fillDeliveryPersonalData(delivery) + delivery.deliveredAt = deliveredAt + } + + private fun fillDeliveryPersonalData(delivery: GiftDelivery) { + delivery.senderName = "홍길동" + delivery.senderPhoneNumber = "01012345678" + delivery.senderZipCode = "06234" + delivery.senderAddress = "서울시 강남구" + delivery.senderAddressDetail = "101호" + delivery.recipientName = "수취인" + delivery.recipientPhoneNumber = "01087654321" + delivery.recipientZipCode = "05555" + delivery.recipientAddress = "서울시 송파구" + delivery.recipientAddressDetail = "202호" + delivery.courierCompanyName = "택배사" + delivery.trackingNumber = "TRACKING-123" + } + private fun saveGift( status: GiftStatus, trackingDeadlineAt: LocalDateTime, recipientAddressDeadlineAt: LocalDateTime? = null, recipientAddress: String? = null, - applicationNo: String = APPLICATION_NO + applicationNo: String = APPLICATION_NO, + senderMemberId: Long = SENDER_MEMBER_ID, + recipientMemberId: Long = RECIPIENT_MEMBER_ID, + canUsageId: Long = CAN_USAGE_ID ): Pair { val gift = giftRepository.save( Gift( applicationNo = applicationNo, - senderMemberId = SENDER_MEMBER_ID, - recipientMemberId = RECIPIENT_MEMBER_ID, + senderMemberId = senderMemberId, + recipientMemberId = recipientMemberId, status = status, sizeCode = GiftSize.SMALL, categoryId = CATEGORY_ID, categoryNameSnapshot = "인형", salePriceCan = 80, - canUsageId = CAN_USAGE_ID, + canUsageId = canUsageId, senderTermsAgreed = true, senderPrivacyAgreed = true, senderTermsAgreedAt = APPLIED_AT, @@ -341,11 +909,52 @@ class GiftSchedulerTest @Autowired constructor( } private companion object { + @JvmStatic + fun scheduledEntryPoints() = listOf( + ScheduledEntryPoint( + "runExpiredRecipientAddressGiftExpiration", + "markExpiredRecipientAddressGiftsUndeliverable", + "0 10 * * * *", + "lock:gift-expired-recipient-address-undeliverable", + GiftScheduler::runExpiredRecipientAddressGiftExpiration + ), + ScheduledEntryPoint( + "runDeliveredGiftPersonalDataErasure", + "eraseDeliveredGiftPersonalData", + "0 20 * * * *", + "lock:gift-delivered-personal-data-erasure", + GiftScheduler::runDeliveredGiftPersonalDataErasure + ), + ScheduledEntryPoint( + "runDeadlineReminderGifts", + "sendDeadlineReminderGifts", + "0 40 * * * *", + "lock:gift-deadline-reminders", + GiftScheduler::runDeadlineReminderGifts + ) + ) + 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) + val ERASURE_NOW: LocalDateTime = LocalDateTime.of(2026, 10, 23, 12, 0) + val ERASURE_CUTOFF: LocalDateTime = LocalDateTime.of(2026, 10, 16, 12, 0) + val PERSONAL_DATA_FIELDS = listOf( + GiftDelivery::senderName, + GiftDelivery::senderPhoneNumber, + GiftDelivery::senderZipCode, + GiftDelivery::senderAddress, + GiftDelivery::senderAddressDetail, + GiftDelivery::recipientName, + GiftDelivery::recipientPhoneNumber, + GiftDelivery::recipientZipCode, + GiftDelivery::recipientAddress, + GiftDelivery::recipientAddressDetail, + GiftDelivery::courierCompanyName, + GiftDelivery::trackingNumber + ) } }