From 2bda30e7e2f6df60d5c9c9bc073d8d5172230e74 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:38:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=EC=8B=A0=EC=B2=AD=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9C=EA=B8=89=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/GiftApplicationNoGenerator.kt | 19 ++++-- .../v2/gift/application/GiftCommandService.kt | 2 +- .../v2/gift/GiftCommandServiceTest.kt | 12 +++- .../GiftApplicationNoGeneratorTest.kt | 65 ++++++++++++++++--- 4 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt index 450cc499..f060ad0d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.gift.application 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.GiftApplicationNoSequenceRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.PlatformTransactionManager @@ -18,15 +19,23 @@ class GiftApplicationNoGenerator( ) { 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) for (attempt in 1..MAX_RETRY_COUNT) { try { return transactionTemplate.execute { - val sequence = repository.findBySequenceDateForUpdate(sequenceDate) - ?: repository.saveAndFlush(GiftApplicationNoSequence(sequenceDate = sequenceDate, lastSequence = 0)) + val sequence = repository.findByCategoryIdAndSequenceDateForUpdate(categoryId, sequenceDate) + ?: repository.saveAndFlush( + GiftApplicationNoSequence( + categoryId = categoryId, + sequenceDate = sequenceDate, + lastSequence = 0 + ) + ) 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") } catch (_: DataIntegrityViolationException) { if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed") @@ -37,7 +46,7 @@ class GiftApplicationNoGenerator( companion object { 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 } } 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 348539ce..a22cd12c 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 @@ -126,7 +126,7 @@ class GiftCommandService( val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest() val appliedAt = now() val trackingDeadlineAt = appliedAt.plusDays(3) - val applicationNo = applicationNoGenerator.generate(appliedAt) + val applicationNo = applicationNoGenerator.generate(category, appliedAt) val canUsageId = canPaymentService.spendGiftCan( senderMemberId, sizePrice.salePriceCan, 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 be038d71..4c9e5e17 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 @@ -454,7 +454,15 @@ class GiftCommandServiceTest { val giftPushService = Mockito.mock(GiftPushService::class.java) 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 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) lateinit var savedGift: Gift lateinit var savedDelivery: GiftDelivery @@ -486,7 +494,7 @@ class GiftCommandServiceTest { setCreatorExists(true) Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category)) 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`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation -> invocation.getArgument(0).apply { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt index 64996948..6d62f1a6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt @@ -1,7 +1,10 @@ package kr.co.vividnext.sodalive.v2.gift.application 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.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -24,24 +27,56 @@ import java.util.concurrent.Executors @DisplayName("선물 신청번호 채번 테스트") class GiftApplicationNoGeneratorTest @Autowired constructor( private val repository: GiftApplicationNoSequenceRepository, + private val categoryRepository: GiftCategoryRepository, private val transactionManager: PlatformTransactionManager ) { @BeforeEach fun setUp() { repository.deleteAll() + categoryRepository.deleteAll() } @Test - @DisplayName("같은 날짜 신청번호를 순서대로 발급한다") - fun shouldGenerateSequentialApplicationNoByDate() { + @DisplayName("같은 카테고리와 날짜 신청번호를 순서대로 발급한다") + fun shouldGenerateSequentialApplicationNoByCategoryAndDate() { val generator = GiftApplicationNoGenerator(repository, transactionManager) val now = LocalDateTime.of(2026, 9, 29, 12, 0) + val category = saveCategory("100", "DOLL", "A") - val first = generator.generate(now) - val second = generator.generate(now) + val first = generator.generate(category, now) + val second = generator.generate(category, now) - assertEquals("G20260929000001", first) - assertEquals("G20260929000002", second) + assertEquals("A-1002609290001", first) + 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 @@ -49,12 +84,13 @@ class GiftApplicationNoGeneratorTest @Autowired constructor( fun shouldGenerateUniqueApplicationNoConcurrently() { val generator = GiftApplicationNoGenerator(repository, transactionManager) val now = LocalDateTime.of(2026, 9, 29, 12, 0) + val category = saveCategory("100", "DOLL", "A") val executor = Executors.newFixedThreadPool(4) val applicationNos = try { executor.invokeAll( (1..10).map { - Callable { generator.generate(now) } + Callable { generator.generate(category, now) } } ).map { it.get() } } finally { @@ -62,7 +98,18 @@ class GiftApplicationNoGeneratorTest @Autowired constructor( } assertEquals(10, applicationNos.toSet().size) - assertEquals("G20260929000001", applicationNos.minOrNull()) - assertEquals("G20260929000010", applicationNos.maxOrNull()) + assertEquals("A-1002609290001", applicationNos.minOrNull()) + 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 = "피규어" + ) + ) }