From febda506d54a9ab8a8caf3be804cf41e6067baf1 Mon Sep 17 00:00:00 2001 From: wooh Date: Tue, 26 May 2026 19:10:52 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20=EB=AA=A8=EC=9D=98=20=EC=84=9C?= =?UTF-8?q?=EB=A5=98=20=EC=9E=AC=EB=8F=84=EC=A0=84=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 모의 서류 지원을 기준으로 새 회차 지원을 생성하는 재도전 API 추가 - 재도전 시 기존 공고 정보를 새 JobPosting으로 복제하도록 구현 - 기존 선택 문항을 새 지원에 복사하고 답변은 빈 값으로 초기화 - 문항이 복사된 재도전 지원은 자소서 작성 단계 상태로 설정 - 홈 목록 응답에 sequence 필드를 추가해 회차 정보를 표시할 수 있도록 수정 - 재도전 생성 및 홈 목록 sequence 응답 테스트 추가 --- .../controller/MockApplyController.java | 27 +++++++++ .../response/MockApplyHomeItemResponse.java | 2 + .../dto/response/MockApplyRetryResponse.java | 25 ++++++++ .../repository/MockApplyRepository.java | 12 ++++ .../service/MockApplyPersistenceService.java | 10 ++++ .../mockapply/service/MockApplyService.java | 60 +++++++++++++++++++ .../service/MockApplyServiceTest.java | 53 ++++++++++++++++ 7 files changed, 189 insertions(+) create mode 100644 src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyRetryResponse.java diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java index df720cf..48ee421 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/controller/MockApplyController.java @@ -5,6 +5,7 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyRetryResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplySequenceResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.mockapply.service.MockApplyService; @@ -197,6 +198,32 @@ public ApiResponse createMockApply( ); } + @Operation( + summary = "모의 서류 지원 재도전", + description = "기존 모의 서류 지원의 공고와 선택 문항을 복사해 새 회차의 모의 서류 지원을 생성합니다. 답변은 비워진 상태로 자소서 입력 단계부터 다시 진행합니다." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "재도전 모의 서류 지원 생성 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(value = "{\"isSuccess\":true,\"code\":\"COMMON2000\",\"message\":\"재도전 모의 서류 지원이 생성되었습니다.\",\"result\":{\"sourceMockApplyId\":10,\"jobPostingId\":2,\"mockApplyId\":11,\"applyType\":\"MOCK\",\"status\":\"ANSWER_WRITE\",\"sequence\":2},\"error\":null}") + ) + ) + }) + @PostMapping("/{mockApplyId}/retry") + public ApiResponse retryMockApply( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long mockApplyId + ) { + return ApiResponse.onSuccess( + "재도전 모의 서류 지원이 생성되었습니다.", + mockApplyService.retryMockApply(userDetails.getUser(), mockApplyId) + ); + } + @Operation( summary = "모의 서류 지원의 생성 공고 조회", description = "mockApplyId에 연결된 생성 공고를 조회합니다." diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java index ec1dffc..3f891dc 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java @@ -12,6 +12,7 @@ public record MockApplyHomeItemResponse( Long mockApplyId, String resumePath, Long jobPostingId, + int sequence, MockApplyStatus status, String companyName, String detailClassificationName, @@ -29,6 +30,7 @@ public static MockApplyHomeItemResponse from(MockApply mockApply) { mockApply.getId(), resumePath(mockApply), jobPosting.getId(), + mockApply.getSequence() == null ? 1 : mockApply.getSequence(), mockApply.getStatus(), jobPosting.getCompany().getName(), detailClassificationName, diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyRetryResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyRetryResponse.java new file mode 100644 index 0000000..6e793be --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyRetryResponse.java @@ -0,0 +1,25 @@ +package com.jobdri.jobdri_api.domain.mockapply.dto.response; + +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; +import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus; + +public record MockApplyRetryResponse( + Long sourceMockApplyId, + Long jobPostingId, + Long mockApplyId, + ApplyType applyType, + MockApplyStatus status, + int sequence +) { + public static MockApplyRetryResponse of(Long sourceMockApplyId, MockApply mockApply) { + return new MockApplyRetryResponse( + sourceMockApplyId, + mockApply.getJobPosting().getId(), + mockApply.getId(), + mockApply.getApplyType(), + mockApply.getStatus(), + mockApply.getSequence() + ); + } +} 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 d739925..3b89f1e 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 @@ -6,12 +6,24 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface MockApplyRepository extends JpaRepository { List findAllByUserId(Long userId); List findAllByJobPostingId(Long jobPostingId); long countByUserIdAndJobPostingId(Long userId, Long jobPostingId); + @Query(""" + select ma + from MockApply ma + join fetch ma.user + join fetch ma.jobPosting jp + join fetch jp.company + join fetch jp.detailClassification + where ma.id = :mockApplyId + """) + Optional findByIdWithJobPosting(@Param("mockApplyId") Long mockApplyId); + @Query(""" select coalesce(max(ma.sequence), 0) from MockApply ma diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java index d4c9f8d..bea522e 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyPersistenceService.java @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.mockapply.service; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository; import lombok.RequiredArgsConstructor; @@ -7,14 +9,22 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor public class MockApplyPersistenceService { private final MockApplyRepository mockApplyRepository; + private final QuestionRepository questionRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public MockApply saveAndFlush(MockApply mockApply) { return mockApplyRepository.saveAndFlush(mockApply); } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public List saveQuestions(List questions) { + return questionRepository.saveAll(questions); + } } 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 0b8e62a..d5838e4 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 @@ -1,5 +1,7 @@ package com.jobdri.jobdri_api.domain.mockapply.service; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.company.entity.Company; import com.jobdri.jobdri_api.domain.company.repository.CompanyRepository; import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent; @@ -15,6 +17,7 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeItemResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyRetryResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplySequenceResponse; import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; @@ -47,6 +50,7 @@ public class MockApplyService { private final MockApplyRepository mockApplyRepository; private final JobPostingRepository jobPostingRepository; private final CompanyRepository companyRepository; + private final QuestionRepository questionRepository; private final MockJobPostingGenerationService mockJobPostingGenerationService; private final JobPostingService jobPostingService; private final UserService userService; @@ -74,6 +78,48 @@ public MockApplyCreateResponse createActualApply(User user, Long jobPostingId, I return MockApplyCreateResponse.from(mockApply); } + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @AuditLogEvent(action = "MOCK_APPLY_RETRY", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") + public MockApplyRetryResponse retryMockApply(User user, Long mockApplyId) { + User validatedUser = userService.validateUser(user); + MockApply sourceMockApply = getOwnedMockApplyWithJobPosting(validatedUser, mockApplyId); + JobPosting sourceJobPosting = sourceMockApply.getJobPosting(); + + JobPosting clonedJobPosting = jobPostingRepository.save(JobPosting.create( + validatedUser, + sourceJobPosting.getCompany(), + sourceJobPosting.getDetailClassification(), + sourceJobPosting.getTask(), + sourceJobPosting.getRequirement(), + sourceJobPosting.getPreferred() + )); + + MockApply retryMockApply = saveMockApplyWithSequence( + validatedUser, + clonedJobPosting, + sourceMockApply.getApplyType(), + null + ); + + List sourceQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(sourceMockApply.getId()); + if (!sourceQuestions.isEmpty()) { + MockApply targetMockApply = retryMockApply; + List retryQuestions = sourceQuestions.stream() + .map(question -> Question.create( + targetMockApply, + question.getContent(), + question.getLimit(), + "" + )) + .toList(); + mockApplyPersistenceService.saveQuestions(retryQuestions); + retryMockApply.updateStatus(MockApplyStatus.ANSWER_WRITE); + retryMockApply = mockApplyPersistenceService.saveAndFlush(retryMockApply); + } + + return MockApplyRetryResponse.of(sourceMockApply.getId(), retryMockApply); + } + @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) { @@ -199,6 +245,20 @@ private MockApply getOwnedMockApply(User user, Long mockApplyId) { return mockApply; } + private MockApply getOwnedMockApplyWithJobPosting(User user, Long mockApplyId) { + MockApply mockApply = mockApplyRepository.findByIdWithJobPosting(mockApplyId) + .orElseThrow(() -> new GeneralException( + GeneralErrorCode.MOCK_APPLY_NOT_FOUND, + "해당 모의 서류 지원을 찾을 수 없습니다. mockApplyId=" + mockApplyId + )); + + if (!mockApply.getUser().getId().equals(user.getId())) { + throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 모의 서류 지원에 접근할 수 없습니다."); + } + + return mockApply; + } + private int resolveSequence(User user, JobPosting jobPosting, Integer requestedSequence) { if (isPositiveSequence(requestedSequence)) { return requestedSequence; 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 e51a370..39c3bbc 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 @@ -1,7 +1,9 @@ package com.jobdri.jobdri_api.domain.mockapply.service; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; +import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; import com.jobdri.jobdri_api.domain.classification.entity.Classification; import com.jobdri.jobdri_api.domain.classification.entity.DetailClassification; import com.jobdri.jobdri_api.domain.classification.entity.MiddleClassification; @@ -17,6 +19,7 @@ import com.jobdri.jobdri_api.domain.mockapply.dto.request.MockApplyCreateMockRequest; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyCreateResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyHomeResponse; +import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplyRetryResponse; import com.jobdri.jobdri_api.domain.mockapply.dto.response.MockApplySequenceResponse; import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply; @@ -62,6 +65,9 @@ class MockApplyServiceTest { @Autowired private AnalysisRepository analysisRepository; + @Autowired + private QuestionRepository questionRepository; + @Autowired private JobPostingRepository jobPostingRepository; @@ -229,6 +235,42 @@ void createMockApplyFromJobPosting() { assertThat(mockApply.getSequence()).isEqualTo(1); } + @Test + @DisplayName("기존 지원의 공고와 문항을 복사해 재도전 지원을 생성한다") + void retryMockApply() { + User user = saveUser("retry-mock-apply@example.com"); + JobPosting jobPosting = saveJobPosting(user, "백엔드 개발"); + MockApply sourceMockApply = saveMockApply(user, jobPosting, ApplyType.MOCK, 1); + saveQuestion(sourceMockApply, "지원 동기와 입사 후 목표를 작성해주세요.", 700, "기존 답변"); + saveQuestion(sourceMockApply, "직접 추가한 문항입니다.", 1000, "기존 직접 추가 답변"); + + MockApplyRetryResponse response = mockApplyService.retryMockApply(user, sourceMockApply.getId()); + + MockApply retryMockApply = mockApplyRepository.findById(response.mockApplyId()).orElseThrow(); + JobPosting retryJobPosting = jobPostingRepository.findById(response.jobPostingId()).orElseThrow(); + List retryQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(response.mockApplyId()); + assertThat(response.sourceMockApplyId()).isEqualTo(sourceMockApply.getId()); + assertThat(response.sequence()).isEqualTo(2); + assertThat(response.status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + assertThat(retryMockApply.getApplyType()).isEqualTo(ApplyType.MOCK); + assertThat(retryMockApply.getSequence()).isEqualTo(2); + assertThat(retryMockApply.getStatus()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + assertThat(retryJobPosting.getId()).isNotEqualTo(jobPosting.getId()); + assertThat(retryJobPosting.getCompany().getId()).isEqualTo(jobPosting.getCompany().getId()); + assertThat(retryJobPosting.getDetailClassification().getId()).isEqualTo(jobPosting.getDetailClassification().getId()); + assertThat(retryJobPosting.getTask()).isEqualTo(jobPosting.getTask()); + assertThat(retryQuestions).hasSize(2); + assertThat(retryQuestions) + .extracting(Question::getContent) + .containsExactly( + "지원 동기와 입사 후 목표를 작성해주세요.", + "직접 추가한 문항입니다." + ); + assertThat(retryQuestions) + .extracting(Question::getAnswer) + .containsExactly("", ""); + } + @Test @DisplayName("mockApplyId로 생성된 모의 공고를 조회한다") void getMockApplyJobPosting() { @@ -290,6 +332,7 @@ void getMyMockApplies() { assertThat(response.completed()).hasSize(1); assertThat(response.inProgress().get(0).mockApplyId()).isEqualTo(inProgress.getId()); assertThat(response.inProgress().get(0).jobPostingId()).isEqualTo(backendPosting.getId()); + assertThat(response.inProgress().get(0).sequence()).isEqualTo(1); assertThat(response.inProgress().get(0).status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); assertThat(response.inProgress().get(0).companyName()).isEqualTo("테스트 기업"); assertThat(response.inProgress().get(0).detailClassificationName()).isEqualTo("백엔드 개발"); @@ -298,6 +341,7 @@ void getMyMockApplies() { assertThat(response.inProgress().get(0).score()).isNull(); assertThat(response.inProgress().get(0).resumePath()).isEqualTo("/mock-applies/" + inProgress.getId() + "/answers"); assertThat(response.completed().get(0).mockApplyId()).isEqualTo(completed.getId()); + assertThat(response.completed().get(0).sequence()).isEqualTo(1); assertThat(response.completed().get(0).score()).isEqualTo(71); assertThat(response.completed().get(0).applyType()).isEqualTo(ApplyType.MOCK); assertThat(response.completed().get(0).resumePath()).isEqualTo("/mock-applies/" + completed.getId() + "/analysis"); @@ -447,6 +491,15 @@ private MockApply saveMockApply(User user, JobPosting jobPosting, ApplyType appl )); } + private Question saveQuestion(MockApply mockApply, String content, int limit, String answer) { + return inNewTransaction(() -> questionRepository.save(Question.create( + mockApplyRepository.findById(mockApply.getId()).orElseThrow(), + content, + limit, + answer + ))); + } + private T inNewTransaction(Supplier action) { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);