diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java index 5206055..c6a0217 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java @@ -1,6 +1,8 @@ package com.mydreamjob.onboarding; +import jakarta.persistence.LockModeType; 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; @@ -23,4 +25,15 @@ public interface OnboardingChecklistItemRepository extends JpaRepository findEffectiveChecklistItems(@Param("companyId") Long companyId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT item + FROM OnboardingChecklistItem item + WHERE item.scopeType = 'COMPANY' + AND item.scopeId = :companyId + AND item.companyId = :companyId + ORDER BY item.id ASC + """) + List findCompanyItemsForUpdate(@Param("companyId") Long companyId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java index eac9f55..37233fa 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; @@ -146,7 +147,7 @@ public class OnboardingChecklistService { .toList(); } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public CompanyChecklistItemView createCompanyChecklistItem( Long userId, CompanyChecklistItemCreateRequest request) { @@ -165,7 +166,7 @@ public class OnboardingChecklistService { title, description, category, sortOrder, required); List companyItems = - ownedCompanyItems(checklistItemRepository.findAll(), company.getId()); + checklistItemRepository.findCompanyItemsForUpdate(company.getId()); if (idempotencyKey != null) { OnboardingChecklistItem existing = companyItems.stream() .filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey)) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java index 29fc74b..3f15dcc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java @@ -90,7 +90,7 @@ class OnboardingCompanyChecklistServiceTest { @Test void createCompanyChecklistItem_shouldPersistNormalizedItemAndStableHash() { stubCompanyOwner(); - when(checklistItemRepository.findAll()).thenReturn(List.of()); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of()); ArgumentCaptor captor = ArgumentCaptor.forClass(OnboardingChecklistItem.class); when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) @@ -125,7 +125,7 @@ class OnboardingCompanyChecklistServiceTest { void createCompanyChecklistItem_idempotentReplay_shouldReturnOriginalWithoutSecondSave() { stubCompanyOwner(); AtomicReference stored = new AtomicReference<>(); - when(checklistItemRepository.findAll()).thenAnswer(invocation -> stored.get() == null + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenAnswer(invocation -> stored.get() == null ? List.of() : List.of(stored.get())); when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) .thenAnswer(invocation -> { @@ -154,7 +154,7 @@ class OnboardingCompanyChecklistServiceTest { OnboardingChecklistItem existing = item( 31L, COMPANY_ID, "existing", "ADMIN", 200, true, 0L, "same-key"); existing.setRequestHash("a".repeat(64)); - when(checklistItemRepository.findAll()).thenReturn(List.of(existing)); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of(existing)); assertThatThrownBy(() -> service.createCompanyChecklistItem( COMPANY_USER_ID, @@ -173,7 +173,7 @@ class OnboardingCompanyChecklistServiceTest { existing.add(item((long) index + 1, COMPANY_ID, "item-" + index, "OTHER", index, true, 0L, null)); } - when(checklistItemRepository.findAll()).thenReturn(existing); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(existing); assertThatThrownBy(() -> service.createCompanyChecklistItem( COMPANY_USER_ID, diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java index 5f52d0d..d562e75 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java @@ -13,6 +13,7 @@ import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -97,23 +98,6 @@ class OnboardingConcurrencyIntegrationTest { .hasSize(1); } - @Test - void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception { - CompanyOwner owner = createCompanyOwner("上限企业"); - seedCompanyItems(owner.companyId(), 49); - - List> attempts = runConcurrently( - () -> checklistService.createCompanyChecklistItem( - owner.userId(), createRequest("边界A", "limit-a")), - () -> checklistService.createCompanyChecklistItem( - owner.userId(), createRequest("边界B", "limit-b"))); - - assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); - assertThat(attempts).filteredOn(Attempt::failedWithItemLimit).hasSize(1); - assertThat(companyRows(owner.companyId())) - .filteredOn(item -> Boolean.TRUE.equals(item.getActive())) - .hasSize(50); - } @Test void inactiveItemsDoNotConsumeLimitAndExistingKeyStillReplays() throws Exception { @@ -241,6 +225,31 @@ class OnboardingConcurrencyIntegrationTest { .isEqualTo(1L); } + + @Nested + @TestPropertySource(properties = + "spring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ") + class RepeatableReadLimitBoundary { + + @Test + void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception { + CompanyOwner owner = createCompanyOwner("上限企业"); + seedCompanyItems(owner.companyId(), 49); + + List> attempts = runConcurrently( + () -> checklistService.createCompanyChecklistItem( + owner.userId(), createRequest("边界A", "limit-a")), + () -> checklistService.createCompanyChecklistItem( + owner.userId(), createRequest("边界B", "limit-b"))); + + assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1); + assertThat(attempts).filteredOn(Attempt::failedWithItemLimit).hasSize(1); + assertThat(companyRows(owner.companyId())) + .filteredOn(item -> Boolean.TRUE.equals(item.getActive())) + .hasSize(50); + } + } + private int failedWith(ThrowingSupplier supplier) { try { supplier.get();