From f46a6c4c7c893bdabcc53d06d1fb46b9146b913f Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 08:44:44 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(gift):=20=EB=B0=B0=EC=86=A1=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=EC=9D=98=20null=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=EC=9D=84=20=EC=A7=80=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift-schema.sql | 8 ++ .../adapter/out/persistence/GiftDelivery.kt | 18 ++-- .../gift/application/GiftAdminQueryService.kt | 8 +- .../v2/gift/application/GiftQueryService.kt | 8 +- .../gift/adapter/in/web/GiftControllerTest.kt | 81 ++++++++++++++++ .../out/persistence/GiftEntityMappingTest.kt | 62 +++++++++++- .../application/GiftAdminQueryServiceTest.kt | 53 ++++++++++- .../gift/application/GiftQueryServiceTest.kt | 94 ++++++++++++++++++- 8 files changed, 312 insertions(+), 20 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/gift-schema.sql b/docs/20260929_크리에이터_선물하기/gift-schema.sql index af09cb64..9163dd36 100644 --- a/docs/20260929_크리에이터_선물하기/gift-schema.sql +++ b/docs/20260929_크리에이터_선물하기/gift-schema.sql @@ -128,3 +128,11 @@ CREATE TABLE gift_application_no_sequence ( ) COMMENT='선물 신청번호 날짜별 채번'; ALTER TABLE gift_category DROP INDEX uk_gift_category_receipt_code; + +-- 2026-10-23: 전달 완료 후 배송정보 삭제를 위한 발신자 컬럼 NULL 허용. +-- 기존 DB에는 아래 ALTER TABLE 문만 적용하며, 이 파일 전체를 재실행하지 않는다. +ALTER TABLE gift_delivery + MODIFY COLUMN sender_name VARCHAR(50) NULL COMMENT '발신자 이름', + MODIFY COLUMN sender_phone_number VARCHAR(30) NULL COMMENT '발신자 휴대폰 번호', + MODIFY COLUMN sender_zip_code VARCHAR(20) NULL COMMENT '발신자 우편번호', + MODIFY COLUMN sender_address VARCHAR(255) NULL COMMENT '발신자 주소'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt index 748e1757..37adce3a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt @@ -16,20 +16,20 @@ class GiftDelivery( @Column(name = "gift_id", nullable = false) val giftId: Long, - @Column(name = "sender_name", nullable = false, length = 50) - val senderName: String, + @Column(name = "sender_name", length = 50) + var senderName: String?, - @Column(name = "sender_phone_number", nullable = false, length = 30) - val senderPhoneNumber: String, + @Column(name = "sender_phone_number", length = 30) + var senderPhoneNumber: String?, - @Column(name = "sender_zip_code", nullable = false, length = 20) - val senderZipCode: String, + @Column(name = "sender_zip_code", length = 20) + var senderZipCode: String?, - @Column(name = "sender_address", nullable = false, length = 255) - val senderAddress: String, + @Column(name = "sender_address", length = 255) + var senderAddress: String?, @Column(name = "sender_address_detail", length = 255) - val senderAddressDetail: String? = null, + var senderAddressDetail: String? = null, @Column(name = "recipient_name", length = 50) var recipientName: String? = null, 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 79e9c34d..0f2bb43a 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 @@ -115,8 +115,8 @@ class GiftAdminQueryService( applicationNo = gift.applicationNo, senderInfo = AdminGiftMemberInfoResult( nickname = sender.nickname, - name = delivery.senderName, - phoneNumber = delivery.senderPhoneNumber, + name = delivery.senderName.orEmpty(), + phoneNumber = delivery.senderPhoneNumber.orEmpty(), address = formatAddress(delivery.senderZipCode, delivery.senderAddress, delivery.senderAddressDetail) ), recipientInfo = AdminGiftMemberInfoResult( @@ -174,8 +174,8 @@ class GiftAdminQueryService( return formatAddress(zipCode, address, recipientAddressDetail) } - private fun formatAddress(zipCode: String, address: String, detail: String?) = - "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" + private fun formatAddress(zipCode: String?, address: String?, detail: String?) = + if (zipCode == null || address == null) "" else "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index 961060c5..b4e2306e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -259,8 +259,8 @@ class GiftQueryService( } private fun GiftDelivery.toSenderInfo() = GiftAddressResult( - name = senderName, - phoneNumber = senderPhoneNumber, + name = senderName.orEmpty(), + phoneNumber = senderPhoneNumber.orEmpty(), address = formatAddress(senderZipCode, senderAddress, senderAddressDetail) ) @@ -305,8 +305,8 @@ class GiftQueryService( ) } - private fun formatAddress(zipCode: String, address: String, detail: String?) = - "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" + private fun formatAddress(zipCode: String?, address: String?, detail: String?) = + if (zipCode == null || address == null) "" else "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index 2deec4e5..f461254b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -29,8 +29,11 @@ import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResu import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.hamcrest.Matchers.endsWith +import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -219,6 +222,84 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z")) } + @Test + @DisplayName("배송 개인정보가 지워져도 보낸 선물과 받은 선물의 상세 응답 계약을 유지한다") + fun shouldReturnCompatibleErasedGiftDetailsForSenderAndRecipient() { + val sender = memberRepository.save(Member(password = "password", nickname = "erased-fan")) + val recipient = memberRepository.save(Member(password = "password", nickname = "erased-creator")) + val gift = saveGift("G-CONTROLLER-ERASED", sender.id!!, recipient.id!!, GiftStatus.DELIVERED) + val delivery = GiftDelivery( + giftId = gift.id!!, + senderName = null, + senderPhoneNumber = null, + senderZipCode = null, + senderAddress = null, + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0), + recipientAddressDeadlineAt = LocalDateTime.of(2026, 10, 7, 12, 0), + deliveredAt = LocalDateTime.of(2026, 10, 3, 12, 0) + ) + giftDeliveryRepository.saveAndFlush(delivery) + + mockMvc.perform(get("/api/v2/gifts/{applicationNo}", gift.applicationNo).with(user(MemberAdapter(sender)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.direction").value("SENT")) + .andExpect(jsonPath("$.data.status").value("DELIVERED")) + .andExpect(jsonPath("$.data.senderInfo").isMap) + .andExpect(jsonPath("$.data.senderInfo.name").value("")) + .andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("")) + .andExpect(jsonPath("$.data.senderInfo.address").value("")) + .andExpect(jsonPath("$.data.recipientAddress").value(nullValue())) + .andExpect(jsonPath("$.data.giftInfo.tracking").value(nullValue())) + .andExpect(jsonPath("$.data.giftInfo.recipientCreatorNickname").value("erased-creator")) + .andExpect(jsonPath("$.data.trackingRequired").value(false)) + .andExpect(jsonPath("$.data.recipientAddressRequired").value(false)) + .andExpect(jsonPath("$.data.mailbox").value(nullValue())) + .andExpect(jsonPath("$.data.statusTimeline[4].occurredAt").value("2026-10-03T12:00:00Z")) + + mockMvc.perform(get("/api/v2/gifts/{applicationNo}", gift.applicationNo).with(user(MemberAdapter(recipient)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.direction").value("RECEIVED")) + .andExpect(jsonPath("$.data.senderInfo").value(nullValue())) + .andExpect(jsonPath("$.data.recipientAddress").value(nullValue())) + .andExpect(jsonPath("$.data.giftInfo.tracking").value(nullValue())) + .andExpect(jsonPath("$.data.giftInfo.senderNickname").value("erased-fan")) + .andExpect(jsonPath("$.data.trackingRequired").value(false)) + .andExpect(jsonPath("$.data.recipientAddressRequired").value(false)) + .andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-07T12:00:00Z")) + } + + @ParameterizedTest + @ValueSource(strings = ["senderName", "senderPhoneNumber", "senderZipCode", "senderAddress"]) + @DisplayName("저장 필드가 nullable이어도 신청의 필수 발신자 입력 누락을 거부한다") + fun shouldKeepRequiredSenderRequestValidation(missingField: String) { + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = objectMapper.createObjectNode() + .put("recipientMemberId", RECIPIENT_ID) + .put("senderName", "sender") + .put("senderPhoneNumber", "phone") + .put("senderZipCode", "zip") + .put("senderAddress", "address") + .put("sizeCode", "SMALL") + .put("categoryId", CATEGORY_ID) + .put("senderTermsAgreed", true) + .put("senderPrivacyAgreed", true) + .put("damageWaiverAgreed", true) + request.remove(missingField) + + mockMvc.perform( + post("/api/v2/gifts") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + + Mockito.verifyNoInteractions(commandService) + } + @Test @DisplayName("발신자는 전달 완료 전 정상 진행 선물 상세에서 받을 주소를 조회한다") fun shouldGetMailboxForSentNormalProgressGiftDetailBeforeDelivered() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt index 577bcd03..0abded75 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -13,6 +14,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import org.springframework.dao.DataIntegrityViolationException import java.time.LocalDateTime +import javax.persistence.EntityManager @DataJpaTest( properties = [ @@ -28,7 +30,8 @@ class GiftEntityMappingTest @Autowired constructor( private val giftCategoryRepository: GiftCategoryRepository, private val giftSizePriceRepository: GiftSizePriceRepository, private val giftReviewRepository: GiftReviewRepository, - private val giftApplicationNoSequenceRepository: GiftApplicationNoSequenceRepository + private val giftApplicationNoSequenceRepository: GiftApplicationNoSequenceRepository, + private val entityManager: EntityManager ) { @Test @DisplayName("선물 엔티티와 배송 정보를 저장하고 조회한다") @@ -91,6 +94,63 @@ class GiftEntityMappingTest @Autowired constructor( assertNotNull(saved.updatedAt) } + @Test + @DisplayName("발신자와 수신자 및 운송장 개인정보를 모두 null로 저장하고 다시 조회한다") + fun shouldPersistNullForAllSenderFields() { + val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A")) + val gift = giftRepository.save(gift(category.id!!, "G-NULL-DELIVERY")) + val deadline = LocalDateTime.of(2026, 10, 2, 12, 0) + val delivery = giftDeliveryRepository.saveAndFlush( + GiftDelivery( + giftId = gift.id!!, + senderName = "sender", + senderPhoneNumber = "sender-phone", + senderZipCode = "sender-zip", + senderAddress = "sender-address", + senderAddressDetail = "sender-detail", + recipientName = "recipient", + recipientPhoneNumber = "recipient-phone", + recipientZipCode = "recipient-zip", + recipientAddress = "recipient-address", + recipientAddressDetail = "recipient-detail", + courierCompanyName = "courier", + trackingNumber = "tracking", + trackingDeadlineAt = deadline + ) + ) + delivery.senderName = null + delivery.senderPhoneNumber = null + delivery.senderZipCode = null + delivery.senderAddress = null + delivery.senderAddressDetail = null + delivery.recipientName = null + delivery.recipientPhoneNumber = null + delivery.recipientZipCode = null + delivery.recipientAddress = null + delivery.recipientAddressDetail = null + delivery.courierCompanyName = null + delivery.trackingNumber = null + + entityManager.flush() + entityManager.clear() + + val reloaded = requireNotNull(giftDeliveryRepository.findByGiftId(gift.id!!)) + assertNull(reloaded.senderName) + assertNull(reloaded.senderPhoneNumber) + assertNull(reloaded.senderZipCode) + assertNull(reloaded.senderAddress) + assertNull(reloaded.senderAddressDetail) + assertNull(reloaded.recipientName) + assertNull(reloaded.recipientPhoneNumber) + assertNull(reloaded.recipientZipCode) + assertNull(reloaded.recipientAddress) + assertNull(reloaded.recipientAddressDetail) + assertNull(reloaded.courierCompanyName) + assertNull(reloaded.trackingNumber) + assertEquals(false, reloaded.hasCompleteRecipientInformation()) + assertEquals(deadline, reloaded.trackingDeadlineAt) + } + @Test @DisplayName("신청번호와 카테고리 분류번호 및 코드와 리뷰 선물 ID는 중복 저장할 수 없다") fun shouldRejectDuplicateUniqueValues() { 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 ef9568b0..e6407534 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 @@ -13,13 +13,18 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull 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.NullAndEmptySource +import org.junit.jupiter.params.provider.ValueSource 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 = [ @@ -33,7 +38,8 @@ class GiftAdminQueryServiceTest @Autowired constructor( private val giftRepository: GiftRepository, private val deliveryRepository: GiftDeliveryRepository, private val categoryRepository: GiftCategoryRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val entityManager: EntityManager ) { @Test @DisplayName("관리자는 선물함 목록을 필터링하고 가능한 액션을 조회한다") @@ -124,6 +130,51 @@ class GiftAdminQueryServiceTest @Autowired constructor( assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions) } + @Test + @DisplayName("개인정보가 지워진 관리자 선물 응답은 닉네임과 종료 상태 액션을 유지한다") + fun shouldMapErasedDeliveryStringsWithoutChangingActions() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val gift = saveGift("A-ERASED-DETAIL", sender, recipient, saveCategory(), GiftStatus.DELIVERED) + requireNotNull(deliveryRepository.findByGiftId(gift.id!!)).apply { + senderName = null + senderPhoneNumber = null + senderZipCode = null + senderAddress = null + senderAddressDetail = null + } + entityManager.flush() + entityManager.clear() + + val detail = service().getGiftDetail(gift.applicationNo) + val item = service().getGifts(GiftStatus.DELIVERED, null, null, 0, 20).items.single() + + assertEquals(AdminGiftMemberInfoResult("fan-alpha", "", "", ""), detail.senderInfo) + assertEquals(AdminGiftMemberInfoResult("creator-alpha", "", "", ""), detail.recipientInfo) + assertNull(detail.inboundDeliveryInfo.courierCompanyName) + assertNull(detail.inboundDeliveryInfo.trackingNumber) + assertEquals(GiftStatus.DELIVERED, detail.status) + assertEquals(emptyList(), detail.availableActions) + assertEquals("fan-alpha", item.senderNickname) + assertEquals("creator-alpha", item.recipientNickname) + assertNull(item.courierCompanyName) + assertNull(item.trackingNumber) + assertEquals(emptyList(), item.availableActions) + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = [" "]) + @DisplayName("관리자 발신자 주소도 상세주소가 없으면 구분자를 생략한다") + fun shouldOmitAbsentSenderAddressDetail(detail: String?) { + val gift = saveGift("A-NO-DETAIL", saveMember("fan"), saveMember("creator"), saveCategory(), GiftStatus.RECEIVED) + requireNotNull(deliveryRepository.findByGiftId(gift.id!!)).senderAddressDetail = detail + + val result = service().getGiftDetail(gift.applicationNo) + + assertEquals("(06234) 서울시 강남구", result.senderInfo.address) + } + @Test @DisplayName("관리자 선물 목록과 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다") fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index e1c06d67..5a46de9e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -27,10 +27,14 @@ import org.junit.jupiter.api.Assertions.assertNull 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.NullAndEmptySource +import org.junit.jupiter.params.provider.ValueSource 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 = [ @@ -46,7 +50,8 @@ class GiftQueryServiceTest @Autowired constructor( private val deliveryRepository: GiftDeliveryRepository, private val memberRepository: MemberRepository, private val giftMailboxRepository: GiftMailboxRepository, - private val creatorLocalizedNameService: CreatorLocalizedNameService + private val creatorLocalizedNameService: CreatorLocalizedNameService, + private val entityManager: EntityManager ) { @Test fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() { @@ -313,6 +318,93 @@ class GiftQueryServiceTest @Autowired constructor( assertNull(result.statusTimeline[1].occurredAt) } + @Test + @DisplayName("개인정보가 지워진 보낸 선물은 발신자 객체와 빈 문자열을 유지한다") + fun shouldMapErasedSentSenderDetailsToEmptyStrings() { + val sender = saveMember("fan") + val recipient = saveMember("creator", "profile/creator.png") + val gift = saveGift("G-ERASED-SENT", sender.id!!, recipient.id!!, GiftStatus.DELIVERED) + saveDelivery(gift).apply { + applyStatusTimes(GiftStatus.DELIVERED) + recipientAddressDeadlineAt = NOW.plusDays(7) + senderName = null + senderPhoneNumber = null + senderZipCode = null + senderAddress = null + senderAddressDetail = null + } + entityManager.flush() + entityManager.clear() + + val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo, Lang.KO) + val item = queryService().getGifts(sender.id!!, GiftListType.SENT, 0, 20, Lang.KO).items.single() + + assertEquals(GiftDirection.SENT, result.direction) + assertEquals(GiftStatus.DELIVERED, result.status) + assertEquals(GiftAddressResult("", "", ""), result.senderInfo) + assertNull(result.recipientAddress) + assertNull(result.giftInfo.tracking) + assertNull(result.mailbox) + assertEquals("creator", result.giftInfo.recipientCreatorNickname) + assertEquals(false, result.trackingRequired) + assertEquals(false, result.recipientAddressRequired) + assertEquals(NOW.plusHours(4), result.statusTimeline.last().occurredAt) + assertEquals("creator", item.counterpartNickname) + assertEquals("https://cdn.test/profile/creator.png", item.counterpartProfileImageUrl) + assertEquals(false, item.recipientAddressRegistered) + assertEquals(NOW.plusDays(7), item.recipientAddressDeadlineAt) + } + + @Test + @DisplayName("개인정보가 지워진 받은 선물은 발신자 정보와 수신자 배송지를 null로 유지한다") + fun shouldKeepReceivedSenderInfoNullAndRecipientAddressNullWhenErased() { + val sender = saveMember("fan", "profile/fan.png") + val recipient = saveMember("creator") + val gift = saveGift("G-ERASED-RECEIVED", sender.id!!, recipient.id!!, GiftStatus.DELIVERED) + saveDelivery(gift).apply { + applyStatusTimes(GiftStatus.DELIVERED) + recipientAddressDeadlineAt = NOW.plusDays(7) + senderName = null + senderPhoneNumber = null + senderZipCode = null + senderAddress = null + senderAddressDetail = null + } + entityManager.flush() + entityManager.clear() + + val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo, Lang.KO) + val item = queryService().getGifts(recipient.id!!, GiftListType.RECEIVED, 0, 20, Lang.KO).items.single() + + assertEquals(GiftDirection.RECEIVED, result.direction) + assertNull(result.senderInfo) + assertNull(result.recipientAddress) + assertNull(result.giftInfo.tracking) + assertEquals("fan", result.giftInfo.senderNickname) + assertEquals(false, result.trackingRequired) + assertEquals(false, result.recipientAddressRequired) + assertEquals(NOW.plusDays(7), result.recipientAddressDeadlineAt) + assertEquals("fan", item.counterpartNickname) + assertEquals("https://cdn.test/profile/fan.png", item.counterpartProfileImageUrl) + assertEquals(false, item.recipientAddressRegistered) + assertEquals(NOW.plusDays(7), item.recipientAddressDeadlineAt) + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = [" "]) + @DisplayName("발신자 상세주소가 없으면 주소 뒤 구분자를 생략한다") + fun shouldOmitAbsentSenderAddressDetail(detail: String?) { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val gift = saveGift("G-NO-ADDRESS-DETAIL", sender.id!!, recipient.id!!, GiftStatus.RECEIVED) + saveDelivery(gift).senderAddressDetail = detail + + val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo, Lang.KO) + + assertEquals("(06234) 서울시 강남구", result.senderInfo?.address) + } + @Test @DisplayName("보낸 선물의 전달 완료 전 정상 진행 상세는 받을 주소를 반환한다") fun shouldReturnMailboxForSentNormalProgressGiftDetailBeforeDelivered() { From 8b2ac38b836600826b511224e2502d44741051e9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 08:44:59 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(gift):=20=EB=B0=B0=EC=86=A1=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EA=B3=BC=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B3=B4=ED=98=B8=EB=A5=BC=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 --- .../out/persistence/GiftDeliveryRepository.kt | 19 +- .../adapter/out/persistence/GiftRepository.kt | 42 +- .../adapter/out/scheduler/GiftScheduler.kt | 179 ++++- .../v2/gift/application/GiftAdminService.kt | 18 +- .../v2/gift/application/GiftCommandService.kt | 12 +- .../adapter/in/web/AdminGiftControllerTest.kt | 523 ++++++++++++++- .../v2/gift/GiftCommandServiceTest.kt | 12 +- .../out/scheduler/GiftSchedulerTest.kt | 621 +++++++++++++++++- 8 files changed, 1360 insertions(+), 66 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt index 2a4c3487..d885410d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt @@ -1,9 +1,26 @@ package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import javax.persistence.EntityManager +import javax.persistence.LockModeType -interface GiftDeliveryRepository : JpaRepository { +interface GiftDeliveryRepository : JpaRepository, GiftDeliveryLockRepository { fun findByGiftId(giftId: Long): GiftDelivery? fun findByGiftIdIn(giftIds: Collection): List } + +interface GiftDeliveryLockRepository { + fun findByGiftIdForUpdate(giftId: Long): GiftDelivery? +} + +@Repository +class GiftDeliveryLockRepositoryImpl(private val entityManager: EntityManager) : GiftDeliveryLockRepository { + override fun findByGiftIdForUpdate(giftId: Long): GiftDelivery? = entityManager + .createQuery("select d from GiftDelivery d where d.giftId = :giftId", GiftDelivery::class.java) + .setParameter("giftId", giftId) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .resultList.singleOrNull() + ?.also { entityManager.refresh(it, LockModeType.PESSIMISTIC_WRITE) } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt index 8048ff04..e18c12cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt @@ -7,16 +7,14 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository import java.time.LocalDateTime +import javax.persistence.EntityManager import javax.persistence.LockModeType -interface GiftRepository : JpaRepository { +interface GiftRepository : JpaRepository, GiftLockRepository { fun findByApplicationNo(applicationNo: String): Gift? - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select g from Gift g where g.applicationNo = :applicationNo") - fun findByApplicationNoForUpdate(@Param("applicationNo") applicationNo: String): Gift? - fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page fun findByRecipientMemberIdAndStatusIn( @@ -67,6 +65,7 @@ interface GiftRepository : JpaRepository { pageable: Pageable ): Page + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query( """ select g @@ -78,9 +77,9 @@ interface GiftRepository : JpaRepository { where d.giftId = g.id and d.trackingDeadlineAt < :now ) + order by g.id """ ) - @Lock(LockModeType.PESSIMISTIC_WRITE) fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List @Query( @@ -105,6 +104,22 @@ interface GiftRepository : JpaRepository { @Param("now") now: LocalDateTime ): List + @Query( + """ + select g + from Gift g + where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED + and exists ( + select d.id + from GiftDelivery d + where d.giftId = g.id + and d.deliveredAt is not null + and d.deliveredAt <= :cutoff + ) + """ + ) + fun findDeliveredGiftsWithDeliveryBeforeOrAt(@Param("cutoff") cutoff: LocalDateTime): List + @Query( """ select g @@ -120,7 +135,6 @@ interface GiftRepository : JpaRepository { ) """ ) - @Lock(LockModeType.PESSIMISTIC_WRITE) fun findReceivedGiftsWithTrackingDeadlineReminderDue( @Param("now") now: LocalDateTime, @Param("reminderUntil") reminderUntil: LocalDateTime @@ -151,3 +165,17 @@ interface GiftRepository : JpaRepository { @Param("reminderUntil") reminderUntil: LocalDateTime ): List } + +interface GiftLockRepository { + fun findByApplicationNoForUpdate(applicationNo: String): Gift? +} + +@Repository +class GiftLockRepositoryImpl(private val entityManager: EntityManager) : GiftLockRepository { + override fun findByApplicationNoForUpdate(applicationNo: String): Gift? = entityManager + .createQuery("select g from Gift g where g.applicationNo = :applicationNo", Gift::class.java) + .setParameter("applicationNo", applicationNo) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .resultList.singleOrNull() + ?.also { entityManager.refresh(it, LockModeType.PESSIMISTIC_WRITE) } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt index 3d7b57c5..1c64c951 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt @@ -5,10 +5,13 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepo import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.redisson.api.RLock import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime import java.util.concurrent.TimeUnit @@ -39,63 +42,183 @@ class GiftScheduler( @Transactional fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int { val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now) - expiredGifts.forEach { gift -> + return expiredGifts.sortedBy { it.id }.count { candidate -> + val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo)) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id))) + if (gift.status != GiftStatus.RECEIVED || !delivery.trackingDeadlineAt.isBefore(now)) { + return@count false + } gift.status = GiftStatus.CANCELED - requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now + delivery.canceledAt = now canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId) giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId) + true + } + } + + @Scheduled(cron = "0 10 * * * *", zone = "Asia/Seoul") + @Transactional + fun runExpiredRecipientAddressGiftExpiration() { + val lock = redissonClient.getLock(EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY) + if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { + return } - return expiredGifts.size + try { + markExpiredRecipientAddressGiftsUndeliverable(LocalDateTime.now()) + } finally { + unlockAfterTransaction(lock) + } } @Transactional fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int { val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now) - expiredGifts.forEach { gift -> + return expiredGifts.sortedBy { it.id }.count { candidate -> + val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo)) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id))) + if (gift.status !in RECIPIENT_ADDRESS_STATUSES || delivery.recipientAddress != null || + delivery.recipientAddressDeadlineAt?.isBefore(now) != true + ) { + return@count false + } gift.status = GiftStatus.UNDELIVERABLE - val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) delivery.undeliverableAt = now delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON + true + } + } + + @Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul") + @Transactional + fun runDeliveredGiftPersonalDataErasure() { + val lock = redissonClient.getLock(DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY) + if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { + return } - return expiredGifts.size + try { + eraseDeliveredGiftPersonalData(LocalDateTime.now()) + } finally { + unlockAfterTransaction(lock) + } + } + + @Transactional + fun eraseDeliveredGiftPersonalData(now: LocalDateTime): Int { + val cutoff = now.minusDays(7) + val expiredGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff) + + return expiredGifts.count { gift -> + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + if ( + listOf( + delivery.senderName, + delivery.senderPhoneNumber, + delivery.senderZipCode, + delivery.senderAddress, + delivery.senderAddressDetail, + delivery.recipientName, + delivery.recipientPhoneNumber, + delivery.recipientZipCode, + delivery.recipientAddress, + delivery.recipientAddressDetail, + delivery.courierCompanyName, + delivery.trackingNumber + ).all { it == null } + ) { + false + } else { + delivery.senderName = null + delivery.senderPhoneNumber = null + delivery.senderZipCode = null + delivery.senderAddress = null + delivery.senderAddressDetail = null + delivery.recipientName = null + delivery.recipientPhoneNumber = null + delivery.recipientZipCode = null + delivery.recipientAddress = null + delivery.recipientAddressDetail = null + delivery.courierCompanyName = null + delivery.trackingNumber = null + true + } + } + } + + @Scheduled(cron = "0 40 * * * *", zone = "Asia/Seoul") + @Transactional + fun runDeadlineReminderGifts() { + val lock = redissonClient.getLock(DEADLINE_REMINDER_GIFTS_LOCK_KEY) + if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) { + return + } + + try { + sendDeadlineReminderGifts(LocalDateTime.now()) + } finally { + unlockAfterTransaction(lock) + } } @Transactional fun sendDeadlineReminderGifts(now: LocalDateTime): Int { val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS) - val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil) - val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil) - - return trackingReminderCount + recipientAddressReminderCount + val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil) + + giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil) + return gifts.distinctBy { it.id }.sortedBy { it.id }.count { candidate -> + val gift = requireNotNull(giftRepository.findByApplicationNoForUpdate(candidate.applicationNo)) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id))) + val recipientDeadline = delivery.recipientAddressDeadlineAt + when { + gift.status == GiftStatus.RECEIVED && delivery.trackingDeadlineAt.isAfter(now) && + !delivery.trackingDeadlineAt.isAfter(reminderUntil) && delivery.trackingDeadlineReminderSentAt == null -> { + delivery.trackingDeadlineReminderSentAt = now + giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId) + true + } + gift.status in RECIPIENT_ADDRESS_STATUSES && delivery.recipientAddress == null && + recipientDeadline != null && recipientDeadline.isAfter(now) && !recipientDeadline.isAfter(reminderUntil) && + delivery.recipientAddressDeadlineReminderSentAt == null -> { + delivery.recipientAddressDeadlineReminderSentAt = now + giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId) + true + } + else -> false + } + } } - private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int { - val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil) - gifts.forEach { gift -> - val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) - delivery.trackingDeadlineReminderSentAt = now - giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId) + private fun unlockAfterTransaction(lock: RLock) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + return } - return gifts.size - } - - private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int { - val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil) - gifts.forEach { gift -> - val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) - delivery.recipientAddressDeadlineReminderSentAt = now - giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId) - } - - return gifts.size + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCompletion(status: Int) { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + ) } private companion object { + val RECIPIENT_ADDRESS_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) const val DEADLINE_REMINDER_HOURS = 24L const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation" + const val EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY = "lock:gift-expired-recipient-address-undeliverable" + const val DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY = "lock:gift-delivered-personal-data-erasure" + const val DEADLINE_REMINDER_GIFTS_LOCK_KEY = "lock:gift-deadline-reminders" const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과" } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt index f25d3f11..2fe893dc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -157,7 +157,7 @@ class GiftAdminService( fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.RECEIVED) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() val canceledAt = now.truncatedTo(ChronoUnit.SECONDS) gift.status = GiftStatus.CANCELED @@ -169,9 +169,9 @@ class GiftAdminService( @Transactional fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() gift.status = GiftStatus.ARRIVED_AT_MAILBOX delivery.arrivedAtMailboxAt = now @@ -183,9 +183,9 @@ class GiftAdminService( @Transactional fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() gift.status = GiftStatus.INSPECTION_COMPLETED delivery.inspectionCompletedAt = now @@ -200,11 +200,11 @@ class GiftAdminService( reason: String, now: LocalDateTime = LocalDateTime.now() ): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) { throw invalidRequest() } - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() gift.status = GiftStatus.UNDELIVERABLE delivery.undeliverableAt = now @@ -217,9 +217,9 @@ class GiftAdminService( @Transactional fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest() - val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftIdForUpdate(requireNotNull(gift.id)) ?: throw invalidRequest() if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest() gift.status = GiftStatus.DELIVERED diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt index 86f1c82e..47755809 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt @@ -212,7 +212,7 @@ class GiftCommandService( val canceledAt = now() gift.status = GiftStatus.CANCELED - deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt + deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId) return GiftCancellationResult( @@ -234,7 +234,7 @@ class GiftCommandService( val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() validate(command) - val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val trackingRegisteredAt = now() val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7) @@ -263,12 +263,12 @@ class GiftCommandService( command: GiftRecipientAddressRegistrationCommand ): GiftRecipientAddressRegistrationResult { val recipientMemberId = member.id ?: throw invalidRequest() - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) { throw invalidRequest() } validate(command) - val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val registeredAt = now() delivery.recipientName = command.name @@ -292,11 +292,11 @@ class GiftCommandService( @Transactional fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult { val recipientMemberId = member.id ?: throw invalidRequest() - val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest() if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) { throw invalidRequest() } - val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() val deliveredAt = now() gift.status = GiftStatus.DELIVERED diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index ae27c462..13a343c0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -28,15 +28,19 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepo import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftScheduler +import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.hamcrest.Matchers.endsWith +import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -50,6 +54,8 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito +import org.springframework.aop.framework.Advised +import org.springframework.aop.support.AopUtils import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -63,6 +69,9 @@ import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor +import org.springframework.scheduling.config.CronTask +import org.springframework.scheduling.support.ScheduledMethodRunnable import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication @@ -78,6 +87,8 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.interceptor.TransactionInterceptor +import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.context.request.RequestContextHolder import org.springframework.web.context.request.ServletRequestAttributes @@ -113,12 +124,14 @@ class AdminGiftControllerTest @Autowired constructor( private val useCanRepository: UseCanRepository, private val calculateRepository: UseCanCalculateRepository, private val commandService: GiftCommandService, + private val adminService: GiftAdminService, private val scheduler: GiftScheduler, private val transactionManager: PlatformTransactionManager, private val entityManager: EntityManager, private val objectMapper: ObjectMapper, private val restTemplate: TestRestTemplate, - private val tokenProvider: TokenProvider + private val tokenProvider: TokenProvider, + private val scheduledProcessor: ScheduledAnnotationBeanPostProcessor ) { @LocalServerPort private var port: Int = 0 @@ -131,6 +144,25 @@ class AdminGiftControllerTest @Autowired constructor( @BeforeEach fun deleteGiftFixtures() { + val scheduledJobs = scheduledProcessor.scheduledTasks.filter { + (it.task.runnable as? ScheduledMethodRunnable)?.target === scheduler + } + assertTrue(AopUtils.isAopProxy(scheduler)) + assertTrue((scheduler as Advised).advisors.any { it.advice is TransactionInterceptor }) + assertEquals(4, scheduledJobs.size) + assertEquals( + mapOf( + "runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *", + "runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *", + "runDeliveredGiftPersonalDataErasure" to "0 20 * * * *", + "runDeadlineReminderGifts" to "0 40 * * * *" + ), + scheduledJobs.associate { + (it.task.runnable as ScheduledMethodRunnable).method.name to (it.task as CronTask).expression + } + ) + println("P15_SPRING transactionalProxy=true registeredMinutes=00,10,20,40 cancelingTestJobs=${scheduledJobs.size}") + scheduledJobs.forEach { it.cancel() } RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) giftDeliveryRepository.deleteAll() giftRepository.deleteAll() @@ -270,6 +302,248 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE")) } + @Test + @DisplayName("관리자는 개인정보가 지워진 전달 완료 선물을 기존 빈 문자열과 null 응답으로 조회한다") + fun shouldReturnCompatibleErasedAdminGiftResponses() { + val (gift, delivery) = saveAdminGift( + applicationNo = "A-ERASED-CONTROLLER", + status = GiftStatus.DELIVERED, + senderNickname = "erased-admin-fan", + recipientNickname = "erased-admin-creator", + withTracking = false + ) + delivery.senderName = null + delivery.senderPhoneNumber = null + delivery.senderZipCode = null + delivery.senderAddress = null + delivery.senderAddressDetail = null + giftDeliveryRepository.saveAndFlush(delivery) + + mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.status").value("DELIVERED")) + .andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan")) + .andExpect(jsonPath("$.data.senderInfo.name").value("")) + .andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("")) + .andExpect(jsonPath("$.data.senderInfo.address").value("")) + .andExpect(jsonPath("$.data.recipientInfo.nickname").value("erased-admin-creator")) + .andExpect(jsonPath("$.data.recipientInfo.name").value("")) + .andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value("")) + .andExpect(jsonPath("$.data.recipientInfo.address").value("")) + .andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value(nullValue())) + .andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value(nullValue())) + .andExpect(jsonPath("$.data.availableActions").isEmpty) + + mockMvc.perform( + get("/api/v2/admin/gifts") + .with(user("admin").roles("ADMIN")) + .param("applicationNo", gift.applicationNo) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].senderNickname").value("erased-admin-fan")) + .andExpect(jsonPath("$.data.items[0].recipientNickname").value("erased-admin-creator")) + .andExpect(jsonPath("$.data.items[0].courierCompanyName").value(nullValue())) + .andExpect(jsonPath("$.data.items[0].trackingNumber").value(nullValue())) + .andExpect(jsonPath("$.data.items[0].availableActions").isEmpty) + } + + @Test + @DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다") + fun shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts() { + val now = NOW.plusDays(14) + val recipient = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient") + ) + val unrelated = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-unrelated") + ) + val fixture = saveFundedGift(recipient) + assertEquals(4, setOf(fixture.sender.id, recipient.id, fixture.admin.id, unrelated.id).size) + val giftId = requireNotNull(fixture.gift.id) + val transaction = TransactionTemplate(transactionManager) + val review = requireNotNull( + transaction.execute { + val gift = giftRepository.findById(giftId).orElseThrow() + gift.status = GiftStatus.DELIVERED + gift.recipientTermsAgreed = true + gift.recipientPrivacyAgreed = true + gift.recipientTermsAgreedAt = NOW + gift.recipientPrivacyAgreedAt = NOW + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)) + delivery.senderAddressDetail = "101동 1001호" + delivery.recipientName = "김소다" + delivery.recipientPhoneNumber = "01098765432" + delivery.recipientZipCode = "04524" + delivery.recipientAddress = "서울시 중구" + delivery.recipientAddressDetail = "202호" + delivery.courierCompanyName = "CJ대한통운" + delivery.trackingNumber = "1234567890" + delivery.trackingRegisteredAt = NOW.minusDays(2) + delivery.arrivedAtMailboxAt = NOW.minusDays(1) + delivery.inspectionCompletedAt = NOW.plusDays(1) + delivery.deliveredAt = now.minusDays(7) + delivery.recipientAddressDeadlineAt = NOW.plusDays(5) + delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3) + delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4) + GiftReview(giftId, requireNotNull(fixture.sender.id), 5, "SAFE", "retained review").also { + entityManager.persist(it) + } + } + ) + val personalFields = listOf( + "senderName", "senderPhoneNumber", "senderZipCode", "senderAddress", "senderAddressDetail", + "recipientName", "recipientPhoneNumber", "recipientZipCode", "recipientAddress", "recipientAddressDetail", + "courierCompanyName", "trackingNumber" + ) + val originalGift = objectMapper.valueToTree(giftRepository.findById(giftId).orElseThrow()) + val originalDelivery = objectMapper.valueToTree(giftDeliveryRepository.findByGiftId(giftId)) + personalFields.forEach { + assertTrue(originalDelivery.path(it).isTextual && originalDelivery.path(it).textValue().isNotBlank()) + } + originalDelivery.remove(personalFields + "updatedAt") + val originalReview = transaction.execute { + objectMapper.valueToTree(requireNotNull(entityManager.find(GiftReview::class.java, review.id))) + } + assertRefundState(fixture, refunded = false) + + val senderToken = tokenFor(fixture.sender) + val recipientToken = tokenFor(recipient) + val adminToken = tokenFor(fixture.admin) + val unrelatedToken = tokenFor(unrelated) + val detailPath = "/api/v2/gifts/$APPLICATION_NO" + val adminPath = "/api/v2/admin/gifts/$APPLICATION_NO" + val adminListPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO" + val senderAddress = objectMapper.readTree( + """{"name":"홍길동","phoneNumber":"01012345678","address":"(06234) 서울시 강남구, 101동 1001호"}""" + ) + val recipientAddress = objectMapper.readTree( + """{"name":"김소다","phoneNumber":"01098765432","address":"(04524) 서울시 중구, 202호"}""" + ) + val emptyAddress = objectMapper.readTree("""{"name":"","phoneNumber":"","address":""}""") + val json = objectMapper.nodeFactory + var originalTimeline: JsonNode? = null + listOf(false, true).forEach { erased -> + if (erased) { + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(now)) + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(now)) + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) + } + listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach { + giftHttp(HttpMethod.GET, it, expectedStatus = HttpStatus.UNAUTHORIZED) + } + listOf(detailPath, adminPath, adminListPath).forEach { path -> + val denied = giftHttp(HttpMethod.GET, path, unrelatedToken) + assertEquals(json.booleanNode(false), denied.path("success")) + assertEquals(json.nullNode(), denied.path("data")) + assertEquals( + json.textNode(if (path == detailPath) "Invalid request." else "You do not have permission."), + denied.path("message") + ) + } + val sender = giftHttp(HttpMethod.GET, detailPath, senderToken) + val received = giftHttp(HttpMethod.GET, detailPath, recipientToken) + val admin = giftHttp(HttpMethod.GET, adminPath, adminToken) + listOf(sender, received, admin).forEach { assertEquals(json.booleanNode(true), it.path("success")) } + val sentData = sender.path("data") + val receivedData = received.path("data") + val adminData = admin.path("data") + assertEquals(if (erased) emptyAddress else senderAddress, sentData.path("senderInfo")) + assertEquals(json.nullNode(), sentData.path("recipientAddress")) + assertEquals(json.nullNode(), receivedData.path("senderInfo")) + assertEquals(if (erased) json.nullNode() else recipientAddress, receivedData.path("recipientAddress")) + assertEquals(json.nullNode(), receivedData.path("giftInfo").path("tracking")) + assertEquals(json.textNode("SENT"), sentData.path("direction")) + assertEquals(json.textNode("RECEIVED"), receivedData.path("direction")) + assertEquals(json.textNode(recipient.nickname), sentData.path("giftInfo").path("recipientCreatorNickname")) + assertEquals(json.textNode(fixture.sender.nickname), receivedData.path("giftInfo").path("senderNickname")) + assertEquals( + if (erased) json.nullNode() else json.textNode("CJ대한통운_1234567890"), + sentData.path("giftInfo").path("tracking") + ) + originalTimeline = originalTimeline ?: sentData.path("statusTimeline") + assertTrue(requireNotNull(originalTimeline).isArray) + assertEquals(5, requireNotNull(originalTimeline).size()) + assertEquals(json.textNode("2026-10-10T12:00:00Z"), requireNotNull(originalTimeline).last().path("occurredAt")) + listOf(sentData, receivedData).forEach { data -> + assertEquals(originalTimeline, data.path("statusTimeline")) + assertEquals(json.booleanNode(false), data.path("trackingRequired")) + assertEquals(json.booleanNode(false), data.path("recipientAddressRequired")) + assertEquals(json.nullNode(), data.path("mailbox")) + } + listOf(sentData, receivedData, adminData).forEach { data -> + assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo")) + assertEquals(json.textNode("DELIVERED"), data.path("status")) + } + listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) -> + val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname + assertEquals( + (if (erased) emptyAddress else address).deepCopy().put("nickname", nickname), + adminData.path(field) + ) + } + val adminList = giftHttp(HttpMethod.GET, adminListPath, adminToken) + assertEquals(json.booleanNode(true), adminList.path("success")) + assertEquals(json.numberNode(1), adminList.path("data").path("totalCount")) + val adminItem = adminList.path("data").path("items").single() + assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo")) + assertEquals(json.textNode("DELIVERED"), adminItem.path("status")) + assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname")) + assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname")) + listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data -> + assertEquals(if (erased) json.nullNode() else json.textNode("CJ대한통운"), data.path("courierCompanyName")) + assertEquals(if (erased) json.nullNode() else json.textNode("1234567890"), data.path("trackingNumber")) + } + listOf(adminData, adminItem).forEach { assertEquals(json.arrayNode(), it.path("availableActions")) } + listOf(Triple("SENT", senderToken, recipient.nickname), Triple("RECEIVED", recipientToken, fixture.sender.nickname)) + .forEach { (direction, token, nickname) -> + val list = giftHttp(HttpMethod.GET, "/api/v2/gifts?type=$direction", token) + assertEquals(json.booleanNode(true), list.path("success")) + assertEquals(json.numberNode(1), list.path("data").path("totalCount")) + val item = list.path("data").path("items").single() + assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo")) + assertEquals(json.textNode("DELIVERED"), item.path("status")) + assertEquals(json.textNode(direction), item.path("direction")) + assertEquals(json.textNode(nickname), item.path("counterpartNickname")) + assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered")) + } + println( + "P15_HTTP erased=$erased status=200 success=true " + + "senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " + + "recipientAddressNull=${receivedData.path("recipientAddress").isNull} " + + "trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " + + "requiredFlags=false nicknamePreserved=true timelinePreserved=true lists=1/1/1 auth=401/denied" + ) + } + transaction.executeWithoutResult { + entityManager.clear() + assertEquals(1L, giftRepository.count()) + assertEquals(1L, giftDeliveryRepository.count()) + assertEquals(originalGift, objectMapper.valueToTree(giftRepository.findById(giftId).orElseThrow())) + val reloadedDelivery = objectMapper.valueToTree(giftDeliveryRepository.findByGiftId(giftId)) + personalFields.forEach { assertEquals(json.nullNode(), reloadedDelivery.path(it), it) } + reloadedDelivery.remove(personalFields + "updatedAt") + assertEquals(originalDelivery, reloadedDelivery) + assertEquals( + originalReview, + objectMapper.valueToTree(entityManager.find(GiftReview::class.java, review.id)) + ) + val funding = chargeRepository.findById(fixture.fundingId).orElseThrow() + assertEquals(PaymentStatus.COMPLETE, funding.payment?.status) + assertEquals(PaymentGateway.PG, funding.payment?.paymentGateway) + } + assertRefundState(fixture, refunded = false) + Mockito.verify(canPaymentService, Mockito.never()) + .refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId) + Mockito.verifyNoInteractions(giftPushService) + println( + "P15_DB erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 paymentConsentReviewHistoryPreserved=true" + ) + } + @Test @DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다") fun shouldManageGiftCategories() { @@ -614,6 +888,66 @@ class AdminGiftControllerTest @Autowired constructor( Mockito.verifyNoInteractions(giftPushService) } + @Test + @DisplayName("자동 취소 배치 조회는 ID 순서로 모든 후보를 잠그고 두 번째 선물 취소도 커밋까지 대기시킨다") + fun shouldLockExpiredTrackingBatchUntilTransactionCommits() { + val (firstGift, _) = saveAdminGift( + applicationNo = "A-BATCH-FIRST", + status = GiftStatus.RECEIVED, + senderNickname = "batch-first-sender", + recipientNickname = "batch-first-recipient", + withTracking = false + ) + val firstCategory = categoryRepository.findById(firstGift.categoryId).orElseThrow() + firstCategory.classificationNumber = "101" + categoryRepository.saveAndFlush(firstCategory) + val fixture = saveFundedGift() + val winnerPaused = CountDownLatch(1) + val releaseWinner = CountDownLatch(1) + val contenderStarted = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val winner = executor.submit> { + requireNotNull( + TransactionTemplate(transactionManager).execute { + val ids = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(NOW).map { requireNotNull(it.id) } + assertEquals(listOf(firstGift.id, fixture.gift.id), ids) + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + ids + } + ) + } + var contender: java.util.concurrent.Future? = null + try { + assertTrue(winnerPaused.await(10, TimeUnit.SECONDS)) + contender = executor.submit { + contenderStarted.countDown() + executeRaceOperation(RaceOperation.USER, fixture) + } + assertTrue(contenderStarted.await(10, TimeUnit.SECONDS)) + val waitingContender = contender + assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) } + releaseWinner.countDown() + assertEquals(listOf(firstGift.id, fixture.gift.id), winner.get(10, TimeUnit.SECONDS)) + assertEquals("CANCELED", contender.get(10, TimeUnit.SECONDS)) + assertEquals(GiftStatus.CANCELED, giftRepository.findById(requireNotNull(fixture.gift.id)).orElseThrow().status) + assertTrue(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt != null) + assertRefundState(fixture, refunded = true) + assertEquals("REJECTED", executeRaceOperation(RaceOperation.USER, fixture)) + assertRefundState(fixture, refunded = true) + println("P15_BATCH_LOCK orderedCandidates=2 contenderBlockedUntilCommit=true refundCan=80 refundCount=1") + } finally { + releaseWinner.countDown() + executor.shutdown() + if (!executor.awaitTermination(15, TimeUnit.SECONDS)) { + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS) + contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) } + } + } + @ParameterizedTest @MethodSource("cancellationRaces") @DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다") @@ -695,6 +1029,167 @@ class AdminGiftControllerTest @Autowired constructor( } } + @ParameterizedTest + @MethodSource("recipientMutationRaces") + @DisplayName("배송지 등록과 예약·운영 변경은 오래된 영속 엔티티가 있어도 커밋까지 직렬화된다") + fun shouldSerializeRecipientMutations(operations: Pair) { + val fixture = saveFundedGift() + val giftId = requireNotNull(fixture.gift.id) + val expiring = RecipientMutation.EXPIRY in listOf(operations.first, operations.second) + val initialStatus = when { + RecipientMutation.INSPECT in listOf(operations.first, operations.second) -> GiftStatus.ARRIVED_AT_MAILBOX + listOf(operations.first, operations.second).any { + it in listOf(RecipientMutation.ADMIN_DELIVERY, RecipientMutation.CONFIRM) + } -> GiftStatus.INSPECTION_COMPLETED + else -> GiftStatus.TRACKING_REGISTERED + } + TransactionTemplate(transactionManager).executeWithoutResult { + giftRepository.findById(giftId).orElseThrow().status = initialStatus + requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddressDeadlineAt = + if (expiring) NOW.minusSeconds(1) else NOW.plusHours(1) + } + val winnerPaused = CountDownLatch(1) + val releaseWinner = CountDownLatch(1) + val contenderStarted = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val winner = executor.submit { + requireNotNull( + TransactionTemplate(transactionManager).execute { + val result = executeRecipientMutation(operations.first, fixture) + winnerPaused.countDown() + assertTrue(releaseWinner.await(10, TimeUnit.SECONDS)) + result + } + ) + } + var contender: java.util.concurrent.Future? = null + try { + assertTrue(winnerPaused.await(10, TimeUnit.SECONDS)) + contender = executor.submit { + try { + requireNotNull( + TransactionTemplate(transactionManager).execute { + assertEquals(initialStatus, giftRepository.findById(giftId).orElseThrow().status) + assertEquals(null, requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddress) + contenderStarted.countDown() + executeRecipientMutation(operations.second, fixture) + } + ) + } catch (exception: SodaException) { + assertEquals("common.error.invalid_request", exception.messageKey) + "REJECTED" + } + } + assertTrue(contenderStarted.await(10, TimeUnit.SECONDS)) + val waitingContender = contender + val blockedUntilCommit = try { + waitingContender.get(300, TimeUnit.MILLISECONDS) + false + } catch (exception: TimeoutException) { + true + } + releaseWinner.countDown() + val firstResult = winner.get(10, TimeUnit.SECONDS) + val secondResult = contender.get(10, TimeUnit.SECONDS) + val addressRegistered = operations.first == RecipientMutation.ADDRESS || + (operations.second == RecipientMutation.ADDRESS && operations.first != RecipientMutation.EXPIRY) + TransactionTemplate(transactionManager).executeWithoutResult { + entityManager.clear() + val gift = giftRepository.findById(giftId).orElseThrow() + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)) + assertEquals( + if (addressRegistered) { + listOf("recipient", "01098765432", "04524", "registered address", "202") + } else { + List(5) { null } + }, + listOf( + delivery.recipientName, + delivery.recipientPhoneNumber, + delivery.recipientZipCode, + delivery.recipientAddress, + delivery.recipientAddressDetail + ), + "committed recipient fields: $operations" + ) + assertEquals(addressRegistered, gift.recipientTermsAgreed) + assertEquals(addressRegistered, gift.recipientPrivacyAgreed) + assertEquals(addressRegistered, gift.recipientTermsAgreedAt != null) + assertEquals(addressRegistered, gift.recipientPrivacyAgreedAt != null) + assertEquals(gift.recipientTermsAgreedAt, gift.recipientPrivacyAgreedAt) + val expectedStatus = when { + operations.first == RecipientMutation.EXPIRY -> GiftStatus.UNDELIVERABLE + RecipientMutation.ARRIVE in listOf(operations.first, operations.second) -> GiftStatus.ARRIVED_AT_MAILBOX + RecipientMutation.INSPECT in listOf(operations.first, operations.second) -> GiftStatus.INSPECTION_COMPLETED + RecipientMutation.UNDELIVERABLE in listOf(operations.first, operations.second) -> GiftStatus.UNDELIVERABLE + listOf(operations.first, operations.second).any { + it in listOf(RecipientMutation.ADMIN_DELIVERY, RecipientMutation.CONFIRM) + } -> GiftStatus.DELIVERED + else -> initialStatus + } + assertEquals(expectedStatus, gift.status) + assertEquals(operations.first == RecipientMutation.EXPIRY, delivery.undeliverableAt != null && expiring) + assertEquals( + operations.first == RecipientMutation.REMINDER, + delivery.recipientAddressDeadlineReminderSentAt != null + ) + assertEquals(expectedStatus == GiftStatus.DELIVERED, delivery.deliveredAt != null) + assertEquals( + RecipientMutation.ARRIVE in listOf(operations.first, operations.second), + delivery.arrivedAtMailboxAt != null + ) + assertEquals( + RecipientMutation.INSPECT in listOf(operations.first, operations.second), + delivery.inspectionCompletedAt != null + ) + } + assertTrue(blockedUntilCommit, "contender must wait for winner commit: $operations") + if (operations.first == RecipientMutation.ADDRESS && operations.second in listOf( + RecipientMutation.REMINDER, + RecipientMutation.EXPIRY + ) + ) { + assertEquals("0", secondResult) + } + if (operations.first == RecipientMutation.EXPIRY) assertEquals("REJECTED", secondResult) + if (operations.first == RecipientMutation.REMINDER) assertEquals("1", firstResult) + assertEquals(0, scheduler.sendDeadlineReminderGifts(NOW)) + assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW)) + Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.REMINDER) 1 else 0)) + .sendRecipientAddressDeadlineReminder(APPLICATION_NO, fixture.gift.recipientMemberId) + assertRefundState(fixture, refunded = false) + println( + "P15_R1_RACE $operations first=$firstResult second=$secondResult serialized=$blockedUntilCommit persisted=true" + ) + } finally { + releaseWinner.countDown() + executor.shutdown() + if (!executor.awaitTermination(15, TimeUnit.SECONDS)) { + executor.shutdownNow() + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + } + if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS) + contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) } + } + } + + private fun executeRecipientMutation(operation: RecipientMutation, fixture: FundedGift): String = when (operation) { + RecipientMutation.ADDRESS -> commandService.registerRecipientAddress( + fixture.admin, + APPLICATION_NO, + GiftRecipientAddressRegistrationCommand("recipient", "01098765432", "04524", "registered address", "202", true, true) + ).status.name + RecipientMutation.REMINDER -> scheduler.sendDeadlineReminderGifts(NOW).toString() + RecipientMutation.EXPIRY -> scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW).toString() + RecipientMutation.ARRIVE -> adminService.arriveMailbox(APPLICATION_NO, NOW).status.name + RecipientMutation.INSPECT -> adminService.completeInspection(APPLICATION_NO, NOW).status.name + RecipientMutation.UNDELIVERABLE -> adminService.markUndeliverable(APPLICATION_NO, "operator reason", NOW).status.name + RecipientMutation.ADMIN_DELIVERY -> adminService.completeDelivery(APPLICATION_NO, NOW).status.name + RecipientMutation.CONFIRM -> commandService.confirmDelivery(fixture.admin, APPLICATION_NO).status.name + } + + enum class RecipientMutation { ADDRESS, REMINDER, EXPIRY, ARRIVE, INSPECT, UNDELIVERABLE, ADMIN_DELIVERY, CONFIRM } + private fun executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String { val requestAttributes = ServletRequestAttributes(MockHttpServletRequest()) RequestContextHolder.setRequestAttributes(requestAttributes) @@ -772,7 +1267,7 @@ class AdminGiftControllerTest @Autowired constructor( return body } - private fun saveFundedGift(): FundedGift = requireNotNull( + private fun saveFundedGift(recipient: Member? = null): FundedGift = requireNotNull( TransactionTemplate(transactionManager).execute { val sender = memberRepository.save( Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply { @@ -811,7 +1306,7 @@ class AdminGiftControllerTest @Autowired constructor( Gift( applicationNo = APPLICATION_NO, senderMemberId = requireNotNull(sender.id), - recipientMemberId = requireNotNull(admin.id), + recipientMemberId = requireNotNull((recipient ?: admin).id), status = GiftStatus.RECEIVED, sizeCode = GiftSize.SMALL, categoryId = requireNotNull(category.id), @@ -971,6 +1466,28 @@ class AdminGiftControllerTest @Autowired constructor( } private companion object { + @JvmStatic + fun recipientMutationRaces() = listOf( + RecipientMutation.REMINDER to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.REMINDER, + RecipientMutation.EXPIRY to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.EXPIRY, + RecipientMutation.ARRIVE to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.ARRIVE, + RecipientMutation.INSPECT to RecipientMutation.ADDRESS, + RecipientMutation.ADDRESS to RecipientMutation.INSPECT, + RecipientMutation.ADDRESS to RecipientMutation.UNDELIVERABLE, + RecipientMutation.ADDRESS to RecipientMutation.ADMIN_DELIVERY, + RecipientMutation.ADDRESS to RecipientMutation.CONFIRM, + RecipientMutation.REMINDER to RecipientMutation.CONFIRM, + RecipientMutation.CONFIRM to RecipientMutation.REMINDER, + RecipientMutation.EXPIRY to RecipientMutation.CONFIRM, + RecipientMutation.CONFIRM to RecipientMutation.EXPIRY, + RecipientMutation.REMINDER to RecipientMutation.UNDELIVERABLE, + RecipientMutation.UNDELIVERABLE to RecipientMutation.REMINDER, + RecipientMutation.REMINDER to RecipientMutation.REMINDER + ) + @JvmStatic fun cancellationRaces() = listOf( RaceOperation.ADMIN to RaceOperation.ADMIN, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt index a7cf2efb..7c94c356 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt @@ -547,7 +547,7 @@ class GiftCommandServiceTest { trackingDeadlineAt = NOW.plusDays(3) ) Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(cancellationGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery) + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn(cancellationDelivery) } fun prepareGiftForTracking( @@ -580,7 +580,7 @@ class GiftCommandServiceTest { trackingDeadlineAt = NOW.plusDays(3) ) Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(trackingGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn( trackingDelivery.takeIf { deliveryExists } ) } @@ -614,8 +614,8 @@ class GiftCommandServiceTest { senderAddress = "서울시 강남구", trackingDeadlineAt = NOW.plusDays(3) ) - Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(recipientAddressGift) + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn( recipientAddressDelivery.takeIf { deliveryExists } ) } @@ -649,8 +649,8 @@ class GiftCommandServiceTest { senderAddress = "서울시 강남구", trackingDeadlineAt = NOW.plusDays(3) ) - Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift) - Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(deliveryConfirmationGift) + Mockito.`when`(deliveryRepository.findByGiftIdForUpdate(GIFT_ID)).thenReturn( deliveryConfirmationDelivery.takeIf { deliveryExists } ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt index 72cf16ad..90b17aab 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt @@ -1,23 +1,43 @@ package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito +import org.redisson.api.RLock import org.redisson.api.RedissonClient import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager import org.springframework.context.annotation.Import +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.test.context.transaction.TestTransaction +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @DataJpaTest( properties = [ @@ -29,16 +49,296 @@ import java.time.LocalDateTime @DisplayName("선물 자동처리 스케줄러 테스트") class GiftSchedulerTest @Autowired constructor( private val giftRepository: GiftRepository, - private val giftDeliveryRepository: GiftDeliveryRepository + private val giftDeliveryRepository: GiftDeliveryRepository, + private val entityManager: TestEntityManager ) { private val canPaymentService = Mockito.mock(CanPaymentService::class.java) private val giftPushService = Mockito.mock(GiftPushService::class.java) + private val redissonClient = Mockito.mock(RedissonClient::class.java) private val scheduler = GiftScheduler( giftRepository, giftDeliveryRepository, canPaymentService, giftPushService, - Mockito.mock(RedissonClient::class.java) + redissonClient + ) + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("신규 진입점은 각 KST 분의 parameterless transactional 스케줄로 등록된다") + fun shouldScheduleEachEntryPointAtItsKstMinute(entryPoint: ScheduledEntryPoint) { + val method = GiftScheduler::class.java.getDeclaredMethod(entryPoint.methodName) + val scheduled = requireNotNull(method.getAnnotation(Scheduled::class.java)) + + assertEquals(entryPoint.cron, scheduled.cron) + assertEquals("Asia/Seoul", scheduled.zone) + assertNotNull(method.getAnnotation(Transactional::class.java)) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 독립 lock을 획득하면 현재 시각으로 한 번 위임하고 해제한다") + fun shouldAcquireDistinctLockAndDelegateWithWallClockNow(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + val schedulerSpy = Mockito.spy(scheduler) + val before = LocalDateTime.now() + + entryPoint.run(schedulerSpy) + + assertWallClockDelegation(schedulerSpy, entryPoint, before, LocalDateTime.now()) + TestTransaction.end() + Mockito.verify(redissonClient).getLock(entryPoint.lockKey) + Mockito.verifyNoMoreInteractions(redissonClient) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock).unlock() + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 lock 획득 실패 시 업무 위임과 unlock을 하지 않는다") + fun shouldSkipEachDelegateWhenItsLockIsNotAcquired(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = false, owned = false) + val schedulerSpy = Mockito.spy(scheduler) + + entryPoint.run(schedulerSpy) + + assertEquals(listOf(entryPoint.methodName), Mockito.mockingDetails(schedulerSpy).invocations.map { it.method.name }) + Mockito.verify(redissonClient).getLock(entryPoint.lockKey) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 업무 메서드 예외를 전파하면서 소유한 lock을 해제한다") + fun shouldUnlockEachOwnedLockWhenDelegateThrows(entryPoint: ScheduledEntryPoint) { + TestTransaction.end() + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + val failure = IllegalStateException("repository unavailable") + val failingScheduler = GiftScheduler( + Mockito.mock(GiftRepository::class.java) { throw failure }, + giftDeliveryRepository, + canPaymentService, + giftPushService, + redissonClient + ) + + val exception = assertThrows(IllegalStateException::class.java) { entryPoint.run(failingScheduler) } + + assertSame(failure, exception) + Mockito.verify(redissonClient).getLock(entryPoint.lockKey) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock).unlock() + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 진입점은 업무 실행 후 현재 스레드가 소유하지 않은 lock을 해제하지 않는다") + fun shouldNotUnlockEachLockWhenCurrentThreadDoesNotOwnIt(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = true, owned = false) + val schedulerSpy = Mockito.spy(scheduler) + val before = LocalDateTime.now() + + entryPoint.run(schedulerSpy) + + assertWallClockDelegation(schedulerSpy, entryPoint, before, LocalDateTime.now()) + TestTransaction.end() + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 lock은 실제 commit 직전과 직후에도 재진입을 막고 완료 후 해제된다") + fun shouldHoldEachLockThroughCommitAndAllowReentryAfterCompletion(entryPoint: ScheduledEntryPoint) { + val (lock, contender) = contendingLocks(entryPoint) + var beforeCommitCalled = false + var afterCommitCalled = false + var completionStatus: Int? = null + + entryPoint.run(scheduler) + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + override fun beforeCommit(readOnly: Boolean) { + beforeCommitCalled = true + Mockito.verify(lock, Mockito.never()).unlock() + assertFalse(contender.tryLock(0, -1, TimeUnit.SECONDS)) + } + + override fun afterCommit() { + afterCommitCalled = true + Mockito.verify(lock, Mockito.never()).unlock() + assertFalse(contender.tryLock(0, -1, TimeUnit.SECONDS)) + } + + override fun afterCompletion(status: Int) { + completionStatus = status + } + }) + TestTransaction.flagForCommit() + + TestTransaction.end() + + assertTrue(beforeCommitCalled) + assertTrue(afterCommitCalled) + assertEquals(TransactionSynchronization.STATUS_COMMITTED, completionStatus) + Mockito.verify(lock).unlock() + assertTrue(contender.tryLock(0, -1, TimeUnit.SECONDS)) + contender.unlock() + assertEquals(0L, giftRepository.count()) + assertEquals(0L, giftDeliveryRepository.count()) + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("각 신규 lock은 업무 예외 후 실제 rollback 직전까지 재진입을 막고 완료 후 해제된다") + fun shouldHoldEachLockOnDelegateFailureUntilRollback(entryPoint: ScheduledEntryPoint) { + val (lock, contender) = contendingLocks(entryPoint) + val failure = IllegalStateException("repository unavailable") + val failingScheduler = GiftScheduler( + Mockito.mock(GiftRepository::class.java) { throw failure }, + giftDeliveryRepository, + canPaymentService, + giftPushService, + redissonClient + ) + var heldBeforeRollback = false + var reenteredBeforeRollback = true + var completionStatus: Int? = null + val exception = assertThrows(IllegalStateException::class.java) { entryPoint.run(failingScheduler) } + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + override fun beforeCompletion() { + heldBeforeRollback = lock.isHeldByCurrentThread + reenteredBeforeRollback = contender.tryLock(0, -1, TimeUnit.SECONDS) + } + + override fun afterCompletion(status: Int) { + completionStatus = status + } + }) + TestTransaction.flagForRollback() + + TestTransaction.end() + + assertSame(failure, exception) + assertTrue(heldBeforeRollback) + assertFalse(reenteredBeforeRollback) + assertEquals(TransactionSynchronization.STATUS_ROLLED_BACK, completionStatus) + Mockito.verify(lock).unlock() + assertTrue(contender.tryLock(0, -1, TimeUnit.SECONDS)) + contender.unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("신규 lock 소유권은 등록 시점이 아닌 transaction 완료 시점에 확인한다") + fun shouldCheckEachLockOwnershipAtTransactionCompletion(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + + entryPoint.run(scheduler) + + Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + TestTransaction.end() + + Mockito.verify(lock).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("lock 획득 실패는 소유 여부와 무관하게 해제 callback을 등록하지 않는다") + fun shouldNotRegisterReleaseForEachUnacquiredLock(entryPoint: ScheduledEntryPoint) { + val lock = schedulerLock(entryPoint, acquired = false, owned = true) + val synchronizations = TransactionSynchronizationManager.getSynchronizations() + + entryPoint.run(scheduler) + + assertEquals(synchronizations, TransactionSynchronizationManager.getSynchronizations()) + TestTransaction.end() + Mockito.verify(lock, Mockito.never()).isHeldByCurrentThread + Mockito.verify(lock, Mockito.never()).unlock() + } + + @ParameterizedTest + @MethodSource("scheduledEntryPoints") + @DisplayName("transaction 없는 직접 호출은 업무 실행을 마친 뒤에만 lock을 즉시 해제한다") + fun shouldReleaseEachLockAfterDirectInvocationWithoutTransaction(entryPoint: ScheduledEntryPoint) { + TestTransaction.end() + assertFalse(TransactionSynchronizationManager.isSynchronizationActive()) + val lock = schedulerLock(entryPoint, acquired = true, owned = true) + val directScheduler = Mockito.spy( + GiftScheduler( + Mockito.mock(GiftRepository::class.java), + Mockito.mock(GiftDeliveryRepository::class.java), + canPaymentService, + giftPushService, + redissonClient + ) + ) + val before = LocalDateTime.now() + + entryPoint.run(directScheduler) + + assertWallClockDelegation(directScheduler, entryPoint, before, LocalDateTime.now()) + val delegateCall = Mockito.mockingDetails(directScheduler).invocations.single { + it.method.name == entryPoint.delegateName + } + val unlockCall = Mockito.mockingDetails(lock).invocations.single { it.method.name == "unlock" } + assertTrue(delegateCall.sequenceNumber < unlockCall.sequenceNumber) + } + + private fun contendingLocks(entryPoint: ScheduledEntryPoint): Pair { + val lock = Mockito.mock(RLock::class.java) + val contender = Mockito.mock(RLock::class.java) + val heldBy = AtomicReference() + listOf(lock, contender).forEach { candidate -> + Mockito.`when`(candidate.tryLock(0, -1, TimeUnit.SECONDS)).thenAnswer { heldBy.compareAndSet(null, candidate) } + Mockito.`when`(candidate.isHeldByCurrentThread).thenAnswer { heldBy.get() === candidate } + Mockito.doAnswer { + assertTrue(heldBy.compareAndSet(candidate, null)) + null + }.`when`(candidate).unlock() + } + Mockito.`when`(redissonClient.getLock(entryPoint.lockKey)).thenReturn(lock) + return lock to contender + } + + private fun schedulerLock(entryPoint: ScheduledEntryPoint, acquired: Boolean, owned: Boolean): RLock { + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(entryPoint.lockKey)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(acquired) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(owned) + return lock + } + + private fun assertWallClockDelegation( + schedulerSpy: GiftScheduler, + entryPoint: ScheduledEntryPoint, + before: LocalDateTime, + after: LocalDateTime + ) { + val calls = Mockito.mockingDetails(schedulerSpy).invocations.filter { + it.method.parameterTypes.contentEquals(arrayOf(LocalDateTime::class.java)) + } + assertEquals(listOf(entryPoint.delegateName), calls.map { it.method.name }) + val now = calls.single().arguments.single() as LocalDateTime + assertTrue(!now.isBefore(before) && !now.isAfter(after)) + } + + data class ScheduledEntryPoint( + val methodName: String, + val delegateName: String, + val cron: String, + val lockKey: String, + val run: (GiftScheduler) -> Unit ) @Test @@ -299,24 +599,292 @@ class GiftSchedulerTest @Autowired constructor( Mockito.verifyNoInteractions(giftPushService) } + @Test + @DisplayName("전달 완료 후 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다") + fun shouldEraseDeliveredGiftWhenDeliveredAtEqualsSevenDayCutoff() { + val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF) + flushAndClear() + + val eligibleGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(ERASURE_CUTOFF) + assertEquals(listOf(gift.id), eligibleGifts.map { it.id }) + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + } + + @Test + @DisplayName("전달 완료 후 7일보다 1초 덜 지난 선물의 개인정보는 보존한다") + fun shouldIgnoreDeliveredGiftOneSecondNewerThanSevenDayCutoff() { + val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1)) + val originalPersonalData = personalData(delivery) + flushAndClear() + + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(originalPersonalData, personalData(reloadDelivery(gift))) + } + + @Test + @DisplayName("도입 시점보다 오래전에 생성되고 전달 완료된 과거 누적 선물도 지운다") + fun shouldEraseOlderHistoricalDeliveredGifts() { + val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0) + val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10)) + gift.createdAt = historicalAt + delivery.createdAt = historicalAt + flushAndClear() + + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + assertEquals(historicalAt, reloadDelivery(gift).createdAt) + assertEquals(historicalAt, entityManager.find(Gift::class.java, gift.id).createdAt) + } + + @Test + @DisplayName("완료 시각이 없거나 전달 완료가 아닌 모든 상태는 개인정보를 보존한다") + fun shouldIgnoreNullDeliveredAtAndNonDeliveredGifts() { + val gifts = listOf(saveGiftForErasure(null)) + GiftStatus.values() + .filter { it != GiftStatus.DELIVERED } + .mapIndexed { index, status -> + saveGiftForErasure(ERASURE_CUTOFF.minusDays(30), status, "IGNORE-$index") + } + val originals = gifts.map { (_, delivery) -> personalData(delivery) } + flushAndClear() + + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + gifts.forEachIndexed { index, (gift, _) -> + assertEquals(gift.status, entityManager.find(Gift::class.java, gift.id).status) + assertEquals(originals[index], personalData(reloadDelivery(gift))) + } + } + + @Test + @DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다") + fun shouldEraseRemainingFieldsFromPartiallyClearedGift() { + val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField -> + saveGiftForErasure(ERASURE_CUTOFF, applicationNo = "PARTIAL-$index").also { (_, delivery) -> + PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) } + } + } + flushAndClear() + + assertEquals(12, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) } + } + + @Test + @DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다") + fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull() { + val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF) + PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) } + flushAndClear() + val updatedAt = reloadDelivery(gift).updatedAt + + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + assertEquals(updatedAt, reloadDelivery(gift).updatedAt) + } + + @Test + @DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다") + fun shouldReturnZeroWhenErasureRunsAgain() { + val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF) + flushAndClear() + + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + val updatedAt = reloadDelivery(gift).updatedAt + assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW.plusDays(1))) + flushAndClear() + + assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) + assertEquals(updatedAt, reloadDelivery(gift).updatedAt) + } + + @Test + @DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다") + fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps() { + val sender = entityManager.persist( + Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png") + .apply { pgChargeCan = 120 } + ) + val recipient = entityManager.persist( + Member(password = "test-password", nickname = "recipient", profileImage = "recipient-profile.png") + ) + val payment = entityManager.persist( + UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender } + ) + val (gift, delivery) = saveGift( + GiftStatus.DELIVERED, + APPLIED_AT.plusDays(3), + senderMemberId = requireNotNull(sender.id), + recipientMemberId = requireNotNull(recipient.id), + canUsageId = requireNotNull(payment.id) + ) + fillDeliveryPersonalData(delivery) + gift.recipientTermsAgreed = true + gift.recipientPrivacyAgreed = true + gift.recipientTermsAgreedAt = APPLIED_AT.plusDays(2) + gift.recipientPrivacyAgreedAt = APPLIED_AT.plusDays(2) + delivery.recipientAddressDeadlineAt = APPLIED_AT.plusDays(8) + delivery.trackingDeadlineReminderSentAt = APPLIED_AT.plusDays(1) + delivery.recipientAddressDeadlineReminderSentAt = APPLIED_AT.plusDays(7) + delivery.trackingRegisteredAt = APPLIED_AT.plusDays(2) + delivery.arrivedAtMailboxAt = APPLIED_AT.plusDays(3) + delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4) + delivery.deliveredAt = ERASURE_CUTOFF + delivery.canceledAt = APPLIED_AT.plusHours(1) + delivery.undeliverableAt = APPLIED_AT.plusHours(2) + delivery.undeliverableReason = "보존할 업무 사유" + val review = entityManager.persist( + GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다") + ) + flushAndClear() + val originalGift = entityManager.find(Gift::class.java, gift.id) + val originalDelivery = reloadDelivery(gift) + val originalPayment = entityManager.find(UseCan::class.java, payment.id) + val originalReview = entityManager.find(GiftReview::class.java, review.id) + flushAndClear() + + assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW)) + flushAndClear() + + val reloadedGift = entityManager.find(Gift::class.java, gift.id) + assertEquals(originalGift.id, reloadedGift.id) + assertEquals(originalGift.applicationNo, reloadedGift.applicationNo) + assertEquals(originalGift.status, reloadedGift.status) + assertEquals(originalGift.senderMemberId, reloadedGift.senderMemberId) + assertEquals(originalGift.recipientMemberId, reloadedGift.recipientMemberId) + assertEquals(originalGift.sizeCode, reloadedGift.sizeCode) + assertEquals(originalGift.categoryId, reloadedGift.categoryId) + assertEquals(originalGift.categoryNameSnapshot, reloadedGift.categoryNameSnapshot) + assertEquals(originalGift.salePriceCan, reloadedGift.salePriceCan) + assertEquals(originalGift.canUsageId, reloadedGift.canUsageId) + assertEquals(originalGift.senderTermsAgreed, reloadedGift.senderTermsAgreed) + assertEquals(originalGift.senderPrivacyAgreed, reloadedGift.senderPrivacyAgreed) + assertEquals(originalGift.senderTermsAgreedAt, reloadedGift.senderTermsAgreedAt) + assertEquals(originalGift.senderPrivacyAgreedAt, reloadedGift.senderPrivacyAgreedAt) + assertEquals(originalGift.recipientTermsAgreed, reloadedGift.recipientTermsAgreed) + assertEquals(originalGift.recipientPrivacyAgreed, reloadedGift.recipientPrivacyAgreed) + assertEquals(originalGift.recipientTermsAgreedAt, reloadedGift.recipientTermsAgreedAt) + assertEquals(originalGift.recipientPrivacyAgreedAt, reloadedGift.recipientPrivacyAgreedAt) + assertEquals(originalGift.damageWaiverAgreed, reloadedGift.damageWaiverAgreed) + assertEquals(originalGift.createdAt, reloadedGift.createdAt) + + val reloadedDelivery = reloadDelivery(gift) + assertEquals(List(12) { null }, personalData(reloadedDelivery)) + assertEquals(originalDelivery.id, reloadedDelivery.id) + assertEquals(originalDelivery.giftId, reloadedDelivery.giftId) + assertEquals(originalDelivery.createdAt, reloadedDelivery.createdAt) + assertEquals(originalDelivery.trackingDeadlineAt, reloadedDelivery.trackingDeadlineAt) + assertEquals(originalDelivery.recipientAddressDeadlineAt, reloadedDelivery.recipientAddressDeadlineAt) + assertEquals(originalDelivery.trackingDeadlineReminderSentAt, reloadedDelivery.trackingDeadlineReminderSentAt) + assertEquals( + originalDelivery.recipientAddressDeadlineReminderSentAt, + reloadedDelivery.recipientAddressDeadlineReminderSentAt + ) + assertEquals(originalDelivery.trackingRegisteredAt, reloadedDelivery.trackingRegisteredAt) + assertEquals(originalDelivery.arrivedAtMailboxAt, reloadedDelivery.arrivedAtMailboxAt) + assertEquals(originalDelivery.inspectionCompletedAt, reloadedDelivery.inspectionCompletedAt) + assertEquals(originalDelivery.deliveredAt, reloadedDelivery.deliveredAt) + assertEquals(originalDelivery.canceledAt, reloadedDelivery.canceledAt) + assertEquals(originalDelivery.undeliverableAt, reloadedDelivery.undeliverableAt) + assertEquals(originalDelivery.undeliverableReason, reloadedDelivery.undeliverableReason) + + val reloadedPayment = entityManager.find(UseCan::class.java, payment.id) + assertEquals(payment.id, reloadedPayment.id) + assertEquals(sender.id, reloadedPayment.member?.id) + assertEquals(CanUsage.GIFT, reloadedPayment.canUsage) + assertEquals(60, reloadedPayment.can) + assertEquals(20, reloadedPayment.rewardCan) + assertEquals(false, reloadedPayment.isRefund) + assertEquals(originalPayment.createdAt, reloadedPayment.createdAt) + val reloadedSender = entityManager.find(Member::class.java, sender.id) + val reloadedRecipient = entityManager.find(Member::class.java, recipient.id) + assertEquals("sender", reloadedSender.nickname) + assertEquals("sender-profile.png", reloadedSender.profileImage) + assertEquals(120, reloadedSender.pgChargeCan) + assertEquals(0, reloadedSender.pgRewardCan) + assertEquals("recipient", reloadedRecipient.nickname) + assertEquals("recipient-profile.png", reloadedRecipient.profileImage) + val reloadedReview = entityManager.find(GiftReview::class.java, review.id) + assertEquals(originalReview.id, reloadedReview.id) + assertEquals(originalReview.giftId, reloadedReview.giftId) + assertEquals(originalReview.senderMemberId, reloadedReview.senderMemberId) + assertEquals(originalReview.rating, reloadedReview.rating) + assertEquals(originalReview.keywords, reloadedReview.keywords) + assertEquals(originalReview.comment, reloadedReview.comment) + assertEquals(originalReview.createdAt, reloadedReview.createdAt) + assertEquals(1L, giftRepository.count()) + assertEquals(1L, giftDeliveryRepository.count()) + Mockito.verifyNoInteractions(canPaymentService, giftPushService) + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } + + private fun reloadDelivery(gift: Gift): GiftDelivery = + requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + + private fun personalData(delivery: GiftDelivery): List = PERSONAL_DATA_FIELDS.map { it.get(delivery) } + + private fun saveGiftForErasure( + deliveredAt: LocalDateTime?, + status: GiftStatus = GiftStatus.DELIVERED, + applicationNo: String = APPLICATION_NO + ): Pair = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo) + .also { (_, delivery) -> + fillDeliveryPersonalData(delivery) + delivery.deliveredAt = deliveredAt + } + + private fun fillDeliveryPersonalData(delivery: GiftDelivery) { + delivery.senderName = "홍길동" + delivery.senderPhoneNumber = "01012345678" + delivery.senderZipCode = "06234" + delivery.senderAddress = "서울시 강남구" + delivery.senderAddressDetail = "101호" + delivery.recipientName = "수취인" + delivery.recipientPhoneNumber = "01087654321" + delivery.recipientZipCode = "05555" + delivery.recipientAddress = "서울시 송파구" + delivery.recipientAddressDetail = "202호" + delivery.courierCompanyName = "택배사" + delivery.trackingNumber = "TRACKING-123" + } + private fun saveGift( status: GiftStatus, trackingDeadlineAt: LocalDateTime, recipientAddressDeadlineAt: LocalDateTime? = null, recipientAddress: String? = null, - applicationNo: String = APPLICATION_NO + applicationNo: String = APPLICATION_NO, + senderMemberId: Long = SENDER_MEMBER_ID, + recipientMemberId: Long = RECIPIENT_MEMBER_ID, + canUsageId: Long = CAN_USAGE_ID ): Pair { val gift = giftRepository.save( Gift( applicationNo = applicationNo, - senderMemberId = SENDER_MEMBER_ID, - recipientMemberId = RECIPIENT_MEMBER_ID, + senderMemberId = senderMemberId, + recipientMemberId = recipientMemberId, status = status, sizeCode = GiftSize.SMALL, categoryId = CATEGORY_ID, categoryNameSnapshot = "인형", salePriceCan = 80, - canUsageId = CAN_USAGE_ID, + canUsageId = canUsageId, senderTermsAgreed = true, senderPrivacyAgreed = true, senderTermsAgreedAt = APPLIED_AT, @@ -341,11 +909,52 @@ class GiftSchedulerTest @Autowired constructor( } private companion object { + @JvmStatic + fun scheduledEntryPoints() = listOf( + ScheduledEntryPoint( + "runExpiredRecipientAddressGiftExpiration", + "markExpiredRecipientAddressGiftsUndeliverable", + "0 10 * * * *", + "lock:gift-expired-recipient-address-undeliverable", + GiftScheduler::runExpiredRecipientAddressGiftExpiration + ), + ScheduledEntryPoint( + "runDeliveredGiftPersonalDataErasure", + "eraseDeliveredGiftPersonalData", + "0 20 * * * *", + "lock:gift-delivered-personal-data-erasure", + GiftScheduler::runDeliveredGiftPersonalDataErasure + ), + ScheduledEntryPoint( + "runDeadlineReminderGifts", + "sendDeadlineReminderGifts", + "0 40 * * * *", + "lock:gift-deadline-reminders", + GiftScheduler::runDeadlineReminderGifts + ) + ) + const val APPLICATION_NO = "G20260929000001" const val SENDER_MEMBER_ID = 1L const val RECIPIENT_MEMBER_ID = 2L const val CATEGORY_ID = 3L const val CAN_USAGE_ID = 4L val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) + val ERASURE_NOW: LocalDateTime = LocalDateTime.of(2026, 10, 23, 12, 0) + val ERASURE_CUTOFF: LocalDateTime = LocalDateTime.of(2026, 10, 16, 12, 0) + val PERSONAL_DATA_FIELDS = listOf( + GiftDelivery::senderName, + GiftDelivery::senderPhoneNumber, + GiftDelivery::senderZipCode, + GiftDelivery::senderAddress, + GiftDelivery::senderAddressDetail, + GiftDelivery::recipientName, + GiftDelivery::recipientPhoneNumber, + GiftDelivery::recipientZipCode, + GiftDelivery::recipientAddress, + GiftDelivery::recipientAddressDetail, + GiftDelivery::courierCompanyName, + GiftDelivery::trackingNumber + ) } } From d052dd50de1e21f4f6a2e453a3e2c2726e016ebd Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 08:45:25 +0900 Subject: [PATCH 3/8] =?UTF-8?q?docs(gift):=20Phase=2015=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=EC=A0=95=EB=B3=B4=20=EC=A0=95=EB=A6=AC=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EA=B3=BC=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 | 311 +++++++++++++++++- docs/20260929_크리에이터_선물하기/prd.md | 65 +++- 2 files changed, 370 insertions(+), 6 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 360372d5..cd69110a 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -4,12 +4,12 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | Phase 14 완료, 전체 테스트 사용자 요청으로 제외 | +| 상태 | Phase 15 서버 구현·검증·리뷰 완료, 운영 반영 전 | | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | -| 현재 Phase | Phase 14 관리자 선물 취소 | -| 현재 활성 Goal | 없음, `P14-T4` 시각 정밀도 회귀 수정 완료 | +| 현재 Phase | Phase 15 전달 완료 배송 개인정보 정리 | +| 현재 활성 Goal | 없음. `P15-GATE` 완료, 운영 반영은 별도 승인 | ## 목표 @@ -42,6 +42,7 @@ | 9 | 완료 | `3/3` | 완료 | 없음 | | 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 | | 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 | +| 15 | 서버 구현·Gate 완료 | `4/4` Task, `P15-R1` 완료 | 없음 | 운영 schema/reader 배포 확인은 별도 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -1662,3 +1663,307 @@ P14-T1 문서 검증 → 사용자 구현 승인 → P14-T2 → P14-T3 → P14-G - 남은 항목: 외부 의존, 후속 범위 또는 없음 - 문서: 갱신한 PRD/plan/review 링크 ``` + +## Phase 15: 전달 완료 배송 개인정보 정리 + +**Phase 결과:** 전달 완료 후 7일 이상 지난 선물의 배송 개인정보만 DB에서 지우고, 기존 사용자/관리자 응답 형태와 결제·동의·리뷰·업무 이력을 유지한다. + +**2026-10-23 실행 승인:** 사용자가 권장한 서브에이전트 단계별 실행을 승인하고 worktree 없이 현재 `test` 브랜치에서 진행하도록 지정했다. `P15-T2`부터 순차 TDD·리뷰를 수행한다. 운영 SQL/배포와 커밋·푸시는 하지 않는다. 후속 ULW 검증 요청에 따라 단계별 focused 검증 후 Gate에서 전체 테스트도 1회 시도하며, 기존 전체 테스트 제외 규칙은 이번 Gate에 한해 대체한다. 무관한 실패는 수정 범위를 넓히지 않고 보고한다. + +**후속 지시:** 사용자가 `git commit/push는 진행하지 않는다. 나머지는 계속 진행한다.`고 재확인했다. 모든 변경은 현재 브랜치의 미커밋 상태로 남기고 구현·검증·리뷰만 완료한다. + +**선행조건:** Phase 14와 P15-T1 문서 검증 후 사용자 실행 승인으로 T2~T4, R1 및 Gate를 완료했다. 요구사항은 PRD `GIFT-018`~`GIFT-021`을 따른다. + +**Phase 완료 조건:** `P15-T1`~`P15-T4`, 후속 `P15-R1` 및 `P15-GATE` 완료. 실제 test·lint·HTTP QA·리뷰 기록을 누적했다. SQL은 문서로만 작성했으며 운영 적용 완료를 주장하지 않는다. + +### 목표와 제약 + +- 적격 조건은 `Gift.status=DELIVERED`, `GiftDelivery.deliveredAt != null`, `deliveredAt <= now.minusDays(7)`이다. 7일 경계 포함, 그 이전의 과거 누적 및 앞으로 만료되는 건을 모두 처리한다. +- Kotlin `GiftDelivery`의 `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail` 다섯 property는 모두 `var String?`가 된다. DB에서 현재 NOT NULL인 열은 앞의 네 개뿐이다. `sender_address_detail`은 이미 nullable이므로 ALTER하지 않는다. +- 정리 시 `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `courierCompanyName`, `trackingNumber`만 null 처리한다. +- 적격 delivery를 `GiftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff: LocalDateTime): List`로 조회한다. `GiftScheduler.eraseDeliveredGiftPersonalData(now: LocalDateTime): Int`가 `now.minusDays(7)`을 전달해 반환한 delivery 개인정보를 dirty checking으로 비운다. 이미 12개가 모두 null인 건은 건너뛰어 재실행 변경 수는 0으로 한다. +- 상태/ID, Member nickname/profile, 결제, 동의 값/시각, 리뷰와 모든 업무 lifecycle/deadline/reminder 시각 및 `createdAt`은 보존한다. `updatedAt`은 `BaseEntity @PreUpdate`의 정상 동작을 따르며 이를 우회할 native SQL update는 사용하지 않는다. +- 기존 필수 request 타입과 validation, public DTO schema, outer nullable object, visibility/derived flags/deadlines는 유지한다. 데이터가 없는 기존 non-null response 문자열은 `""`, 삭제된 전체 주소도 정확히 `""`이다. `SENT.senderInfo`는 객체와 빈 문자열을 유지하고 `RECEIVED.senderInfo`는 null이다. 모든 recipient address가 null이면 `recipientAddress=null`; user tracking과 admin courier/tracking nullable 값은 null이다. `recipientAddressRegistered`는 실제 필드 기준 false이며 `DELIVERED`는 재입력 prompt/action을 보이지 않는다. +- 새 erased flag/table/retention config/batch knob/dependency/index/fallback은 추가하지 않는다. 푸시/환불 동작은 추가하지 않는다. 테스트 범위는 위 실행 승인 기록에 따라 focused 우선, 최종 전체 테스트 1회 시도로 한다. +- 기존 scheduled entrypoint는 자동취소 `0 0 * * * *`뿐이다. 추가하는 독립 wrapper는 recipient expiry `0 10 * * * *`, erasure `0 20 * * * *`, reminders `0 40 * * * *`, 모두 `Asia/Seoul`이다. Wrapper는 기존 업무 메서드를 호출하며 각각 기존 방식의 독립 Redisson lock과 `@Transactional`을 사용한다. 작업 시간 차이를 보장하지 않으므로 작업 간 비중첩은 가정하지 않는다. 기존 `LocalDateTime` clock convention 그대로 wrapper에서 `LocalDateTime.now()`를 전달하며 Clock 설정/주입은 추가하지 않는다. +- SQL은 30줄 미만 변경이므로 별도 migration 파일 대신 이미 `gift-schema.sql` 끝에 5줄 ALTER로 작성됐다. 기존 CREATE block은 NOT NULL 상태로 유지되며, schema 전체를 fresh install 순서로 적용하면 trailing ALTER 뒤 최종 네 필드는 nullable이다. 기존 DB는 ALTER 구문만 적용해야 한다. +- 운영 rollout만 actual schema metadata 검토, DDL 별도 승인 및 적용, nullable schema/compatible reader의 전체 instance 준비를 요구한다. 이는 local code/H2 test 선행조건이 아니다. 첫 erase 전 모든 reader는 nullable 호환이어야 한다. 이후 non-null reader rollback은 unsafe하며 코드만으로 삭제 데이터를 복구할 수 없다. 자동 production SQL 실행/backup 변경은 없다. + +### 리뷰 포커스 + +1. `deliveredAt` 경계, 오래된 이력, null 시각 및 다른 status 구별: `GiftSchedulerTest` 실제 DB predicate 검증. +2. 반복/부분 실행과 보호 데이터 보존: flush/clear/reload 후 `GiftSchedulerTest` 확인. +3. 삭제 이후 역할별 nullable/non-null API mapping과 정확한 주소 문자열: query 및 controller 테스트. +4. scheduler 분·lock·transaction wrapper 분리, 같은 클래스 delegation: scheduler annotation/reflection/delegation 테스트. +5. nullable 컬럼과 non-null reader 간 rollout 순서/rollback 위험: `P15-T1`, `P15-GATE` deployment checklist 대조. + +### Task 15.1 Phase15 문서 및 추적 기준 + +**Goal 실행 `P15-T1`:** PRD와 이 계획에 승인된 데이터 정리 계약, 테스트 기준, 배포 안전 게이트를 일치시킨다. + +- **시작 조건:** Phase 14 완료, 사용자 Phase15 승인 범위 확인. +- **완료 증거:** PRD `GIFT-018`~`GIFT-021`/Decision Log와 본 Phase의 계약·Tasks·dependency 일치. lead가 문서 최종 검증 명령을 실행하고 이 Task의 마지막 체크박스를 완료 처리한다. +- **범위 밖:** source/test/client-summary 변경, SQL 실행, 설정 변경 및 구현 완료 표시. `gift-schema.sql`은 lead가 이미 추가한 SQL 문서 범위에 포함하며 이 문서 편집에서는 수정하지 않는다. +- **TDD 예외 사유:** 문서 전용 작업이며 production 동작을 변경하지 않는다. +- **대체 검증 방법:** PRD/plan traceability와 변경 경로를 diff로 대조하고 `./gradlew --no-daemon tasks --all` 및 `git diff --check`를 실행한다. 실제 결과는 하단 `P15-T1 최종 문서 검증`에 기록한다. + +**Files:** + +- Modify: `docs/20260929_크리에이터_선물하기/prd.md` +- Modify: `docs/20260929_크리에이터_선물하기/plan-task.md` +- Include in scope: `docs/20260929_크리에이터_선물하기/gift-schema.sql`의 lead 추가 trailing ALTER section. SQL documentation은 준비됨; lead 검토 대상이며 이 Task에서는 SQL 파일을 수정/실행하지 않는다. + +**Interfaces:** + +- Consumes: 승인된 계약 `GIFT-018`~`GIFT-021`, `DEC-017`~`DEC-021`. +- Produces: `P15-T2`~`P15-GATE`의 구현 경계와 수용 조건. + +- [x] **문서 초안:** PRD 요구사항/정책과 계획 task/deployment gate를 갱신한다. 문서 작성 완료만 표시하며 검증 완료를 의미하지 않는다. +- [x] **최종 검증:** 문서 검증 명령을 실행하고 PRD/plan과 `gift-schema.sql` 하단 ALTER만 변경됐음을 확인했다. Phase14 이력 보존 및 승인 계약 추적을 대조하고 하단에 실제 검증 결과를 기록했다. 소스 구현이나 DB 반영 완료를 의미하지 않는다. + +### Task 15.2 nullable persistence와 안전한 응답 mapping + +**Goal 실행 `P15-T2`:** 네 sender column nullable schema와 배송 개인정보 없는 기존 응답 표현을 함께 지원한다. + +- **시작 조건:** `P15-T1` lead 검증 완료 및 사용자 구현 승인. 실제 schema metadata/DDL 승인이나 운영 access는 local code/H2 test의 선행조건이 아니다. +- **완료 증거:** entity mapping/DDL 검토 및 query/controller focused test에서 non-null 응답 문자열, nullable 필드, 기존 request validation/visibility/action 계약 통과. +- **범위 밖:** HTTP schema 변경, 개인정보 보관 정책 추가, 사용자 입력 nullable화, 운영 SQL 실행. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` +- Verify only: request/response DTO types; mapping은 기존 query service 경계에서 처리하고 DTO 변경을 전제하지 않는다. + +**Interfaces:** + +- Consumes: 현재 response DTO와 mapping, 기존 필수 request validation. +- Produces: `GiftDelivery.senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail` 모두 `var String?`. 현행 NOT NULL DB sender 열 4개만 기존 `gift-schema.sql` trailing ALTER에서 nullable화. Response schema는 변경 없이 empty-string/null mapping 유지. + +- [x] **RED:** `GiftEntityMappingTest.shouldPersistNullForAllSenderFields`, 양방향 query 및 관리자 query, 실제 controller 응답 검증을 추가했다. 필수 sender 빈 문자열 거부는 기존 `GiftCommandServiceTest.shouldRejectInvalidGiftApplication`의 네 case를 재사용하고, `GiftControllerTest.shouldKeepRequiredSenderRequestValidation`으로 누락 네 case를 추가했다. +- [x] **RED 확인:** entity focused 5개 중 1개 실패, 관련 6개 클래스 129개 중 신규 6개 실패를 확인했다. 저장 제약 3건과 query null 매핑 3건이며 기존 113개는 모두 통과했다. Kotlin reflection은 RED에서만 사용하고 최종 테스트에서는 제거했다. +- [x] **GREEN:** 발신자 다섯 property를 `var String?`로 바꾸고 query service 두 곳에서 이름/전화번호 `.orEmpty()` 및 nullable 주소 포맷을 적용했다. DTO·요청 검증·업무 시각은 유지했다. +- [x] **GREEN 확인:** 관련 focused 테스트 129개 실패·오류·skip 0, Kotlin 컴파일과 ktlint 통과. 12개 필드 null 저장을 flush/clear/reload로 확인했다. 기존 SQL ALTER와 엔티티의 네 nullable 컬럼을 대조했으며 SQL은 실행하지 않았다. +- [x] **REFACTOR:** source diff 3개 파일의 최소 변경을 확인했고 독립 query/entity 32개 재실행과 lint를 통과했다. 단계 리뷰는 명세·품질 모두 승인됐다. 상세 결과는 아래 P15-T2 기록을 따른다. + +#### P15-T2 구현·검증 기록 — 2026-10-23 + +- Baseline: `./gradlew --no-daemon test --tests '*GiftEntityMappingTest' --tests '*GiftQueryServiceTest' --tests '*GiftAdminQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0, 113개 통과(1분 44초). +- RED: 동일 클래스 범위 신규 테스트 129개 중 6개 실패(1분 2초), null 저장 미지원 및 non-null 응답에 null 전달을 재현했다. HTTP 두 case는 저장 선행조건 실패였으며 HTTP 매핑 자체의 RED로 계산하지 않았다. +- GREEN: baseline과 같은 focused 명령 exit 0(1분 18초), entity 5 + user query 19 + admin query 8 + command 50 + user controller 15 + admin controller 32 = 129개, 실패·오류·skip 0. 신규 16 invocation과 기존 입력 검증이 통과했다. +- 독립 재검증: `./gradlew --no-daemon test --tests '*GiftEntityMappingTest' --tests '*GiftQueryServiceTest' --tests '*GiftAdminQueryServiceTest' ktlintCheck` exit 0(39초), XML에서 32개 실패·오류·skip 0 확인. LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. +- 리뷰: 독립 P15-T2 명세·품질 리뷰 승인, 차단 사항 없음. 보존된 실행 로그 및 diff를 대조했다. 전체129 XML은 독립32 재실행으로 덮어써졌으므로 reviewer는 전체 개별 XML까지 재검증했다고 주장하지 않는다. +- 한계/관찰: H2의 기존 chat schema 및 비관련 translation/charge scheduler `SKIP LOCKED` 경고, 중복 JSON/Netty/Security 경고, Gradle deprecation이 있었다. 새 실패는 없으며 무관한 코드는 수정하지 않았다. 이번 HTTP는 MockMvc 검증이며 삭제 후 real-socket 시나리오는 Gate에서 수행한다. +- 자원: 전용 TMPDIR/JVM temp 경로를 사용했고 Gradle 종료 후 관련 Gradle/Redis 프로세스 잔류가 없음을 확인했다. 커밋·worktree·운영 DB 작업 없음. + +### Task 15.3 적격 query와 결정적 개인정보 정리 + +**Goal 실행 `P15-T3`:** 주어진 `now`에서 정확히 7일 이상 지난 DELIVERED 선물의 12개 배송 개인정보를 idempotent하게 지운다. + +- **시작 조건:** `P15-T2` 완료. local JPA test는 변경한 nullable entity를 H2에서 검증하며 production schema 확인/DDL 실행을 요구하지 않는다. +- **완료 증거:** 실제 `GiftRepository` predicate와 scheduler/service의 embedded H2 JPA 테스트에서 경계/상태/null/보존/재실행 확인. 기존 `@DataJpaTest`가 지정 datasource를 대체함을 확인했으므로 MySQL-mode 또는 MySQL 실서버 검증으로 표시하지 않는다. +- **범위 밖:** native SQL로 auditing 우회, erased metadata, push/refund, 상태 전이, 미전달 상태 데이터 정리. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt` + +**Interfaces:** + +- Consumes: `P15-T2`의 nullable `GiftDelivery`. +- Produces: `GiftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff: LocalDateTime): List`; `GiftScheduler.eraseDeliveredGiftPersonalData(now: LocalDateTime): Int`는 eligible delivery를 dirty checking으로 비우고 변경 건수를 반환한다. + +- [x] **RED:** `shouldEraseDeliveredGiftWhenDeliveredAtEqualsSevenDayCutoff`, `shouldIgnoreDeliveredGiftOneSecondNewerThanSevenDayCutoff`, `shouldEraseOlderHistoricalDeliveredGifts`, `shouldIgnoreNullDeliveredAtAndNonDeliveredGifts`를 실제 repository/JPA fixture로 작성했다. +- [x] **RED:** `shouldEraseRemainingFieldsFromPartiallyClearedGift`로 12개 중 하나씩 남은 12개 행을 검증하고, 최초 all-null 및 재실행 변경 수 0 테스트를 추가했다. +- [x] **RED:** `shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps`에서 실제 Member·UseCan·GiftReview와 선물·배송 업무 필드를 flush/clear/reload로 비교했다. 실제 변경된 행의 updatedAt은 고정하지 않고, no-op의 추가 수정 없음만 검증했다. +- [x] **RED 확인:** scheduler 기존 14개 통과 후 신규 8개가 미구현 query/service `NoSuchMethodException`으로 실패했다. 임시 reflection 호출은 최종 구현에서 정적 호출로 교체했다. +- [x] **GREEN:** 기존 JPQL exists 패턴의 적격 조회와 `@Transactional eraseDeliveredGiftPersonalData(now)`를 추가했다. 정확히 12개 필드만 dirty checking으로 null 처리하며 완전 null은 skip한다. +- [x] **GREEN 확인:** scheduler 22개 실패·오류·skip 0. 결제/동의/리뷰/Member/업무 시각 보존과 no push/refund를 확인했다. +- [x] **REFACTOR:** 새 테스트 한 줄의 lint 길이 위반을 줄바꿈으로 수정한 뒤 같은 focused/lint 명령을 통과했다. 독립 실제 재실행과 단계 리뷰도 통과했다. + +#### P15-T3 구현·검증 기록 — 2026-10-23 + +- Baseline: `./gradlew --no-daemon test --tests '*GiftSchedulerTest' ktlintCheck` exit 0(27초), 기존 14개 통과. +- RED: `./gradlew --no-daemon test --tests '*GiftSchedulerTest'` exit 1(33초), 22개 중 신규 8개 메서드 부재 실패, 기존 14개 통과. fixture는 실제 DB 저장 후 실패했으며 오타/설정 실패가 아니다. +- GREEN: 첫 실행 JPA22개 통과 후 새 테스트의 130자 초과 lint 실패를 수정했다. 최종 baseline 명령 exit 0(36초), 22개 실패·오류·skip 0. +- 독립 검증: 첫 실행은 `UP-TO-DATE`였으므로 실제 재실행으로 계산하지 않았다. `./gradlew --no-daemon test --rerun --tests '*GiftSchedulerTest' ktlintCheck` exit 0(23초), XML 22개 실패·오류·skip 0, timestamp `2026-10-23T11:05:56` 확인. +- 범위: repository query 16줄, 삭제 업무 메서드 42줄 및 실제 DB 테스트 8개를 추가했다. T2/SQL/기존 업무 로직은 유지했다. 2020년 이력, 다른 상태 6개, 부분 삭제 각 필드, 재실행 및 실제 UseCan 보존을 포함한다. +- 리뷰: P15-T3 명세·품질 승인, 차단 사항 없음. reviewer가 본 최신 XML은 lead 재실행 산출물이며 implementer의 `11:00:42` 실행과 구분했다. +- 검증 한계 정정: 계획과 설정 문자열에는 MySQL-mode가 있으나 `@DataJpaTest`는 기본 embedded H2로 교체한다. 실제 JDBC URL에는 MODE가 없었으므로 H2 JPA roundtrip으로만 보고한다. 기존 무관한 DDL/JSON/Gradle 경고와 LSP 부재는 유지하며 MySQL 검증·운영 반영은 하지 않았다. + +### Task 15.4 독립 scheduler entrypoint와 분산락 + +**2026-10-23 리뷰 정정:** 신규 wrapper의 즉시 `finally unlock`은 메서드 반환 후 이루어지는 Spring commit보다 빨라 동일 reminder 재진입을 허용할 수 있다. 아래 최초 패턴 요구는 이 정정이 우선한다. 신규 세 작업은 lock 획득 성공 시 `finally`에서 기존 `RecommendationSnapshotRefreshService.unlockAfterTransaction`과 같은 transaction synchronization 패턴으로 해제를 등록하고 `afterCompletion`에서 소유권을 확인해 해제한다. 실제 transaction이 없는 직접 호출에서는 업무 종료 후 즉시 해제한다. 기존 :00 동작은 범위 밖으로 유지한다. commit/rollback 직전에는 재진입이 거부되고 완료 뒤에는 허용되는 실제 transaction regression을 추가한다. + +**Goal 실행 `P15-T4`:** 기존 만료/알림 업무 메서드를 신규 시간별 scheduler wrapper에 등록하고 각각 독립 lock lifecycle을 적용한다. + +- **시작 조건:** `P15-T3` 완료. +- **완료 증거:** scheduler reflection/lock test와 기존 업무 메서드 delegation 회귀 통과. +- **범위 밖:** worker 실행시간 가정, 작업 간 전역 비중첩 보장, 기존 expiry/reminder 업무 로직 변경, Clock config 및 HTTP endpoint. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt` +- Test reference: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotSchedulerTest.kt` + +**Interfaces:** + +- Consumes: `GiftScheduler.eraseDeliveredGiftPersonalData(now: LocalDateTime): Int`, 기존 `markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int`, `sendDeadlineReminderGifts(now: LocalDateTime): Int`. +- Produces: `runExpiredRecipientAddressGiftExpiration()`, `runDeliveredGiftPersonalDataErasure()`, `runDeadlineReminderGifts()` parameterless `@Scheduled`/`@Transactional` wrapper. 각각 `0 10 * * * *`, `0 20 * * * *`, `0 40 * * * *`, `zone="Asia/Seoul"`. Lock key는 각각 `lock:gift-expired-recipient-address-undeliverable`, `lock:gift-delivered-personal-data-erasure`, `lock:gift-deadline-reminders`. 기존 `runExpiredTrackingRegistrationGiftCancellation()`의 :00 cron/key는 바꾸지 않는다. + +- [x] **RED:** JUnit MethodSource로 세 진입점의 cron/zone/transaction, 실제 delegate 및 now, lock 성공/실패/예외/소유권 5종을 검증하는 15사례를 작성했다. +- [x] **RED 확인:** 기존 22개는 통과, 신규 15개는 wrapper 부재로 실패했다. 리뷰 후 실제 beforeCommit 조기 해제 및 rollback/소유권 회귀의 추가 RED도 확인했다. +- [x] **GREEN:** 세 parameterless wrapper와 독립 key를 등록했다. 리뷰 정정에 따라 획득 성공한 신규 lock은 finally에서 transaction synchronization을 등록하고 afterCompletion에 해제한다. 기존 :00과 업무 로직은 유지한다. +- [x] **GREEN 확인:** 최초 37개 통과 후 실제 transaction 완료 경계 15사례를 보강해 최종 52개 실패·오류·skip 0을 확인했다. Kotlin 컴파일과 lint도 통과했다. +- [x] **REFACTOR:** 신규 세 wrapper의 transaction 완료 해제를 작은 helper로 공통화했다. scoped 재리뷰는 차단 사항 해결 및 명세·품질 승인 판정이다. 작업 간 비중첩이나 Redis 장애 시 exactly-once를 주장하지 않는다. + +#### P15-T4 구현·리뷰 수정 기록 — 2026-10-23 + +- 최초 TDD: `./gradlew --no-daemon test --rerun --tests '*GiftSchedulerTest' ktlintCheck` baseline22 통과(24초), 신규 wrapper RED37개 중15실패(35초), GREEN37개/lint 통과(45초). lead 실제 재실행37개/lint도 exit0(27초), XML `11:20:16` 확인. +- 리뷰 차단: @Transactional 메서드의 finally가 proxy commit보다 먼저 unlock하여 다른 인스턴스의 recipient reminder 중복 실행 구간을 만들 수 있음을 확인했다. `PLAN-DEC-020`으로 신규 세 작업의 해제 계약을 정정하고 재현 테스트를 먼저 추가했다. +- 수정 RED: 실제 JpaTransactionManager의 TestTransaction beforeCommit/rollback 경계에서 52개 중15실패(33초)를 확인했다. callback을 수동 호출해 통과시키지 않았다. +- 수정 GREEN: `./gradlew --no-daemon test --tests '*GiftSchedulerTest' ktlintCheck` 첫 실행은120초 도구 timeout으로 결과 미확정. 잔여 JVM 없음 확인 후300초 제한으로 재실행하여 exit0(23초), 52개 실패·오류·skip0, XML `2026-10-23T11:35:27` 확인. compile/lint 동일 입력은 UP-TO-DATE이며 테스트는 실제 실행됐다. +- 보장: 정상 transaction의 beforeCommit/afterCommit과 rollback 직전까지 모의 경쟁 lock 획득이 거부되고 완료 후 허용된다. 실패 획득은 callback 미등록, 완료 시 소유권 상실은 unlock 없음, non-transaction 직접 호출은 업무 후 해제를 검증했다. 실제 Redis 다중 인스턴스 검증은 아니다. +- 재리뷰: 원 reviewer가 단일 blocker를 ADDRESSED, Spec PASS/Quality PASS로 판정했고 신규 회귀 없음. 실제 scheduler bean proxy/등록은 Gate에서 별도로 확인한다. +- 자원/범위: commit 테스트는 fixture를 생성하지 않으며 종료 후 Gift/Delivery count0을 확인했다. 기존 fixture 테스트는 rollback 유지. 프로세스 잔류 없음, 기존00/T2/T3/SQL 보존, 커밋·푸시 없음. + +### Phase 15 Gate + +**최종 리뷰 차단 및 보강 범위:** 전체 테스트·빌드가 통과했으나 신규 :10/:40 작업이 HTTP 배송지 등록과 병행될 때 오래된 Gift/Delivery를 커밋해 주소·동의·상태를 덮어쓸 수 있다. 단순 scheduler 분산락만으로 HTTP 요청을 보호할 수 없으므로 아래 `P15-R1`을 먼저 완료한다. 이미 완료된 T2~T4 기록은 되돌리지 않는다. + +**Goal 실행 `P15-GATE`:** nullable DB 배포 순서, 개인정보 제거, 기존 API와 업무 데이터 보존을 최종 확인한다. + +- **시작 조건:** `P15-T1`~`P15-T4`의 구현 승인 및 완료 증거 충족. +- **완료 증거:** gift focused regression, lint, 테스트 제외 build, 격리된 실제 HTTP QA, schema/배포 checklist 기록. +- **범위 밖:** 운영 DB 변경/삭제 실행, 신규 production HTTP endpoint, 실제 push, backup/log/cache 삭제 주장, `updatedAt` 보존 강제. 전체 테스트는 최신 실행 승인에 따라 1회 시도하되 무관한 실패를 고치지 않는다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftEntityMappingTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest" +./gradlew ktlintCheck +./gradlew build -x test +``` + +**Expected:** 명령 결과는 실행 후 기록하며 현재 통과를 주장하지 않는다. HTTP QA는 독립 fixture를 가진 격리 환경에서 erase business method를 실행한 후 real socket의 기존 SENT/RECEIVED/admin GET으로 재조회한다. 실제 정리 HTTP endpoint는 추가하지 않는다. Production DB를 대상으로 하지 않으며 실제 push가 발생하지 않는다. + +수동/deployment 확인: + +- [x] 격리 실제 HTTP로 전/후 `SENT`/`RECEIVED`/admin 응답의 shape, 정확한 빈 주소/null, nickname, DELIVERED no-action/CTA를 확인했다. +- [x] SQL 문서와 엔티티를 대조해 네 sender column의 nullable 완화와 기존 길이/comment 보존 및 destructive DML 없음을 확인했다. 실제 운영 schema metadata/DDL 적용은 별도 rollout 조건이며 이번 Gate에서 수행하지 않았다. +- [x] 모든 instance의 nullable reader 준비 후 첫 정리 실행, 구버전 non-null reader rollback 위험, 코드만으로 삭제 데이터 복구 불가를 배포 안내에 명시했다. 운영 준비 자체를 확인했다는 의미는 아니다. +- [x] 실제 Spring에서 기존00과 신규10/20/40 등록을 확인하고 erasure의 no push/refund를 검증했다. PRD §8.15의 기존 expiry 팬 알림과 현재 코드의 불일치는 별도 관찰로 유지한다. R1은 새 예약 경로의 동시 저장 안전성만 보강하고 새 업무 정책/푸시는 추가하지 않았다. + +### Phase 15 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 36 | `P15-T1` | 승인된 계약 | 아니요 | 문서 검증 완료 | +| 37 | `P15-T2` | `P15-T1` 검증 + 별도 구현 승인 | 아니요 | 승인 전 구현 금지 | +| 38 | `P15-T3` | `P15-T2` | 아니요 | nullable mapping 완료 후 진행 | +| 39 | `P15-T4` | `P15-T3` | 아니요 | query/service 완료 후 연결 | +| 40 | `P15-GATE` | `P15-T2`~`P15-T4` 완료 | 아니요 | Gate 실패 소유 Task 수정 | + +```text +P15-T1 문서 검증 → 별도 구현 승인 → P15-T2 → P15-T3 → P15-T4 → P15-GATE + +별도 rollout gate: DDL review + 별도 실행 승인 → compatible nullable schema 적용 및 모든 instance reader 확인 → 첫 erasure 허용 +``` + +### Phase 15 Decision Log 추가 + +기존 결정 및 Phase14 기록은 변경하지 않고 다음 결정만 추가한다. + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-10-23 | `PLAN-DEC-013` | 확정 | 정리 조건은 DELIVERED 및 non-null `deliveredAt <= now.minusDays(7)`이며 과거 누적 대상도 포함한다. 정확한 12개 배송 개인정보만 null 처리하고 완전 null 재실행은 변경 0건이다 | 승인 계약, PRD `DEC-017` | `P15-T2`~`P15-T3`, `GIFT-018` | +| 2026-10-23 | `PLAN-DEC-014` | 확정 | sender 필수 request 계약과 기존 public response schema를 유지하고 non-null 응답 문자열은 empty string으로 매핑한다. `updatedAt`은 기존 `BaseEntity @PreUpdate`로 관리한다 | 승인 계약, PRD `DEC-018`; auditing 우회 금지 | `P15-T2`, `P15-GATE` | +| 2026-10-23 | `PLAN-DEC-015` | 확정 | 실제 schema 확인/승인된 네 sender column nullable DDL 및 모든 instance nullable reader 이후 첫 erase를 배포 gate로 둔다. 기존 SQL 하단 ALTER로 fresh install 최종 schema도 nullable을 지원한다. code rollback은 삭제 데이터 복구가 불가능하고 자동 운영 SQL/backup 변경은 하지 않는다 | 데이터 삭제와 nullable reader 전환의 기술적 배포 제약 | `P15-T1`~`P15-GATE` | +| 2026-10-23 | `PLAN-DEC-016` | 확정 | scheduler는 :00/:10/:20/:40에 독립 lock/transaction entrypoint를 두고 기존 expiry/reminder 처리는 바꾸지 않는다. 작업 간 비중첩을 보장하지 않는다 | 승인된 scheduler 등록 계약 | `P15-T4`, `P15-GATE` | +| 2026-10-23 | `PLAN-DEC-017` | 확정 | 변경 SQL은 30줄 미만이므로 기존 `gift-schema.sql` trailing ALTER에 기록한다. CREATE block은 고치지 않으며 기존 DB 적용 및 fresh install 순서, DB 실행 미수행을 구분한다 | latest SQL placement decision, 기존 5줄 ALTER | `P15-T1`, `P15-T2`, `P15-GATE`, PRD `DEC-019` | +| 2026-10-23 | `PLAN-DEC-018` | 확정 | Kotlin sender property 다섯 개를 nullable로 하고 이미 nullable인 `sender_address_detail`을 제외한 네 DB 열만 ALTER한다 | PRD `DEC-020` | `P15-T2`, `GIFT-019` | +| 2026-10-23 | `PLAN-DEC-019` | 확정 | 신규 schedule wrapper는 parameterless이고 각 wrapper가 기존 시간관례의 `LocalDateTime.now()`를 delegate에 전달한다. 정확한 독립 schedule와 lock key는 goal Interfaces에 명시한다 | 현재 `GiftScheduler.kt` 구현과 승인 cron | `P15-T4`, PRD `DEC-021` | +| 2026-10-23 | `PLAN-DEC-020` | 정정 | 신규 세 wrapper의 분산락은 DB commit/rollback 후 afterCompletion에서 해제한다. transaction이 없는 직접 호출은 기존 finally 해제 의미를 유지한다. 기존 :00은 변경하지 않는다 | T4 리뷰: 커밋 전 unlock의 중복 reminder 재진입 위험; 기존 recommendation transaction synchronization 패턴 재사용 | `P15-T4`, `P15-GATE` | + +### Phase 15 문서 초안 기록 + +아래는 최종 검증 전 초안 작성 시점의 기록이며, 최신 상태는 다음 `P15-T1 최종 문서 검증`을 따른다. + +- 상태: 문서 계획 승인, 구현 대기. `P15-T1`의 lead 문서 최종 검증이 미완료다. +- 무엇을: PRD 및 이 Phase 계획에 적격 기준, 개인정보 필드, response mapping, scheduler 분, migration 순서와 TDD 검증을 명시했다. +- 왜: 구현 전 승인된 정확한 동작과 data-loss 배포 순서를 고정하기 위해서다. +- 어떻게: PRD/plan만 편집했다. lead가 기존 `gift-schema.sql` 끝에 nullable ALTER 문서를 준비했으며, 이 수정 단계에서는 SQL을 실행하지 않았다. 계획에는 test/SQL 실행 결과를 기재하지 않았다. +- 남은 항목: lead가 `./gradlew --no-daemon tasks --all`과 `git diff --check` 실행 후 `P15-T1` 검증 checkbox를 갱신한다. 이후에도 `P15-T2` 시작에는 구현 별도 승인이 필요하다. +- 다음 행동: `P15-T1` 최종 문서 검증, 구현 승인 전 `P15-T2` 착수 금지. + +### P15-T1 최종 문서 검증 — 2026-10-23 + +- 상태: 문서 작성 및 검증 완료. `P15-T2`~`P15-T4`, `P15-GATE`는 미착수다. +- 무엇을: 기존 PRD에 `GIFT-018`~`GIFT-021`과 결정 기록을 추가하고, 이 계획에 nullable 저장/응답 호환, 7일 경과 삭제, 세 스케줄 등록 및 focused TDD 검증을 추가했다. 별도 계획 폴더나 SQL 파일은 만들지 않았다. +- 왜: 승인된 삭제 대상 12개와 보존 데이터, 기존 데이터 소급 적용, 10/20/40분 실행 시각을 구현 전에 같은 기준으로 추적하기 위해서다. +- 검증: `./gradlew --no-daemon tasks --all` exit 0, `BUILD SUCCESSFUL in 12s`. `test`, `ktlintCheck`, `build` 명령이 등록돼 있음을 확인했으며 테스트나 빌드 성공을 뜻하지 않는다. +- 검증: `git diff --check` exit 0. `git diff --name-only`에서 이 디렉터리의 `prd.md`, `plan-task.md`, `gift-schema.sql` 세 파일만 변경됐음을 확인했다. Phase14의 완료 체크박스·결정·검증 기록은 보존했다. +- 수동 대조: 발신자 Kotlin 필드 다섯 개와 DB ALTER 열 네 개, 삭제 대상 열 12개, 정확히 7일인 경계 포함, 기존 데이터 포함, 응답의 빈 문자열/nullable 구분, 신규 cron/zone/lock 계약, 요구사항과 Task 연결을 확인했다. 추가 ALTER 본문은 5줄이며 기존 타입·길이·COMMENT를 유지하고 데이터 삭제 구문은 없다. +- 한계: 사용 가능한 LSP 도구가 없어 문서/SQL 대조와 diff 검사로 검증했다. 실제 MySQL 구문 실행, 운영 schema 확인, 애플리케이션 기동, 기능 테스트, ktlint 및 빌드는 수행하지 않았다. 문서/SQL 기록만 변경했으므로 focused/전체 테스트를 생략했다. 기존 Gradle deprecation 경고는 이번 범위 밖이다. +- 남은 항목: 사용자 구현 승인 후 `P15-T2`의 첫 미완료 RED부터 실행한다. 운영 DDL 적용과 실제 데이터 삭제는 별도 배포 승인/조건을 충족해야 하며, 커밋·푸시는 하지 않았다. + +### P15-R1: 신규 스케줄과 배송지 등록의 경합 보강 + +**최종 재리뷰 정정:** 수취인 주소 경합은 공유 Gift→Delivery 잠금과 최신 refresh로 해결됐다. 다만 자동취소 후보의 기존 batch `@Lock` 제거는 동일 발신자 복수 선물의 환불 순서를 바꿀 수 있어 복원한다. `findReceivedGiftsWithTrackingDeadlineBefore`에 기존 `PESSIMISTIC_WRITE`와 `order by g.id`를 적용하여 첫 환불 전 후보를 모두 잠근다. reviewer는 이 복원으로 새 A/B 회귀가 차단됨을 확인했고 공통 `CanPaymentService` Member 잠금 변경 요구를 범위 확대로 철회했다. 공통 결제 로직은 수정하지 않는다. + +**Goal:** 새로 자동 실행되는 배송지 만료·기한 알림이 동시에 성공한 수취인 배송지 등록 또는 진행 상태 변경을 덮어쓰지 않는다. + +- **시작 조건:** 최종 Oracle 리뷰 S3 차단 재현. 전체 테스트·lint·build는 수정 전 `./gradlew --no-daemon test --rerun ktlintCheck build` exit0(9분38초). +- **Files:** `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt`, 필요 시 같은 디렉터리의 `GiftDeliveryRepository.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt`, `GiftAdminService.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` 및 영향받는 기존 선물 테스트. +- **완료 증거:** 실제 독립 transaction·동시 호출에서 reminder 읽기/주소 등록/commit 순서와 반대 순서, expiry/주소 등록 경합의 회귀 통과. 동일 Gift 행 잠금 또는 기존 구조에 맞는 최소 직렬화로 최신 상태·배송지를 확인하며 입력을 잃지 않는다. 최종 scoped 재리뷰 승인. +- **범위 밖:** 신규 기한/푸시/환불 정책, 전역 isolation 변경, 의존성·테이블·버전 컬럼, 운영 DB 실행, 관련 없는 도메인 수정. 단순 DynamicUpdate만으로 상태 경합을 덮지 않는다. +- [x] **RED:** 실제 독립 transaction/latch 11개 사례에서 성공한 주소 등록 값이 reminder/운영 변경의 오래된 entity 커밋으로 null이 되거나 상태가 되돌아가는 실패를 재현했다. +- [x] **GREEN:** 참여 writer는 Gift→Delivery 잠금과 `refresh(..., PESSIMISTIC_WRITE)`를 사용하고, scheduler는 최신 상태·주소·기한·알림표시를 재검사한다. 자동취소 후보의 기존 batch lock도 ID 순서와 함께 복원했다. 공통 결제 로직과 기한 정책은 변경하지 않았다. +- [x] **GREEN 확인:** 지정4클래스181개와 실제 HTTP 삭제 QA·lint 통과 후, 자동취소 잠금 복원 RED/GREEN과2클래스104개 회귀·lint도 통과했다. 최종 전체2396개 테스트·lint·build를 재실행해 통과했다. +- [x] **REFACTOR/리뷰:** 기존 S3 차단 해소와 최신 Gift/Delivery 읽기를 reviewer가 승인했다. 최종 자동취소 batch lock3줄 복원 및 회귀도 별도 scoped evidence 리뷰 APPROVED. 최신 전체2396개/lint/build 통과로 Gate를 완료했다. + +### P15-GATE 1차 검증 및 최종 리뷰 — 2026-10-23 + +- 실제 HTTP: 기존 AdminGiftControllerTest에 `shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts`를 추가했다. 실제 JWT localhost GET28회로 익명401/무관한 회원 거부, 발신자·수취인·관리자 전후 응답을 확인했다. 최초 삭제1/재실행0, persisted null12, Gift/Delivery 각1행과 목록 유지, 실제 결제/동의/리뷰/시각 보존 및 no refund/push를 확인했다. +- 실제 Spring: injected scheduler의 TransactionInterceptor proxy와 ScheduledAnnotationBeanPostProcessor 등록4개(00/10/20/40)를 확인했다. 등록 증거 후 테스트 context의 해당4개 작업만 cancel하여 벽시계와 fixture 경합을 막았다. production 설정이나 작업 등록은 변경하지 않았다. +- HTTP 검증 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest.shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts' --no-daemon --console=plain` exit0(44초), 해당 클래스 전체33개 exit0(35초), `./gradlew ktlintCheck --no-daemon --console=plain` exit0(18초). 최초 lint 줄바꿈 위반은 수정 후 모두 재실행했다. +- 전체 검증: `./gradlew --no-daemon test --rerun ktlintCheck build` exit0, `BUILD SUCCESSFUL in 9m 38s`. JUnit XML322개 집계에서 테스트2377개, 실패·오류·skip0을 확인했다. 이 결과는 `P15-R1` 수정 전 기준이며 수정 후 결과로 재사용하지 않는다. +- 최종 Oracle 리뷰: S1(nullable/API), S2(삭제 범위/보존), S4(HTTP/DDL) PASS, S3는 배송지 입력과 신규 reminder/expiry의 stale entity update 경쟁으로 BLOCKED. callback 이후 락 해제는 승인됐으나 HTTP 요청과 공유되는 DB 잠금 보강이 필요하다. `P15-R1`로 실패 재현·최소 수정을 추가했고 기존 완료 Task를 되돌리지 않았다. +- 환경: LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. 실제 MySQL/운영 DDL/운영 배포/실제 Redis 다중 인스턴스는 검증하지 않았다. QA JVM/Redis/포트 종료를 확인했고 전체 suite 후에도 java/redis-server 프로세스 잔류가 없었다. 커밋·푸시 없음. + +### P15-R1 경합 수정 및 마지막 잠금 복원 검증 — 2026-10-23 + +- RED: `./gradlew --no-daemon test --tests '*AdminGiftControllerTest.shouldSerializeRecipientMutations'` exit1(44초), 독립 transaction 11개 실패. reminder가 먼저 읽고 주소가 커밋된 뒤 recipient5개 필드가 null로 되돌아가는 것을 실제 재조회로 확인했다. +- 수정: Gift/Delivery의 공통 locking/current read와 refresh, scheduler 후보 정렬 및 잠금 뒤 최신 조건 재검사를 적용했다. 주소 등록·수령확인·관리자 상태 변경이 같은 잠금 순서를 사용하며 새 기한 거부·푸시·환불 정책은 추가하지 않았다. +- focused: `./gradlew --no-daemon test --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftAdminServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit0(1분4초), 181개 실패·오류·skip0. 신규 양방향 경합18사례와 실제 JWT HTTP 삭제 QA가 포함됐다. 최초 lint10건은 포맷만 수정한 뒤 재실행했다. +- 재리뷰: 기존 S3는 ADDRESSED. 자동취소 후보의 batch lock 제거가 만든 A/B 환불 순서 회귀 지적은 원래 `PESSIMISTIC_WRITE` 복원과 `order by g.id`로 해소하는 것으로 범위를 합의했다. reviewer는 공통 Member/결제 잠금 변경 요구를 범위 확대로 철회했다. +- 마지막 RED: `./gradlew --no-daemon test --tests '*AdminGiftControllerTest.shouldLockExpiredTrackingBatchUntilTransactionCommits'` exit1(44초). 후보 조회 transaction이 열린 동안 두 번째 선물 취소가 먼저 완료돼 `Expected TimeoutException ... but nothing was thrown`으로 실패했다. 별도의 fixture 분류번호 중복 실패는 RED에서 제외했다. +- 마지막 GREEN: `GiftRepository`의 Lock import/annotation/ID 정렬3줄만 복원했다. 같은 테스트 exit0(44초), `./gradlew --no-daemon test --tests '*AdminGiftControllerTest' --tests '*GiftSchedulerTest' ktlintCheck` exit0(1분1초), 104개 실패·오류·skip0. 실행 SQL `order by gift0_.id for update`, `P15_BATCH_LOCK orderedCandidates=2 contenderBlockedUntilCommit=true refundCan=80 refundCount=1` 확인. +- 최종 전체 실행: `./gradlew --no-daemon test --rerun ktlintCheck build` exit0, `BUILD SUCCESSFUL in 9m 30s`. JUnit XML322개에서 테스트2396개, 실패0·오류0·skip0. 앞선 수정 전2377개/중간2395개 결과와 구분한 최신 코드 기준이다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 산출물을 확인했다. +- 최종 실표면 증거: 최신 `AdminGiftControllerTest` XML52개 통과, `P15_HTTP erased=true status=200 success=true senderStringsEmpty=true recipientAddressNull=true trackingNull=true requiredFlags=false nicknamePreserved=true timelinePreserved=true lists=1/1/1 auth=401/denied`, `P15_DB erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 paymentConsentReviewHistoryPreserved=true` 확인. +- 한계: 실제 MySQL REPEATABLE_READ/Redis 다중 인스턴스 실험은 아니다. LSP 도구 부재는 Kotlin compile/ktlint로 대체했다. 비관련 H2 SKIP LOCKED/DDL 경고와 Gradle deprecation은 기존 현상으로 남겼다. 최종 추가 잠금 복원의 evidence 확인 리뷰와 임시 자원 정리는 아래 최종 판정에서 기록한다. + +### P15-GATE 최종 판정 + +- 판정: 서버 구현·검증 완료. 최종 전체 테스트2396개(322 suites), 실패·오류·skip0, lint/build exit0(9분30초). 실제 JWT HTTP와 DB roundtrip, 스케줄 등록/transaction, commit/rollback 락 해제, 주소 등록 경합18사례 및 batch lock 회귀를 포함한다. +- 리뷰: 최초 전체 Oracle 리뷰의 S3 차단은 공유 Gift→Delivery 잠금/current-read로 ADDRESSED. 그 수정이 건드린 자동취소 batch lock은 원래 비관적 잠금+ID정렬로 복원했고 마지막 scoped 리뷰 APPROVED, 남은 blocker 없음. 원 세션 재개 실패 뒤 증거를 전달한 후속 reviewer가 실제 복원 diff와 테스트를 확인했다. +- 구현상 결정: 업무 시각 보존과 기존 updatedAt auditing을 유지했다. 같은 Gift의 writer는 직렬화되므로 대기 시간이 생길 수 있지만 주소·동의·상태 유실을 방지한다. 공통 결제·회원 잔액 정책은 범위 밖으로 변경하지 않았다. +- 운영 경계: 하단 ALTER는 미실행이다. 실제 MySQL schema와 nullable reader 전체 배포를 확인한 뒤 스케줄을 운영해야 한다. 삭제 후 구버전 reader 복귀는 안전하지 않다. 백업·로그·외부 저장소 삭제는 이번 기능이 아니다. +- 전달 방식: 사용자 지시대로 현재 `test` 브랜치에서 worktree 없이 작업했고 모든 변경은 미커밋 상태다. git commit/push/stage를 수행하지 않았다. +- 최종 문서 검증: `./gradlew --no-daemon tasks --all` exit0(5초), `git diff --check` exit0. HEAD는 시작 시점과 같은 `c837ff18f82e3260c692a7c9f1884dbefccba441`, index 변경 없음이다. +- 자원 정리: 전체 테스트 및 문서 검증 종료 후 java/redis-server 프로세스와 작업 전용5개 디렉터리의 열린 파일이 없음을 확인했다. 생성했던 `gift-p15-t2.mAk3Mv`, `gift-p15-t3.McSvmT`, `gift-p15-t4.mIILMF`, `gift-p15-r1.XJMSPV`, `gift-p15-qa.EJ30P6`와 그 안의 runtime cache/임시 Tomcat/Redis/로그를 삭제하고 각 경로의 부재를 확인했다. 검증 요약은 이 계획에 보존하며 최신 JUnit/HTML/build 산출물과 공유 Gradle 캐시는 유지했다. 작업 notepad도 요약 반영 후 제거한다. + +### Phase 15 커밋 전 재검증 — 2026-10-26 + +- 사용자 후속 승인: `/commit-with-validation`으로 Phase 15 변경 커밋을 요청했다. 위 미커밋 기록은 당시 상태로 보존하며 이번 요청은 커밋을 승인한다. +- 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftEntityMappingTest' --tests '*GiftSchedulerTest' --tests '*GiftQueryServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*GiftCommandServiceTest' --tests '*GiftAdminServiceTest' --tests '*GiftControllerTest' --tests '*AdminGiftControllerTest' ktlintCheck`. +- 결과: exit 0, `BUILD SUCCESSFUL in 1m 18s`. 8개 클래스 229개 테스트 실패·오류·skip 0. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. 실제 JWT HTTP 삭제 전후 계약, 개인정보 12개 null 저장·재실행 0건, 경합 및 자동취소 batch lock 회귀를 포함한다. +- 범위: 기존 구현의 커밋 검증이므로 전체 테스트와 패키징 빌드는 반복하지 않았다. 2026-10-23 전체 2,396개 결과와 이번 focused 결과를 구분한다. 운영 MySQL DDL·배포·실제 Redis 다중 인스턴스 검증 및 push는 수행하지 않는다. diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 797c90df..14de351f 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -4,9 +4,9 @@ | 항목 | 내용 | |---|---| -| 문서 상태 | Phase 14 서버 구현 완료, 전체 테스트 제외 검증 완료 | +| 문서 상태 | Phase 15 서버 구현·전체 테스트·실소켓 QA·리뷰 완료, 운영 반영 전 | | 작성일 | 2026-09-29 | -| 최종 수정일 | 2026-10-22 | +| 최종 수정일 | 2026-10-23 | | 대상 제품 | 크리에이터 선물하기 | | 작성자·결정권자 | 사용자 | | 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 | @@ -15,6 +15,8 @@ > Phase 14 관리자 취소와 목록·상세 `CANCEL` 액션은 서버 코드에 구현됐다. 관련 테스트·실제 JWT HTTP·리뷰·테스트 제외 빌드를 검증했으며, 전체 테스트는 사용자 요청으로 완료 기준에서 제외했다. 운영 배포 여부와 별도 프론트엔드 구현 여부는 이 문서의 완료 상태에 포함하지 않는다. +> Phase 15 서버 구현과 검증을 완료했다. 최종 2,396개 테스트·lint·build와 실제 JWT HTTP 삭제 전후 QA 및 리뷰를 통과했다. `gift-schema.sql` 하단의 nullable ALTER는 기록만 했으며 운영 DB 실행·배포·커밋·푸시는 하지 않았다. + ## 1. Overview 팬이 내부 재화인 캔을 사용해 크리에이터에게 실물 선물을 보낼 수 있는 API를 제공한다. @@ -254,6 +256,10 @@ INSPECTION_COMPLETED -> UNDELIVERABLE | `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` | | `GIFT-016` | 확정 | 관리자는 운송장 등록 전 접수된 선물을 취소할 수 있다 | 관리자만 호출할 수 있고 `RECEIVED`에서만 `CANCELED`로 전이하며 `GiftDelivery.canceledAt`을 저장하고 발신자에게 사용 캔 전액을 리워드캔으로 환불한다 | `P14-T2` | | `GIFT-017` | 확정 | 관리자 선물함 목록과 상세는 현재 가능한 액션으로 취소 가능 여부를 함께 제공한다 | `RECEIVED`에서만 `availableActions=["CANCEL"]`; 다른 상태의 기존 액션은 유지한다 | `P14-T3` | +| `GIFT-018` | 확정 | 전달 완료 후 7일이 지난 선물의 배송 개인정보를 지운다 | 실행 시 기존 업무 시각 관례의 `now`에 대해 `status=DELIVERED`, `deliveredAt != null`, `deliveredAt <= now.minusDays(7)`인 과거 누적 건과 이후 조건을 충족하는 모든 건의 `GiftDelivery` 이름/연락처/우편번호/주소/상세주소, 택배사명/운송장번호 12개 필드를 null 처리한다. 식별자·상태·회원 닉네임·결제·동의·리뷰와 업무 lifecycle/deadline/reminder 시각은 보존하며 재실행은 추가 변경 없이 끝난다 | `P15-T2`, `P15-T3` | +| `GIFT-019` | 확정 | 배송 개인정보를 nullable persistence로 저장한다 | `GiftDelivery`의 sender 다섯 Kotlin property는 모두 `var String?`; 현행 NOT NULL sender DB 열 네 개만 nullable로 완화한다. `sender_address_detail`은 이미 nullable이며 ALTER하지 않는다. 기존 CREATE 정의는 유지하고 `gift-schema.sql` 끝에 추가된 ALTER가 fresh install 최종 schema에도 적용된다 | `P15-T1`, `P15-T2` | +| `GIFT-020` | 확정 | 개인정보 null 상태에서도 기존 사용자/관리자 API 계약을 유지한다 | 기존 request 필수 타입/validation, response schema/바깥 객체/visibility와 flags/deadlines 계산 규칙을 유지한다. 데이터 없는 non-null 문자열은 `""`; 주소 전체가 null이면 주소 문자열은 정확히 `""`. nullable 바깥 객체와 tracking/admin courier/tracking은 기존 null 정책 유지 | `P15-T2`, `P15-GATE` | +| `GIFT-021` | 확정 | 배송지 기한 만료와 deadline reminder 업무 메서드를 시간별 스케줄로 등록한다 | 기존 매시 00분 자동취소 등록은 유지한다. Asia/Seoul 기준 매시 10분은 기존 recipient expiry, 20분은 신규 erasure, 40분은 기존 reminders를 각자 독립 `@Scheduled`/lock/transaction wrapper로 위임한다. 기존 업무 로직은 바꾸지 않고 작업 간 비중첩을 보장하지 않는다 | `P15-T3`, `P15-T4` | ## 8. API 계약 @@ -919,6 +925,20 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. 스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다. 배송지 입력 기한과 현재 시각이 같으면 아직 기한 초과가 아니며, `RECEIVED`, `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 상태는 배송지 미입력 전달불가 대상에서 제외한다. +### 8.16 전달 완료 배송 개인정보 정리 + +- 매 실행 시각 `now` 기준 `Gift.status=DELIVERED`, `GiftDelivery.deliveredAt != null`, `deliveredAt <= now.minusDays(7)`인 모든 건을 처리한다. 7일 경계와 그 이전의 과거 누적 건을 포함한다. +- `GiftDelivery`의 다음 12개 값만 null로 지운다: `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `courierCompanyName`, `trackingNumber`. +- 기존 입력 request의 필수/non-null 계약과 검증은 유지한다. 공개 DTO 스키마와 바깥 nullable 객체는 유지하고, 기존 non-null 응답 문자열에서 데이터가 없으면 `""`로 표현한다. 주소는 완전 삭제 상태에서 정확히 빈 문자열이며 `"() "`, `"(null) null"`, 문자열 `"null"`을 만들지 않는다. +- `SENT`의 `senderInfo` 객체는 유지하고 각 문자열은 빈 문자열로 응답한다. `RECEIVED`의 `senderInfo`는 계속 null이다. 모든 받는 주소 값이 null이면 `recipientAddress`는 null이다. 사용자 tracking과 관리자 courier/tracking nullable 값은 null이다. +- 기존 `recipientAddressRegistered`는 실제 필드 완비 여부를 따르므로 삭제 뒤 false다. 별도 등록 이력을 만들지 않는다. `DELIVERED` 상태, 가시성, 기존 derived flags/deadlines, 관리자 액션(빈 목록)을 유지해 재입력/운영 액션을 요구하지 않는다. +- 발신/수신 배송 개인정보만 지운다. Member nickname/profile, Gift 및 GiftDelivery 식별자·status, 결제, 동의 여부/시각, 리뷰, `createdAt`, 업무 lifecycle/deadline/reminder 시각은 보존한다. `updatedAt`은 기존 `BaseEntity @PreUpdate` 동작을 따른다. +- 비대상 상태, null `deliveredAt`, 아직 7일이 안 된 건은 유지한다. 부분 삭제된 적격 건은 나머지를 지우며 완전 null 건의 재실행 변경 수는 0이다. +- 이 작업은 푸시/환불/상태 전이를 추가하지 않는다. 앱 서버 내 DB 배송정보 정리이며 백업·로그·캐시의 삭제를 뜻하지 않는다. +- 기존 자동취소만 이미 `:00`에 예약되어 있다. 다음 3개 업무 메서드를 별도 Asia/Seoul 시간별 wrapper로 등록한다. 배송지 만료 `0 10 * * * *`, 개인정보 정리 `0 20 * * * *`, 기한 안내 `0 40 * * * *`. 업무 로직은 기존 메서드 또는 새 정리 메서드로 위임한다. +- 세 신규 wrapper는 `tryLock(0, -1, SECONDS)`, `@Transactional`과 서로 다른 lock key를 사용한다. 획득한 lock은 `finally`에서 해제를 예약하고 DB commit/rollback 이후 `afterCompletion`에서 소유권 확인 후 해제한다. transaction 없는 직접 호출에서는 업무 종료 후 해제한다. 기존 `:00` 취소 wrapper 및 key는 변경하지 않는다. 다른 작업 간 비중첩은 보장하지 않는다. +- `LocalDateTime`에 KST를 자동 변환하지 않는다. 기존 작업의 clock convention을 따르고 고정 `now`를 전달해 판단한다. + ## 9. 푸시 알림 정책 ### 9.1 팬 대상 푸시 @@ -1035,6 +1055,18 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. - 내부 운영 화면에서는 필요한 권한을 가진 사용자에게만 개인정보를 노출한다. - 약관 동의 여부와 동의시각은 선물 신청 건 감사 근거로 보존한다. +### 11.1 전달 완료 배송 개인정보 수명주기 + +- 정리 범위는 선물 도메인 DB의 지정된 `GiftDelivery` 열 12개다. 배송지 입력 당시 사용한 요청 필드와 검증 규칙은 그대로 필수다. +- 이 정리는 회원 계정, `Member` nickname/profile, Gift 신청/상태/ID, 캔 결제 내역, 약관 동의 snapshot, 리뷰를 삭제하지 않는다. +- `GiftDelivery`의 업무 시각 및 기한은 운영 이력으로 보존한다. `createdAt`은 유지하고 `updatedAt`은 공통 entity auditing 규칙대로 갱신될 수 있다. +- 이 정책은 백업, 애플리케이션/인프라 로그, 외부 관측 시스템, 캐시의 삭제나 보존기간을 정의하지 않는다. 전체 개인정보 삭제 완료로 설명하지 않는다. +- 정리 작업 자체는 상태 전이·환불·알림을 일으키지 않는다. 기존 업무 정책이 정리 task를 통해 달라지지 않도록 한다. +- `GiftDelivery`의 sender 다섯 Kotlin property를 nullable로 선언한다. `sender_address_detail`은 이미 nullable이다. 현재 NOT NULL인 `sender_name`, `sender_phone_number`, `sender_zip_code`, `sender_address`만 schema에서 nullable로 바꾼다. +- 네 열 DDL은 30줄 미만이므로 기존 `gift-schema.sql` 끝에 5줄 ALTER 구문으로 기록한다. 기존 CREATE block의 NOT NULL 정의를 고치지 않는다. SQL 전체를 재실행하지 않는 운영 인스턴스에는 ALTER section만 별도로 승인·적용한다. Fresh install에는 뒤따르는 ALTER를 포함한 전체 schema 순서가 적용되어 최종 네 열이 nullable이다. +- 운영 schema metadata 검토, 별도 DDL 실행 승인, 실행과 결과 확인은 rollout gate다. 코드 작성/H2 test의 선행조건이나 production DB 접속 요건이 아니다. 애플리케이션이 SQL을 자동 실행하지 않는다. +- nullable 값 호환 버전이 모든 서버에서 동작하기 전에 첫 정리를 실행하지 않는다. 이미 정리된 값을 읽지 못하는 non-null reader로의 rollback은 안전하지 않으며 코드 rollback으로 원 개인정보를 복구할 수 없다. + ## 12. 테스트와 품질 요구사항 - 선물 신청은 캔 부족, 비활성 카테고리, 필수 약관 미동의, 파손면책 미동의, 최종 결제 금액 스냅샷을 테스트한다. @@ -1051,6 +1083,16 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. - 관리자 목록과 상세 모두 `RECEIVED`에서만 `CANCEL` 액션을 제공하며 기존 `COMPLETE_DELIVERY` 조건부 액션을 포함한 나머지 상태 매핑은 유지한다. - 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다. - 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다. +- 개인정보 정리는 7일 경계 포함/1초 미경과 제외, 더 오래된 이력 포함, null 완료시각 및 비전달완료 상태 제외, 부분 삭제/완전 삭제 재실행 0건을 실제 JPA 저장·flush/clear/reload로 검증한다. 보호할 결제/동의/리뷰/회원/업무 시각도 재조회해 불변임을 확인한다. +- `GiftDelivery` 매핑은 sender 다섯 property 모두 null 저장을 허용해야 한다. DDL review는 네 nonnull sender columns만 변경하고 기존 `gift-schema.sql` trailing ALTER를 확인한다. +- 사용자 및 관리자 query/controller는 삭제 후 기존 DTO 형태, 빈 non-null 문자열의 정확한 주소 형식, nullable 필드, nickname, `DELIVERED`의 액션/CTA 미노출을 검증한다. +- 실제 `GiftRepository` query를 기존 `@DataJpaTest`의 embedded H2에서 실행한다. 실제 datasource replacement를 확인해 MySQL-mode/운영 MySQL 검증과 구분한다. 고정 `now`에서 cutoff 동일 시각은 포함하고 cutoff보다 1초 늦은 시각은 제외한다. +- 오래된 과거 이력, null `deliveredAt`, 다른 Gift status도 각 fixture에서 독립 확인한다. 미래에 cutoff를 지나는 데이터도 매번 조회되어야 하며 첫 정리에서만 처리되는 신규 데이터 제한을 두지 않는다. +- 정리 전후 DB를 flush, clear, reload해 null 저장이 실제 반영됐는지 확인한다. 재실행 후 반환 변경 수가 0인지 확인한다. +- 매핑 test는 12개 필드를 모두 null로 저장할 수 있음을 확인하되 지정 외 상태 시각 필드는 그대로 남는지 검증한다. +- controller 테스트는 기존 JSON object shape를 비교하고 사용자 요청 DTO 필수 validation이 유지되는지 확인한다. +- 관리자 조회는 삭제 뒤 이름/전화/주소 non-null field를 빈 문자열로 반환하되 courier/tracking은 기존 nullable field로 null을 반환한다. +- 스케줄 테스트는 expression, zone, 분산락 소유 확인/unlock, 예외 경로와 적절한 delegate를 검증한다. 스케줄 실행시간으로 다른 진입점이 완료될 것이라는 보장은 테스트 요구로 만들지 않는다. ## 13. 성공 기준 @@ -1065,6 +1107,12 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. - [ ] 선물 사용자/관리자 API의 날짜/시간 응답을 클라이언트가 UTC ISO 문자열로 해석할 수 있다. - [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다. - [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다. +- [x] 7일 이상 지난 전달 완료 건의 지정된 배송 개인정보만 DB에서 지워지고 기존 업무 데이터와 API 계약은 유지된다. 격리 DB 및 실제 HTTP로 검증했다. (`GIFT-018`, `P15-GATE`) +- [x] 다섯 sender property nullable mapping 및 네 DB 열의 ALTER 문서가 schema 요구를 충족한다. 운영 적용은 별도다. (`GIFT-019`, `P15-T1`, `P15-T2`) +- [x] 삭제 후 빈 문자열/null 표현으로 기존 API schema와 사용자 입력 검증을 유지한다. (`GIFT-020`, `P15-T2`, `P15-GATE`) +- [x] 만료/정리/reminder 업무 메서드가 각각 Asia/Seoul `:10`/`:20`/`:40` schedule wrapper로 등록된다. 실제 Spring 등록을 확인했다. (`GIFT-021`, `P15-T3`, `P15-T4`) + +Phase 15 서버 구현 완료 판정에는 `P15-T2`~`P15-T4` focused test와 격리 HTTP QA, MySQL DDL 및 배포 순서 체크리스트 검토가 필요하다. 실제 운영 DDL 적용과 nullable reader 선행 배포 확인은 별도 rollout 조건이며, 이 문서 수정은 구현 검증이나 운영 반영 완료의 증거가 아니다. ## 14. Open Questions @@ -1082,6 +1130,12 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. | `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 | | `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 | | `GIFT-016`, `GIFT-017` | 14 | 관리자 취소 service/controller 및 query service 테스트 | `RECEIVED` 액션과 취소/환불 결과 확인 | +| `GIFT-018` | 15 | repository predicate 및 erasure method test | 격리 fixture 이후 삭제 필드와 보존 데이터 재조회 | +| `GIFT-019` | 15 | GiftEntityMappingTest, SQL section review | no production DB execution in Gate | +| `GIFT-020` | 15 | query/controller contract regression | fixture erase 후 실제 socket HTTP SENT/RECEIVED/admin 조회 | +| `GIFT-021` | 15 | scheduler reflection/lock/delegation tests | schedule/zone/lock key 대조 | + +`GIFT-019` rollout은 운영 schema 확인, DDL review 및 별도 실행 승인, nullable schema와 호환 reader 준비를 거친다. Gate는 운영 DB에서 SQL 또는 정리 작업을 실행하지 않는다. ## 16. Decision Log @@ -1103,7 +1157,12 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. | 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` | +| 2026-10-23 | `DEC-017` | 확정 | `DELIVERED`이고 `deliveredAt <= now.minusDays(7)`인 과거 누적 및 미래 대상의 `GiftDelivery` 배송 개인정보 12개를 null 처리하고 업무·결제·동의·리뷰·회원 데이터를 보존한다 | 승인된 Phase15 계약 | `GIFT-018`, `8.16`, `P15-T2`~`P15-T3` | +| 2026-10-23 | `DEC-018` | 확정 | 기존 public request/response 스키마는 유지하고, 개인정보 없는 non-null 응답 문자열은 빈 문자열로 매핑한다. KST :20 스케줄은 기존 분산락/트랜잭션 패턴을 따르며 다른 작업의 기존 동작을 바꾸지 않는다 | 승인된 API 보존 및 스케줄러 등록 계약 | `GIFT-018`, `8.16`, `P15-T2`~`P15-T4` | +| 2026-10-23 | `DEC-019` | 확정 | 네 sender DB column 변경은 30줄 미만이므로 신규 SQL 파일 대신 기존 `gift-schema.sql` 말미에 작성한다. 기존 CREATE block은 두고 추가 ALTER로 fresh install 최종 schema도 nullable로 만든다. 실제 운영 SQL 실행은 별도 승인 rollout gate이며 아직 실행하지 않았다 | 사용자의 latest SQL placement decision; 기존 파일에 추가된 5줄 ALTER | `GIFT-019`, `P15-T1`, `P15-T2`, `P15-GATE` | +| 2026-10-23 | `DEC-020` | 확정 | sender 다섯 Kotlin property 중 `senderAddressDetail` 포함 다섯 모두 nullable, DB상 현재 NOT NULL인 sender 열 네 개만 변경한다 | entity 필드와 DDL nullable 경계의 구별 | `GIFT-019`, `P15-T2` | +| 2026-10-23 | `DEC-021` | 확정 | 신규 시간별 scheduler wrapper는 기존 만료/안내 업무 메서드에 위임하며, 기존 예약은 자동취소 `:00`뿐이다. 신규 등록은 `:10`, `:20`, `:40`이다 | 현재 scheduler 코드와 승인 일정 | `GIFT-021`, `P15-T3`~`P15-T4` | ## 문서 상태 안내 -Phase 14 서버 구현과 관련 검증은 완료했다. 전체 테스트는 1차 실행이 도구의 10분 제한으로 중단되고 2차는 사용자 중단 후 제외됐다. 전체 테스트 통과를 주장하지 않으며 검증 명령과 한계는 `plan-task.md`의 P14-GATE 기록을 따른다. 관리자 프론트엔드는 별도 기능 추가 프롬프트의 확정 계약을 사용한다. +Phase 14의 당시 전체 테스트 제외 및 검증 이력은 `plan-task.md`의 P14-GATE 기록을 보존한다. Phase 15는 후속 실행 승인에 따라 서버 구현과 최종 전체 테스트 2,396개·lint·build·실소켓 QA·리뷰를 완료했다. 운영 MySQL DDL 실행 및 배포는 별도 승인/조건이며 이번 완료 범위에 포함하지 않는다. From cd865da6e03e9ab904a23f8aff32e4ff992fdde3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 10:26:12 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix(gift):=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EB=B0=9C=EC=8B=A0=EC=9E=90=20=ED=91=B8=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84=ED=95=98=EA=B3=A0=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=EC=9D=84=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/scheduler/GiftScheduler.kt | 1 + .../v2/gift/application/GiftCommandService.kt | 1 + .../adapter/in/web/AdminGiftControllerTest.kt | 248 ++++++++++++++++++ .../v2/gift/GiftCommandServiceTest.kt | 22 +- .../out/scheduler/GiftSchedulerTest.kt | 9 + 5 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt index 1c64c951..1f7e2960 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt @@ -85,6 +85,7 @@ class GiftScheduler( gift.status = GiftStatus.UNDELIVERABLE delivery.undeliverableAt = now delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON + giftPushService.sendUndeliverableToSender(gift.applicationNo, gift.senderMemberId) true } } 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 47755809..88d49ebc 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 @@ -301,6 +301,7 @@ class GiftCommandService( gift.status = GiftStatus.DELIVERED delivery.deliveredAt = deliveredAt + giftPushService.sendDeliveredToSender(gift.applicationNo, gift.senderMemberId) return GiftDeliveryConfirmationResult( applicationNo = gift.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 13a343c0..2eac4430 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 @@ -33,9 +33,11 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftScheduler import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand 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 @@ -167,6 +169,7 @@ class AdminGiftControllerTest @Autowired constructor( giftDeliveryRepository.deleteAll() giftRepository.deleteAll() categoryRepository.deleteAll() + sizePriceRepository.deleteAll() } @AfterEach @@ -544,6 +547,78 @@ class AdminGiftControllerTest @Autowired constructor( ) } + @Test + @DisplayName("실제 JWT HTTP 수령 확인은 발신자만 한 번 알리고 거부·반복 요청은 추가 알림을 보내지 않는다") + fun shouldConfirmDeliveryThroughRealJwtHttpAndNotifyOnlySenderOnce() { + val recipient = memberRepository.saveAndFlush( + Member( + email = "${UUID.randomUUID()}@test.com", + password = "password", + nickname = "confirmation-creator", + role = MemberRole.CREATOR + ) + ) + val fixture = saveFundedGift(recipient) + val giftId = requireNotNull(fixture.gift.id) + TransactionTemplate(transactionManager).executeWithoutResult { + giftRepository.findById(giftId).orElseThrow().status = GiftStatus.INSPECTION_COMPLETED + } + val path = "/api/v2/gifts/$APPLICATION_NO/delivery-complete" + giftHttp(HttpMethod.POST, path, expectedStatus = HttpStatus.UNAUTHORIZED) + val denied = giftHttp(HttpMethod.POST, path, tokenFor(fixture.sender)) + assertFalse(denied.path("success").asBoolean()) + assertEquals("Invalid request.", denied.path("message").asText()) + assertEquals(GiftStatus.INSPECTION_COMPLETED, giftRepository.findById(giftId).orElseThrow().status) + assertEquals(null, giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt) + Mockito.verifyNoInteractions(giftPushService) + + val recipientToken = tokenFor(recipient) + val confirmed = giftHttp(HttpMethod.POST, path, recipientToken) + assertTrue(confirmed.path("success").asBoolean()) + assertEquals(APPLICATION_NO, confirmed.path("data").path("applicationNo").asText()) + assertEquals("DELIVERED", confirmed.path("data").path("status").asText()) + val deliveredAt = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt) + assertEquals(deliveredAt.toUtcIso(), confirmed.path("data").path("deliveredAt").asText()) + assertEquals(GiftStatus.DELIVERED, giftRepository.findById(giftId).orElseThrow().status) + Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, requireNotNull(fixture.sender.id)) + Mockito.verifyNoMoreInteractions(giftPushService) + + val repeated = giftHttp(HttpMethod.POST, path, recipientToken) + assertFalse(repeated.path("success").asBoolean()) + assertEquals("Invalid request.", repeated.path("message").asText()) + assertEquals(deliveredAt, giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt) + Mockito.verifyNoMoreInteractions(giftPushService) + assertRefundState(fixture, refunded = false) + println("P16_CONFIRM_HTTP status=200 success=true rejected=true repeated=false senderPush=1 recipientPush=0") + } + + @Test + @DisplayName("실제 transactional 스케줄러 배송지 만료는 발신자만 한 번 알리고 재실행은 추가 알림을 보내지 않는다") + fun shouldExpireRecipientAddressThroughTransactionalSchedulerAndNotifyOnlySenderOnce() { + val fixture = saveFundedGift() + val giftId = requireNotNull(fixture.gift.id) + TransactionTemplate(transactionManager).executeWithoutResult { + giftRepository.findById(giftId).orElseThrow().status = GiftStatus.TRACKING_REGISTERED + requireNotNull(giftDeliveryRepository.findByGiftId(giftId)).recipientAddressDeadlineAt = NOW.minusSeconds(1) + } + + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) + assertEquals(1, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW)) + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()) + assertEquals(GiftStatus.UNDELIVERABLE, giftRepository.findById(giftId).orElseThrow().status) + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)) + assertEquals(NOW, delivery.undeliverableAt) + assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) + Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, requireNotNull(fixture.sender.id)) + Mockito.verifyNoMoreInteractions(giftPushService) + + assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW.plusMinutes(1))) + assertEquals(NOW, giftDeliveryRepository.findByGiftId(giftId)?.undeliverableAt) + Mockito.verifyNoMoreInteractions(giftPushService) + assertRefundState(fixture, refunded = false) + println("P16_EXPIRY_DB applicationNo=$APPLICATION_NO transitioned=1 repeated=0 senderPush=1 recipientPush=0") + } + @Test @DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다") fun shouldManageGiftCategories() { @@ -635,6 +710,166 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(false)) } + @Test + @DisplayName("실제 신청은 가격 변경과 카테고리 비활성화 후에도 기존 가격·카테고리명·결제 스냅샷을 보존한다") + fun shouldPreserveExistingGiftSnapshotsAfterPriceChangeAndCategoryDeactivation() { + val transaction = TransactionTemplate(transactionManager) + val sender = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "snapshot-sender").apply { + pgChargeCan = 300 + } + ) + val creator = memberRepository.saveAndFlush( + Member( + email = "${UUID.randomUUID()}@test.com", + password = "password", + nickname = "snapshot-creator", + role = MemberRole.CREATOR + ) + ) + assertEquals(creator.id, memberRepository.findCreatorByIdOrNull(requireNotNull(creator.id))?.id) + val funding = chargeRepository.saveAndFlush( + Charge(300, 0).apply { + member = sender + payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG) + } + ) + val category = categoryRepository.saveAndFlush( + GiftCategory("100", "SNAPSHOT", "인형", "A", "피규어") + ) + val categoryId = requireNotNull(category.id) + val senderId = requireNotNull(sender.id) + val originalUseCount = useCanRepository.count() + val originalCalculateCount = calculateRepository.count() + val originalChargeCount = chargeRepository.count() + adminService.updateSizePrice(GiftSize.SMALL, GiftSizePriceCommand(150, 80, true)) + val command = GiftApplicationCommand( + recipientMemberId = requireNotNull(creator.id), + senderName = "sender", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "test address", + senderAddressDetail = null, + sizeCode = GiftSize.SMALL, + categoryId = categoryId, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + damageWaiverAgreed = false + ) + + val first = commandService.registerGift(sender, command) + assertEquals(80, first.priceCan) + val firstUsageId = requireNotNull( + transaction.execute { + entityManager.flush() + entityManager.clear() + val gift = requireNotNull(giftRepository.findByApplicationNo(first.applicationNo)) + assertEquals(80, gift.salePriceCan) + assertEquals("인형", gift.categoryNameSnapshot) + assertEquals(80, useCanRepository.findById(gift.canUsageId).orElseThrow().can) + assertEquals(220, memberRepository.findById(senderId).orElseThrow().pgChargeCan) + assertEquals(220, chargeRepository.findById(requireNotNull(funding.id)).orElseThrow().chargeCan) + gift.canUsageId + } + ) + adminService.updateSizePrice(GiftSize.SMALL, GiftSizePriceCommand(150, 120, true)) + val second = commandService.registerGift(sender, command) + assertEquals(120, second.priceCan) + assertNotEquals(first.applicationNo, second.applicationNo) + val prices = mapOf(first.applicationNo to 80, second.applicationNo to 120) + val usageIds = prices.keys.associateWith { + requireNotNull(giftRepository.findByApplicationNo(it)).canUsageId + } + assertEquals(firstUsageId, usageIds[first.applicationNo]) + assertNotEquals(firstUsageId, usageIds[second.applicationNo]) + val senderToken = tokenFor(sender) + + listOf(false, true).forEach { deactivated -> + if (deactivated) { + assertFalse(adminService.deleteCategory(categoryId).isActive) + val exception = assertThrows(SodaException::class.java) { commandService.registerGift(sender, command) } + assertEquals("common.error.invalid_request", exception.messageKey) + } + transaction.executeWithoutResult { + entityManager.flush() + entityManager.clear() + assertEquals(!deactivated, categoryRepository.findById(categoryId).orElseThrow().isActive) + assertEquals(120, requireNotNull(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).salePriceCan) + prices.forEach { (applicationNo, price) -> + val gift = requireNotNull(giftRepository.findByApplicationNo(applicationNo)) + assertEquals(price, gift.salePriceCan) + assertEquals("인형", gift.categoryNameSnapshot) + assertEquals(categoryId, gift.categoryId) + assertEquals(usageIds[applicationNo], gift.canUsageId) + val usage = useCanRepository.findById(gift.canUsageId).orElseThrow() + assertEquals(CanUsage.GIFT, usage.canUsage) + assertEquals(senderId, usage.member?.id) + assertEquals(price, usage.can) + assertEquals(0, usage.rewardCan) + assertFalse(usage.isRefund) + val calculations = calculateRepository.findByUseCanIdAndStatus( + gift.canUsageId, + UseCanCalculateStatus.RECEIVED + ) + assertEquals(1, calculations.size) + assertEquals(price, calculations.single().can) + assertEquals(PaymentGateway.PG, calculations.single().paymentGateway) + } + val reloadedSender = memberRepository.findById(senderId).orElseThrow() + val reloadedFunding = chargeRepository.findById(requireNotNull(funding.id)).orElseThrow() + assertEquals(100, reloadedSender.pgChargeCan) + assertEquals(0, reloadedSender.pgRewardCan) + assertEquals(100, reloadedFunding.chargeCan) + assertEquals(0, reloadedFunding.rewardCan) + assertEquals(ChargeStatus.CHARGE, reloadedFunding.status) + assertEquals(PaymentStatus.COMPLETE, reloadedFunding.payment?.status) + assertEquals(PaymentGateway.PG, reloadedFunding.payment?.paymentGateway) + assertEquals(2L, giftRepository.count()) + assertEquals(2L, giftDeliveryRepository.count()) + assertEquals(originalUseCount + 2, useCanRepository.count()) + assertEquals(originalCalculateCount + 2, calculateRepository.count()) + assertEquals(originalChargeCount, chargeRepository.count()) + } + val options = giftHttp(HttpMethod.GET, "/api/v2/gifts/form-options", senderToken) + assertTrue(options.path("success").asBoolean()) + assertEquals( + !deactivated, + options.path("data").path("categories").any { it.path("categoryId").asLong() == categoryId } + ) + assertEquals( + 120, + options.path("data").path("sizes").single { it.path("sizeCode").asText() == "SMALL" } + .path("salePriceCan").asInt() + ) + prices.forEach { (applicationNo, price) -> + val detail = giftHttp(HttpMethod.GET, "/api/v2/gifts/$applicationNo", senderToken) + assertTrue(detail.path("success").asBoolean()) + assertEquals(applicationNo, detail.path("data").path("applicationNo").asText()) + assertEquals(price, detail.path("data").path("giftInfo").path("paidCan").asInt()) + assertEquals("인형", detail.path("data").path("giftInfo").path("categoryName").asText()) + println("P16_SNAPSHOT_HTTP deactivated=$deactivated applicationNo=$applicationNo paidCan=$price categoryName=인형") + } + val list = giftHttp(HttpMethod.GET, "/api/v2/gifts?type=SENT", senderToken) + assertTrue(list.path("success").asBoolean()) + assertEquals(2, list.path("data").path("totalCount").asInt()) + val items = list.path("data").path("items").associateBy { it.path("applicationNo").asText() } + assertEquals(prices.keys, items.keys) + prices.forEach { (applicationNo, price) -> + val item = requireNotNull(items[applicationNo]) + assertEquals(price, item.path("priceCan").asInt()) + assertEquals("인형", item.path("categoryName").asText()) + } + println( + "P16_SNAPSHOT_DB deactivated=$deactivated A=80 B=120 categoryName=인형 usageIdsPreserved=true " + + "actualUseCan=80,120 memberBalance=100 chargeBalance=100 gifts=2 usesDelta=2 chargesDelta=0 listCount=2" + ) + } + Mockito.verify(canPaymentService).spendGiftCan(senderId, 80, "web") + Mockito.verify(canPaymentService).spendGiftCan(senderId, 120, "web") + assertEquals(2, Mockito.mockingDetails(canPaymentService).invocations.count { it.method.name == "spendGiftCan" }) + Mockito.verifyNoInteractions(giftPushService) + } + @Test @DisplayName("관리자는 사서함 도착과 검수완료를 처리한다") fun shouldUpdateGiftOperationStatuses() { @@ -1157,6 +1392,19 @@ class AdminGiftControllerTest @Autowired constructor( assertEquals(0, scheduler.markExpiredRecipientAddressGiftsUndeliverable(NOW)) Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.REMINDER) 1 else 0)) .sendRecipientAddressDeadlineReminder(APPLICATION_NO, fixture.gift.recipientMemberId) + if ( + listOf(operations.first, operations.second).any { + it in listOf(RecipientMutation.CONFIRM, RecipientMutation.EXPIRY) + } + ) { + val confirmed = RecipientMutation.CONFIRM in listOf(operations.first, operations.second) && + operations.first != RecipientMutation.EXPIRY + Mockito.verify(giftPushService, Mockito.times(if (confirmed) 1 else 0)) + .sendDeliveredToSender(APPLICATION_NO, requireNotNull(fixture.sender.id)) + Mockito.verify(giftPushService, Mockito.times(if (operations.first == RecipientMutation.EXPIRY) 1 else 0)) + .sendUndeliverableToSender(APPLICATION_NO, requireNotNull(fixture.sender.id)) + Mockito.verifyNoMoreInteractions(giftPushService) + } assertRefundState(fixture, refunded = false) println( "P15_R1_RACE $operations first=$firstResult second=$secondResult serialized=$blockedUntilCommit persisted=true" 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 7c94c356..5d7796d5 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 @@ -241,7 +241,26 @@ class GiftCommandServiceTest { ) assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status) assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt) - Mockito.verifyNoInteractions(fixture.giftPushService) + Mockito.verify(fixture.giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_ID) + Mockito.verifyNoMoreInteractions(fixture.giftPushService) + } + + @Test + @DisplayName("수령 확인을 반복하면 전달 완료 시각을 보존하고 발신자에게 추가 알림을 보내지 않는다") + fun shouldNotNotifySenderAgainForRepeatedDeliveryConfirmation() { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForDeliveryConfirmation() + fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO) + + val exception = assertThrows(SodaException::class.java) { + fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status) + assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt) + Mockito.verify(fixture.giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_ID) + Mockito.verifyNoMoreInteractions(fixture.giftPushService) } @Test @@ -309,6 +328,7 @@ class GiftCommandServiceTest { assertEquals("common.error.invalid_request", exception.messageKey) assertEquals(scenario.status, fixture.deliveryConfirmationGift.status) assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt) + Mockito.verifyNoInteractions(fixture.giftPushService) } @ParameterizedTest diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt index 90b17aab..4278a082 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt @@ -415,6 +415,8 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(GiftStatus.UNDELIVERABLE, gift.status) assertEquals(now, delivery.undeliverableAt) assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) + Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verifyNoMoreInteractions(giftPushService) } @Test @@ -437,7 +439,9 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(GiftStatus.UNDELIVERABLE, gift.status) assertEquals(now, delivery.undeliverableAt) assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) + Mockito.verify(giftPushService).sendUndeliverableToSender(gift.applicationNo, SENDER_MEMBER_ID) } + Mockito.verifyNoMoreInteractions(giftPushService) } @Test @@ -456,6 +460,7 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status) assertEquals(null, delivery.undeliverableAt) assertEquals(null, delivery.undeliverableReason) + Mockito.verifyNoInteractions(giftPushService) } @Test @@ -475,6 +480,7 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status) assertEquals(null, delivery.undeliverableAt) assertEquals(null, delivery.undeliverableReason) + Mockito.verifyNoInteractions(giftPushService) } @Test @@ -515,6 +521,7 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(null, delivery.undeliverableAt) assertEquals(null, delivery.undeliverableReason) } + Mockito.verifyNoInteractions(giftPushService) } @Test @@ -533,6 +540,8 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(1, firstUpdatedCount) assertEquals(0, secondUpdatedCount) assertEquals(now, delivery.undeliverableAt) + Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verifyNoMoreInteractions(giftPushService) } @Test From 52e5c974233cf335435fc9f9fbdc9e9603876adf Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 10:26:24 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs(gift):=20Phase=2016=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=EC=99=80=20=EA=B0=80=EA=B2=A9=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=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 | 85 ++++++++++++++++++- docs/20260929_크리에이터_선물하기/prd.md | 21 ++++- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index cd69110a..aeeecc52 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -4,12 +4,12 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | Phase 15 서버 구현·검증·리뷰 완료, 운영 반영 전 | +| 상태 | Phase 16 서버 구현·검증·리뷰 완료, 운영 반영 전 | | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | -| 현재 Phase | Phase 15 전달 완료 배송 개인정보 정리 | -| 현재 활성 Goal | 없음. `P15-GATE` 완료, 운영 반영은 별도 승인 | +| 현재 Phase | Phase 16 누락 푸시와 가격 스냅샷 검증 | +| 현재 활성 Goal | 없음. `P16-GATE` 완료, 운영 반영은 별도 | ## 목표 @@ -43,6 +43,7 @@ | 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 | | 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 | | 15 | 서버 구현·Gate 완료 | `4/4` Task, `P15-R1` 완료 | 없음 | 운영 schema/reader 배포 확인은 별도 | +| 16 | 완료 | `2/2` Task, Gate 완료 | 없음 | 현재 test 브랜치, Phase16 커밋 승인·미배포 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -1967,3 +1968,81 @@ P15-T1 문서 검증 → 별도 구현 승인 → P15-T2 → P15-T3 → P15-T4 - 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftEntityMappingTest' --tests '*GiftSchedulerTest' --tests '*GiftQueryServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*GiftCommandServiceTest' --tests '*GiftAdminServiceTest' --tests '*GiftControllerTest' --tests '*AdminGiftControllerTest' ktlintCheck`. - 결과: exit 0, `BUILD SUCCESSFUL in 1m 18s`. 8개 클래스 229개 테스트 실패·오류·skip 0. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. 실제 JWT HTTP 삭제 전후 계약, 개인정보 12개 null 저장·재실행 0건, 경합 및 자동취소 batch lock 회귀를 포함한다. - 범위: 기존 구현의 커밋 검증이므로 전체 테스트와 패키징 빌드는 반복하지 않았다. 2026-10-23 전체 2,396개 결과와 이번 focused 결과를 구분한다. 운영 MySQL DDL·배포·실제 Redis 다중 인스턴스 검증 및 push는 수행하지 않는다. + +### Phase 16: 누락 발신자 푸시와 가격 스냅샷 검증 + +**Goal:** 수령확인·배송지 기한 초과 시 발신자 알림을 PRD대로 발행하고, 설정 변경이 기존 선물 신청과 결제를 바꾸지 않음을 실제 흐름으로 증명한다. + +- 승인: 2026-10-26 사용자 후속 작업 진행 요청. 기존 확정 정책을 보완하며 재인터뷰하지 않는다. `test` 브랜치에서 worktree 없이 수행하고 커밋·푸시·운영 DB·실제 FCM 발송은 하지 않는다. +- 기준: PRD `9.3`, `DEC-022`; 기존 완료 Task/검증 이력은 보존한다. 두 호출자의 누락이 원인이며 GiftPushService/FCM 기반을 새로 만들지 않는다. +- 의존성: `P16-T1` → `P16-T2` → `P16-GATE`. 공유 controller test 때문에 한 구현 담당이 순서대로 실행하고, 변경 전체의 독립 리뷰는 Gate에서 수행한다. + +#### Task 16.1 누락 발신자 푸시 보완 + +**Goal ID: `P16-T1`** + +- 시작 조건: 승인된 기존 푸시 문구·대상·딥링크와 현재 누락 호출자 확인. +- Files: `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`. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`; 기존 `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt` 재사용. +- Interfaces: 기존 `confirmDelivery(...)` 및 `markExpiredRecipientAddressGiftsUndeliverable(now)` 성공 분기에 기존 send 메서드 호출만 추가한다. API·cron·잠금·상태/환불 정책 불변. +- 범위 밖: 새 푸시 문구/알림종류, 직접 취소/관리자 취소 알림, 기한 계산이나 완료 허용 조건 변경. +- 완료 증거: 발신자에게 정확한 applicationNo로 1회 호출, 수취인 추가 알림0, 거부/재실행의 추가 호출0, 기존 경합 및 rollback 회귀와 이벤트 계약 통과. +- [x] **RED:** 수령확인의 stale 무푸시 기대를 정확한 발신자1회·추가0회로 교체하고 반복/거절·만료3상태/비대상/재실행 검증을 보강했다. 승인된 새 발행에 관련된 경합 기대값만 변경했다. +- [x] **RED 확인:** `./gradlew --no-daemon test --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest'` baseline102개 통과 후 production 수정 전103개 중5개가 실제 누락 호출 `WantedButNotInvoked`로 실패했다. +- [x] **GREEN:** 상태 전이 성공 분기에 기존 `sendDeliveredToSender`, `sendUndeliverableToSender` 호출 두 줄만 추가했다. 공통 푸시/금융/잠금/cron/DTO는 불변이다. +- [x] **GREEN 확인:** 관련4클래스168개 통과, 실제 JWT 수령확인 성공·권한 거부·반복 및 actual transaction scheduler 만료의 발신자1회·수취인0회·추가0을 확인했다. 기존 payload 테스트로 문구/딥링크를 대조했다. +- [x] **REFACTOR:** 기존 취소·rollback·erasure no-push 검증과18개 경합을 유지했다. 최종 관련216개와 lint 통과, 독립 리뷰 승인. 아래 Progress에 실제 한계를 기록했다. + +#### Task 16.2 기존 신청 스냅샷 보존 통합 테스트 + +**Goal ID: `P16-T2`** + +- 시작 조건: `P16-T1` 통과. 가격/카테고리 production 동작은 이미 구현되어 있으므로 테스트를 위해 임의로 깨뜨리지 않는다. +- Test: 기존 실제 command/admin/query/결제를 주입하는 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`의 fixture/TransactionTemplate/JWT HTTP 헬퍼를 재사용한다. +- Interfaces: `registerGift`, `updateSizePrice`, `deleteCategory`, 기존 목록/상세 GET. 신규 endpoint/helper production 코드 없음. +- 범위 밖: 가격/카테고리 정책 재설계, 새 dependency, 전체 fixture framework, 새 snapshot 필드. +- 검증: 최초 판매가80으로 신청A→판매가120으로 변경→신청B. A의 저장·조회·결제80과 B의120을 flush/clear/reload 및 HTTP로 비교한다. 카테고리 비활성화 뒤 기존A/B와 결제는 유지, 옵션에서 제외, 신규 신청은 거부되며 추가 차감/행 생성이 없다. +- [x] **검증 추가:** 실제 registerGift로 만든 A80/B120, 실제 가격 변경/카테고리 비활성화, DB reload와 JWT HTTP 목록/상세/옵션을 연결했다. 최초 기존가격 fixture 격리를 위해 클래스 초기화의 sizePrice 삭제 한 줄만 보강했다. +- [x] **실행:** 단독 테스트와 관련 클래스 통과. 기존 가격 동작의 검증 보강이므로 임의 production RED는 만들지 않았다. 최초 테스트 끝의 Mockito matcher NPE는 fixture 검증 코드 오류로 구분하고 수정 후 같은 검증을 재실행했다. 가격 production은 변경하지 않았다. +- [x] **정리:** 실제 UseCan80/120·원 결제 연결·정산·잔액100을 보존하고 비활성화 후 추가 Gift/차감0을 확인했다. 운영 데이터·실제 FCM·공유 설정은 건드리지 않았다. + +#### Phase 16 Gate + +**Goal ID: `P16-GATE`** + +- 시작 조건: 두 Task의 실제 증거 확보. 완료 기준: 누락 알림과 가격/카테고리 시나리오 PASS, 관련/전체 회귀·lint/build·독립 리뷰 및 임시자원 정리. +- [x] 관련6클래스216개 테스트/lint를 통과했고 최종 전체 실행에서도 해당 테스트를 포함해 통과했다. 아래 실제 명령을 기록했으며 별도 미실행 명령을 실행했다고 표시하지 않는다. +- [x] 최초 전체 실행15분 timeout을 성공으로 계산하지 않았다. 상세 로그를 남긴20분 제한 재실행은18분22초에 exit0, 전체2400개·lint·build 통과했다. +- [x] 실제 localhost HTTP로 가격/기존 신청 보존, 수령확인 푸시 경계와 만료 transaction을 검증했다. 실제 transport/기기 실수신은 미검증이다. +- [x] 독립 reviewer APPROVE, 차단 사항 없음. PRD 가격/푸시 성공 기준2개만 새 증거로 완료 표시하고 과거 나머지 항목은 유지했다. +- [x] `tasks --all`·diff 검사를 통과했고 전용 임시자원 삭제/부재를 확인했다. 기존 사용자 GradleDaemon과 공유캐시를 보존했으며 커밋·푸시는 하지 않았다. + +#### Phase 16 Progress + +- 2026-10-26: 기존 PRD에 따른 후속 작업을 승인받아 위 계획을 기록했다. P16-T1/T2/Gate는 아직 구현 검증 전이며 Phase15 기록은 보존했다. + +#### P16-T1/T2 실행 결과 — 2026-10-26 + +- Baseline: `./gradlew --no-daemon test --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest'` exit0(31초),102개 통과. 동일 명령 RED는103개 중5개 누락 호출 실패(52초), production 두 줄 수정 후 관련4클래스168개 통과(1분16초). +- Snapshot: 실제 creator/member/Charge300 및 활성카테고리를 준비해 A80결제(잔액220)→가격120→B120결제(잔액100)→카테고리 비활성화를 검증했다. 실제 등록·결제·DB flush/clear/reload 및 TestRestTemplate JWT GET이다. 기존 신청 이름/가격/UseCan ID·정산 보존, 옵션 제외, 이후 신청 거부/추가차감0을 확인했다. +- 단독 명령: `./gradlew --no-daemon test --tests '*AdminGiftControllerTest.shouldPreserveExistingGiftSnapshotsAfterPriceChangeAndCategoryDeactivation'` exit0(51초). 최초 테스트 matcher NPE는 업무결함 RED가 아니며 정확한80/120 호출 검증과 총2회 확인으로 수정했다. +- 관련 최종 명령: `./gradlew --no-daemon test --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' --tests '*GiftPushServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' --tests '*GiftQueryServiceTest' ktlintCheck` exit0(1분26초),51+52+11+55+28+19=216개,실패·오류·skip0. 최초 들여쓰기 lint2건은 수정 후 재실행했다. +- 실표면: `P16_CONFIRM_HTTP status=200 success=true rejected=true repeated=false senderPush=1 recipientPush=0`, `P16_EXPIRY_DB transitioned=1 repeated=0 senderPush=1 recipientPush=0`. JWT 미인증401·발신자 수령확인 거부·재요청 거부, 원 결제/시각 보존을 함께 검증했다. +- 이벤트 범위: 실제 HTTP/transaction 호출자에서 mock GiftPushService 경계의 정확한 호출을 확인하고 기존 GiftPushServiceTest에서 실제 FcmEvent payload를 검증했다. 실제 FCM 전송/기기 수신이나 외부 provider 성공을 증명한 것은 아니다. + +#### P16-GATE 최종 검증 — 2026-10-26 + +- 전체1차: `./gradlew --no-daemon test --rerun ktlintCheck build`는900초 도구 제한으로 종료됐다. 로그는 `:test`에서 끝났고 실패 메시지는 없었으나 성공으로 계산하지 않았다. 해당 실행의 JVM 잔류 없음 확인 후 재실행했다. +- 전체2차: `./gradlew --no-daemon test --rerun ktlintCheck build --info`를1200초 제한으로 실행해 exit0, `BUILD SUCCESSFUL in 18m 22s`. 최신 JUnit XML322개에서 테스트2400개,실패0·오류0·skip0 확인. lint/build도 성공했다. +- 최신 실제 HTTP 증거: AdminGiftControllerTest55개 성공 XML에서 수령확인·만료 senderPush1/recipientPush0 및 `P16_SNAPSHOT_DB deactivated=false/true A=80 B=120 usageIdsPreserved=true actualUseCan=80,120 memberBalance=100 chargeBalance=100 gifts=2 usesDelta=2 chargesDelta=0 listCount=2`를 확인했다. +- 리뷰: 최초 reviewer 결과 수집이 세션 소실로 실패해 승인 근거로 쓰지 않았다. 별도 read-only reviewer가 현재 diff/소스/테스트/보존로그를 대조해 범위 한정 APPROVE,차단 사항 없음으로 판정했고 보고서를 파일로 보존했다. reviewer가 통과를 확인한216개와 lead의 이후 전체2400개를 구분한다. +- 검증 한계: LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. 기존 H2/비선물 SKIP LOCKED·duplicate JSON·Netty·Gradle 경고는 수정 범위 밖이며 새 테스트 실패는 없다. 운영 MySQL/실제 FCM/배포는 미실행이다. 기존 DataGrip·작업 전부터 있던 GradleDaemon은 사용자 자원으로 보존한다. +- 최종 문서/자원: `./gradlew --no-daemon tasks --all` exit0(6초), `git diff --check` exit0, index 변경 없음, HEAD는 시작 시점의 `d052dd50` 유지. `build/libs/sodalive-0.0.1-SNAPSHOT.jar`와 최신 JUnit/HTML 산출물을 확인했다. 작업 root `gift-p16.xKuBMZ`의 열린 파일과 이번 JVM/Redis 잔류가 없음을 확인한 뒤 내부 캐시·임시 Tomcat·Redis·로그·메모를 제거하고 경로 부재를 확인했다. 작업 전08:44 시작한 기존 GradleDaemon PID25133과 공유 Gradle cache/build 산출물은 보존했다. +- 최종 판정: `P16-T1`, `P16-T2`, `P16-GATE` 완료. 이번 production 수정은 호출 두 줄이며 가격/카테고리 코드는 불변이다. 변경은 현재 test 브랜치의 미커밋 상태로 남긴다. + +#### Phase 16 커밋 전 재검증 — 2026-10-26 + +- 사용자 `/commit-with-validation` 요청으로 기존 Phase 16 변경의 커밋을 승인받았다. 위 미커밋 기록은 당시 실행 결과로 보존한다. +- 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' --tests '*GiftPushServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' --tests '*GiftQueryServiceTest' ktlintCheck`. +- 결과: exit 0, `BUILD SUCCESSFUL in 1m 19s`. 최신 JUnit XML 6개에서 테스트 216개, 실패·오류·skip 0을 확인했다. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. +- 범위: 기존 변경의 커밋 검증이므로 관련 회귀를 재실행하고 전체 테스트·패키징 빌드는 반복하지 않았다. 위 전체 2,400개 결과는 이전 Gate 기록이며 이번 재실행 결과와 구분한다. 운영 MySQL·실제 FCM·배포는 미검증이다. diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 14de351f..8fac2042 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -4,9 +4,9 @@ | 항목 | 내용 | |---|---| -| 문서 상태 | Phase 15 서버 구현·전체 테스트·실소켓 QA·리뷰 완료, 운영 반영 전 | +| 문서 상태 | Phase 16 누락 푸시 보완·가격 스냅샷 검증 완료, 운영 반영 전 | | 작성일 | 2026-09-29 | -| 최종 수정일 | 2026-10-23 | +| 최종 수정일 | 2026-10-26 | | 대상 제품 | 크리에이터 선물하기 | | 작성자·결정권자 | 사용자 | | 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 | @@ -17,6 +17,8 @@ > Phase 15 서버 구현과 검증을 완료했다. 최종 2,396개 테스트·lint·build와 실제 JWT HTTP 삭제 전후 QA 및 리뷰를 통과했다. `gift-schema.sql` 하단의 nullable ALTER는 기록만 했으며 운영 DB 실행·배포·커밋·푸시는 하지 않았다. +> Phase 16은 수령확인·배송지 만료의 발신자 알림 두 곳과 기존 신청 가격 보존 검증을 완료했다. 최신 전체 2,400개 테스트·lint·build 및 독립 리뷰를 통과했다. 실제 FCM/기기 수신과 운영 배포는 미검증이며, 후속 커밋 승인에 따른 관련 216개 테스트 재검증 결과는 `plan-task.md`에 기록했다. + ## 1. Overview 팬이 내부 재화인 캔을 사용해 크리에이터에게 실물 선물을 보낼 수 있는 API를 제공한다. @@ -964,6 +966,14 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. 크리에이터가 직접 수령확인 API를 호출해 `DELIVERED`가 된 경우에는 크리에이터 대상 `배송 완료` 푸시를 보내지 않는다. +### 9.3 Phase 16 호출 누락 보완 + +- 수취인 직접 수령확인 성공 시 기존 `sendDeliveredToSender(applicationNo, senderMemberId)`를 호출한다. 수취인에게는 추가 발송하지 않으며 실패·반복 요청으로 중복 발행하지 않는다. +- 배송지 입력 기한 초과로 실제 `UNDELIVERABLE` 전환한 선물만 기존 `sendUndeliverableToSender(applicationNo, senderMemberId)`를 호출한다. 비대상·기한 동일·주소 입력 완료·재실행은 추가 발행하지 않는다. +- 기존 GiftPushService의 제목·본문·수신자 지정·`GIFT_DETAIL`/신청번호 딥링크와 transaction 이벤트 경계를 재사용한다. 새 문구·수신자·알림 종류·스케줄·테이블은 추가하지 않는다. +- 가격·카테고리 검증은 실제 신청→관리자 가격 변경→재신청→카테고리 비활성화 흐름으로 수행한다. 이전 신청의 `salePriceCan`, `categoryNameSnapshot`, 결제 연결/금액은 유지하고, 새 신청에는 최신 가격을 적용하며 비활성화 이후 신규 신청은 거부한다. +- 이번 검증은 서버 이벤트 발행·DB/HTTP 계약까지이며 운영 FCM 및 실제 기기 수신 성공을 의미하지 않는다. + ## 10. 데이터 모델 요구사항 ### 10.1 Gift @@ -1102,8 +1112,8 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다. - [ ] 크리에이터는 배송지와 수령 약관 2개를 등록할 수 있다. - [ ] 운영자는 상태별 전용 API로 상태를 전이하고, 잘못된 상태 전이는 실패한다. - [ ] 팬은 `DELIVERED` 이후 리뷰를 1회 작성할 수 있다. -- [ ] 관리자 카테고리 논리삭제와 사이즈별 가격 변경이 새 신청에만 반영된다. -- [ ] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발송된다. +- [x] 관리자 카테고리 논리삭제와 사이즈별 가격 변경이 새 신청에만 반영된다. 실제 신청A80→가격120→신청B120→비활성화 후 기존 DB/HTTP/결제 보존과 신규 신청 거부를 검증했다. (`P16-T2`) +- [x] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발행된다. 누락된 두 호출과 발신자1회·수취인추가0회·거부/반복추가0, 기존 payload 계약을 서버 경계에서 검증했다. 실제 FCM·기기 수신은 별도다. (`P16-T1`) - [ ] 선물 사용자/관리자 API의 날짜/시간 응답을 클라이언트가 UTC ISO 문자열로 해석할 수 있다. - [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다. - [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다. @@ -1162,7 +1172,10 @@ Phase 15 서버 구현 완료 판정에는 `P15-T2`~`P15-T4` focused test와 격 | 2026-10-23 | `DEC-019` | 확정 | 네 sender DB column 변경은 30줄 미만이므로 신규 SQL 파일 대신 기존 `gift-schema.sql` 말미에 작성한다. 기존 CREATE block은 두고 추가 ALTER로 fresh install 최종 schema도 nullable로 만든다. 실제 운영 SQL 실행은 별도 승인 rollout gate이며 아직 실행하지 않았다 | 사용자의 latest SQL placement decision; 기존 파일에 추가된 5줄 ALTER | `GIFT-019`, `P15-T1`, `P15-T2`, `P15-GATE` | | 2026-10-23 | `DEC-020` | 확정 | sender 다섯 Kotlin property 중 `senderAddressDetail` 포함 다섯 모두 nullable, DB상 현재 NOT NULL인 sender 열 네 개만 변경한다 | entity 필드와 DDL nullable 경계의 구별 | `GIFT-019`, `P15-T2` | | 2026-10-23 | `DEC-021` | 확정 | 신규 시간별 scheduler wrapper는 기존 만료/안내 업무 메서드에 위임하며, 기존 예약은 자동취소 `:00`뿐이다. 신규 등록은 `:10`, `:20`, `:40`이다 | 현재 scheduler 코드와 승인 일정 | `GIFT-021`, `P15-T3`~`P15-T4` | +| 2026-10-26 | `DEC-022` | 확정 | 기존 PRD의 수령확인·배송지 만료 발신자 알림 호출 누락 두 곳을 보완하고 관리자 가격 변경/카테고리 비활성화 후 기존 신청 스냅샷 보존 통합 테스트를 추가한다 | 사용자 후속 작업 승인 | `GIFT-008`, `GIFT-011`, `GIFT-013`, `P16-T1`~`P16-GATE` | ## 문서 상태 안내 Phase 14의 당시 전체 테스트 제외 및 검증 이력은 `plan-task.md`의 P14-GATE 기록을 보존한다. Phase 15는 후속 실행 승인에 따라 서버 구현과 최종 전체 테스트 2,396개·lint·build·실소켓 QA·리뷰를 완료했다. 운영 MySQL DDL 실행 및 배포는 별도 승인/조건이며 이번 완료 범위에 포함하지 않는다. + +Phase 16은 2026-10-26 후속 승인 범위의 서버 구현·검증을 완료했다. 최종 전체 테스트 2,400개·lint·build 통과 및 실제 localhost HTTP/DB 시나리오와 독립 리뷰 승인을 확인했다. Phase15의 과거 기록과 달리 이번 두 성공 기준은 새 검증 근거로 완료 표시했으며, 나머지 과거 성공 기준은 일괄 체크하지 않았다. From c97b028dd55bd5d0907ca430a52a3a9890f35ef3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 12:02:34 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(gift):=20=EC=88=98=EB=A0=B9=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EA=B0=81=EC=9D=84=20=EC=B4=88=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=A0=95=EA=B7=9C=ED=99=94=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/application/GiftCommandService.kt | 3 +- .../adapter/in/web/AdminGiftControllerTest.kt | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) 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 88d49ebc..ba8be075 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 @@ -18,6 +18,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 GiftApplicationCommand( val recipientMemberId: Long, @@ -297,7 +298,7 @@ class GiftCommandService( throw invalidRequest() } val delivery = deliveryRepository.findByGiftIdForUpdate(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() - val deliveredAt = now() + val deliveredAt = now().truncatedTo(ChronoUnit.SECONDS) gift.status = GiftStatus.DELIVERED delivery.deliveredAt = deliveredAt 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 2eac4430..8768e9d1 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 @@ -22,6 +22,7 @@ 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.api.gift.dto.GiftDeliveryConfirmationResponse import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository @@ -29,11 +30,13 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftScheduler import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGenerator import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand @@ -55,6 +58,7 @@ 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.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.aop.framework.Advised import org.springframework.aop.support.AopUtils @@ -121,6 +125,8 @@ class AdminGiftControllerTest @Autowired constructor( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val giftDeliveryRepository: GiftDeliveryRepository, + private val giftReviewRepository: GiftReviewRepository, + private val applicationNoGenerator: GiftApplicationNoGenerator, private val memberRepository: MemberRepository, private val chargeRepository: ChargeRepository, private val useCanRepository: UseCanRepository, @@ -592,6 +598,53 @@ class AdminGiftControllerTest @Autowired constructor( println("P16_CONFIRM_HTTP status=200 success=true rejected=true repeated=false senderPush=1 recipientPush=0") } + @ParameterizedTest + @ValueSource(ints = [0, 123456789, 999999999]) + @DisplayName("수령 확인 시각은 나노초 입력에도 초 단위 DB 저장값과 응답이 일치한다") + fun shouldPersistAndReturnSameSecondPrecisionDeliveryTime(nanos: Int) { + val (gift, _) = saveAdminGift( + applicationNo = APPLICATION_NO, + status = GiftStatus.INSPECTION_COMPLETED, + senderNickname = "precision-sender", + recipientNickname = "precision-recipient", + withTracking = true + ) + val giftId = requireNotNull(gift.id) + val fixedNow = LocalDateTime.of(2026, 10, 22, 23, 59, 59, nanos) + val service = GiftCommandService( + giftRepository = giftRepository, + deliveryRepository = giftDeliveryRepository, + reviewRepository = giftReviewRepository, + categoryRepository = categoryRepository, + sizePriceRepository = sizePriceRepository, + memberRepository = memberRepository, + applicationNoGenerator = applicationNoGenerator, + canPaymentService = canPaymentService, + giftPushService = giftPushService, + now = { fixedNow } + ) + + val result = requireNotNull( + TransactionTemplate(transactionManager).execute { + val recipient = memberRepository.findById(gift.recipientMemberId).orElseThrow() + service.confirmDelivery(recipient, gift.applicationNo).also { + entityManager.flush() + entityManager.clear() + } + } + ) + val persisted = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)?.deliveredAt) + println( + "P16_CONFIRM_PRECISION nanos=$nanos input=$fixedNow returned=${result.deliveredAt} " + + "reloaded=$persisted expected=2026-10-22T23:59:59" + ) + assertEquals(persisted, result.deliveredAt) + assertEquals(LocalDateTime.of(2026, 10, 22, 23, 59, 59), persisted) + assertEquals(0, persisted.nano) + assertEquals("2026-10-22T23:59:59Z", GiftDeliveryConfirmationResponse.from(result).deliveredAt) + assertEquals(GiftStatus.DELIVERED, giftRepository.findById(giftId).orElseThrow().status) + } + @Test @DisplayName("실제 transactional 스케줄러 배송지 만료는 발신자만 한 번 알리고 재실행은 추가 알림을 보내지 않는다") fun shouldExpireRecipientAddressThroughTransactionalSchedulerAndNotifyOnlySenderOnce() { From 96a59021ffd881e7f6c7a9d168d5a7464f4bd52c Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 12:02:57 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs(agents):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=20=EC=A0=95=EB=B0=80=EB=8F=84=EC=99=80=20DB?= =?UTF-8?q?=20=EC=99=95=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9D=84=20=EB=AA=85=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +++ docs/agent-guides/코드스타일.md | 13 +++++++++++++ docs/agent-guides/테스트스타일.md | 14 ++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5e98d913..80ac2da9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,9 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - `docs/agent-guides/커밋메시지.md` - 공개 API 스키마는 임의 변경하지 말고, 작은 단위로 안전하게 수정한다. - 기존 코드베이스 관례를 우선하며, 불확실한 규칙은 추측하지 말고 근거 파일을 먼저 확인한다. +- DB에 저장하고 응답으로 돌려주는 시각은 DDL의 소수초 정밀도를 먼저 확인하고 한 번 정규화한 같은 값을 사용한다. + 날짜/시간 작업 전 [코드 스타일의 시각 정밀도 규칙](docs/agent-guides/코드스타일.md)과 + [테스트 스타일의 시각 왕복 검증 규칙](docs/agent-guides/테스트스타일.md)을 읽는다. 테스트 비교를 완화해 차이를 숨기지 않는다. ## PRD 및 구현 계획/TASK 문서 규칙 - 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다. diff --git a/docs/agent-guides/코드스타일.md b/docs/agent-guides/코드스타일.md index d2142092..092d0a60 100644 --- a/docs/agent-guides/코드스타일.md +++ b/docs/agent-guides/코드스타일.md @@ -79,3 +79,16 @@ - 주석은 한 문장으로 간결하게 작성한다. - 주석은 코드의 의도와 구조를 설명한다. - 주석은 코드 변경 시 업데이트를 잊지 않는다. + +### 12) DB 저장 시각과 응답 정밀도 +- 상태 변경 시각을 DB에 저장하면서 API 응답에도 반환할 때는 먼저 해당 컬럼의 DDL 정밀도를 확인한다. + MySQL의 소수초 정밀도를 지정하지 않은 `TIMESTAMP`/`DATETIME`은 초 단위다. `(3)`/`(6)` 컬럼에 초 절삭을 일괄 적용하지 않는다. +- OS/JDK의 `LocalDateTime.now()`는 나노초까지 반환할 수 있고, H2/JDBC/MySQL은 저장 시 반올림·절삭할 수 있다. + 로컬에서 DB 재조회값과 응답이 같았다는 사실만으로 다른 OS/JDK에서도 같다고 가정하지 않는다. +- 시각을 한 번 얻고 저장 컬럼의 정밀도·업무 계약에 맞게 정규화한 단일 변수를 엔티티와 응답에 공통 사용한다. + 선물의 소수초 없는 TIMESTAMP 상태 시각은 기존 관례대로 `now.truncatedTo(ChronoUnit.SECONDS)`를 사용한다. +- 정밀도 조정은 해당 업무 저장 경계에서 처리한다. 응답만 절삭하거나 저장/응답에서 각각 `now()`를 호출하지 않는다. + 시간대 변환은 별도 계약이며 이 문제를 고치기 위해 전역 `toUtcIso`, Jackson, timezone 또는 DDL을 임의 변경하지 않는다. +- 관련된 메서드만 수정하고 다른 도메인/상태 시각을 무단으로 일괄 정규화하지 않는다. 검증은 테스트 스타일의 왕복 규칙을 따른다. +- 선례: `82ce6f88`의 `GiftAdminService.cancelGift` 및 선물하기 계획 `P14-T4`. + 같은 원인으로 수령확인 `deliveredAt` 비교가 재발해 `P16-R1`에서 이 규칙을 명시했다. diff --git a/docs/agent-guides/테스트스타일.md b/docs/agent-guides/테스트스타일.md index 33059d1e..a78bd418 100644 --- a/docs/agent-guides/테스트스타일.md +++ b/docs/agent-guides/테스트스타일.md @@ -18,3 +18,17 @@ - embedded Redis는 모든 테스트에 전역 등록하지 않는다. `src/test/resources/META-INF/spring.factories`로 `EmbeddedRedisInitializer`를 등록하면 Redis가 필요 없는 `@DataJpaTest`까지 Redis를 시작하므로 금지한다. - Redis가 필요한 통합 테스트만 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 명시적으로 opt-in 한다. - Redis가 필요 없는 JPA/QueryDSL 슬라이스 테스트는 기존 관례처럼 `@DataJpaTest(properties = ["spring.cache.type=none"])`로 캐시를 끈다. + +## 저장 시각·응답 왕복 검증 규칙 +- DB 재조회 시각과 API 응답을 비교하는 테스트는 OS의 현재 시각만 사용하지 않는다. 기존 시각 주입 seam을 재사용해 + 나노초 `0`, `123456789`, `999999999`와 `23:59:59` 같은 초/날짜 경계 입력을 고정한다. +- 실제 repository/DB에 저장한 뒤 `flush`/`clear`/재조회 또는 별도 트랜잭션 재조회로 DB 정밀도 변환을 통과시킨다. + 저장 인자나 managed entity, mock 반환값만 비교해서는 DB 왕복을 검증했다고 하지 않는다. +- 초 단위 컬럼이면 고정 기대값의 나노초가 0이고 다음 초/날짜로 반올림되지 않으며, DB 값과 서비스 결과 및 HTTP 응답이 + 기존 직렬화 계약으로 정확히 일치하는지 확인한다. 기존 실제 HTTP의 완전 일치 assertion도 유지한다. +- assertion 양쪽을 잘라 비교하거나 시간 허용오차·문자열 일부 비교·skip으로 저장/응답 불일치를 숨기지 않는다. + 먼저 미정규화 코드의 실패를 확인하고 production 저장 경계에서 같은 값을 쓰도록 고친다. +- H2 기본 timestamp 정밀도가 운영 MySQL DDL과 같다고 가정하지 않는다. 사용한 DB/컬럼과 검증 경계를 기록하고, + CI의 expected/actual이 없으면 고정 입력 재현 결과와 CI 원본 재현 여부를 구분한다. +- 선례: `GiftAdminServiceTest.shouldPersistAndReturnSameSecondPrecisionCancellationTime` 및 + 선물하기 계획 `P14-T4`/`P16-R1`. 공유 formatter의 변경 없이 업무별 단일 시각 정규화를 검증한다. From 251890a748b4e71f735546cdcfed1ddf6b0b3282 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 26 Oct 2026 12:03:18 +0900 Subject: [PATCH 8/8] =?UTF-8?q?docs(gift):=20=EC=88=98=EB=A0=B9=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EA=B0=81=20=EC=A0=95=EB=B0=80=EB=8F=84=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EA=B3=BC=20=ED=9A=8C=EA=B7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=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 | 39 +++++++++++++++++-- docs/20260929_크리에이터_선물하기/prd.md | 7 +++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index aeeecc52..c07e9704 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -4,12 +4,12 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | Phase 16 서버 구현·검증·리뷰 완료, 운영 반영 전 | +| 상태 | Phase 16 및 P16-R1 시각 정밀도 회귀 수정·검증 완료 | | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | | 현재 Phase | Phase 16 누락 푸시와 가격 스냅샷 검증 | -| 현재 활성 Goal | 없음. `P16-GATE` 완료, 운영 반영은 별도 | +| 현재 활성 Goal | 없음. `P16-R1` 완료, CI 재실행·배포는 별도 | ## 목표 @@ -43,7 +43,7 @@ | 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 | | 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 | | 15 | 서버 구현·Gate 완료 | `4/4` Task, `P15-R1` 완료 | 없음 | 운영 schema/reader 배포 확인은 별도 | -| 16 | 완료 | `2/2` Task, Gate 완료 | 없음 | 현재 test 브랜치, Phase16 커밋 승인·미배포 | +| 16 | 기존 구현·회귀 수정 완료 | `2/2` Task, `P16-R1` 완료 | 없음 | 최신 전체2403개 통과, CI 재실행·배포 별도 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -2046,3 +2046,36 @@ P15-T1 문서 검증 → 별도 구현 승인 → P15-T2 → P15-T3 → P15-T4 - 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' --tests '*GiftPushServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' --tests '*GiftQueryServiceTest' ktlintCheck`. - 결과: exit 0, `BUILD SUCCESSFUL in 1m 19s`. 최신 JUnit XML 6개에서 테스트 216개, 실패·오류·skip 0을 확인했다. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. - 범위: 기존 변경의 커밋 검증이므로 관련 회귀를 재실행하고 전체 테스트·패키징 빌드는 반복하지 않았다. 위 전체 2,400개 결과는 이전 Gate 기록이며 이번 재실행 결과와 구분한다. 운영 MySQL·실제 FCM·배포는 미검증이다. + +### P16-R1: 수령확인 완료 시각 정밀도 회귀 수정 + +**Goal:** OS/JDK의 소수초 정밀도와 무관하게 수령확인 응답 `deliveredAt`과 DB 재조회값을 정확히 일치시킨다. + +- 시작 조건: 배포 중 `AdminGiftControllerTest.kt:581` 실패 보고 및 이전과 같은 수정·재발 방지 문서화 승인. +- 근거: 이전 `82ce6f88`/`P14-T4`는 관리자 취소 시각의 나노초→DB 반올림 차이를 초 단위 정규화로 해결했다. 이번 `confirmDelivery`는 raw `now()`를 그대로 쓰고 `delivered_at` DDL은 소수초 없는 TIMESTAMP다. CI actual/expected 원문은 미제공이므로 고정 입력의 실제 DB roundtrip으로 실패 메커니즘을 재현한다. +- Files: Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt`; Test 기존 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`, 필요 시 `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt`; Verify 기존 `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`의 P14 회귀; Docs `AGENTS.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, 기존 PRD/plan. +- Interfaces: 기존 `confirmDelivery`와 UTC ISO 응답 스키마는 유지하고, 저장/응답에 같은 초 단위 단일 값을 사용한다. 푸시 호출/수신자·잠금·상태 전이는 그대로다. +- 범위 밖: 전역 `toUtcIso`/Jackson/timezone 변경, DDL 정밀도 변경, 다른 도메인/상태 메서드의 일괄 절삭, 테스트 허용오차/문자열 잘라 비교/skip, CI 설정·운영 DB·배포·커밋·푸시. +- [x] **RED:** 고정 입력3개 중 나노초0은 통과하고123456789/999999999 두 입력은 DB 재조회와 반환값 불일치로 실패했다. 실제 transaction/flush/clear/재조회로 이전과 같은 반올림 메커니즘을 확인했다. +- [x] **GREEN:** `now().truncatedTo(ChronoUnit.SECONDS)`를 한 번 계산하고 기존 변수로 저장·응답에 사용했다. production 변경은 import와 한 줄이며 원래581행 완전 일치 비교를 유지했다. +- [x] **검증:** 관련137개와 최신 전체2403개 테스트 실패·오류·skip0, lint/build 통과. 실제 JWT HTTP 수령확인과 세 나노초 입력 모두 저장·응답의 완전 일치를 확인했다. +- [x] **재발 방지/리뷰:** AGENTS에 연결 규칙을 추가하고 코드·테스트 가이드에 DDL 선확인/단일값/경계입력/실제 DB 왕복/assertion완화금지를 기록했다. 단일 독립 리뷰 APPROVE. 문서검증·자원 정리 결과는 아래 누적한다. + +#### P16-R1 재현·수정·최종 검증 — 2026-10-26 + +- 이전 근거: `git log -S 'truncatedTo(ChronoUnit.SECONDS)'`와 `git show 82ce6f88`에서 관리자 취소의 동일 수정과 DB roundtrip 테스트를 확인했다. 원 실패581행은 DB `deliveredAt.toUtcIso()`와 HTTP 반환 문자열 비교다. `toUtcIso`는 값을 UTC ISO로 형식화할 뿐 저장 정밀도를 보정하지 않는다. +- RED: `AdminGiftControllerTest.shouldPersistAndReturnSameSecondPrecisionDeliveryTime`을 고정0/123456789/999999999나노초로 실행했다. 3개 중2개 실패: `2026-10-22T23:59:59.123456789`는 DB에서 `.123457`, `.999999999`는 다음날`2026-10-23T00:00`으로 반올림됐고 반환값은 원래 나노초였다. CI actual/expected 원문은 없으므로 CI 원문 비교가 아니라 동일 실패 메커니즘의 로컬 재현이다. +- 수정: 기존 시각 주입 seam과 실제 repository를 쓰는 local service를 transaction 안에서 호출하고 저장·응답·DTO의 초단위 완전일치를 검증했다. 응답만 절삭하거나 assertion tolerance/substring/skip을 추가하지 않았고 기존 실제 HTTP assertion도 유지했다. +- 진행 복구: 담당 세션이 RED와 source 수정 이후 GREEN 실행을 시작하지 않아 프로세스/세션 상태를 확인했다. task 취소 조회는 not found였고 active test worker는 없었다. lead가 직접 검증을 이어받았으며 동시에 Gradle을 중복 실행하지 않았다. +- GREEN: `./gradlew --no-daemon test --tests '*GiftCommandServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' ktlintCheck` exit0(1분31초),51+58+28=137개 실패·오류·skip0. 세 고정 입력 모두 returned/reloaded=`2026-10-22T23:59:59`, DTO=`2026-10-22T23:59:59Z`. +- 최종 전체: `./gradlew --no-daemon test --rerun ktlintCheck build --info` exit0,`BUILD SUCCESSFUL in 16m 37s`. 최신 JUnit XML322개에서2403개 테스트,실패0·오류0·skip0. 실제 HTTP 수령확인 status200/senderPush1/recipientPush0 및 고정입력3개 통과 로그를 다시 확인했다. +- 리뷰: read-only reviewer가 source/test/DDL/이전수정 및 AGENTS 연결 문서를 확인하고 APPROVE,차단 사항 없음. 결과 수집 도구 오류는 reviewer가 저장한 보고서로 확인했으며 재검토를 중복 실행하지 않았다. +- 한계: LSP 도구 없음으로 Kotlin compile/ktlint 대체. 운영 MySQL/Jenkins 재실행·배포는 하지 않았다. 기존 H2/비관련 scheduler/Gradle 경고는 별도이며 테스트 실패는 없다. 기존 사용자 GradleDaemon PID25133은 이번 작업 소유가 아니어서 보존한다. 커밋·푸시 없음. +- 문서/자원: `./gradlew --no-daemon tasks --all` exit0(13초), `git diff --check` exit0, 기존 HEAD`52e5c974` 및 index 불변. AGENTS→코드/테스트 가이드 링크와 규칙을 확인했다. 작업전용 `gift-p16-timefix.UnkCmG`의 열린 파일/작업 JVM·Redis가 없음을 확인하고 로그·캐시·메모·임시 Tomcat/Redis를 제거했으며 경로 부재를 확인했다. 최신 build/JUnit 산출물과 공유캐시는 유지했다. +- 판정: P16-R1 수정·회귀·재발 방지 문서화 완료. 현재 test 브랜치 미커밋 변경이며 CI 재실행/운영 반영 완료를 의미하지 않는다. + +#### P16-R1 커밋 전 재검증 — 2026-10-26 + +- 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftCommandServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' ktlintCheck`. +- 결과: exit 0, `BUILD SUCCESSFUL in 1m 33s`. 최신 JUnit XML 3개에서 137개 테스트, 실패·오류·skip 0을 확인했다. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. `git diff --check`도 통과했다. +- 범위: 수령확인 시각 정밀도 수정의 커밋 검증으로 관련 서비스·HTTP·DB 왕복 회귀를 재실행했다. 전체 테스트와 패키징 빌드는 반복하지 않았으며 위 2,403개 결과는 이전 검증 기록이다. 운영 MySQL·Jenkins 재실행·배포는 미검증이다. diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 8fac2042..095929f5 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -4,7 +4,7 @@ | 항목 | 내용 | |---|---| -| 문서 상태 | Phase 16 누락 푸시 보완·가격 스냅샷 검증 완료, 운영 반영 전 | +| 문서 상태 | Phase 16 및 수령확인 시각 정밀도 회귀 수정·검증 완료 | | 작성일 | 2026-09-29 | | 최종 수정일 | 2026-10-26 | | 대상 제품 | 크리에이터 선물하기 | @@ -636,6 +636,11 @@ Response: ### 8.9 크리에이터 수령확인 +수령확인 완료 시각은 운영 `gift_delivery.delivered_at TIMESTAMP`의 초 단위 정밀도에 맞춰 +`now()`를 한 번 `truncatedTo(ChronoUnit.SECONDS)`로 정규화한 값을 DB 저장과 응답에 공통 사용한다. +전역 날짜 변환/시간대나 다른 상태 변경의 정책은 바꾸지 않는다. 근거는 이전 관리자 취소 정밀도 수정 `82ce6f88`과 동일하다. +고정 나노초 3개 입력의 DB 왕복과 기존 실제 HTTP 비교를 유지한 회귀를 `P16-R1`에서 검증했다. + `POST /api/v2/gifts/{applicationNo}/delivery-complete` Request: 없음