Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -197,6 +198,32 @@ public ApiResponse<MockApplyCreateResponse> 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<MockApplyRetryResponse> retryMockApply(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId
) {
return ApiResponse.onSuccess(
"재도전 모의 서류 지원이 생성되었습니다.",
mockApplyService.retryMockApply(userDetails.getUser(), mockApplyId)
);
}

@Operation(
summary = "모의 서류 지원의 생성 공고 조회",
description = "mockApplyId에 연결된 생성 공고를 조회합니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public record MockApplyHomeItemResponse(
Long mockApplyId,
String resumePath,
Long jobPostingId,
int sequence,
MockApplyStatus status,
String companyName,
String detailClassificationName,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,24 @@
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface MockApplyRepository extends JpaRepository<MockApply, Long> {
List<MockApply> findAllByUserId(Long userId);
List<MockApply> 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<MockApply> findByIdWithJobPosting(@Param("mockApplyId") Long mockApplyId);

@Query("""
select coalesce(max(ma.sequence), 0)
from MockApply ma
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
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;
import org.springframework.stereotype.Service;
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<Question> saveQuestions(List<Question> questions) {
return questionRepository.saveAll(questions);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Question> sourceQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(sourceMockApply.getId());
if (!sourceQuestions.isEmpty()) {
MockApply targetMockApply = retryMockApply;
List<Question> 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);
}
Comment on lines +81 to +121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make retry creation atomic to prevent partial persisted state.

retryMockApply runs with NOT_SUPPORTED, while inner saves commit in separate transactions (jobPostingRepository.save, saveAndFlush, saveQuestions). If question copy or status update fails, previously committed entities remain, leaving inconsistent retry data.

Suggested direction
-    `@Transactional`(propagation = Propagation.NOT_SUPPORTED)
+    `@Transactional`
     `@AuditLogEvent`(action = "MOCK_APPLY_RETRY", targetType = "MOCK_APPLY", targetId = "`#result.mockApplyId`()")
     public MockApplyRetryResponse retryMockApply(User user, Long mockApplyId) {
         ...
-        mockApplyPersistenceService.saveQuestions(retryQuestions);
+        mockApplyPersistenceService.saveQuestions(retryQuestions); // align this path to join outer tx
         retryMockApply.updateStatus(MockApplyStatus.ANSWER_WRITE);
-        retryMockApply = mockApplyPersistenceService.saveAndFlush(retryMockApply);
+        retryMockApply = mockApplyPersistenceService.saveAndFlush(retryMockApply);
         ...
     }

Also align MockApplyPersistenceService methods used in this flow to join the caller transaction (remove REQUIRES_NEW for this path), or add explicit compensation cleanup on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java`
around lines 81 - 121, The retry flow in retryMockApply is annotated with
Propagation.NOT_SUPPORTED while inner saves (jobPostingRepository.save,
saveMockApplyWithSequence, mockApplyPersistenceService.saveQuestions,
mockApplyPersistenceService.saveAndFlush) commit separately, allowing partial
persistence on failure; make the whole retry creation atomic by removing
NOT_SUPPORTED and annotating retryMockApply with `@Transactional` (default
REQUIRED) so all saves participate in one transaction, and ensure the
MockApplyPersistenceService methods used here do not force REQUIRES_NEW (adjust
their propagation to join the caller) or alternatively implement a compensating
rollback cleanup on failure; target symbols: retryMockApply,
getOwnedMockApplyWithJobPosting, jobPostingRepository.save,
saveMockApplyWithSequence, questionRepository.findAllByMockApplyIdOrderByIdAsc,
mockApplyPersistenceService.saveQuestions,
mockApplyPersistenceService.saveAndFlush, MockApplyStatus.ANSWER_WRITE.


@Transactional(propagation = Propagation.NOT_SUPPORTED)
@AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()")
public MockApplyCreateResponse createMockApplyFromJobPosting(User user, Long jobPostingId) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -62,6 +65,9 @@ class MockApplyServiceTest {
@Autowired
private AnalysisRepository analysisRepository;

@Autowired
private QuestionRepository questionRepository;

@Autowired
private JobPostingRepository jobPostingRepository;

Expand Down Expand Up @@ -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<Question> 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() {
Expand Down Expand Up @@ -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("백엔드 개발");
Expand All @@ -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");
Expand Down Expand Up @@ -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> T inNewTransaction(Supplier<T> action) {
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
Expand Down
Loading