feat(gift): 배송 개인정보 정리 스케줄과 동시 변경 보호를 추가한다

This commit is contained in:
2026-10-26 08:44:59 +09:00
parent f46a6c4c7c
commit 8b2ac38b83
8 changed files with 1360 additions and 66 deletions
@@ -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<GiftDelivery, Long> {
interface GiftDeliveryRepository : JpaRepository<GiftDelivery, Long>, GiftDeliveryLockRepository {
fun findByGiftId(giftId: Long): GiftDelivery?
fun findByGiftIdIn(giftIds: Collection<Long>): List<GiftDelivery>
}
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) }
}
@@ -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<Gift, Long> {
interface GiftRepository : JpaRepository<Gift, Long>, 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<Gift>
fun findByRecipientMemberIdAndStatusIn(
@@ -67,6 +65,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
pageable: Pageable
): Page<Gift>
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
select g
@@ -78,9 +77,9 @@ interface GiftRepository : JpaRepository<Gift, Long> {
where d.giftId = g.id
and d.trackingDeadlineAt < :now
)
order by g.id
"""
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
@Query(
@@ -105,6 +104,22 @@ interface GiftRepository : JpaRepository<Gift, Long> {
@Param("now") now: LocalDateTime
): List<Gift>
@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<Gift>
@Query(
"""
select g
@@ -120,7 +135,6 @@ interface GiftRepository : JpaRepository<Gift, Long> {
)
"""
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
@Param("now") now: LocalDateTime,
@Param("reminderUntil") reminderUntil: LocalDateTime
@@ -151,3 +165,17 @@ interface GiftRepository : JpaRepository<Gift, Long> {
@Param("reminderUntil") reminderUntil: LocalDateTime
): List<Gift>
}
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) }
}
@@ -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 = "배송지 미입력 기한 초과"
}
}
@@ -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
@@ -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