diff --git a/docs/20260411_에이전트정산비율수정오류수정.md b/docs/20260411_에이전트정산비율수정오류수정.md new file mode 100644 index 00000000..5c2edef2 --- /dev/null +++ b/docs/20260411_에이전트정산비율수정오류수정.md @@ -0,0 +1,19 @@ +# 에이전트 정산 비율 수정 오류 대응 계획 + +- [x] 에이전트 정산 비율 수정 API/서비스/엔티티/리포지토리 흐름을 확인한다. +- [x] `Duplicate entry '2-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'` 발생 조건과 현재 활성 비율 갱신 방식의 충돌 지점을 확인한다. +- [x] 실패를 재현하는 테스트를 먼저 추가하고, 테스트가 의도한 이유로 실패하는지 확인한다. +- [x] 기존 활성 비율을 안전하게 종료한 뒤 새 비율을 저장하도록 최소 범위로 수정한다. +- [x] 관련 테스트와 필요한 검증 명령을 실행하고 결과를 문서 하단에 누적 기록한다. + +## 검증 기록 + +- 1차 구현 + - 무엇을: 에이전트 정산 비율 수정 시 기존 활성 row 종료를 먼저 flush하도록 바꾸고, active unique 제약 충돌 시 같은 세션 재조회 없이 비즈니스 예외로 변환하도록 수정했다. + - 왜: 기존 활성 row가 DB에 닫히기 전에 새 row insert가 먼저 flush되면 `uk_agent_settlement_ratio_member_active` 충돌이 발생하고, 그 뒤 같은 세션 재조회가 이어지면 Hibernate `null id` assertion이 연쇄로 발생할 수 있기 때문이다. + - 어떻게: + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest` → 실패, 수정 전 flush 순서 검증이 깨지고 예외 후 재조회 검증도 실패해 재현 확인. + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AgentSettlementRatioServiceTest` → 성공. + - `./gradlew test --tests kr.co.vividnext.sodalive.admin.partner.agent.ratio.AdminAgentSettlementRatioControllerTest` → 성공. + - `./gradlew test --tests "kr.co.vividnext.sodalive.admin.partner.agent.ratio.*"` → 성공. + - `./gradlew ktlintCheck` → 성공. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt index a035ee3b..e84b7fe4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt @@ -27,7 +27,7 @@ class AdminAgentSettlementRatioService( val ratio = request.toEntity() ratio.member = agent - saveRatioOrThrow(request, ratio) + saveRatioOrThrow(ratio) } @Transactional @@ -39,11 +39,11 @@ class AdminAgentSettlementRatioService( ?: throw SodaException(messageKey = "partner.agent.ratio.not_found") validateNewEffectiveFrom(existing.effectiveFrom, request.effectiveFrom) existing.close(request.effectiveFrom) - repository.save(existing) + repository.saveAndFlush(existing) val ratio = request.toEntity() ratio.member = agent - saveRatioOrThrow(request, ratio) + saveRatioOrThrow(ratio) } @Transactional(readOnly = true) @@ -95,16 +95,20 @@ class AdminAgentSettlementRatioService( } } - private fun saveRatioOrThrow( - request: CreateAgentSettlementRatioRequest, - ratio: AgentSettlementRatio - ) { + private fun saveRatioOrThrow(ratio: AgentSettlementRatio) { try { repository.saveAndFlush(ratio) } catch (e: DataIntegrityViolationException) { - repository.findFirstByMemberIdAndEffectiveToIsNull(request.memberId) - ?: throw e - throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from") + if (isActiveRatioConflict(e)) { + throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from") + } + throw e } } + + private fun isActiveRatioConflict(exception: Throwable): Boolean { + return generateSequence(exception) { it.cause } + .mapNotNull { it.message } + .any { it.contains("uk_agent_settlement_ratio_member_active") } + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt index edb69fff..a805ca1c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt @@ -225,16 +225,15 @@ class AgentSettlementRatioServiceTest { settlementRatio = 15, effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) ) - val currentRatio = AgentSettlementRatio( - settlementRatio = 12, - effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + val duplicateException = DataIntegrityViolationException( + "duplicate", + RuntimeException("Duplicate entry '31-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'") ) - currentRatio.member = agent Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) - Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null, currentRatio) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null) Mockito.`when`(repository.saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))) - .thenThrow(DataIntegrityViolationException("duplicate")) + .thenThrow(duplicateException) val exception = assertThrows(SodaException::class.java) { service.createAgentSettlementRatio(request) @@ -286,7 +285,7 @@ class AgentSettlementRatioServiceTest { } @Test - @DisplayName("관리자는 기존 에이전트 정산 비율을 수정할 수 있다") + @DisplayName("관리자는 기존 활성 비율 종료를 먼저 flush한 뒤 새 에이전트 정산 비율을 수정 저장한다") fun shouldUpdateAgentSettlementRatio() { val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) agent.id = 31L @@ -311,7 +310,7 @@ class AgentSettlementRatioServiceTest { val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java) val inOrder: InOrder = Mockito.inOrder(repository) - inOrder.verify(repository).save(existingRatio) + inOrder.verify(repository).saveAndFlush(existingRatio) inOrder.verify(repository).saveAndFlush(ratioCaptor.capture()) assertEquals(agent, ratioCaptor.value.member) @@ -320,6 +319,46 @@ class AgentSettlementRatioServiceTest { assertEquals(null, ratioCaptor.value.effectiveTo) } + @Test + @DisplayName("정산 비율 수정 중 활성 unique 제약이 충돌하면 같은 세션 재조회 없이 잘못된 effectiveFrom 예외로 변환한다") + fun shouldThrowWithoutRequeryWhenUpdatingRatioViolatesActiveUniqueConstraint() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val existingRatio = AgentSettlementRatio( + settlementRatio = 10, + effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0) + ) + existingRatio.member = agent + val newEffectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 18, + effectiveFrom = newEffectiveFrom + ) + val duplicateException = DataIntegrityViolationException( + "duplicate", + RuntimeException("Duplicate entry '31-1' for key 'agent_settlement_ratio.uk_agent_settlement_ratio_member_active'") + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(existingRatio) + Mockito.`when`(repository.saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))).thenAnswer { invocation -> + val ratio = invocation.getArgument(0) + if (ratio === existingRatio) { + ratio + } else { + throw duplicateException + } + } + + val exception = assertThrows(SodaException::class.java) { + service.updateAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey) + Mockito.verify(repository, Mockito.times(1)).findFirstByMemberIdAndEffectiveToIsNull(31L) + } + @Test @DisplayName("기존 정산 비율이 없으면 수정할 수 없다") fun shouldThrowWhenSettlementRatioDoesNotExist() {