From 6a9e76c13d80deb836723c9d8cf41f3a904c0a3c Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:22:57 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(gift):=20=EC=A0=91=EC=88=98=20=EC=84=A0?= =?UTF-8?q?=EB=AC=BC=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/adapter/out/persistence/GiftRepository.kt | 8 ++++++++ .../sodalive/v2/gift/application/GiftCommandService.kt | 4 ++-- .../vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) 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 376fafde..8048ff04 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 @@ -4,13 +4,19 @@ import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable 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 java.time.LocalDateTime +import javax.persistence.LockModeType interface GiftRepository : JpaRepository { 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( @@ -74,6 +80,7 @@ interface GiftRepository : JpaRepository { ) """ ) + @Lock(LockModeType.PESSIMISTIC_WRITE) fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List @Query( @@ -113,6 +120,7 @@ interface GiftRepository : JpaRepository { ) """ ) + @Lock(LockModeType.PESSIMISTIC_WRITE) fun findReceivedGiftsWithTrackingDeadlineReminderDue( @Param("now") now: LocalDateTime, @Param("reminderUntil") reminderUntil: LocalDateTime 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 a22cd12c..86f1c82e 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 @@ -207,7 +207,7 @@ class GiftCommandService( @Transactional fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult { val senderMemberId = member.id ?: throw invalidRequest() - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() val canceledAt = now() @@ -231,7 +231,7 @@ class GiftCommandService( command: GiftTrackingRegistrationCommand ): GiftTrackingRegistrationResult { val senderMemberId = member.id ?: throw invalidRequest() - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + 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() 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 4c9e5e17..a7cf2efb 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 @@ -546,7 +546,7 @@ class GiftCommandServiceTest { senderAddress = "서울시 강남구", trackingDeadlineAt = NOW.plusDays(3) ) - Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift) + Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(cancellationGift) Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery) } @@ -579,7 +579,7 @@ class GiftCommandServiceTest { senderAddress = "서울시 강남구", trackingDeadlineAt = NOW.plusDays(3) ) - Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift) + Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(trackingGift) Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( trackingDelivery.takeIf { deliveryExists } ) From 710b49c17540a88fc9f40ee542a7731f2311c3f2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:23:18 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=20=EC=B7=A8=EC=86=8C=EC=99=80=20=EB=B0=9C?= =?UTF-8?q?=EC=8B=A0=EC=9E=90=20=ED=99=98=EB=B6=88=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftAdminService.kt | 17 ++++++- .../gift/application/GiftAdminServiceTest.kt | 45 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) 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 a4ba308d..64e7eaa9 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 @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.gift.application +import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository @@ -73,7 +74,8 @@ class GiftAdminService( private val giftRepository: GiftRepository, private val giftDeliveryRepository: GiftDeliveryRepository, private val giftMailboxRepository: GiftMailboxRepository, - private val giftPushService: GiftPushService + private val giftPushService: GiftPushService, + private val canPaymentService: CanPaymentService ) { @Transactional(readOnly = true) fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult() @@ -150,6 +152,19 @@ class GiftAdminService( return sizePriceRepository.save(sizePrice).toResult() } + @Transactional + 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() + + gift.status = GiftStatus.CANCELED + delivery.canceledAt = now + canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + @Transactional fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt index 86610855..447c2631 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.gift.application +import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift @@ -17,6 +18,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired @@ -40,13 +42,15 @@ class GiftAdminServiceTest @Autowired constructor( private val giftMailboxRepository: GiftMailboxRepository ) { private val giftPushService = Mockito.mock(GiftPushService::class.java) + private val canPaymentService = Mockito.mock(CanPaymentService::class.java) private val service = GiftAdminService( categoryRepository, sizePriceRepository, giftRepository, giftDeliveryRepository, giftMailboxRepository, - giftPushService + giftPushService, + canPaymentService ) @Test @@ -312,6 +316,45 @@ class GiftAdminServiceTest @Autowired constructor( Mockito.verifyNoInteractions(giftPushService) } + @Test + @DisplayName("운영자는 접수 선물을 취소하고 원 발신자의 결제 내역을 한 번 환불한다") + fun shouldCancelReceivedGiftWithOriginalSenderRefund() { + val (gift, delivery) = saveGift(GiftStatus.RECEIVED) + + val result = service.cancelGift(APPLICATION_NO, NOW) + + assertEquals(GiftOperationStatusResult(APPLICATION_NO, GiftStatus.CANCELED, "신청 취소", NOW), result) + assertEquals(GiftStatus.CANCELED, gift.status) + assertEquals(NOW, delivery.canceledAt) + assertThrows(SodaException::class.java) { service.cancelGift(APPLICATION_NO, NOW.plusSeconds(1)) } + assertEquals(NOW, delivery.canceledAt) + Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, 10L) + Mockito.verifyNoInteractions(giftPushService) + } + + @ParameterizedTest + @EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE) + @DisplayName("운영자는 접수가 아닌 모든 상태의 선물을 취소할 수 없다") + fun shouldRejectCancellationForEveryNonReceivedStatus(status: GiftStatus) { + val (gift, delivery) = saveGift(status) + + val exception = assertThrows(SodaException::class.java) { service.cancelGift(APPLICATION_NO, NOW) } + + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(status, gift.status) + assertEquals(null, delivery.canceledAt) + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @Test + @DisplayName("운영자는 없는 선물을 취소할 수 없다") + fun shouldRejectCancellationForMissingGift() { + val exception = assertThrows(SodaException::class.java) { service.cancelGift("missing", NOW) } + + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + private fun populateRecipientInformation(delivery: GiftDelivery) { delivery.recipientName = "김소다" delivery.recipientPhoneNumber = "01098765432" From c6f9142c69abb8d2fb8396f852a41eb50cb58c20 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:24:01 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EC=B7=A8=EC=86=8C=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EB=85=B8=EC=B6=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/application/GiftAdminQueryService.kt | 3 +- .../application/GiftAdminQueryServiceTest.kt | 34 ++++++++----------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt index f9996146..79e9c34d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt @@ -157,6 +157,7 @@ class GiftAdminQueryService( ) private fun GiftStatus.availableActions(delivery: GiftDelivery) = when (this) { + GiftStatus.RECEIVED -> listOf("CANCEL") GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE") GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE") GiftStatus.INSPECTION_COMPLETED -> if (delivery.hasCompleteRecipientInformation()) { @@ -164,7 +165,7 @@ class GiftAdminQueryService( } else { listOf("MARK_UNDELIVERABLE") } - GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList() + GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList() } private fun GiftDelivery.recipientAddressResult(): String { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt index 1af001b9..ef9568b0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt @@ -125,7 +125,7 @@ class GiftAdminQueryServiceTest @Autowired constructor( } @Test - @DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다") + @DisplayName("관리자 선물 목록과 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다") fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() { val sender = saveMember("fan-alpha") val recipient = saveMember("creator-alpha") @@ -136,26 +136,20 @@ class GiftAdminQueryServiceTest @Autowired constructor( val service = service() - assertEquals(emptyList(), service.getGiftDetail("A-100260930100").availableActions) - assertEquals( - listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), - service.getGiftDetail("A-100260930101").availableActions + val expectedActions = mapOf( + GiftStatus.RECEIVED to listOf("CANCEL"), + GiftStatus.TRACKING_REGISTERED to listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), + GiftStatus.ARRIVED_AT_MAILBOX to listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), + GiftStatus.INSPECTION_COMPLETED to listOf("MARK_UNDELIVERABLE"), + GiftStatus.DELIVERED to emptyList(), + GiftStatus.UNDELIVERABLE to emptyList(), + GiftStatus.CANCELED to emptyList() ) - assertEquals( - listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), - service.getGiftDetail("A-100260930102").availableActions - ) - assertEquals( - listOf("MARK_UNDELIVERABLE"), - service.getGiftDetail("A-100260930103").availableActions - ) - assertEquals( - listOf("MARK_UNDELIVERABLE"), - service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions - ) - assertEquals(emptyList(), service.getGiftDetail("A-100260930104").availableActions) - assertEquals(emptyList(), service.getGiftDetail("A-100260930105").availableActions) - assertEquals(emptyList(), service.getGiftDetail("A-100260930106").availableActions) + expectedActions.forEach { (status, actions) -> + val item = service.getGifts(status, null, null, 0, 20).items.single() + assertEquals(actions, service.getGiftDetail(item.applicationNo).availableActions, status.name) + assertEquals(actions, item.availableActions, status.name) + } val exception = assertThrows(SodaException::class.java) { service.getGiftDetail("UNKNOWN") } From 30a2f7e37b460439a237d763e49c6ea49388a2b0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:24:47 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=20API=EC=99=80=20=ED=99=98=EB=B6=88=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/AdminGiftController.kt | 5 + .../adapter/in/web/AdminGiftControllerTest.kt | 541 +++++++++++++++++- 2 files changed, 545 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt index be9c88d1..bc710a34 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt @@ -83,6 +83,11 @@ class AdminGiftController( @RequestBody request: AdminGiftSizePriceRequest ) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand()))) + @PostMapping("/gifts/{applicationNo}/cancel") + fun cancelGift(@PathVariable applicationNo: String) = ApiResponse.ok( + AdminGiftOperationStatusResponse.from(service.cancelGift(applicationNo)) + ) + @PostMapping("/gifts/{applicationNo}/arrive-mailbox") fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok( AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo)) 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 98064c95..ae27c462 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 @@ -1,7 +1,26 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory @@ -11,16 +30,42 @@ 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.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.GiftCommandService +import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +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.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +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.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration @@ -29,11 +74,24 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delet import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +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.support.TransactionTemplate +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicInteger +import javax.persistence.EntityManager @SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = [ "cloud.aws.cloud-front.host=https://cdn.test", "spring.datasource.url=jdbc:h2:mem:gift-admin-controller;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" + @@ -50,15 +108,41 @@ class AdminGiftControllerTest @Autowired constructor( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val giftDeliveryRepository: GiftDeliveryRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val chargeRepository: ChargeRepository, + private val useCanRepository: UseCanRepository, + private val calculateRepository: UseCanCalculateRepository, + private val commandService: GiftCommandService, + private val scheduler: GiftScheduler, + private val transactionManager: PlatformTransactionManager, + private val entityManager: EntityManager, + private val objectMapper: ObjectMapper, + private val restTemplate: TestRestTemplate, + private val tokenProvider: TokenProvider ) { + @LocalServerPort + private var port: Int = 0 + + @SpyBean + private lateinit var canPaymentService: CanPaymentService + + @MockBean + private lateinit var giftPushService: GiftPushService + @BeforeEach fun deleteGiftFixtures() { + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) giftDeliveryRepository.deleteAll() giftRepository.deleteAll() categoryRepository.deleteAll() } + @AfterEach + fun clearRequestContext() { + (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.requestCompleted() + RequestContextHolder.resetRequestAttributes() + } + @Test @DisplayName("관리자는 받을 주소를 조회하고 저장한다") fun shouldManageGiftMailbox() { @@ -352,6 +436,448 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(false)) } + @Test + @DisplayName("발신자가 아닌 관리자는 접수 선물을 취소하고 원 결제액을 발신자 리워드캔으로 한 번만 환불한다") + fun shouldCancelReceivedGiftAndRefundOriginalSenderOnlyOnce() { + val fixture = saveFundedGift() + assertNotEquals(fixture.sender.id, fixture.admin.id) + assertRefundState(fixture, refunded = false) + + val response = mockMvc.perform(cancelRequest(fixture)) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("CANCELED")) + .andExpect(jsonPath("$.data.statusName").value("신청 취소")) + .andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) + .andReturn().response + val data = objectMapper.readTree(response.contentAsByteArray).path("data") + assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet()) + val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText()) + assertEquals(GiftStatus.CANCELED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status) + assertRefundState(fixture, refunded = true) + + mockMvc.perform(cancelRequest(fixture)) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) } + assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertRefundState(fixture, refunded = true) + Mockito.verifyNoInteractions(giftPushService) + } + + @Test + @DisplayName("실제 JWT HTTP에서 관리자만 접수 선물의 취소 액션을 조회하고 한 번 취소·환불한다") + fun shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions() { + val fixture = saveFundedGift() + val listPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO" + val detailPath = "/api/v2/admin/gifts/$APPLICATION_NO" + val cancelPath = "$detailPath/cancel" + val endpoints = listOf(HttpMethod.GET to listPath, HttpMethod.GET to detailPath, HttpMethod.POST to cancelPath) + assertNotEquals(fixture.sender.id, fixture.admin.id) + assertRefundState(fixture, refunded = false) + + endpoints.forEach { (method, path) -> + giftHttp(method, path, expectedStatus = HttpStatus.UNAUTHORIZED) + } + MemberRole.values().filter { it != MemberRole.ADMIN }.forEach { role -> + val member = if (role == MemberRole.USER) { + fixture.sender + } else { + memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "nonadmin", role = role) + ) + } + val token = tokenFor(member) + endpoints.forEach { (method, path) -> + val denied = giftHttp(method, path, token) + assertFalse(denied.path("success").asBoolean()) + assertEquals("You do not have permission.", denied.path("message").asText()) + assertTrue(denied.path("data").isNull) + } + } + assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status) + assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertRefundState(fixture, refunded = false) + + val adminToken = tokenFor(fixture.admin) + val beforeList = giftHttp(HttpMethod.GET, listPath, adminToken) + val beforeDetail = giftHttp(HttpMethod.GET, detailPath, adminToken) + listOf(beforeList, beforeDetail).forEach { assertTrue(it.path("success").asBoolean()) } + assertEquals(1, beforeList.path("data").path("totalCount").asInt()) + listOf(beforeList.path("data").path("items").single(), beforeDetail.path("data")).forEach { data -> + assertEquals(APPLICATION_NO, data.path("applicationNo").asText()) + assertEquals("RECEIVED", data.path("status").asText()) + assertEquals(listOf("CANCEL"), data.path("availableActions").map { it.asText() }) + } + + val canceled = giftHttp(HttpMethod.POST, cancelPath, adminToken) + assertTrue(canceled.path("success").asBoolean()) + val data = canceled.path("data") + assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet()) + assertEquals(APPLICATION_NO, data.path("applicationNo").asText()) + assertEquals("CANCELED", data.path("status").asText()) + assertEquals("신청 취소", data.path("statusName").asText()) + assertTrue(data.path("occurredAt").asText().endsWith("Z")) + val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText()) + assertRefundState(fixture, refunded = true) + + val repeated = giftHttp(HttpMethod.POST, cancelPath, adminToken) + assertFalse(repeated.path("success").asBoolean()) + assertEquals("Invalid request.", repeated.path("message").asText()) + assertTrue(repeated.path("data").isNull) + assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertRefundState(fixture, refunded = true) + + val afterList = giftHttp(HttpMethod.GET, listPath, adminToken) + val afterDetail = giftHttp(HttpMethod.GET, detailPath, adminToken) + listOf(afterList, afterDetail).forEach { assertTrue(it.path("success").asBoolean()) } + assertEquals(1, afterList.path("data").path("totalCount").asInt()) + listOf(afterList.path("data").path("items").single(), afterDetail.path("data")).forEach { item -> + assertEquals(APPLICATION_NO, item.path("applicationNo").asText()) + assertEquals("CANCELED", item.path("status").asText()) + assertEquals(emptyList(), item.path("availableActions").map { it.asText() }) + } + Mockito.verifyNoInteractions(giftPushService) + } + + @ParameterizedTest + @EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE) + @DisplayName("관리자 취소는 모든 비접수 상태를 공통 오류로 거부하고 DB를 변경하지 않는다") + fun shouldRejectCancellationForEveryNonReceivedStatus(giftStatus: GiftStatus) { + val fixture = saveFundedGift() + TransactionTemplate(transactionManager).executeWithoutResult { + requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO)).status = giftStatus + } + + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) } + + val gift = requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO)) + assertEquals(giftStatus, gift.status) + assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))?.canceledAt) + assertRefundState(fixture, refunded = false) + } + + @Test + @DisplayName("관리자 취소는 존재하지 않는 신청번호를 공통 오류로 거부한다") + fun shouldRejectCancellationForMissingGift() { + mockMvc.perform(post("/api/v2/admin/gifts/missing/cancel").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) } + } + + @Test + @DisplayName("미인증 사용자와 일반 사용자 및 크리에이터는 관리자 취소를 실행할 수 없다") + fun shouldDenyCancellationWithoutAdminRole() { + val fixture = saveFundedGift() + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel")) + .andDo(print()) + .andExpect(status().isUnauthorized) + listOf("USER", "CREATOR").forEach { role -> + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("other").roles(role))) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect { assertTrue(it.resolvedException is org.springframework.security.access.AccessDeniedException) } + } + assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status) + assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertRefundState(fixture, refunded = false) + } + + @Test + @DisplayName("실제 환불을 DB에 flush한 뒤 예외가 발생하면 상태와 시각 및 전체 환불 DB 변경이 롤백된다") + fun shouldRollbackCancellationAndRealRefundWhenRefundFails() { + val fixture = saveFundedGift() + Mockito.doAnswer { invocation -> + invocation.callRealMethod() + entityManager.flush() + assertEquals(80, memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow().pgRewardCan) + throw SodaException(messageKey = "common.error.invalid_request") + }.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId) + + mockMvc.perform(cancelRequest(fixture)) + .andDo(print()) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status) + assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt) + assertRefundState(fixture, refunded = false) + Mockito.verifyNoInteractions(giftPushService) + } + + @ParameterizedTest + @MethodSource("cancellationRaces") + @DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다") + fun shouldSerializeCancellationAgainstReceivedMutations(operations: Pair) { + val fixture = saveFundedGift() + val winnerPaused = CountDownLatch(1) + val releaseWinner = CountDownLatch(1) + val contenderStarted = CountDownLatch(1) + val refundCalls = AtomicInteger() + Mockito.doAnswer { invocation -> + if (refundCalls.incrementAndGet() == 1 && operations.first != RaceOperation.TRACKING && + operations.first != RaceOperation.REMINDER + ) { + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + } + invocation.callRealMethod() + }.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId) + if (operations.first == RaceOperation.TRACKING) { + Mockito.doAnswer { + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + null + }.`when`(giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, fixture.gift.recipientMemberId) + } + if (operations.first == RaceOperation.REMINDER) { + Mockito.doAnswer { + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + null + }.`when`(giftPushService).sendTrackingDeadlineReminder(APPLICATION_NO, requireNotNull(fixture.sender.id)) + } + val executor = Executors.newFixedThreadPool(2) + val winner = executor.submit { executeRaceOperation(operations.first, fixture) } + var contender: java.util.concurrent.Future? = null + try { + assertTrue(winnerPaused.await(10, TimeUnit.SECONDS)) + contender = executor.submit { + contenderStarted.countDown() + executeRaceOperation(operations.second, fixture) + } + assertTrue(contenderStarted.await(10, TimeUnit.SECONDS)) + val waitingContender = contender + assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) } + releaseWinner.countDown() + val expectedWinner = when (operations.first) { + RaceOperation.TRACKING -> "TRACKING_REGISTERED" + RaceOperation.REMINDER -> "REMINDED" + else -> "CANCELED" + } + val expectedContender = when { + operations.second in listOf(RaceOperation.AUTO, RaceOperation.REMINDER) -> "SKIPPED" + operations.first == RaceOperation.REMINDER -> "CANCELED" + else -> "REJECTED" + } + assertEquals(expectedWinner, winner.get(10, TimeUnit.SECONDS)) + assertEquals(expectedContender, contender.get(10, TimeUnit.SECONDS)) + val trackingWon = operations.first == RaceOperation.TRACKING + assertEquals( + if (trackingWon) GiftStatus.TRACKING_REGISTERED else GiftStatus.CANCELED, + giftRepository.findByApplicationNo(APPLICATION_NO)?.status + ) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))) + assertEquals(!trackingWon, delivery.canceledAt != null) + assertEquals(trackingWon, delivery.trackingRegisteredAt != null) + assertEquals(operations.first == RaceOperation.REMINDER, delivery.trackingDeadlineReminderSentAt != null) + assertRefundState(fixture, refunded = !trackingWon) + Mockito.verify(giftPushService, Mockito.times(if (operations.first == RaceOperation.AUTO) 1 else 0)) + .sendAutoCanceledToSender(APPLICATION_NO, requireNotNull(fixture.sender.id)) + } 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 executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String { + val requestAttributes = ServletRequestAttributes(MockHttpServletRequest()) + RequestContextHolder.setRequestAttributes(requestAttributes) + return try { + when (operation) { + RaceOperation.ADMIN -> { + val result = mockMvc.perform(cancelRequest(fixture)).andExpect(status().isOk).andReturn() + if (objectMapper.readTree(result.response.contentAsByteArray).path("success").asBoolean()) { + "CANCELED" + } else { + assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey) + "REJECTED" + } + } + RaceOperation.USER -> commandService.cancelGift(fixture.sender, APPLICATION_NO).status.name + RaceOperation.AUTO -> if (scheduler.cancelExpiredTrackingRegistrationGifts(NOW) == 1) "CANCELED" else "SKIPPED" + RaceOperation.REMINDER -> { + if (scheduler.sendDeadlineReminderGifts(NOW.minusHours(1)) == 1) "REMINDED" else "SKIPPED" + } + RaceOperation.TRACKING -> commandService.registerTracking( + fixture.sender, + APPLICATION_NO, + GiftTrackingRegistrationCommand("CJ대한통운", "1234567890") + ).status.name + } + } catch (exception: SodaException) { + assertEquals("common.error.invalid_request", exception.messageKey) + "REJECTED" + } finally { + requestAttributes.requestCompleted() + RequestContextHolder.resetRequestAttributes() + } + } + + private fun cancelRequest(fixture: FundedGift) = post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with( + authentication( + UsernamePasswordAuthenticationToken( + MemberAdapter(fixture.admin), + "test", + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + ) + ) + + private fun tokenFor(member: Member): String = tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))), + requireNotNull(member.id) + ) + + private fun giftHttp( + method: HttpMethod, + path: String, + token: String? = null, + expectedStatus: HttpStatus = HttpStatus.OK + ): JsonNode { + val headers = HttpHeaders().apply { + token?.let { setBearerAuth(it) } + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + } + val response = restTemplate.exchange( + "http://127.0.0.1:$port$path", + method, + HttpEntity(headers), + String::class.java + ) + val body = objectMapper.readTree(response.body.orEmpty().ifBlank { "{}" }) + val evidence = body.deepCopy() + val data = evidence.path("data") + if (method == HttpMethod.GET && data.isObject) { + (data as ObjectNode).retain("applicationNo", "status", "availableActions", "totalCount", "items") + data.path("items").forEach { (it as ObjectNode).retain("applicationNo", "status", "availableActions") } + } + println("GIFT_HTTP $method $path status=${response.statusCodeValue} body=$evidence") + assertEquals(expectedStatus, response.statusCode) + return body + } + + private fun saveFundedGift(): FundedGift = requireNotNull( + TransactionTemplate(transactionManager).execute { + val sender = memberRepository.save( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply { + pgChargeCan = 100 + pgRewardCan = 20 + } + ) + val admin = memberRepository.save( + Member( + email = "${UUID.randomUUID()}@test.com", + password = "password", + nickname = "cancel-admin", + role = MemberRole.ADMIN + ).apply { + pgChargeCan = 50 + pgRewardCan = 7 + } + ) + val funding = chargeRepository.save( + Charge(100, 20).apply { + member = sender + payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG) + } + ) + val usageId = canPaymentService.spendGiftCan(requireNotNull(sender.id), 80, "web") + val category = categoryRepository.save( + GiftCategory( + classificationNumber = "100", + categoryCode = "FUNDED", + name = "인형", + receiptCode = "A", + representativeItem = "피규어" + ) + ) + val gift = giftRepository.save( + Gift( + applicationNo = APPLICATION_NO, + senderMemberId = requireNotNull(sender.id), + recipientMemberId = requireNotNull(admin.id), + status = GiftStatus.RECEIVED, + sizeCode = GiftSize.SMALL, + categoryId = requireNotNull(category.id), + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = usageId, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW.minusDays(4), + senderPrivacyAgreedAt = NOW.minusDays(4) + ) + ) + giftDeliveryRepository.save( + GiftDelivery( + giftId = requireNotNull(gift.id), + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.minusSeconds(1) + ) + ) + FundedGift(gift, sender, admin, requireNotNull(funding.id)) + } + ) + + private fun assertRefundState(fixture: FundedGift, refunded: Boolean) { + TransactionTemplate(transactionManager).executeWithoutResult { + val sender = memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow() + val admin = memberRepository.findById(requireNotNull(fixture.admin.id)).orElseThrow() + assertEquals(40, sender.pgChargeCan) + assertEquals(if (refunded) 80 else 0, sender.pgRewardCan) + assertEquals(50, admin.pgChargeCan) + assertEquals(7, admin.pgRewardCan) + val usage = useCanRepository.findById(fixture.gift.canUsageId).orElseThrow() + assertEquals(CanUsage.GIFT, usage.canUsage) + assertEquals(60, usage.can) + assertEquals(20, usage.rewardCan) + assertEquals(fixture.sender.id, usage.member?.id) + assertEquals(refunded, usage.isRefund) + val calculations = calculateRepository.findByUseCanIdAndStatus( + fixture.gift.canUsageId, + if (refunded) UseCanCalculateStatus.REFUND else UseCanCalculateStatus.RECEIVED + ) + assertEquals(1, calculations.size) + assertEquals(80, calculations.sumOf { it.can }) + val refunds = chargeRepository.findAll().filter { it.useCan?.id == fixture.gift.canUsageId } + assertEquals(if (refunded) 1 else 0, refunds.size) + assertEquals(if (refunded) 80 else 0, refunds.sumOf { it.rewardCan }) + refunds.forEach { + assertEquals(ChargeStatus.REFUND_CHARGE, it.status) + assertEquals(0, it.chargeCan) + assertEquals(fixture.sender.id, it.member?.id) + assertEquals(PaymentGateway.PG, it.payment?.paymentGateway) + assertEquals(PaymentStatus.COMPLETE, it.payment?.status) + } + val funding = chargeRepository.findById(fixture.fundingId).orElseThrow() + assertEquals(40, funding.chargeCan) + assertEquals(0, funding.rewardCan) + } + } + + private data class FundedGift(val gift: Gift, val sender: Member, val admin: Member, val fundingId: Long) + + enum class RaceOperation { ADMIN, USER, AUTO, TRACKING, REMINDER } + private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) { val gift = giftRepository.save( Gift( @@ -445,6 +971,19 @@ class AdminGiftControllerTest @Autowired constructor( } private companion object { + @JvmStatic + fun cancellationRaces() = listOf( + RaceOperation.ADMIN to RaceOperation.ADMIN, + RaceOperation.ADMIN to RaceOperation.USER, + RaceOperation.USER to RaceOperation.ADMIN, + RaceOperation.ADMIN to RaceOperation.AUTO, + RaceOperation.AUTO to RaceOperation.ADMIN, + RaceOperation.ADMIN to RaceOperation.TRACKING, + RaceOperation.TRACKING to RaceOperation.ADMIN, + RaceOperation.ADMIN to RaceOperation.REMINDER, + RaceOperation.REMINDER to RaceOperation.ADMIN + ) + const val APPLICATION_NO = "G20260929000001" const val SENDER_MEMBER_ID = 1L const val RECIPIENT_MEMBER_ID = 2L From cd67112a8d2f629b3f9a57ddf6de5feadd308160 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:25:36 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EA=B3=BC=20=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260929_크리에이터_선물하기/plan-task.md | 182 +++++++++++++++++- docs/20260929_크리에이터_선물하기/prd.md | 53 ++++- 2 files changed, 225 insertions(+), 10 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 0800680d..0305810d 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -4,16 +4,16 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | 완료 | +| 상태 | Phase 14 완료, 전체 테스트 사용자 요청으로 제외 | | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | -| 현재 Phase | Phase 12 구현 완료 | -| 현재 활성 Goal | 완료 | +| 현재 Phase | Phase 14 관리자 선물 취소 | +| 현재 활성 Goal | 없음, `P14-GATE` 완료 | ## 목표 -팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적할 수 있게 한다. +팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적한다. Phase 14는 `RECEIVED` 선물의 ADMIN 취소·환불과 목록/상세 액션을 제공한다. ## 아키텍처 @@ -41,6 +41,7 @@ | 8 | 완료 | `1/1` | 완료 | 없음 | | 9 | 완료 | `3/3` | 완료 | 없음 | | 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 | +| 14 | 완료 | `3/3` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -52,6 +53,7 @@ - 선물 신청, 목록, 상세, 운송장 등록, 취소, 크리에이터 배송지 입력, 크리에이터 수령확인, 리뷰 작성 사용자 API - 관리자 카테고리 API, 관리자 사이즈 가격 API, 운영 상태변경 API +- Phase 14 관리자 신청 취소·환불과 목록/상세 `CANCEL` 액션, 별도 관리자 기능 추가 프롬프트. - 운송장 미등록 자동취소, 배송지 미입력 전달불가, 기한 24시간 전 안내 스케줄러 - 캔 사용내역 `선물하기` 추가와 선물 신청 내부 연결 - 팬/크리에이터 대상 푸시 이벤트와 단일 딥링크 `GIFT_DETAIL` + `applicationNo` @@ -1430,6 +1432,174 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 - [x] **GREEN:** 기존 `GiftDelivery` 정보를 재사용해 `recipientAddressRegistered`, `recipientAddressDeadlineAt`, `canceledAt`을 매핑했다. - [x] **검증:** focused 테스트와 `ktlintCheck`, `git diff --check`를 실행했다. +### Phase 14 문서 초안 — 2026-10-22 + +- 상태: 문서 초안 작성 완료, 최종 검증 대기 +- 무엇을: PRD, 구현 계획, 클라이언트 요약에 ADMIN 선물 취소 계약을 추가하고 기존 화면에 취소 기능만 추가하는 프롬프트를 별도 문서로 분리한다. 전체 화면 구현용 `admin-page-prompts.md`는 보존한다. +- 왜: 승인된 `RECEIVED` 전용 취소 범위를 구현과 분리해 먼저 문서화하기 위해서다. +- 어떻게: + - 문서 변경만 수행했다. 코드/테스트/설정/DDL은 수정하지 않았다. + - 초안 작성 시점에는 문서 검증을 완료하지 않았다. 최종 검증 결과는 후속 기록에 누적한다. +- 남은 항목: 최종 문서 검증, 사용자 구현 승인, 이후 구현 및 검증 +- 다음 행동: 문서 검증 후 P14-T1 체크를 갱신하고, 구현 승인은 별도로 받는다. + +## Phase 14: 관리자 선물 취소 + +**Phase 결과:** 관리자가 `RECEIVED` 선물만 취소하고 발신자에게 전액 리워드캔을 환불하며, 목록/상세에서 `CANCEL`을 선택할 수 있다. 서버 구현과 관련 검증을 완료했다. + +**선행조건:** Phase 13 완료, PRD `DEC-015` 및 `GIFT-016`~`GIFT-017` 확정. + +**2026-10-22 실행 승인:** 사용자의 "문서대로 진행" 요청으로 `P14-T2`부터 재개한다. 현재 서버 저장소에서 취소 API·환불·액션 응답을 구현하며 별도 프론트엔드 화면 구현은 포함하지 않는다. + +**Phase 완료 조건:** `P14-T1`~`P14-T3` 및 `P14-GATE` 완료. 전체 테스트는 후속 사용자 요청으로 제외하고 관련 회귀·실제 HTTP·리뷰·테스트 제외 빌드로 검증했다. + +### 문서 작업 + +#### Task 14.1 관리자 취소 계약 문서화 + +**Goal 실행 `P14-T1`:** 승인된 관리자 취소 범위와 계획 상태를 PRD, 계획, 클라이언트 요약, 별도 기능 추가 프롬프트에 일치시킨다. + +- **시작 조건:** 관리자 취소가 `RECEIVED` 전용이며 목록/상세 액션으로 제공된다는 사용자 승인. +- **완료 증거:** 네 문서의 endpoint, 액션, 상태/환불/응답 계약 일치, 기존 `admin-page-prompts.md` 무변경 및 문서 검증 기록. +- **범위 밖:** 서버/프론트엔드 구현, 테스트 실행, mock/fallback, 새 오류/푸시/스키마. +- **TDD 예외 사유:** 문서만 수정하며 production 동작을 변경하지 않는다. +- **대체 검증 방법:** 네 문서 간 계약 대조, `./gradlew tasks --all`, `git diff --check`, 변경 파일 범위 확인을 수행하고 결과를 누적한다. + +**Files:** + +- Modify: `docs/20260929_크리에이터_선물하기/prd.md`, `docs/20260929_크리에이터_선물하기/plan-task.md`, `docs/20260929_크리에이터_선물하기/client-api-summary.md`. +- Create: `docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md` — 새 화면을 만들지 않고 기존 화면에 취소 기능만 추가하는 프롬프트. + +- [x] 네 문서의 계약과 기존 전체 화면 프롬프트 무변경을 대조하고 검증 명령의 실제 결과를 기록한다. + +### P14-T1 최종 문서 검증 — 2026-10-22 + +- 상태: 문서 반영 및 검증 완료. `P14-T2`, `P14-T3`, `P14-GATE`는 미착수다. +- 무엇을: PRD/계획/API 요약에 관리자 취소와 목록·상세 `CANCEL` 계약을 반영하고, 기존 화면의 기능 추가만 지시하는 `admin-gift-cancel-addition-prompt.md`를 신규 작성했다. +- 왜: 운송장 등록 전 취소 정책을 명시하고, 기존 전체 페이지 구현 프롬프트를 변경하지 말라는 사용자 요청을 반영하기 위해서다. +- 어떻게: 네 문서의 `RECEIVED` 전용 취소, 발신자 리워드캔 전액 환불, body 없는 POST, 기존 관리자 응답 네 필드와 UTC 시각, 목록/상세 액션 및 미구현 표시를 대조했다. 신규 프롬프트 전체와 문서 링크도 확인했다. +- 검증: `./gradlew tasks --all` exit 0, `git diff --check` exit 0, 신규 프롬프트의 `git diff --no-index --check /dev/null docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md` exit 0, 기존 `admin-page-prompts.md`의 `git diff --exit-code HEAD -- docs/20260929_크리에이터_선물하기/admin-page-prompts.md` exit 0. +- 변경 범위: 기존 문서 3개 수정과 기능 추가 프롬프트 1개 생성만 확인했다. 소스/테스트/설정/DDL과 기존 완료 Task·검증 이력은 변경하지 않았다. +- 미실행: 문서만 변경했으므로 focused/전체 테스트, lint, 애플리케이션 빌드와 브라우저 QA는 실행하지 않았다. 명령 확인은 기능 동작 검증을 의미하지 않는다. +- 다음 행동: 사용자 구현 승인 후 `P14-T2`의 첫 미완료 RED 단계부터 시작한다. + +### 구현 작업 (사용자 승인 후 완료) + +#### Task 14.2 관리자 취소 상태 전이와 환불 구현 + +**Goal 실행 `P14-T2`:** 기존 관리자 권한으로 `RECEIVED` 선물만 취소하고 발신자에게 전액 리워드캔 환불을 원자적으로 처리한다. + +- **시작 조건:** `P14-T1` 검증과 사용자 구현 승인. +- **완료 증거:** service/controller focused test에서 상태, 환불, 권한, 공통 오류와 응답 계약 통과. +- **범위 밖:** 새로운 환불 체계/스키마/의존성, 취소 사유/감사 테이블, 취소 전용 푸시, 사용자 취소/자동취소 정책 변경, 전면 리팩터링. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt` — `fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult` 계약을 기존 관리자 operation method에 맞춰 추가한다. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt` — body 없는 `POST /api/v2/admin/gifts/{applicationNo}/cancel`, 기존 `AdminGiftOperationStatusResponse`와 `ApiResponse.ok(...)` 사용. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`. +- 조건부 확인/수정: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt` — 경합 테스트가 입증한 상태 전이/환불 중복 원인에 한해 공통 경계를 최소 수정한다. 기존 사용자/자동취소 정책은 변경하지 않는다. + +- [x] **RED:** service 테스트에서 `RECEIVED` 관리자 취소의 `CANCELED`, `GiftDelivery.canceledAt`, 원 결제액 전액 `refundGiftCan` 1회 호출을 검증한다. 발신자가 아닌 관리자도 가능하고, 존재하지 않거나 다른 상태면 기존 `common.error.invalid_request`이며 상태/환불은 변경되지 않는지 검증한다. 환불 실패 rollback 및 중복 환불 방지도 검증한다. +- [x] **RED:** `AdminGiftControllerTest`에 실제 Spring 트랜잭션을 통과하는 관리자 취소 통합 테스트를 추가한다. ADMIN 성공과 비관리자/미인증 거부, 모든 비허용 상태 거부, 응답 네 필드와 UTC 시각, 발신자의 실제 리워드캔/환불 내역 복원 및 관리자 잔액 불변을 검증한다. 사용자 취소·자동취소·운송장 등록·다른 관리자 취소와의 동시 요청, 환불 예외 시 DB 상태/취소 시각/환불 내역 롤백도 검증한다. mock 호출 횟수만으로 실제 환불/롤백/경합 검증을 대체하지 않는다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 새 assertion 또는 취소 메서드 미구현으로 실패해야 하며 설정/fixture 오류는 수정한 뒤 다시 확인한다. +- [x] **GREEN:** 기존 transaction, 예외, 캔 환불 경로와 `GiftOperationStatusResult`를 재사용해 최소 구현한다. 신규 push는 발행하지 않는다. +- [x] **GREEN 확인:** 같은 service focused test와 `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 정상 envelope, 응답 필드 및 UTC `occurredAt=canceledAt`, 기존 ADMIN 권한 거부를 확인한다. +- [x] **REFACTOR:** 이번 변경으로 생긴 중복만 정리하고 아래 focused test와 `./gradlew ktlintCheck`를 실행해 실제 결과를 기록한다. + +#### P14-T2 구현·검증 기록 — 2026-10-22 + +- 무엇을: ADMIN 취소 endpoint와 `GiftAdminService.cancelGift`를 추가하고 사용자 취소·운송장 등록·자동취소의 최초 선물 조회에 DB 쓰기 잠금을 적용했다. 기한 안내가 취소 시각을 덮어쓰는 경합도 RED로 확인해 동일 잠금을 적용했다. 기존 분산 job lock과 상태/환불 정책은 유지했다. +- RED: 기존 focused 92개 통과 후 신규 취소 HTTP 404 실패, service 메서드 미구현 실패, 잠금 없는 핵심 경합 7개 실패, 안내 경합 2개 실패를 각각 확인했다. +- GREEN: `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit 0, 119개 테스트(25/30/50/14) 실패·오류·skip 0. 담당자 외 독립 재실행도 `test --rerun`으로 exit 0(48초)을 확인했다. +- 실제 DB 확인: 80캔 사용(충전 60 + 리워드 20)이 발신자 리워드 80으로 환불되고 관리자 잔액은 불변이다. 실제 환불/flush 후 예외 발생 시 상태·취소 시각·잔액·환불 내역이 모두 롤백된다. 9개 동시 요청 순서에서 최종 상태와 환불 1회 불변조건을 확인했다. +- 리뷰: 독립 T2 스펙/코드 리뷰 APPROVE, 차단 사항 없음. 커밋 `44bec81a`. +- 후속 사용자 요청: `44bec81a` 커밋은 취소하고 변경 파일을 작업 트리에 보존했다. 이후 커밋은 사용자가 확인 후 직접 수행하며 에이전트는 커밋하지 않는다. +- 검증 경계: H2/Spring 트랜잭션과 MockMvc 검증이다. 실제 socket HTTP는 후속 T3/Gate에서 수행하며 MySQL 실서버는 사용하지 않았다. LSP 도구가 없어 Kotlin 컴파일과 ktlint로 대체했다. 기존 타 스케줄러의 H2 `SKIP LOCKED` 오류 로그와 Gradle deprecation 경고는 별도 관찰로 남긴다. + +#### Task 14.3 관리자 목록/상세 취소 액션 구현 + +**Goal 실행 `P14-T3`:** 관리자 목록과 상세에서 `RECEIVED`만 `CANCEL` 액션을 노출한다. + +- **시작 조건:** `P14-T2` 완료. +- **완료 증거:** query service 테스트에서 모든 상태 매핑과 기존 `COMPLETE_DELIVERY` 수취인 정보 조건부 노출 통과. +- **범위 밖:** 다른 상태 액션 재설계, 관리자 UI 구현. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt`. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt`. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` — 목록/상세 JSON 액션 및 취소 후 `CANCELED`/빈 액션 재조회 검증. + +- [x] **RED:** 목록과 상세 각각 `RECEIVED -> ["CANCEL"]`이며 다른 상태의 기존 액션이 그대로이고 `INSPECTION_COMPLETED`의 `COMPLETE_DELIVERY` 조건이 유지되는 테스트를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest"` 실행. 신규 assertion 실패를 확인한다. +- [x] **GREEN:** 두 응답의 공통 상태별 action 계산만 필요한 범위에서 수정한다. +- [x] **GREEN 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 목록/상세 JSON 매핑, 취소 후 빈 액션과 기존 조건부 액션 통과를 확인한다. +- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test와 `./gradlew ktlintCheck` 결과를 기록한다. + +#### P14-T3 구현·검증 기록 — 2026-10-22 + +- 무엇을: 공통 `GiftStatus.availableActions`의 `RECEIVED` 분기에 `CANCEL`만 추가했다. 기존 모든 상태 매핑과 수취인 정보 조건을 보존했다. +- RED: 기존 34개 통과 후 신규 상세/실제 HTTP 목록 액션 assertion 2개만 실패하는 것을 확인했다. RANDOM_PORT fixture의 request context 오류는 수정 후 유효 RED를 재확인했으며 기능 실패로 계산하지 않았다. +- GREEN: `./gradlew --no-daemon test --tests '*GiftAdminQueryServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0, 35개(query 4 + controller 31) 실패·오류·skip 0. 기존 9개 경합·롤백 테스트도 포함됐다. +- 실제 HTTP: RANDOM_PORT Tomcat과 실제 TokenProvider JWT를 사용해 익명 401, 모든 비ADMIN 역할의 공통 실패 응답, ADMIN 목록/상세의 `RECEIVED/[CANCEL]`, body 없는 취소 성공, 반복 거부, 재조회 `CANCELED/[]`를 확인했다. 원 발신자 충전 40/리워드 0에서 리워드 80으로 환불되며 관리자 잔액과 환불 1회 조건을 실제 DB로 검증했다. +- 증거: `AdminGiftControllerTest.shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions`의 `GIFT_HTTP` 출력에서 취소 성공 `success=true`, `status=CANCELED`, `statusName=신청 취소`, UTC `occurredAt` 네 필드를 확인했다. GET 증거는 개인정보를 제거하고 JWT는 출력하지 않았다. +- 리뷰: T2/T3 전체 코드·테스트 최종 리뷰 APPROVE, 차단 사항 없음. + +### Phase 14 Gate + +**Goal 실행 `P14-GATE`:** 승인된 구현의 취소, query action 및 경합 회귀를 판정한다. + +- **시작 조건:** `P14-T2`, `P14-T3` 완료. +- **완료 증거:** focused test, 직접 영향 회귀, lint 통과 및 사용자 흐름 기록. +- **범위 밖:** 실패와 무관한 구현, 전체 선물 도메인 리팩터링. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest" +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest" +./gradlew ktlintCheck +``` + +**Expected:** focused 명령과 lint가 exit 0. 취소와 운송장 등록, 사용자 취소, 자동취소, 다른 관리자 취소가 경합해도 허용 상태 전이와 환불 1회 불변조건이 유지되고, 환불 실패 시 취소 상태가 커밋되지 않는다. 기존 테스트 구조 안에서 검증하며 새 의존성이나 스키마는 추가하지 않는다. + +- [x] 위 focused test와 직접 영향 회귀, lint 결과를 기록한다. +- [x] 관리자 권한, 발신자 전액 환불, 목록/상세의 `CANCEL` 노출과 취소 후 빈 액션, 자동취소 전용 푸시 미발행을 검증한다. +- [x] 문서의 구현 예정 표시를 실제 검증된 상태로 갱신한다. 전체 회귀는 공통 경계 변경 또는 focused test로 영향 범위를 판단할 수 없는 경우에만 실행하고, 생략 시 근거와 대체 명령을 기록한다. + +#### P14-GATE 최종 판정 — 2026-10-22 + +- 판정: 사용자 요청에 따른 전체 테스트 제외 범위에서 완료. 기능·실제 HTTP·권한·환불·롤백·경합 검증과 독립 최종 리뷰를 통과했다. +- 최종 빌드: `./gradlew --no-daemon build -x test ktlintCheck` exit 0, `BUILD SUCCESSFUL in 12s`. 최신 소스/테스트 컴파일 및 lint 산출물이 up-to-date이며 전체 build 작업이 성공했다. +- 전체 테스트: `./gradlew --no-daemon test --rerun ktlintCheck build` 1차는 도구의 600초 제한으로 중단됐다. 제한을 늘린 2차는 사용자가 중단했다. 이후 사용자가 전체 테스트를 제외하고 마무리하도록 지시해 재실행하지 않았다. 전체 테스트 성공/실패를 확정하지 않는다. +- 대체 증거: T2 focused 119개(독립 재실행 포함), T3 focused 35개(일부 중복), 실제 JWT socket HTTP 시나리오 및 최종 코드 리뷰 APPROVE. 테스트 삭제·skip·완화는 추가하지 않았다. +- 한계: LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. H2 검증이며 실제 MySQL REPEATABLE_READ/다중 인스턴스/운영 배포와 별도 프론트엔드 화면 QA는 수행하지 않았다. 기존 타 스케줄러 H2 문법 경고와 Gradle deprecation 경고는 수정 범위 밖이다. +- 프롬프트: 사용자 요청대로 서버 구현/준비 여부를 선행조건으로 두지 않고 확정 Request/Response와 테스트 fixture를 기준으로 프론트엔드 병행 구현을 안내한다. 기존 `admin-page-prompts.md`는 그대로 보존한다. +- 커밋: 생성했던 커밋은 사용자 요청으로 취소했다. 변경은 모두 unstaged/untracked 작업 트리에 유지하며 추가 커밋/푸시는 하지 않는다. +- 문서 검증: `./gradlew --no-daemon tasks --all` exit 0, `git diff --check` 통과. 기존 전체 프롬프트 비교와 index 비교는 각각 exit 0, HEAD는 작업 전 `45b97088`이다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 산출물을 확인했다. +- 검증 기록 정정: P14-T1의 신규 파일 `git diff --no-index --check /dev/null ...` exit 0 표기는 부정확했다. 최종 확인에서는 공백 오류 출력 없이 신규 파일 차이로 exit 1이다. 기존 추적 파일 `git diff --check`는 exit 0이며, 이를 테스트/빌드 실패와 혼동하지 않는다. +- 자원 정리: 테스트 worker/Redis 프로세스 잔류 없음과 작업 전용 디렉터리에 열린 파일 없음 확인 후 `gift-p14.xbpOuF`, `gift-p14-impl.d9YMmw`, `gift-p14-t3.Hx06BJ` 및 임시 진행 노트를 삭제하고 네 경로의 부재를 확인했다. 검증 요약은 이 문서에 보존했으며 공유 Gradle 캐시와 build 산출물은 삭제하지 않았다. + +### Phase 14 커밋 전 검증 — 2026-10-22 + +- 승인: 사용자가 `/commit-with-validation`으로 변경을 적절히 나누어 커밋하도록 후속 요청했다. 이전 커밋 보류 기록 이후의 새 승인으로 적용한다. +- 검증: `./gradlew --no-daemon test --rerun --tests '*GiftAdminServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit 0, `BUILD SUCCESSFUL in 58s`. +- 결과: 관련 5개 클래스 124개 테스트에서 실패·오류·skip 0. lint는 UP-TO-DATE이며 `git diff --check`도 통과했다. +- 범위: 관리자 취소와 직접 영향받는 사용자 명령·스케줄러 회귀를 재실행했다. 기존 사용자 요청에 따라 전체 테스트는 제외했고 운영 MySQL 검증은 수행하지 않았다. + +### Phase 14 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 31 | `P14-T1` | 관리자 취소 승인 범위 문서화 | 아니요 | 문서 검증 | +| 32 | `P14-T2` | `P14-T1` 검증 및 사용자 구현 승인 | 아니요 | 승인 대기, 코드 변경 금지 | +| 33 | `P14-T3` | `P14-T2` 계약/승인 | 아니요 | 선행 구현 승인 대기 | +| 34 | `P14-GATE` | `P14-T2`, `P14-T3` | 아니요 | 실패 원인 Task에 반영 | + +```text +P14-T1 문서 검증 → 사용자 구현 승인 → P14-T2 → P14-T3 → P14-GATE +``` + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | @@ -1442,6 +1612,10 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 | 2026-09-30 | `PLAN-DEC-006` | 확정 | 선물 API response datetime은 전역 Jackson 설정이 아니라 DTO response boundary에서 UTC ISO 문자열로 변환한다 | 선물 API만의 공개 계약 보강이며 전역 변경은 기존 API 영향이 큼 | `P8-T1`, `client-api-summary.md` | | 2026-09-30 | `PLAN-DEC-007` | 확정 | 기존 `categoryCode`는 내부 범용코드로 유지하고 분류번호/접수코드/대표품목을 추가하며, 신청번호는 카테고리+날짜별 sequence로 발급한다 | 사용자 확정: 카테고리 필드 의미와 사용자 폼 옵션 표시 규칙 분리 | `P9-T1`~`P9-T3` | | 2026-10-02 | `PLAN-DEC-008` | 확정 | `COMPLETE_DELIVERY`는 수취인 필수 정보가 모두 입력된 경우에만 허용하고, 배송지 미입력 기한 초과는 주소 입력 가능 상태 3개 전체에서 `UNDELIVERABLE`로 종료한다 | 배송지 없는 선물의 전달 완료와 관리자 상태 진행 후 만료 누락 방지 | `P12-T1`, `P12-T2` | +| 2026-10-22 | `PLAN-DEC-009` | 확정 | Phase 14는 문서화만 먼저 진행하며 관리자 취소 구현은 사용자 별도 승인 후 시작한다. `RECEIVED` 전액 환불은 기존 경로와 관리자 응답/action 계약을 재사용한다 | 승인된 범위와 구현 전 사람 승인 경계 | `P14-T1`~`P14-GATE`, PRD `DEC-015` | +| 2026-10-22 | `PLAN-DEC-010` | 확정 | 기존 `admin-page-prompts.md`는 보존하고 기존 화면에 취소 기능만 추가하는 프롬프트를 `admin-gift-cancel-addition-prompt.md`로 분리한다 | 사용자 수정 요청 | `P14-T1`, PRD `DEC-016` | +| 2026-10-22 | `PLAN-DEC-011` | 확정 | 프론트엔드 추가 프롬프트에서 서버 준비 대기 조건을 제거하고 확정 API 계약으로 병행 구현한다 | 사용자 후속 요청 | `admin-gift-cancel-addition-prompt.md` | +| 2026-10-22 | `PLAN-DEC-012` | 확정 | 전체 테스트를 제외하고 관련 검증·테스트 제외 빌드·문서·임시 자원 정리로 마무리한다. 커밋은 사용자가 직접 한다 | 사용자 마무리 및 커밋 금지 요청 | `P14-GATE` | ## 발견된 문제 diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 6ca8bb46..1afd3713 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -4,15 +4,17 @@ | 항목 | 내용 | |---|---| -| 문서 상태 | 구현 중 | +| 문서 상태 | Phase 14 서버 구현 완료, 전체 테스트 제외 검증 완료 | | 작성일 | 2026-09-29 | -| 최종 수정일 | 2026-09-30 | +| 최종 수정일 | 2026-10-22 | | 대상 제품 | 크리에이터 선물하기 | | 작성자·결정권자 | 사용자 | | 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 | | 관련 구현 계획 | `docs/20260929_크리에이터_선물하기/plan-task.md` | | 관련 review | 없음 | +> Phase 14 관리자 취소와 목록·상세 `CANCEL` 액션은 서버 코드에 구현됐다. 관련 테스트·실제 JWT HTTP·리뷰·테스트 제외 빌드를 검증했으며, 전체 테스트는 사용자 요청으로 완료 기준에서 제외했다. 운영 배포 여부와 별도 프론트엔드 구현 여부는 이 문서의 완료 상태에 포함하지 않는다. + ## 1. Overview 팬이 내부 재화인 캔을 사용해 크리에이터에게 실물 선물을 보낼 수 있는 API를 제공한다. @@ -34,6 +36,7 @@ - 팬이 선물 사이즈, 카테고리, 발신자 정보, 약관 동의를 입력해 선물 보내기를 신청할 수 있다. - 선물 신청 시 캔을 즉시 차감하고 내부적으로 선물 신청과 캔 사용내역을 연결한다. - 팬은 운송장 등록 전 선물 신청을 취소할 수 있고, 3일 내 운송장을 등록하지 않으면 자동취소 및 전액 환불된다. +- 관리자는 운송장 등록 전 `RECEIVED` 선물을 취소하고 발신자에게 사용 캔 전액을 리워드캔으로 환불할 수 있다. - 크리에이터는 팬이 운송장을 등록한 선물부터 받은 선물 목록과 상세를 조회하고 7일 이내 배송지와 수령 약관 동의를 등록할 수 있다. - 팬과 크리에이터는 같은 선물함 목록 API에서 보낸/받은 내역을 함께 또는 개별 조회할 수 있다. - 운영자는 상태별 전용 관리자 API로 사서함 도착, 검수완료, 전달불가, 전달완료를 처리할 수 있다. @@ -55,7 +58,7 @@ |---|---|---|---| | 팬 | 크리에이터에게 선물을 보낸다 | 사이즈/카테고리 조회, 신청, 운송장 등록, 취소, 보낸 내역 조회, 리뷰 작성 | 인증 회원 | | 크리에이터 | 받은 선물을 확인하고 배송지를 입력한다 | 받은 내역 조회, 배송지 입력, 수령 확인 | 인증 회원 중 크리에이터 | -| 관리자 | 선물 운영 설정을 관리한다 | 카테고리, 사이즈 가격 관리 | 관리자 | +| 관리자 | 선물 운영 설정과 선물 상태를 관리한다 | 카테고리, 사이즈 가격 관리, `RECEIVED` 선물 취소 | 관리자 | | 운영자 | 선물 검수/전달 상태를 처리한다 | 사서함 도착, 검수완료, 전달불가, 전달완료 처리 | 관리자 또는 운영 권한 | | 스케줄러 | 기한 초과 건을 자동 처리한다 | 운송장 미등록 자동취소, 배송지 미입력 전달불가 | 시스템 | @@ -74,7 +77,7 @@ | `INSPECTION_COMPLETED` | 검수완료 | 운영자가 선물 검수를 통과 처리한 상태 | 아니오 | | `DELIVERED` | 전달완료 | 크리에이터 수령확인 또는 운영자 전달완료 처리 상태 | 예 | | `UNDELIVERABLE` | 전달불가 | 검수 실패 또는 배송지 미입력 기한 초과로 전달할 수 없는 상태 | 예 | -| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소 또는 자동취소 상태 | 예 | +| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소, ADMIN 취소 또는 자동취소 상태 | 예 | 상태 전이는 다음을 기본으로 한다. @@ -171,6 +174,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE - 선물 신청과 캔 사용내역은 내부적으로 연결되어야 한다. - 사용자 캔 사용내역 화면에서 선물 신청으로 이동할 수 없어도 된다. - 운송장 등록 전 사용자 취소 또는 3일 자동취소 시 사용 캔을 전액 환불한다. +- ADMIN은 운송장 등록 전 `RECEIVED`에서 취소할 수 있으며 기존 `refundGiftCan` 경로로 발신자에게 전액 리워드캔 환불한다. 상태 변경, 취소 시각 저장, 환불은 같은 트랜잭션에서 처리한다. - 운송장 등록 후에는 팬이 취소할 수 없다. ### 6.8 목록 노출 @@ -248,6 +252,8 @@ INSPECTION_COMPLETED -> UNDELIVERABLE | `GIFT-012` | 확정 | 관리자는 카테고리를 등록/수정/논리삭제할 수 있다 | 삭제는 `isActive=false`만 수행한다 | `P1` | | `GIFT-013` | 확정 | 관리자는 사이즈별 가격을 수정할 수 있다 | 새 신청에는 변경 가격이 적용되고 기존 신청 스냅샷은 바뀌지 않는다 | `P1` | | `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` | +| `GIFT-016` | 확정 | 관리자는 운송장 등록 전 접수된 선물을 취소할 수 있다 | 관리자만 호출할 수 있고 `RECEIVED`에서만 `CANCELED`로 전이하며 `GiftDelivery.canceledAt`을 저장하고 발신자에게 사용 캔 전액을 리워드캔으로 환불한다 | `P14-T2` | +| `GIFT-017` | 확정 | 관리자 선물함 목록과 상세는 현재 가능한 액션으로 취소 가능 여부를 함께 제공한다 | `RECEIVED`에서만 `availableActions=["CANCEL"]`; 다른 상태의 기존 액션은 유지한다 | `P14-T3` | ## 8. API 계약 @@ -843,11 +849,11 @@ Request: Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내려준다. `address`는 기존 주소 응답과 동일하게 `(우편번호) 주소, 상세주소` 형식이다. `name`, `phoneNumber`, `zipCode`, `address`는 필수이며 공백만 입력할 수 없다. `addressDetail`은 선택이다. -`availableActions`는 현재 상태별로 다음 값만 내려준다. +관리자 목록과 상세의 `availableActions`는 상태별로 다음 값을 내려준다. `RECEIVED`에서는 `CANCEL`을 제공하고, 다른 상태의 기존 매핑은 유지한다. | 현재 상태 | availableActions | |---|---| -| `RECEIVED` | `[]` | +| `RECEIVED` | `["CANCEL"]` | | `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` | | `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"]` | | `INSPECTION_COMPLETED`, 수취인 정보 입력 완료 | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` | @@ -864,6 +870,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | `ARRIVED_AT_MAILBOX` | `INSPECTION_COMPLETED` | 없음 | | POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` | `UNDELIVERABLE` | `reason` | | POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 저장된 수취인 이름, 휴대폰 번호, 우편번호, 주소 | +| POST | `/api/v2/admin/gifts/{applicationNo}/cancel` | `RECEIVED` | `CANCELED` | 없음 | `complete-delivery`는 `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`가 모두 null 또는 공백이 아닐 때만 성공한다. `recipientAddressDetail`은 선택 입력이다. 조건을 만족하지 않으면 상태, 전달 완료 시각, 푸시를 변경하지 않고 요청을 거부한다. @@ -875,6 +882,29 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 } ``` +#### 8.14.1 관리자 선물 신청 취소 + +`POST /api/v2/admin/gifts/{applicationNo}/cancel` + +Request body: 없음 + +기존 관리자 API의 `hasRole('ADMIN')` 권한을 따르며 관리자 계정은 선물 발신자가 아니어도 취소할 수 있다. `RECEIVED`에서만 `CANCELED`로 전이하며 운송장 등록 이후에는 취소할 수 없다. 같은 트랜잭션에서 `GiftDelivery.canceledAt`을 기록하고 기존 `refundGiftCan(gift.senderMemberId, gift.canUsageId)` 경로로 발신자에게 원 결제액 전액을 리워드캔으로 환불한다. 관리자의 잔액은 변경하지 않는다. + +Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. + +```json +{ + "applicationNo": "A-1002609300001", + "status": "CANCELED", + "statusName": "신청 취소", + "occurredAt": "2026-09-30T12:00:00Z" +} +``` + +`occurredAt`은 `canceledAt`과 같은 UTC ISO `Z` 문자열이다. 누락되거나 취소할 수 없는 상태의 신청번호는 기존 `SodaException(messageKey = "common.error.invalid_request")`로 거부한다. 정상 `ApiResponse` envelope를 사용하며 신규 오류 스키마나 HTTP status 정책은 추가하지 않는다. + +이 관리자 취소는 기존 사용자 직접 취소와 운송장 미등록 자동취소를 변경하지 않는다. 취소 전용 푸시는 추가하지 않으며, 자동취소 푸시 문구나 자동취소 사유를 관리자 취소에 재사용하지 않는다. + ### 8.15 자동 스케줄링 작업 | 작업 | 대상 | 처리 | 푸시 | @@ -1014,6 +1044,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 - 푸시는 팬/크리에이터 수신자 분리와 크리에이터 수령확인 시 크리에이터 푸시 억제를 테스트한다. - 스케줄러는 중복 실행 시 중복 환불/중복 푸시가 없는지 테스트한다. - 선물 사용자/관리자 API response DTO의 날짜/시간 필드는 UTC ISO 문자열(`Z` 포함)로 직렬화되는지 controller test로 고정한다. +- 관리자 취소는 `RECEIVED`만 허용하고, 발신자에게 전액 리워드캔 환불, `canceledAt` 저장, 환불 실패 시 트랜잭션 롤백 및 중복 환불 방지를 검증한다. 운송장 등록, 사용자 취소, 자동취소, 다른 관리자 취소와의 동시 경합에서도 잘못된 이중 처리/환불이 없어야 한다. +- 관리자 취소 controller는 기존 관리자 인증/권한, 공통 invalid request, 정상 `ApiResponse` envelope와 `AdminGiftOperationStatusResponse`의 UTC `occurredAt`을 검증한다. +- 관리자 목록과 상세 모두 `RECEIVED`에서만 `CANCEL` 액션을 제공하며 기존 `COMPLETE_DELIVERY` 조건부 액션을 포함한 나머지 상태 매핑은 유지한다. - 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다. - 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다. @@ -1029,6 +1062,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 - [ ] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발송된다. - [ ] 선물 사용자/관리자 API의 날짜/시간 응답을 클라이언트가 UTC ISO 문자열로 해석할 수 있다. - [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다. +- [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다. ## 14. Open Questions @@ -1045,6 +1079,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 | `GIFT-010` | 5 | 리뷰 API 테스트 | 전달완료 후 리뷰 작성 확인 | | `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 | | `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 | +| `GIFT-016`, `GIFT-017` | 14 | 관리자 취소 service/controller 및 query service 테스트 | `RECEIVED` 액션과 취소/환불 결과 확인 | ## 16. Decision Log @@ -1064,3 +1099,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 | 2026-09-29 | `DEC-012` | 확정 | 파손면책 동의 필요 카테고리는 팬 신청 시 `damageWaiverAgreed=true`를 요구한다 | 사용자 선택 A | `GIFT-003` | | 2026-09-30 | `DEC-013` | 확정 | 선물 사용자/관리자 API의 response datetime 필드는 `LocalDateTime` 기본 JSON 대신 UTC ISO 문자열로 내려준다 | 클라이언트가 offset 없는 `LocalDateTime` 문자열을 안전하게 해석하지 못함 | `GIFT-014` | | 2026-09-30 | `DEC-014` | 확정 | 카테고리는 분류번호, 내부 범용 카테고리 코드, 분류명, 접수코드, 대표품목, 파손면책 조건을 관리하고 신청번호는 `receiptCode-classificationNumberYYMMDDSequenceNo`로 발급한다 | 관리자 운영 필드와 사용자 표시 요구가 분리됨 | `GIFT-001`, `GIFT-002`, `GIFT-012` | +| 2026-10-22 | `DEC-015` | 확정 | 관리자 취소는 `RECEIVED`에서만 허용하고 기존 `refundGiftCan`으로 발신자에게 전액 리워드캔을 환불한다. 기존 operation status 응답을 재사용하며 관리자 취소 전용 푸시는 이번 범위에서 추가하지 않는다 | 사용자 확정: 취소 허용 상태와 문서 우선 반영. 응답/환불은 기존 계약 재사용, 신규 푸시는 요청 범위 밖 | `GIFT-016`, `GIFT-017`, `P14` | +| 2026-10-22 | `DEC-016` | 확정 | 기존 관리자 화면 전체 구현 프롬프트는 보존하고 취소 기능 추가 프롬프트를 `admin-gift-cancel-addition-prompt.md`로 분리한다 | 사용자 요청: 기존 페이지에 해당 기능만 추가하는 별도 프롬프트 | `GIFT-016`, `GIFT-017`, `P14-T1` | + +## 문서 상태 안내 + +Phase 14 서버 구현과 관련 검증은 완료했다. 전체 테스트는 1차 실행이 도구의 10분 제한으로 중단되고 2차는 사용자 중단 후 제외됐다. 전체 테스트 통과를 주장하지 않으며 검증 명령과 한계는 `plan-task.md`의 P14-GATE 기록을 따른다. 관리자 프론트엔드는 별도 기능 추가 프롬프트의 확정 계약을 사용한다. From 97c7de6f6ed6c301d7eea19f6bd06f9b2a86d61a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 18:26:00 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=20API=20=EC=97=B0=EB=8F=99=EA=B3=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80=20=EC=A7=80=EC=B9=A8?= =?UTF-8?q?=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-gift-cancel-addition-prompt.md | 120 ++++++++++++++++++ .../client-api-summary.md | 7 + 2 files changed, 127 insertions(+) create mode 100644 docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md diff --git a/docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md b/docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md new file mode 100644 index 00000000..f8ccf5f8 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md @@ -0,0 +1,120 @@ +# 기존 관리자 선물함에 신청 취소 기능 추가 프롬프트 + +## 목적과 사용 범위 + +- 이 문서는 이미 존재하는 관리자 선물함에 기능 하나를 추가하기 위한 전달용 프롬프트다. +- 아래 확정된 API Request/Response 계약을 기준으로 타입, API client, 화면 액션과 테스트를 구현한다. +- [기존 전체 페이지 프롬프트](admin-page-prompts.md)는 수정하거나 다시 실행하지 않는다. +- 계약 기준: [PRD 8.14.1 관리자 선물 신청 취소](prd.md#8141-관리자-선물-신청-취소). + +## 복사해서 전달할 증분 구현 프롬프트 + +아래 블록 전체를 기존 프론트엔드 저장소의 구현 담당자에게 전달한다. + +````markdown +# 기존 관리자 선물함에 신청 취소 액션만 추가 + +## 1. 기존 코드 확인 + +이미 구현된 관리자 선물함 목록/상세에 `신청 취소` 기능만 증분으로 추가해줘. +페이지를 새로 만들거나 기존 전체 페이지 구현 프롬프트를 재실행하지 마. + +- 먼저 기존 프론트엔드 코드를 읽고 목록, 상세, 액션 타입, 액션 dispatch 위치를 확인한다. +- API 인증/권한, 공통 API client, 응답 envelope 해제 방식, 확인창, 토스트, 오류 처리를 확인한다. +- 기존 상태 변경 액션의 pending 처리와 목록/상세 재조회 방식을 그대로 사용한다. +- 변경할 실제 파일과 재사용할 기존 패턴을 먼저 짧게 정리한다. +- 아래 API 계약을 기준으로 구현한다. 기존 타입이나 호출부가 계약과 다르면 차이를 보고하고 이 기능의 변경 범위 안에서 맞춘다. + +## 2. 유지할 기존 화면과 액션 + +- 기존 `/gift/list` 라우트, 메뉴, 레이아웃, 테이블 컬럼, 필터, 페이지네이션을 보존한다. +- 기존 상세 모달/패널과 액션 표시·실행 프레임워크를 그대로 확장한다. +- `ARRIVE_MAILBOX`, `COMPLETE_INSPECTION`, `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`을 보존한다. +- 기존 전달 불가 사유 입력/검증 및 각 액션의 확인창·성공·실패 동작을 변경하지 않는다. +- `COMPLETE_DELIVERY`의 수취인 필수 정보 조건부 허용을 유지한다. +- 모든 기존 액션도 서버 `availableActions`를 따르며 로컬 상태 매핑으로 대체하지 않는다. +- 받을 주소, 카테고리, 사이즈 설정 화면은 수정하지 않는다. + +## 3. 최소 타입/dispatch 확장과 버튼 노출 + +- 기존 `AdminGiftAction` 타입에 `CANCEL`을 추가하되 기존 값을 삭제하거나 재정의하지 않는다. +- 기존 액션 라벨/핸들러 dispatch에 `CANCEL`과 `신청 취소`만 추가한다. +- 목록 row와 열린 상세 모두 기존 액션 렌더링/실행 경로에 연결한다. +- 각 화면의 서버 응답 `availableActions.includes("CANCEL")`일 때만 취소 버튼을 표시한다. +- API 계약상 `RECEIVED`에서만 `CANCEL`을 제공한다. 다른 상태에서는 제공하지 않는다. +- `status === "RECEIVED"`만으로 취소 권한을 만들거나 버튼을 강제로 추가하지 않는다. +- 목록/상세 중 한쪽의 로컬 값을 다른 쪽의 권한 판단에 대신 사용하지 않는다. +- 권한의 최종 판단은 서버가 하며, 관리자가 발신자 본인일 필요는 없다. + +## 4. 확정 API 계약 + +액션 조회: +- 목록: `GET /api/v2/admin/gifts`, `data.items[].availableActions`를 사용한다. +- 상세: `GET /api/v2/admin/gifts/{applicationNo}`, `data.availableActions`를 사용한다. +- 접수 완료 응답: `status="RECEIVED"`, `statusName="접수 완료"`, `availableActions=["CANCEL"]`. +- 취소 후 응답: `status="CANCELED"`, `statusName="신청 취소"`, `availableActions=[]`. + +취소 요청: + +- Method: `POST` +- URL: `/api/v2/admin/gifts/{applicationNo}/cancel` +- 권한: `ADMIN` 전용. 기존 관리자 인증/권한 경로를 재사용한다. +- Request body: 없음. `{}`, 취소 사유, 환불액 등을 보내지 않는다. +- 서버 전이: `RECEIVED`에서만 `CANCELED`로 변경한다. +- 환불: 원 발신자에게 사용 캔 전액을 리워드캔으로 환불한다. +- 관리자의 잔액으로 환불하는 기능이 아니다. 프론트에서 환불/잔액 변경을 직접 처리하지 않는다. +- 성공 응답: 공통 envelope의 `success=true`와 아래 `data`이며 기존 client의 envelope 처리 방식에 맞춘다. HTTP 성공 코드만으로 업무 성공을 판단하지 않는다. + +```json +{ + "applicationNo": "A-1002609300001", + "status": "CANCELED", + "statusName": "신청 취소", + "occurredAt": "2026-09-30T12:00:00Z" +} +``` + +`occurredAt`은 서버의 `canceledAt`과 같은 UTC ISO `Z` 문자열이다. +기존 `AdminGiftOperationStatusResponse`를 재사용하고 로컬 현재 시각으로 값을 만들지 않는다. + +## 5. 확인과 요청 중 처리 + +- 기존 확인창 컴포넌트를 사용하며 대상 `applicationNo`를 명확히 표시한다. +- 확인 문구 예: `신청번호 {applicationNo}의 선물 신청을 취소하시겠습니까?` +- 환불 안내: `사용한 캔 전액이 발신자에게 리워드캔으로 환불됩니다.` +- 확인창을 닫거나 취소하면 API를 호출하지 않는다. 명시적으로 확정한 경우에만 호출한다. +- 확정한 신청번호로 body 없는 요청을 한 번만 보낸다. +- pending 동안 확인 버튼과 같은 신청의 목록/상세 취소 버튼을 비활성화해 중복 제출을 막는다. +- 기존 client의 mutation 재시도 정책도 확인하고 취소 요청을 무조건 자동 재시도하지 않는다. +- 키보드 포커스/확인창 접근성과 pending 표시는 기존 관리자 패턴을 유지한다. + +## 6. 성공과 실패 + +- 서버 성공 확인 후 기존 성공 토스트를 표시한다. 응답 전에 성공으로 처리하지 않는다. +- 현재 필터와 현재 페이지/페이지 크기를 유지한 채 목록을 다시 조회한다. +- 상세가 열려 있으면 해당 상세도 다시 조회해 서버의 현재 상태와 액션을 반영한다. +- 취소된 항목의 서버 상태는 `CANCELED`, `statusName`은 `신청 취소`, `availableActions`는 `[]`다. +- 현재 필터에 맞지 않아 목록에서 사라져도 필터를 초기화하거나 첫 페이지로 임의 이동하지 않는다. +- 실패는 기존 공통 오류 토스트/알림을 사용하며 별도 오류 스키마나 HTTP 정책을 만들지 않는다. +- 서버가 상태 변경/잘못된 요청을 알리거나 통신 실패로 처리 결과가 불명확하면 + 기존 패턴에 따라 목록과 열린 상세의 서버 현재 상태를 재조회한다. mutation은 재시도하지 않는다. +- 실패 시 로컬 상태를 가짜 `CANCELED`로 바꾸거나 성공 토스트/환불 완료를 표시하지 않는다. + +## 7. 구현 후 집중 검증 + +확정 계약의 성공·실패 응답 fixture와 기존 API client mock을 테스트 범위에서 사용해 화면 및 요청 동작을 검증한다. 실행 코드에 자동 fallback이나 가짜 성공 처리를 추가하지 않는다. 실제 API 연동 검증 결과는 단위/컴포넌트 테스트 결과와 구분해 보고한다. + +- 목록과 상세 각각 `RECEIVED` + 서버 `CANCEL`이면 버튼이 나오고 기존 dispatch로 실행되는지 확인한다. +- `RECEIVED`여도 `CANCEL`이 없으면 버튼이 없고, 다른 모든 상태에는 `CANCEL`이 없는지 확인한다. +- 확인창에 신청번호/발신자 전액 리워드캔 환불이 표시되고 확인을 닫으면 API 호출이 0회인지 확인한다. +- 확정 시 정확한 URL로 body 없이 1회만 요청하며 연속 클릭과 목록/상세 중복 실행이 막히는지 확인한다. +- 성공 후 필터/페이지가 유지되고 목록과 열린 상세가 갱신되며 취소 건의 액션이 없어지는지 확인한다. +- 실패 시 공통 오류를 표시하고 가짜 취소/성공이 없으며 필요한 현재 상태 재조회가 수행되는지 확인한다. +- 기존 운영 액션과 `COMPLETE_DELIVERY`의 수취인 정보 조건부 노출이 그대로인지 회귀 확인한다. +- 기존 프론트엔드 테스트 도구로 위 변경 범위만 검증하고 실행 결과/미검증 항목을 보고한다. + +## 8. 범위 밖 + +취소 사유 필드, 취소 전용 푸시 흐름, 새 페이지/라우트, 새 디자인 시스템은 추가하지 않는다. +백엔드 구현, 사용자 직접 취소/자동취소 변경, 기존 페이지 전면 재작성도 이 작업에 포함하지 않는다. +```` diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md index 9f7e371c..022d7f93 100644 --- a/docs/20260929_크리에이터_선물하기/client-api-summary.md +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -44,6 +44,7 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 | 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` | | 관리자 | GET | `/api/v2/admin/gifts` | 전체 선물함 목록을 조회하고 상태/신청번호/닉네임으로 검색한다. | query: `status`, `applicationNo`, `nickname`, `page`, `size` | `totalCount`, `items[]`, `page`, `size`, `hasNext` | | 관리자 | GET | `/api/v2/admin/gifts/{applicationNo}` | 선물 상세와 발송인/수취인 개인정보, 상품/배송/상태 정보를 조회한다. | path: `applicationNo` | `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` | +| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/cancel` | 운송장 등록 전 `RECEIVED` 선물을 취소하고 발신자에게 전액 리워드캔을 환불한다. | path: `applicationNo`, body 없음 | `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` (`canceledAt` UTC ISO `Z`) | | 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | | 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | | 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` | @@ -63,6 +64,12 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 - `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` - `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` +관리자 취소는 기존 `hasRole('ADMIN')` 권한을 따르며 관리자가 발신자가 아니어도 `RECEIVED`에서 취소할 수 있다. 같은 트랜잭션에서 `GiftDelivery.canceledAt`을 기록하고 기존 `refundGiftCan`으로 발신자에게 원 결제액 전액을 리워드캔으로 환불한다. 존재하지 않는 신청번호나 다른 상태는 기존 공통 invalid request이며 기존 `ApiResponse` envelope를 사용한다. 기존 사용자 취소 응답은 별도 계약이며 바뀌지 않는다. HTTP 성공 코드만으로 업무 성공을 판단하지 않고 envelope의 `success`를 확인한다. + +관리자 선물함의 action: `RECEIVED`는 목록과 상세 모두 `availableActions=["CANCEL"]`; 그 외 상태는 기존 매핑을 유지한다. 특히 `INSPECTION_COMPLETED`의 `COMPLETE_DELIVERY`는 수취인 정보 완비 조건부 노출이다. + +기존 관리자 화면에 취소 기능만 추가하는 작업은 [관리자 취소 기능 추가 프롬프트](admin-gift-cancel-addition-prompt.md)를 사용한다. 전체 화면 구현용 `admin-page-prompts.md`는 변경하지 않는다. + ## Kotlin DTO 형태 아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`. From 82ce6f88308494490110166c12209c8853811e44 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 19:27:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=8B=9C=EA=B0=81=EC=9D=84=20=EC=B4=88=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=EB=A1=9C=20=EC=A0=95=EA=B7=9C=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftAdminService.kt | 6 +++-- .../gift/application/GiftAdminServiceTest.kt | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) 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 64e7eaa9..f25d3f11 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 @@ -16,6 +16,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime +import java.time.temporal.ChronoUnit data class GiftCategoryCommand( val classificationNumber: String, @@ -157,12 +158,13 @@ class GiftAdminService( val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.RECEIVED) throw invalidRequest() val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val canceledAt = now.truncatedTo(ChronoUnit.SECONDS) gift.status = GiftStatus.CANCELED - delivery.canceledAt = now + delivery.canceledAt = canceledAt canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId) - return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, canceledAt) } @Transactional diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt index 447c2631..b548f8a7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -25,6 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import java.time.LocalDateTime +import javax.persistence.EntityManager @DataJpaTest( properties = [ @@ -39,7 +40,8 @@ class GiftAdminServiceTest @Autowired constructor( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val giftDeliveryRepository: GiftDeliveryRepository, - private val giftMailboxRepository: GiftMailboxRepository + private val giftMailboxRepository: GiftMailboxRepository, + private val entityManager: EntityManager ) { private val giftPushService = Mockito.mock(GiftPushService::class.java) private val canPaymentService = Mockito.mock(CanPaymentService::class.java) @@ -332,6 +334,24 @@ class GiftAdminServiceTest @Autowired constructor( Mockito.verifyNoInteractions(giftPushService) } + @ParameterizedTest + @ValueSource(ints = [0, 123456789, 999999999]) + @DisplayName("관리자 취소 시각은 나노초 입력에도 초 단위 저장값과 응답이 일치한다") + fun shouldPersistAndReturnSameSecondPrecisionCancellationTime(nanos: Int) { + val (gift, _) = saveGift(GiftStatus.RECEIVED) + val giftId = requireNotNull(gift.id) + val now = LocalDateTime.of(2026, 10, 22, 23, 59, 59, nanos) + + val result = service.cancelGift(APPLICATION_NO, now) + entityManager.flush() + entityManager.clear() + + val persisted = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)?.canceledAt) + assertEquals(persisted, result.occurredAt) + assertEquals(LocalDateTime.of(2026, 10, 22, 23, 59, 59), persisted) + assertEquals(GiftStatus.CANCELED, giftRepository.findById(giftId).orElseThrow().status) + } + @ParameterizedTest @EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE) @DisplayName("운영자는 접수가 아닌 모든 상태의 선물을 취소할 수 없다") From c837ff18f82e3260c692a7c9f1884dbefccba441 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 22 Oct 2026 19:27:54 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=20=EC=8B=9C=EA=B0=81=20=EC=A0=95=EB=B0=80?= =?UTF-8?q?=EB=8F=84=EC=99=80=20=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260929_크리에이터_선물하기/plan-task.md | 29 +++++++++++++++++-- docs/20260929_크리에이터_선물하기/prd.md | 2 ++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 0305810d..360372d5 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -9,7 +9,7 @@ | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | | 현재 Phase | Phase 14 관리자 선물 취소 | -| 현재 활성 Goal | 없음, `P14-GATE` 완료 | +| 현재 활성 Goal | 없음, `P14-T4` 시각 정밀도 회귀 수정 완료 | ## 목표 @@ -41,7 +41,7 @@ | 8 | 완료 | `1/1` | 완료 | 없음 | | 9 | 완료 | `3/3` | 완료 | 없음 | | 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 | -| 14 | 완료 | `3/3` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 | +| 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -1587,6 +1587,30 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 - 결과: 관련 5개 클래스 124개 테스트에서 실패·오류·skip 0. lint는 UP-TO-DATE이며 `git diff --check`도 통과했다. - 범위: 관리자 취소와 직접 영향받는 사용자 명령·스케줄러 회귀를 재실행했다. 기존 사용자 요청에 따라 전체 테스트는 제외했고 운영 MySQL 검증은 수행하지 않았다. +### Phase 14 후속 회귀 수정 + +#### Task 14.4 관리자 취소 시각 정밀도 회귀 수정 + +**Goal 실행 `P14-T4`:** 관리자 취소의 DB 재조회 `canceledAt`과 응답 `occurredAt`을 OS/JDK 시계 정밀도와 무관하게 일치시킨다. + +- **시작 조건:** Jenkins의 `AdminGiftControllerTest` 458/526행 실패 보고 및 정밀도 정규화 수정 승인. H2 2.1.214 `timestamp`에 나노초 입력 시 마이크로초 반올림을 별도 JDBC로 재현했다. +- **정책:** 운영 DDL `canceled_at TIMESTAMP`에 맞춰 초 단위 절삭 후 저장·응답에 같은 값을 사용한다. 테스트 비교를 완화하거나 전역 `toUtcIso`, DDL, 사용자/자동취소 동작을 변경하지 않는다. +- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`; Verify `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`; Update 기존 `prd.md`, `plan-task.md`. +- **완료 증거:** 고정 나노초·초 경계 입력의 DB flush/clear 재조회 테스트, 기존 실패 두 HTTP 테스트를 포함한 focused test, lint/테스트 제외 빌드, 변경 리뷰 통과. +- **범위 밖:** 전체 테스트(기존 사용자 제외 요청 유지), 커밋/푸시, 운영 DB/CI 설정 변경. +- [x] **RED:** `0`, `123456789`, `999999999` 나노초 입력을 사용해 초 단위 기대값과 DB 재조회/응답의 완전 일치를 검증하고 기존 코드의 실패를 확인한다. +- [x] **GREEN:** 관리자 취소 메서드에서 취소 시각을 한 번 초 단위로 정규화하고 저장·응답에 사용한다. +- [x] **REFACTOR/검증:** `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` 및 `./gradlew --no-daemon build -x test`를 실행한다. 기존 458/526행 assertion과 실제 JWT HTTP 검증은 유지한다. + +#### P14-T4 진행 기록 — 2026-10-22 + +- RED: 신규 parameterized DB roundtrip 테스트 3개 중 2개 실패(exit 1, 42초). `23:59:59.123456789`는 DB에서 `.123457`로, `23:59:59.999999999`는 다음 날 `00:00`으로 반올림되어 응답과 달랐다. 나노초 0 입력은 통과했다. +- GREEN: 관리자 취소의 `now`를 `ChronoUnit.SECONDS`로 한 번 절삭해 저장과 결과에 같은 값을 사용했다. `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0(1분 26초), service 28 + controller 31 = 59개 실패·오류·skip 0. +- 실제 HTTP: `shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions`에서 body 없는 POST가 `success=true`, `status=CANCELED`, `occurredAt=2026-10-22T19:05:41Z`를 반환하고 DB 재조회값과 정확히 일치했다. Jenkins에서 실패한 기존 두 assertion과 환불·경합 검증은 변경하지 않았다. +- 빌드/문서: `./gradlew --no-daemon build -x test tasks --all` exit 0(14초). 전체 테스트는 기존 사용자 제외 요청을 유지해 실행하지 않았다. 커밋/푸시 없음. +- 한계: Jenkins 재실행과 실제 운영 DB 검증은 하지 않았다. CI 원본 expected/actual은 제공되지 않았으며 로컬 고정 나노초 입력으로 해당 실패 메커니즘을 재현·검증했다. LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. +- 최종 판정: 독립 변경 리뷰 APPROVE, 차단 사항 없음. 기존 HTTP 458/526행의 완전 일치 검증과 전역 날짜 변환/DDL/환불 로직이 변경되지 않았음을 확인했다. `git diff --check` 통과, 후속 커밋 없음. + ### Phase 14 실행 순서와 의존성 | 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | @@ -1595,6 +1619,7 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 | 32 | `P14-T2` | `P14-T1` 검증 및 사용자 구현 승인 | 아니요 | 승인 대기, 코드 변경 금지 | | 33 | `P14-T3` | `P14-T2` 계약/승인 | 아니요 | 선행 구현 승인 대기 | | 34 | `P14-GATE` | `P14-T2`, `P14-T3` | 아니요 | 실패 원인 Task에 반영 | +| 35 | `P14-T4` | Jenkins 정밀도 불일치 후속 수정 승인 | 아니요 | DB roundtrip 실패 원인 확인 | ```text P14-T1 문서 검증 → 사용자 구현 승인 → P14-T2 → P14-T3 → P14-GATE diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 1afd3713..797c90df 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -903,6 +903,8 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. `occurredAt`은 `canceledAt`과 같은 UTC ISO `Z` 문자열이다. 누락되거나 취소할 수 없는 상태의 신청번호는 기존 `SodaException(messageKey = "common.error.invalid_request")`로 거부한다. 정상 `ApiResponse` envelope를 사용하며 신규 오류 스키마나 HTTP status 정책은 추가하지 않는다. +관리자 취소 시각은 `gift-schema.sql`의 `canceled_at TIMESTAMP` 정밀도에 맞춰 초 단위로 절삭한 단일 값을 저장과 응답에 사용한다. OS/JDK 시계가 나노초를 반환해도 DB 재조회 시각과 응답 시각은 일치해야 한다. 전역 날짜 변환 및 다른 상태 변경의 시각 정책은 변경하지 않는다. + 이 관리자 취소는 기존 사용자 직접 취소와 운송장 미등록 자동취소를 변경하지 않는다. 취소 전용 푸시는 추가하지 않으며, 자동취소 푸시 문구나 자동취소 사유를 관리자 취소에 재사용하지 않는다. ### 8.15 자동 스케줄링 작업