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
@@ -0,0 +1,82 @@
package com.jobdri.jobdri_api.domain.analysis.controller;

import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse;
import com.jobdri.jobdri_api.domain.analysis.service.QuestionService;
import com.jobdri.jobdri_api.global.apiPayload.ApiResponse;
import com.jobdri.jobdri_api.global.security.UserDetailsImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/mock-applies/{mockApplyId}/questions")
@Tag(name = "Question", description = "자소서 문항 선택 및 답변 저장 API")
public class QuestionController {

private final QuestionService questionService;

@Operation(summary = "문항 후보 목록 조회", description = "문항 선택 화면에서 사용할 기본 문항 후보와 선택 여부를 조회합니다.")
@GetMapping("/candidates")
public ApiResponse<List<QuestionCandidateResponse>> getQuestionCandidates(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId
) {
return ApiResponse.onSuccess(
"문항 후보 목록 조회에 성공했습니다.",
questionService.getQuestionCandidates(userDetails.getUser(), mockApplyId)
);
}

@Operation(summary = "선택 문항 조회", description = "현재 모의 서류 지원에 저장된 선택 문항 목록을 조회합니다.")
@GetMapping
public ApiResponse<QuestionSelectionResponse> getSelectedQuestions(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId
) {
return ApiResponse.onSuccess(
"선택 문항 조회에 성공했습니다.",
questionService.getSelectedQuestions(userDetails.getUser(), mockApplyId)
);
}

@Operation(summary = "선택 문항 저장", description = "사용자가 선택하거나 직접 추가한 문항을 저장하고 답변 작성 단계로 진입합니다.")
@PutMapping
public ApiResponse<QuestionSelectionResponse> saveSelectedQuestions(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId,
@Valid @RequestBody QuestionSelectionSaveRequest request
) {
return ApiResponse.onSuccess(
"선택 문항이 저장되었습니다.",
questionService.saveSelectedQuestions(userDetails.getUser(), mockApplyId, request)
);
}

@Operation(summary = "자소서 답변 저장/수정", description = "저장된 문항의 답변 내용을 작성하거나 수정합니다. 문항 내용은 수정하지 않습니다.")
@PutMapping("/answers")
public ApiResponse<QuestionAnswerResponse> saveAnswers(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId,
@Valid @RequestBody QuestionAnswerSaveRequest request
) {
return ApiResponse.onSuccess(
"자소서 답변이 저장되었습니다.",
questionService.saveAnswers(userDetails.getUser(), mockApplyId, request)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.jobdri.jobdri_api.domain.analysis.dto.request;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import java.util.List;

public record QuestionAnswerSaveRequest(
@Valid
@NotEmpty(message = "저장할 답변은 1개 이상이어야 합니다.")
List<AnswerItem> answers
) {
public record AnswerItem(
@NotNull(message = "문항 ID는 필수입니다.")
Long questionId,

@NotNull(message = "답변 내용은 필수입니다.")
String answer
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.jobdri.jobdri_api.domain.analysis.dto.request;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

import java.util.List;

public record QuestionSelectionSaveRequest(
@Valid
@NotEmpty(message = "선택 문항은 1개 이상이어야 합니다.")
@Size(max = 5, message = "문항은 최대 5개까지 선택할 수 있습니다.")
List<QuestionItem> questions
) {
public record QuestionItem(
@NotBlank(message = "문항 내용은 필수입니다.")
String content,

@Positive(message = "글자수 제한은 1 이상이어야 합니다.")
Integer charLimit,

Boolean custom
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus;

import java.util.List;

public record QuestionAnswerResponse(
Long mockApplyId,
MockApplyStatus status,
List<QuestionResponse> questions
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

public record QuestionCandidateResponse(
String content,
int charLimit,
boolean selected
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import com.jobdri.jobdri_api.domain.analysis.entity.Question;

public record QuestionResponse(
Long questionId,
String content,
int charLimit,
String answer
) {
public static QuestionResponse from(Question question) {
return new QuestionResponse(
question.getId(),
question.getContent(),
question.getLimit(),
question.getAnswer()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.jobdri.jobdri_api.domain.analysis.dto.response;

import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus;

import java.util.List;

public record QuestionSelectionResponse(
Long mockApplyId,
MockApplyStatus status,
List<QuestionResponse> questions
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@

public interface QuestionRepository extends JpaRepository<Question, Long> {
List<Question> findAllByMockApplyId(Long mockApplyId);
List<Question> findAllByMockApplyIdOrderByIdAsc(Long mockApplyId);
void deleteAllByMockApplyId(Long mockApplyId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse;
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.entity.MockApplyStatus;
import com.jobdri.jobdri_api.domain.mockapply.repository.MockApplyRepository;
import com.jobdri.jobdri_api.domain.user.entity.User;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class QuestionService {

private static final int MIN_SELECTION_COUNT = 1;
private static final int MAX_SELECTION_COUNT = 5;
private static final int DEFAULT_CHAR_LIMIT = 1000;

private static final List<QuestionCandidate> DEFAULT_CANDIDATES = List.of(
new QuestionCandidate("지원 동기와 입사 후 목표를 작성해주세요.", 700),
new QuestionCandidate("지원 직무와 관련된 경험 또는 역량을 구체적으로 작성해주세요.", 1000),
new QuestionCandidate("문제를 해결했던 경험과 그 과정에서의 역할을 작성해주세요.", 1000),
new QuestionCandidate("협업 과정에서 갈등을 해결했던 경험을 작성해주세요.", 800),
new QuestionCandidate("가장 성취감을 느꼈던 프로젝트와 성과를 작성해주세요.", 1000)
);

private final MockApplyRepository mockApplyRepository;
private final QuestionRepository questionRepository;

public List<QuestionCandidateResponse> getQuestionCandidates(User user, Long mockApplyId) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
Set<String> selectedContents = questionRepository.findAllByMockApplyId(mockApply.getId()).stream()
.map(Question::getContent)
.collect(Collectors.toSet());

return DEFAULT_CANDIDATES.stream()
.map(candidate -> new QuestionCandidateResponse(
candidate.content(),
candidate.charLimit(),
selectedContents.contains(candidate.content())
))
.toList();
}

public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
List<QuestionResponse> questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream()
.map(QuestionResponse::from)
.toList();

return new QuestionSelectionResponse(mockApply.getId(), mockApply.getStatus(), questions);
}

@Transactional
public QuestionSelectionResponse saveSelectedQuestions(
User user,
Long mockApplyId,
QuestionSelectionSaveRequest request
) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
validateSelectionCount(request.questions().size());

List<Question> existingQuestions = questionRepository.findAllByMockApplyId(mockApply.getId());
questionRepository.deleteAll(existingQuestions);

List<Question> questions = request.questions().stream()
.map(item -> Question.create(
mockApply,
item.content().trim(),
resolveCharLimit(item.charLimit()),
""
))
.toList();
List<Question> savedQuestions = questionRepository.saveAll(questions);
mockApply.updateStatus(MockApplyStatus.ANSWER_WRITE);

return new QuestionSelectionResponse(
mockApply.getId(),
mockApply.getStatus(),
savedQuestions.stream().map(QuestionResponse::from).toList()
);
}

@Transactional
public QuestionAnswerResponse saveAnswers(
User user,
Long mockApplyId,
QuestionAnswerSaveRequest request
) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
List<Question> questions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId());
Map<Long, Question> questionMap = questions.stream()
.collect(Collectors.toMap(Question::getId, Function.identity()));

for (QuestionAnswerSaveRequest.AnswerItem item : request.answers()) {
Question question = questionMap.get(item.questionId());
if (question == null) {
throw new GeneralException(
GeneralErrorCode.QUESTION_NOT_FOUND,
"해당 지원서의 문항을 찾을 수 없습니다. questionId=" + item.questionId()
);
}
question.updateAnswer(normalizeAnswer(item.answer()));
}

return new QuestionAnswerResponse(
mockApply.getId(),
mockApply.getStatus(),
questions.stream().map(QuestionResponse::from).toList()
);
}

private MockApply getOwnedMockApply(User user, Long mockApplyId) {
MockApply mockApply = mockApplyRepository.findById(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 void validateSelectionCount(int count) {
if (count < MIN_SELECTION_COUNT) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "선택 문항은 1개 이상이어야 합니다.");
}
if (count > MAX_SELECTION_COUNT) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "문항은 최대 5개까지 선택할 수 있습니다.");
}
}

private int resolveCharLimit(Integer charLimit) {
if (charLimit == null) {
return DEFAULT_CHAR_LIMIT;
}
return charLimit;
}

private String normalizeAnswer(String answer) {
if (StringUtils.hasText(answer)) {
return answer;
}
return "";
}

private record QuestionCandidate(String content, int charLimit) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public enum GeneralErrorCode implements BaseErrorCode {
JOB_POSTING_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4041", "채용 공고를 찾을 수 없습니다."),
JOB_POSTING_ASYNC_TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "JOB_POSTING_4042", "채용 공고 비동기 작업을 찾을 수 없습니다."),

// 모의 서류 지원 에러
MOCK_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "MOCK_APPLY_4041", "모의 서류 지원을 찾을 수 없습니다."),
QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "QUESTION_4041", "문항을 찾을 수 없습니다."),

// 유저 에러
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_4041", "유저를 찾을 수 없습니다.");

Expand Down
Loading
Loading