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 @@ -25,6 +25,7 @@ public enum ErrorCode {

// Session
SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."),
SESSION_NOT_FINISHED(HttpStatus.BAD_REQUEST, "아직 진행 중인 세션입니다."),

// Question
QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public interface QuestionRepository extends JpaRepository<InterviewQuestion, UUI

Optional<InterviewQuestion> findByIdAndMemberId(UUID id, UUID memberId);

List<InterviewQuestion> findByInterviewSessionId(UUID sessionId);
Optional<InterviewQuestion> findByIdAndMemberIdAndInterviewSessionId(UUID id, UUID memberId, UUID sessionId);

List<InterviewQuestion> findByInterviewSessionIdOrderByCreatedAtAsc(UUID sessionId);

@Modifying(clearAutomatically = true)
@Query("UPDATE InterviewQuestion q SET q.latestScore = :score " +
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package io.wisoft.prepair.prepair_api.interview.session.controller;

import io.wisoft.prepair.prepair_api.common.response.ApiResponse;
import io.wisoft.prepair.prepair_api.interview.session.dto.response.CreateSessionResponse;
import io.wisoft.prepair.prepair_api.interview.session.dto.response.SessionDetailResponse;
import io.wisoft.prepair.prepair_api.interview.session.dto.response.SessionResponse;
import io.wisoft.prepair.prepair_api.interview.session.service.SessionService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
Expand Down Expand Up @@ -37,4 +41,25 @@ public ApiResponse<SessionDetailResponse> getSession(
SessionDetailResponse data = sessionService.getSessionDetail(sessionId, memberId);
return ApiResponse.ok(data, "세션 결과를 조회했습니다.");
}

@PostMapping("/{sessionId}")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<CreateSessionResponse> createSession(
@PathVariable UUID sessionId,
@RequestHeader("X-User-Id") UUID memberId
) {
CreateSessionResponse data = sessionService.createSession(sessionId, memberId);
return ApiResponse.created(data, "세션이 생성되었습니다.");
}

@PostMapping("/{sessionId}/questions/{questionId}")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<CreateSessionResponse> createQuestion(
@PathVariable UUID sessionId,
@PathVariable UUID questionId,
@RequestHeader("X-User-Id") UUID memberId
) {
CreateSessionResponse data = sessionService.createQuestion(sessionId, questionId, memberId);
return ApiResponse.created(data, "세션이 생성되었습니다.");
}
Comment on lines +45 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

현재 API 경로 설계에서 POST /api/interviews/sessions/{sessionId}POST /api/interviews/sessions/{sessionId}/questions/{questionId}는 기존 세션의 정보를 바탕으로 새로운 세션을 생성하고 있습니다.

하지만 이 경로는 기존 세션 리소스를 수정하거나 기존 세션 하위에 질문을 생성하는 것처럼 오해를 불러일으킬 수 있어 RESTful한 설계 관점에서 아쉬움이 있습니다.

다음과 같은 대안을 고려해 보시는 것을 추천합니다:

  1. 재시도(Retry) 명시: POST /api/interviews/sessions/{sessionId}/retryPOST /api/interviews/sessions/{sessionId}/questions/{questionId}/retry와 같이 행위(retry)를 경로에 명시합니다.
  2. 세션 생성 API 통합: POST /api/interviews/sessions 엔드포인트에 요청 바디(DTO)로 parentSessionId와 선택적으로 questionId를 전달하여 새로운 세션을 생성하도록 단일화합니다.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.wisoft.prepair.prepair_api.interview.session.dto.response;

import java.util.List;
import java.util.UUID;

public record CreateSessionResponse(UUID sessionId, List<UUID> questionIds) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io.wisoft.prepair.prepair_api.interview.answer.repository.FeedbackRepository;
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository;
import io.wisoft.prepair.prepair_api.interview.session.dto.response.CreateSessionResponse;
import io.wisoft.prepair.prepair_api.interview.session.dto.response.SessionDetailResponse;
import io.wisoft.prepair.prepair_api.interview.session.dto.response.SessionResponse;
import io.wisoft.prepair.prepair_api.interview.session.entity.InterviewSession;
Expand Down Expand Up @@ -60,7 +61,7 @@ public SessionDetailResponse getSessionDetail(UUID sessionId, UUID memberId) {
}

public List<SessionDetailResponse.QuestionFeedback> buildQuestionFeedbacks(UUID sessionId) {
List<InterviewQuestion> questions = questionRepository.findByInterviewSessionId(sessionId);
List<InterviewQuestion> questions = questionRepository.findByInterviewSessionIdOrderByCreatedAtAsc(sessionId);
Map<UUID, InterviewAnswer> answerMap = getAnswerMap(sessionId);
Map<UUID, List<InterviewFeedback>> feedbackMap = getFeedbackMap(sessionId);

Expand All @@ -76,7 +77,7 @@ public List<SessionDetailResponse.QuestionFeedback> buildQuestionFeedbacks(UUID
}

List<InterviewFeedback> answerFeedbacks = feedbackMap.getOrDefault(answer.getId(), List.of());
InterviewFeedback combined = findOptionalFeedback(answerFeedbacks, FeedbackType.COMBINED);
InterviewFeedback combined = findFeedback(answerFeedbacks, FeedbackType.COMBINED);

result.add(new SessionDetailResponse.QuestionFeedback(
question.getId(),
Expand All @@ -91,10 +92,68 @@ public List<SessionDetailResponse.QuestionFeedback> buildQuestionFeedbacks(UUID
return result;
}

@Transactional
public CreateSessionResponse createSession(UUID oldSessionId, UUID memberId) {
InterviewSession oldSession = sessionRepository.findById(oldSessionId)
.orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND));

if (!oldSession.getMemberId().equals(memberId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (oldSession.getStatus() == SessionStatus.IN_PROGRESS) {
throw new BusinessException(ErrorCode.SESSION_NOT_FINISHED);
}

List<InterviewQuestion> oldQuestions = questionRepository.findByInterviewSessionIdOrderByCreatedAtAsc(oldSessionId);
InterviewSession newSession = sessionRepository.save(new InterviewSession(memberId, oldQuestions.size()));
Comment on lines +107 to +108
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

이전 세션(oldSessionId)에 등록된 질문이 없는 경우(oldQuestions.isEmpty()), 질문이 0개인 빈 세션이 생성될 수 있습니다. 면접 세션은 최소 한 개 이상의 질문을 가져야 하므로, 질문 목록이 비어있는지 검증하는 로직을 추가하는 것이 안전합니다.

        List<InterviewQuestion> oldQuestions = questionRepository.findByInterviewSessionIdOrderByCreatedAtAsc(oldSessionId);
        if (oldQuestions.isEmpty()) {
            throw new BusinessException(ErrorCode.QUESTION_NOT_FOUND);
        }
        InterviewSession newSession = sessionRepository.save(new InterviewSession(memberId, oldQuestions.size()));


List<UUID> newQuestionIds = oldQuestions.stream()
.map(q -> cloneQuestion(q, newSession).getId())
.toList();
Comment on lines +110 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

스트림(stream()) 내부에서 데이터베이스에 변경을 주는 cloneQuestion (내부적으로 save 호출)을 호출하는 것은 부작용(Side-effect)을 유발하므로 지양해야 합니다. 또한, 여러 개의 질문을 개별적으로 save하는 대신 saveAll을 사용하여 일괄 저장(Batch Insert)하는 것이 성능과 가독성 측면에서 더 좋습니다.

다음과 같이 saveAll을 사용하도록 리팩토링하는 것을 제안합니다.

        List<InterviewQuestion> newQuestions = oldQuestions.stream()
                .map(q -> new InterviewQuestion(
                        q.getMemberId(),
                        q.getQuestion(),
                        q.getQuestionType(),
                        q.getQuestionTag(),
                        q.getJobPosting(),
                        newSession
                ))
                .toList();

        List<UUID> newQuestionIds = questionRepository.saveAll(newQuestions).stream()
                .map(InterviewQuestion::getId)
                .toList();


return new CreateSessionResponse(newSession.getId(), newQuestionIds);
}

@Transactional
public CreateSessionResponse createQuestion(UUID oldSessionId, UUID oldQuestionId, UUID memberId) {
InterviewSession oldSession = sessionRepository.findById(oldSessionId)
.orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND));

if (!oldSession.getMemberId().equals(memberId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (oldSession.getStatus() == SessionStatus.IN_PROGRESS) {
throw new BusinessException(ErrorCode.SESSION_NOT_FINISHED);
}

InterviewQuestion oldQuestion = questionRepository.findByIdAndMemberIdAndInterviewSessionId(
oldQuestionId,
memberId,
oldSessionId
)
.orElseThrow(() -> new BusinessException(ErrorCode.QUESTION_NOT_FOUND));

InterviewSession newSession = sessionRepository.save(new InterviewSession(memberId, 1));
InterviewQuestion newQuestion = cloneQuestion(oldQuestion, newSession);

return new CreateSessionResponse(newSession.getId(), List.of(newQuestion.getId()));
}

private InterviewQuestion cloneQuestion(InterviewQuestion source, InterviewSession newSession) {
return questionRepository.save(new InterviewQuestion(
source.getMemberId(),
source.getQuestion(),
source.getQuestionType(),
source.getQuestionTag(),
source.getJobPosting(),
newSession
));
}

@Transactional
public void saveCompletedSession(UUID sessionId, int finalScore, String finalFeedback) {
InterviewSession session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다."));
.orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND));

if (session.getStatus() == SessionStatus.COMPLETED) {
return;
Expand All @@ -106,7 +165,7 @@ public void saveCompletedSession(UUID sessionId, int finalScore, String finalFee
@Transactional
public void saveFailedSession(UUID sessionId) {
InterviewSession session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 세션입니다."));
.orElseThrow(() -> new BusinessException(ErrorCode.SESSION_NOT_FOUND));

if (session.getStatus() != SessionStatus.IN_PROGRESS) {
return;
Expand All @@ -130,15 +189,15 @@ private Map<UUID, List<InterviewFeedback>> getFeedbackMap(UUID sessionId) {
.collect(Collectors.groupingBy(f -> f.getInterviewAnswer().getId()));
}

private InterviewFeedback findOptionalFeedback(List<InterviewFeedback> feedbacks, FeedbackType type) {
private InterviewFeedback findFeedback(List<InterviewFeedback> feedbacks, FeedbackType type) {
return feedbacks.stream()
.filter(f -> f.getFeedbackType() == type)
.findFirst()
.orElse(null);
}

private String getFeedbackText(List<InterviewFeedback> feedbacks, FeedbackType type) {
InterviewFeedback feedback = findOptionalFeedback(feedbacks, type);
InterviewFeedback feedback = findFeedback(feedbacks, type);
return feedback == null ? null : feedback.getFeedback();
}
}
Loading