feat(gift): 카테고리별 신청번호를 발급한다

This commit is contained in:
2026-09-30 17:38:24 +09:00
parent f3f46c413c
commit 2bda30e7e2
4 changed files with 81 additions and 17 deletions
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
@@ -18,15 +19,23 @@ class GiftApplicationNoGenerator(
) { ) {
private val transactionTemplate = TransactionTemplate(transactionManager) private val transactionTemplate = TransactionTemplate(transactionManager)
fun generate(now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String { fun generate(category: GiftCategory, now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String {
val categoryId = category.id ?: throw SodaException(messageKey = "common.error.invalid_request")
val sequenceDate = now.format(DATE_FORMATTER) val sequenceDate = now.format(DATE_FORMATTER)
for (attempt in 1..MAX_RETRY_COUNT) { for (attempt in 1..MAX_RETRY_COUNT) {
try { try {
return transactionTemplate.execute { return transactionTemplate.execute {
val sequence = repository.findBySequenceDateForUpdate(sequenceDate) val sequence = repository.findByCategoryIdAndSequenceDateForUpdate(categoryId, sequenceDate)
?: repository.saveAndFlush(GiftApplicationNoSequence(sequenceDate = sequenceDate, lastSequence = 0)) ?: repository.saveAndFlush(
GiftApplicationNoSequence(
categoryId = categoryId,
sequenceDate = sequenceDate,
lastSequence = 0
)
)
sequence.lastSequence += 1 sequence.lastSequence += 1
"G$sequenceDate${sequence.lastSequence.toString().padStart(6, '0')}" "${category.receiptCode}-${category.classificationNumber}$sequenceDate" +
sequence.lastSequence.toString().padStart(4, '0')
} ?: throw SodaException(messageKey = "common.error.invalid_request") } ?: throw SodaException(messageKey = "common.error.invalid_request")
} catch (_: DataIntegrityViolationException) { } catch (_: DataIntegrityViolationException) {
if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed") if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed")
@@ -37,7 +46,7 @@ class GiftApplicationNoGenerator(
companion object { companion object {
private val SEOUL_ZONE = ZoneId.of("Asia/Seoul") private val SEOUL_ZONE = ZoneId.of("Asia/Seoul")
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyMMdd")
private const val MAX_RETRY_COUNT = 3 private const val MAX_RETRY_COUNT = 3
} }
} }
@@ -126,7 +126,7 @@ class GiftCommandService(
val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest() val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest()
val appliedAt = now() val appliedAt = now()
val trackingDeadlineAt = appliedAt.plusDays(3) val trackingDeadlineAt = appliedAt.plusDays(3)
val applicationNo = applicationNoGenerator.generate(appliedAt) val applicationNo = applicationNoGenerator.generate(category, appliedAt)
val canUsageId = canPaymentService.spendGiftCan( val canUsageId = canPaymentService.spendGiftCan(
senderMemberId, senderMemberId,
sizePrice.salePriceCan, sizePrice.salePriceCan,
@@ -454,7 +454,15 @@ class GiftCommandServiceTest {
val giftPushService = Mockito.mock(GiftPushService::class.java) val giftPushService = Mockito.mock(GiftPushService::class.java)
val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID } val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID }
val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
val category = GiftCategory("DOLL", "인형", false, true).apply { id = CATEGORY_ID } val category = GiftCategory(
classificationNumber = "100",
categoryCode = "DOLL",
name = "인형",
receiptCode = "A",
representativeItem = "피규어",
requiresDamageWaiver = false,
isActive = true
).apply { id = CATEGORY_ID }
val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true) val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true)
lateinit var savedGift: Gift lateinit var savedGift: Gift
lateinit var savedDelivery: GiftDelivery lateinit var savedDelivery: GiftDelivery
@@ -486,7 +494,7 @@ class GiftCommandServiceTest {
setCreatorExists(true) setCreatorExists(true)
Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category)) Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category))
Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice) Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice)
Mockito.`when`(applicationNoGenerator.generate(NOW)).thenReturn(APPLICATION_NO) Mockito.`when`(applicationNoGenerator.generate(category, NOW)).thenReturn(APPLICATION_NO)
Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID) Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID)
Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation -> Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation ->
invocation.getArgument<Gift>(0).apply { invocation.getArgument<Gift>(0).apply {
@@ -1,7 +1,10 @@
package kr.co.vividnext.sodalive.v2.gift.application package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -24,24 +27,56 @@ import java.util.concurrent.Executors
@DisplayName("선물 신청번호 채번 테스트") @DisplayName("선물 신청번호 채번 테스트")
class GiftApplicationNoGeneratorTest @Autowired constructor( class GiftApplicationNoGeneratorTest @Autowired constructor(
private val repository: GiftApplicationNoSequenceRepository, private val repository: GiftApplicationNoSequenceRepository,
private val categoryRepository: GiftCategoryRepository,
private val transactionManager: PlatformTransactionManager private val transactionManager: PlatformTransactionManager
) { ) {
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
repository.deleteAll() repository.deleteAll()
categoryRepository.deleteAll()
} }
@Test @Test
@DisplayName("같은 날짜 신청번호를 순서대로 발급한다") @DisplayName("같은 카테고리와 날짜 신청번호를 순서대로 발급한다")
fun shouldGenerateSequentialApplicationNoByDate() { fun shouldGenerateSequentialApplicationNoByCategoryAndDate() {
val generator = GiftApplicationNoGenerator(repository, transactionManager) val generator = GiftApplicationNoGenerator(repository, transactionManager)
val now = LocalDateTime.of(2026, 9, 29, 12, 0) val now = LocalDateTime.of(2026, 9, 29, 12, 0)
val category = saveCategory("100", "DOLL", "A")
val first = generator.generate(now) val first = generator.generate(category, now)
val second = generator.generate(now) val second = generator.generate(category, now)
assertEquals("G20260929000001", first) assertEquals("A-1002609290001", first)
assertEquals("G20260929000002", second) assertEquals("A-1002609290002", second)
}
@Test
@DisplayName("카테고리와 날짜가 다르면 sequence를 따로 발급한다")
fun shouldGenerateApplicationNoByCategoryAndDateIndependently() {
val generator = GiftApplicationNoGenerator(repository, transactionManager)
val doll = saveCategory("100", "DOLL", "A")
val food = saveCategory("200", "FOOD", "C")
val dollFirst = generator.generate(doll, LocalDateTime.of(2026, 9, 29, 12, 0))
val foodFirst = generator.generate(food, LocalDateTime.of(2026, 9, 29, 12, 0))
val dollNextDay = generator.generate(doll, LocalDateTime.of(2026, 9, 30, 12, 0))
assertEquals("A-1002609290001", dollFirst)
assertEquals("C-2002609290001", foodFirst)
assertEquals("A-1002609300001", dollNextDay)
}
@Test
@DisplayName("sequence가 9999를 초과하면 자리수를 늘린다")
fun shouldExpandSequenceDigitsAfter9999() {
val generator = GiftApplicationNoGenerator(repository, transactionManager)
val category = saveCategory("100", "DOLL", "A")
val now = LocalDateTime.of(2026, 9, 30, 12, 0)
repository.save(GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260930", lastSequence = 9999))
val applicationNo = generator.generate(category, now)
assertEquals("A-10026093010000", applicationNo)
} }
@Test @Test
@@ -49,12 +84,13 @@ class GiftApplicationNoGeneratorTest @Autowired constructor(
fun shouldGenerateUniqueApplicationNoConcurrently() { fun shouldGenerateUniqueApplicationNoConcurrently() {
val generator = GiftApplicationNoGenerator(repository, transactionManager) val generator = GiftApplicationNoGenerator(repository, transactionManager)
val now = LocalDateTime.of(2026, 9, 29, 12, 0) val now = LocalDateTime.of(2026, 9, 29, 12, 0)
val category = saveCategory("100", "DOLL", "A")
val executor = Executors.newFixedThreadPool(4) val executor = Executors.newFixedThreadPool(4)
val applicationNos = try { val applicationNos = try {
executor.invokeAll( executor.invokeAll(
(1..10).map { (1..10).map {
Callable { generator.generate(now) } Callable { generator.generate(category, now) }
} }
).map { it.get() } ).map { it.get() }
} finally { } finally {
@@ -62,7 +98,18 @@ class GiftApplicationNoGeneratorTest @Autowired constructor(
} }
assertEquals(10, applicationNos.toSet().size) assertEquals(10, applicationNos.toSet().size)
assertEquals("G20260929000001", applicationNos.minOrNull()) assertEquals("A-1002609290001", applicationNos.minOrNull())
assertEquals("G20260929000010", applicationNos.maxOrNull()) assertEquals("A-1002609290010", applicationNos.maxOrNull())
} }
private fun saveCategory(classificationNumber: String, categoryCode: String, receiptCode: String) =
categoryRepository.save(
GiftCategory(
classificationNumber = classificationNumber,
categoryCode = categoryCode,
name = "인형",
receiptCode = receiptCode,
representativeItem = "피규어"
)
)
} }