fix(agent-ratio): 에이전트 정산 비율 수정 충돌 처리를 안정화한다
This commit is contained in:
19
docs/20260411_에이전트정산비율수정오류수정.md
Normal file
19
docs/20260411_에이전트정산비율수정오류수정.md
Normal file
@@ -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` → 성공.
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AgentSettlementRatio>(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() {
|
||||
|
||||
Reference in New Issue
Block a user