From 6731be1a6bfa4cd0b27bd853ee5372ebf77a2460 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 11:29:02 +0800 Subject: [PATCH] fix(auth): avoid gap locks when creating accounts --- .../service/AccountManagementService.java | 13 +++++- .../service/AccountManagementServiceTest.java | 45 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index a8877efa2..5f4fdc138 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -9,6 +9,7 @@ import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DuplicateKeyException; import com.baiyue.common.audit.BusinessAudit; import java.util.Locale; @@ -63,7 +64,9 @@ public class AccountManagementService { String appCode = request.appCode() == null ? "MERCHANT" : request.appCode().trim().toUpperCase(Locale.ROOT); validateScope(appCode, request.tenantId()); - AuthAccount existing = repository.findById(request.accountId()); + // Locking a missing key takes an InnoDB gap lock and can deadlock + // unrelated account inserts. The unique indexes arbitrate creation. + AuthAccount existing = repository.findCurrentById(request.accountId()); if (existing != null) { if (sameIdentity(existing, request, appCode)) return summary(existing); throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用"); @@ -88,7 +91,13 @@ public class AccountManagementService { account.setDefaultMembershipId(request.membershipId()); account.setDefaultAppCode(appCode); account.setDeleted(0); - repository.insert(account); + try { + repository.insert(account); + } catch (DuplicateKeyException duplicate) { + AuthAccount winner = repository.findById(request.accountId()); + if (winner != null && sameIdentity(winner, request, appCode)) return summary(winner); + throw new BizException(ResultCode.CONFLICT, "认证账号标识或登录账号已被使用"); + } return summary(account); } diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index ee951962c..f3fa7cd0a 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -20,6 +20,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.dao.DuplicateKeyException; import jakarta.annotation.Resource; @@ -85,7 +86,7 @@ class AccountManagementServiceTest { } @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() { - when(repository.findById(20L)).thenReturn(null); + when(repository.findCurrentById(20L)).thenReturn(null); when(repository.existsByUsername("platform.user")).thenReturn(false); var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1", @@ -97,6 +98,7 @@ class AccountManagementServiceTest { assertEquals("PLATFORM", captor.getValue().getDefaultAppCode()); assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash())); assertNotEquals("Password1", captor.getValue().getPasswordHash()); + verify(repository, never()).findById(20L); } @Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() { @@ -107,7 +109,7 @@ class AccountManagementServiceTest { } @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { - when(repository.findById(20L)).thenReturn(null); + when(repository.findCurrentById(20L)).thenReturn(null); when(repository.existsByUsername("merchant.user")).thenReturn(false); var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1", @@ -141,6 +143,7 @@ class AccountManagementServiceTest { account.setUsername("platform.user"); account.setDisplayName("平台用户"); account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + when(repository.findCurrentById(10L)).thenReturn(account); var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")); @@ -149,6 +152,44 @@ class AccountManagementServiceTest { verify(repository, never()).insert(any()); } + @Test void concurrentInsertOfSameIdentityReturnsDurableWinner() { + account.setUsername("platform.user"); account.setDefaultTenantId(0L); + account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); + account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + when(repository.findCurrentById(10L)).thenReturn(null); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + + var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", + "平台用户", 0L, 30L, "PLATFORM")); + + assertEquals(10L, result.accountId()); + var order = inOrder(repository); + order.verify(repository).findCurrentById(10L); + order.verify(repository).existsByUsername("platform.user"); + order.verify(repository).insert(any()); + order.verify(repository).findById(10L); + verify(repository, never()).save(any()); + } + + @Test void concurrentInsertOfDifferentIdentityIsRejected() { + when(repository.findCurrentById(10L)).thenReturn(null); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + + assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(10L, + "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM"))); + verify(repository, never()).save(any()); + } + + @Test void concurrentUsernameConflictDoesNotCreateOrReturnAnotherAccount() { + when(repository.findCurrentById(20L)).thenReturn(null); + when(repository.findById(20L)).thenReturn(null); + doThrow(new DuplicateKeyException("username conflict")).when(repository).insert(any()); + + assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(20L, + "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM"))); + verify(repository, never()).save(any()); + } + @Test void synchronizeDisplayNameRevokesExistingSessions() { account.setDisplayName("旧名称");