diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java new file mode 100644 index 0000000..3245e8d --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/entity/MockApplySequence.java @@ -0,0 +1,58 @@ +package com.jobdri.jobdri_api.domain.mockapply.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table( + name = "mock_apply_sequences", + uniqueConstraints = @UniqueConstraint( + name = "uk_mock_apply_sequences_key", + columnNames = {"user_id", "company_id", "detail_classification_id"} + ) +) +public class MockApplySequence { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "company_id", nullable = false) + private Long companyId; + + @Column(name = "detail_classification_id", nullable = false) + private Long detailClassificationId; + + @Column(nullable = false) + private int lastSequence; + + public static MockApplySequence create( + Long userId, + Long companyId, + Long detailClassificationId, + int lastSequence + ) { + return MockApplySequence.builder() + .userId(userId) + .companyId(companyId) + .detailClassificationId(detailClassificationId) + .lastSequence(lastSequence) + .build(); + } + + public int incrementAndGet() { + lastSequence++; + return lastSequence; + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java index 0e898e0..d739925 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplyRepository.java @@ -12,6 +12,19 @@ public interface MockApplyRepository extends JpaRepository { List findAllByJobPostingId(Long jobPostingId); long countByUserIdAndJobPostingId(Long userId, Long jobPostingId); + @Query(""" + select coalesce(max(ma.sequence), 0) + from MockApply ma + where ma.user.id = :userId + and ma.jobPosting.company.id = :companyId + and ma.jobPosting.detailClassification.id = :detailClassificationId + """) + int findMaxSequenceByUserIdAndCompanyIdAndDetailClassificationId( + @Param("userId") Long userId, + @Param("companyId") Long companyId, + @Param("detailClassificationId") Long detailClassificationId + ); + default int calculateSequence(MockApply mockApply) { if (mockApply.getSequence() != null && mockApply.getSequence() > 0) { return mockApply.getSequence(); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java new file mode 100644 index 0000000..f519d1a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/repository/MockApplySequenceRepository.java @@ -0,0 +1,27 @@ +package com.jobdri.jobdri_api.domain.mockapply.repository; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplySequence; +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 java.util.Optional; + +public interface MockApplySequenceRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select mas + from MockApplySequence mas + where mas.userId = :userId + and mas.companyId = :companyId + and mas.detailClassificationId = :detailClassificationId + """) + Optional findByKeyForUpdate( + @Param("userId") Long userId, + @Param("companyId") Long companyId, + @Param("detailClassificationId") Long detailClassificationId + ); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java new file mode 100644 index 0000000..d6cf1a8 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplySequenceService.java @@ -0,0 +1,37 @@ +package com.jobdri.jobdri_api.domain.mockapply.service; + +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplySequence; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; +import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplySequenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MockApplySequenceService { + + private final MockApplySequenceRepository mockApplySequenceRepository; + private final MockApplyRepository mockApplyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int allocate(Long userId, Long companyId, Long detailClassificationId) { + MockApplySequence sequence = mockApplySequenceRepository + .findByKeyForUpdate(userId, companyId, detailClassificationId) + .orElseGet(() -> mockApplySequenceRepository.saveAndFlush( + MockApplySequence.create( + userId, + companyId, + detailClassificationId, + mockApplyRepository.findMaxSequenceByUserIdAndCompanyIdAndDetailClassificationId( + userId, + companyId, + detailClassificationId + ) + ) + )); + + return sequence.incrementAndGet(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index e0f3046..0b8e62a 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -40,6 +40,7 @@ @Transactional(readOnly = true) public class MockApplyService { private static final int SEQUENCE_SAVE_MAX_RETRY = 5; + private static final int SEQUENCE_ALLOCATE_MAX_RETRY = 5; private static final String SEQUENCE_UNIQUE_CONSTRAINT = "uk_mock_apply_user_posting_sequence"; private static final String UNIQUE_VIOLATION_SQL_STATE = "23505"; @@ -50,6 +51,7 @@ public class MockApplyService { private final JobPostingService jobPostingService; private final UserService userService; private final MockApplyPersistenceService mockApplyPersistenceService; + private final MockApplySequenceService mockApplySequenceService; @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") @@ -201,10 +203,31 @@ private int resolveSequence(User user, JobPosting jobPosting, Integer requestedS if (isPositiveSequence(requestedSequence)) { return requestedSequence; } - return Math.toIntExact(mockApplyRepository.countByUserIdAndJobPostingId( - user.getId(), - jobPosting.getId() - )) + 1; + return allocateSequence(user, jobPosting); + } + + private int allocateSequence(User user, JobPosting jobPosting) { + for (int attempt = 0; attempt < SEQUENCE_ALLOCATE_MAX_RETRY; attempt++) { + try { + return mockApplySequenceService.allocate( + user.getId(), + jobPosting.getCompany().getId(), + jobPosting.getDetailClassification().getId() + ); + } catch (DataIntegrityViolationException e) { + if (attempt == SEQUENCE_ALLOCATE_MAX_RETRY - 1) { + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 할당에 실패했습니다." + ); + } + } + } + + throw new GeneralException( + GeneralErrorCode.INTERNAL_SERVER_ERROR, + "모의 서류 지원 순번 할당에 실패했습니다." + ); } private boolean isPositiveSequence(Integer sequence) { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 22a7842..e51a370 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -118,6 +118,24 @@ void createActualApplyWithRequestedSequence() { assertThat(sequenceResponse.totalCount()).isEqualTo(3); } + @Test + @DisplayName("같은 회사와 직무의 다른 공고로 재지원하면 다음 순번을 저장한다") + void createActualApplySequencesAcrossSameCompanyAndDetailJobPostings() { + User user = saveUser("actual-apply-retry-sequence@example.com"); + Company company = saveCompany("재지원 기업 " + UUID.randomUUID(), CompanySize.MEDIUM); + DetailClassification detailClassification = saveDetailClassification("백엔드 개발"); + JobPosting firstJobPosting = saveJobPosting(user, company, detailClassification, "첫 번째 JD"); + JobPosting secondJobPosting = saveJobPosting(user, company, detailClassification, "복제된 JD"); + + MockApplyCreateResponse firstResponse = mockApplyService.createActualApply(user, firstJobPosting.getId()); + MockApplyCreateResponse secondResponse = mockApplyService.createActualApply(user, secondJobPosting.getId()); + + MockApply secondMockApply = mockApplyRepository.findById(secondResponse.mockApplyId()).orElseThrow(); + assertThat(firstResponse.sequence()).isEqualTo(1); + assertThat(secondResponse.sequence()).isEqualTo(2); + assertThat(secondMockApply.getSequence()).isEqualTo(2); + } + @Test @DisplayName("요청 순번이 0 이하이면 다음 유효 순번을 저장한다") void createActualApplyIgnoresNonPositiveRequestedSequence() { @@ -391,6 +409,22 @@ private JobPosting saveJobPosting(User user, String detailName) { }); } + private JobPosting saveJobPosting( + User user, + Company company, + DetailClassification detailClassification, + String task + ) { + return inNewTransaction(() -> jobPostingRepository.save(JobPosting.create( + userRepository.findById(user.getId()).orElseThrow(), + companyRepository.findById(company.getId()).orElseThrow(), + detailClassificationRepository.findById(detailClassification.getId()).orElseThrow(), + task, + "자격 요건", + "우대 사항" + ))); + } + private DetailClassification saveDetailClassification(String detailName) { return inNewTransaction(() -> saveDetailClassificationInCurrentTransaction(detailName)); }