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 @@ -21,19 +21,19 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/projects")
@Tag(name = "Interview Submission", description = "면접 답변 최종 제출 및 개인정보 감지 API")
@Tag(name = "Interview Submission", description = "면접 답변 최종 제출 API")
public class InterviewSubmissionController {

private final InterviewSubmissionService interviewSubmissionService;

@Operation(
summary = "면접 답변 최종 제출",
description = "프로젝트 세션에 생성된 면접 질문 3개에 대한 최종 답변을 저장합니다. 답변 내 개인정보가 감지되면 제출을 차단하고 감지 유형과 경고 메시지를 반환합니다."
description = "프로젝트 세션에 생성된 면접 질문 3개에 대한 최종 답변을 저장합니다. 개인정보와 민감정보 감지는 최초 소스코드 입력 단계에서 수행합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "최종 제출 성공",
content = @Content(schema = @Schema(implementation = InterviewSubmitResponse.class))),
@ApiResponse(responseCode = "400", description = "필수 값 누락, 질문 3개 미준비, 답변 개수 불일치, 개인정보 감지"),
@ApiResponse(responseCode = "400", description = "필수 값 누락, 질문 3개 미준비, 답변 개수 불일치"),
@ApiResponse(responseCode = "403", description = "해당 프로젝트 세션에 접근할 수 없음"),
@ApiResponse(responseCode = "404", description = "프로젝트 세션을 찾을 수 없음")
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.trouble_log.domain.interview.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
@Schema(description = "개인정보 감지 차단 응답")
public class PersonalInfoDetectionResponse {

@Schema(description = "차단 사유", example = "PERSONAL_INFO_DETECTED")
private String blockedReason;

@Schema(description = "개인정보 감지 경고 목록")
private List<PersonalInfoWarning> warnings;
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
@Transactional(readOnly = true)
public class InterviewService {

private static final String DEFAULT_IMPROVEMENT_MESSAGE =
"현재 답변은 질문의 핵심을 잘 짚고 있어요. 이 흐름을 유지하면서 본인이 직접 고민하고 해결한 과정까지 차분히 이어가면 더 설득력 있는 답변이 될 수 있습니다.";

private final InterviewQuestionRepository questionRepository;
private final InterviewAnswerRepository answerRepository;
private final ProjectSessionRepository sessionRepository;
Expand Down Expand Up @@ -93,7 +96,7 @@ public AnswerResponse saveAnswer(Long sessionId, Long questionId, AnswerRequest
feedbackJson = "{}";
}

improvement = feedbackResult.getImprovement();
improvement = normalizeImprovement(feedbackResult.getImprovement());
warning = feedbackResult.getWarning();
}

Expand All @@ -109,6 +112,12 @@ public AnswerResponse saveAnswer(Long sessionId, Long questionId, AnswerRequest
return new AnswerResponse(saved.getId(), improvement, warning);
}

private String normalizeImprovement(String improvement) {
return improvement == null || improvement.isBlank()
? DEFAULT_IMPROVEMENT_MESSAGE
: improvement;
}

// ── 트러블슈팅 리포트 생성 ────────────────────────────────
@Transactional
public ReportResponse generateReport(Long sessionId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import com.example.trouble_log.domain.interview.dto.InterviewSubmitAnswerRequest;
import com.example.trouble_log.domain.interview.dto.InterviewSubmitRequest;
import com.example.trouble_log.domain.interview.dto.InterviewSubmitResponse;
import com.example.trouble_log.domain.interview.dto.PersonalInfoWarning;
import com.example.trouble_log.domain.interview.entity.InterviewAnswer;
import com.example.trouble_log.domain.interview.entity.InterviewQuestion;
import com.example.trouble_log.domain.interview.exception.PersonalInfoDetectedException;
import com.example.trouble_log.domain.interview.repository.InterviewAnswerRepository;
import com.example.trouble_log.domain.interview.repository.InterviewQuestionRepository;
import com.example.trouble_log.domain.projectSession.entity.ProjectSession;
Expand All @@ -17,7 +15,6 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
Expand All @@ -31,15 +28,13 @@
public class InterviewSubmissionService {

private static final int REQUIRED_QUESTION_COUNT = 3;
private static final String UNANSWERED_MESSAGE = "사용자 답변 안 함";

private final ProjectSessionRepository projectSessionRepository;
private final InterviewQuestionRepository interviewQuestionRepository;
private final InterviewAnswerRepository interviewAnswerRepository;
private final PersonalInfoDetectionService personalInfoDetectionService;
private final AuditLogService auditLogService;

// 최종 답변 3개를 저장한 뒤 개인정보 감지 결과에 따라 리포트 생성 가능 상태를 반환한다.
// 최종 답변 3개를 저장한 뒤 리포트 생성 가능 상태를 반환한다.
@Transactional
public InterviewSubmitResponse submit(Long sessionId, InterviewSubmitRequest request) {
validateRequest(sessionId, request);
Expand All @@ -55,19 +50,6 @@ public InterviewSubmitResponse submit(Long sessionId, InterviewSubmitRequest req
Map<Long, String> finalAnswers = validateAndNormalizeFinalAnswers(questions, request.getAnswers());
List<InterviewAnswer> savedAnswers = saveFinalAnswers(questions, finalAnswers);

String answersText = buildAnswersText(questions, savedAnswers);
List<PersonalInfoWarning> warnings = personalInfoDetectionService.detect(answersText);
if (!warnings.isEmpty()) {
auditLogService.recordFailure(
request.getMemberId(),
sessionId,
"INTERVIEW_ANSWER_SUBMIT",
buildSubmitRequestSummary(questions, savedAnswers),
"개인정보 감지: " + warnings.stream().map(PersonalInfoWarning::getType).toList()
);
throw new PersonalInfoDetectedException(warnings);
}

auditLogService.recordSuccess(
request.getMemberId(),
sessionId,
Expand Down Expand Up @@ -176,21 +158,6 @@ private InterviewAnswer saveFinalAnswer(InterviewQuestion question, String final
return interviewAnswerRepository.save(interviewAnswer);
}

// 개인정보 감지와 리포트 생성 요청에 사용할 질문/답변 텍스트를 만든다.
private String buildAnswersText(List<InterviewQuestion> questions, List<InterviewAnswer> answers) {
Map<Long, InterviewAnswer> answerByQuestionId = answers.stream()
.collect(Collectors.toMap(answer -> answer.getInterviewQuestion().getId(), Function.identity()));

return questions.stream()
.map(question -> question.getQuestion() + "\n" + toReportAnswer(answerByQuestionId.get(question.getId()).getAnswer()))
.reduce("", (left, right) -> left + "\n" + right);
}

// 답변이 비어 있으면 AI 리포트가 미답변 상태를 이해할 수 있는 명시 문구로 변환한다.
private String toReportAnswer(String answer) {
return answer == null || answer.isBlank() ? UNANSWERED_MESSAGE : answer;
}

// 최종 제출 요청 내용을 감사 로그용 요약 문자열로 변환한다.
private String buildSubmitRequestSummary(List<InterviewQuestion> questions, List<InterviewAnswer> answers) {
int answerLength = answers.stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.trouble_log.domain.projectSession.controller;

import com.example.trouble_log.domain.interview.dto.PersonalInfoDetectionResponse;
import com.example.trouble_log.domain.projectSession.dto.PreContextRequest;
import com.example.trouble_log.domain.projectSession.dto.PreContextResponse;
import com.example.trouble_log.domain.projectSession.dto.ProjectSessionRequest;
Expand Down Expand Up @@ -31,12 +32,13 @@ public class ProjectSessionController {

@Operation(
summary = "프로젝트 세션 생성",
description = "회원이 입력한 소스코드와 선택적인 GitHub URL을 저장하고, 이후 사전 컨텍스트와 면접 질문 생성에 사용할 세션 ID를 반환합니다."
description = "회원이 입력한 소스코드에서 개인정보와 민감정보를 먼저 감지합니다. 문제가 없으면 소스코드와 선택적인 GitHub URL을 저장하고, 이후 사전 컨텍스트와 면접 질문 생성에 사용할 세션 ID를 반환합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "프로젝트 세션 생성 성공",
content = @Content(schema = @Schema(implementation = ProjectSessionResponse.class))),
@ApiResponse(responseCode = "400", description = "회원 ID 또는 소스코드 누락"),
@ApiResponse(responseCode = "400", description = "회원 ID 또는 소스코드 누락, 개인정보 또는 민감정보 감지",
content = @Content(schema = @Schema(implementation = PersonalInfoDetectionResponse.class))),
@ApiResponse(responseCode = "404", description = "회원을 찾을 수 없음")
})
@PostMapping()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import com.example.trouble_log.domain.ai.service.AzureOpenAiPromptService;
import com.example.trouble_log.domain.audit.service.AuditLogService;
import com.example.trouble_log.domain.interview.dto.InterviewQuestionResponse;
import com.example.trouble_log.domain.interview.dto.PersonalInfoWarning;
import com.example.trouble_log.domain.interview.entity.InterviewQuestion;
import com.example.trouble_log.domain.interview.exception.PersonalInfoDetectedException;
import com.example.trouble_log.domain.interview.repository.InterviewQuestionRepository;
import com.example.trouble_log.domain.interview.service.PersonalInfoDetectionService;
import com.example.trouble_log.domain.projectSession.dto.PreContextRequest;
import com.example.trouble_log.domain.projectSession.dto.PreContextResponse;
import com.example.trouble_log.domain.projectSession.dto.ProjectSessionRequest;
Expand Down Expand Up @@ -37,12 +40,14 @@ public class ProjectSessionService {
private final InterviewQuestionRepository interviewQuestionRepository;
private final AzureOpenAiPromptService azureOpenAiPromptService;
private final AuditLogService auditLogService;
private final PersonalInfoDetectionService personalInfoDetectionService;

@Transactional
// 회원의 코드와 GitHub URL을 기반으로 새 프로젝트 세션을 생성한다.
public ProjectSessionResponse create(ProjectSessionRequest request) {
validateRequestBody(request);
validateRequiredFields("회원 ID와 소스코드는 필수입니다.", request.getMemberId(), request.getCodeContent());
validateCodeContentDoesNotContainPersonalInfo(request);

Member member = memberRepository.findById(request.getMemberId())
.orElseThrow(() -> {
Expand Down Expand Up @@ -75,6 +80,22 @@ public ProjectSessionResponse create(ProjectSessionRequest request) {
return new ProjectSessionResponse(savedProjectSession.getId());
}

private void validateCodeContentDoesNotContainPersonalInfo(ProjectSessionRequest request) {
List<PersonalInfoWarning> warnings = personalInfoDetectionService.detect(request.getCodeContent());
if (warnings.isEmpty()) {
return;
}

auditLogService.recordFailure(
request.getMemberId(),
null,
"PROJECT_SESSION_CREATE",
buildProjectSessionRequestSummary(request),
"개인정보 감지: " + warnings.stream().map(PersonalInfoWarning::getType).toList()
);
throw new PersonalInfoDetectedException(warnings);
}

@Transactional
// 사전 컨텍스트를 저장하고 AI 면접 질문을 생성해 함께 반환한다.
public PreContextResponse createPreContext(Long sessionId, PreContextRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package com.example.trouble_log.global.exception;

import com.example.trouble_log.domain.interview.dto.InterviewSubmitResponse;
import com.example.trouble_log.domain.interview.dto.PersonalInfoDetectionResponse;
import com.example.trouble_log.domain.interview.exception.PersonalInfoDetectedException;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand All @@ -12,10 +11,8 @@
public class GlobalExceptionHandler {

@ExceptionHandler(PersonalInfoDetectedException.class)
public ResponseEntity<InterviewSubmitResponse> handlePersonalInfoDetected(PersonalInfoDetectedException e) {
InterviewSubmitResponse response = new InterviewSubmitResponse(
false,
false,
public ResponseEntity<PersonalInfoDetectionResponse> handlePersonalInfoDetected(PersonalInfoDetectedException e) {
PersonalInfoDetectionResponse response = new PersonalInfoDetectionResponse(
"PERSONAL_INFO_DETECTED",
e.getWarnings()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
class InterviewServiceTest {

private InterviewAnswerRepository answerRepository;
private AzureOpenAiPromptService promptService;
private InterviewService interviewService;

@BeforeEach
Expand All @@ -38,7 +39,7 @@ void setUp() {
answerRepository = mock(InterviewAnswerRepository.class);
ProjectSessionRepository sessionRepository = mock(ProjectSessionRepository.class);
PreContextRepository preContextRepository = mock(PreContextRepository.class);
AzureOpenAiPromptService promptService = mock(AzureOpenAiPromptService.class);
promptService = mock(AzureOpenAiPromptService.class);

interviewService = new InterviewService(
questionRepository,
Expand Down Expand Up @@ -85,6 +86,48 @@ void saveAnswerUpdatesExistingAnswerInsteadOfCreatingDuplicate() {
assertThat(existingAnswer.getFeedback()).isNotBlank();
}

@Test
void saveAnswerReturnsDefaultImprovementWhenAiImprovementIsNull() {
AnswerFeedbackResult feedbackResult = new AnswerFeedbackResult();
feedbackResult.setImprovement(null);
when(promptService.evaluateAnswer(any(ProjectSession.class), any(PreContext.class), any(), any()))
.thenReturn(feedbackResult);

InterviewAnswer existingAnswer = new InterviewAnswer(question(), null, true, null);
ReflectionTestUtils.setField(existingAnswer, "id", 10L);
when(answerRepository.findByInterviewQuestionId(1L)).thenReturn(Optional.of(existingAnswer));

AnswerRequest request = new AnswerRequest();
request.setAnswer("좋은 답변입니다.");

AnswerResponse response = interviewService.saveAnswer(1L, 1L, request);

assertThat(response.getImprovement()).isEqualTo(
"현재 답변은 질문의 핵심을 잘 짚고 있어요. 이 흐름을 유지하면서 본인이 직접 고민하고 해결한 과정까지 차분히 이어가면 더 설득력 있는 답변이 될 수 있습니다."
);
}

@Test
void saveAnswerReturnsDefaultImprovementWhenAiImprovementIsBlank() {
AnswerFeedbackResult feedbackResult = new AnswerFeedbackResult();
feedbackResult.setImprovement(" ");
when(promptService.evaluateAnswer(any(ProjectSession.class), any(PreContext.class), any(), any()))
.thenReturn(feedbackResult);

InterviewAnswer existingAnswer = new InterviewAnswer(question(), null, true, null);
ReflectionTestUtils.setField(existingAnswer, "id", 10L);
when(answerRepository.findByInterviewQuestionId(1L)).thenReturn(Optional.of(existingAnswer));

AnswerRequest request = new AnswerRequest();
request.setAnswer("좋은 답변입니다.");

AnswerResponse response = interviewService.saveAnswer(1L, 1L, request);

assertThat(response.getImprovement()).isEqualTo(
"현재 답변은 질문의 핵심을 잘 짚고 있어요. 이 흐름을 유지하면서 본인이 직접 고민하고 해결한 과정까지 차분히 이어가면 더 설득력 있는 답변이 될 수 있습니다."
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private ProjectSession projectSession() {
Member member = new Member("user@example.com", "password", "user");
ReflectionTestUtils.setField(member, "id", 1L);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.example.trouble_log.domain.interview.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
Expand All @@ -13,7 +12,6 @@
import com.example.trouble_log.domain.interview.dto.InterviewSubmitResponse;
import com.example.trouble_log.domain.interview.entity.InterviewAnswer;
import com.example.trouble_log.domain.interview.entity.InterviewQuestion;
import com.example.trouble_log.domain.interview.exception.PersonalInfoDetectedException;
import com.example.trouble_log.domain.interview.repository.InterviewAnswerRepository;
import com.example.trouble_log.domain.interview.repository.InterviewQuestionRepository;
import com.example.trouble_log.domain.projectSession.entity.ProjectSession;
Expand Down Expand Up @@ -41,7 +39,6 @@ void setUp() {
projectSessionRepository,
interviewQuestionRepository,
interviewAnswerRepository,
new PersonalInfoDetectionService(),
mock(AuditLogService.class)
);

Expand All @@ -50,7 +47,7 @@ void setUp() {
}

@Test
void submitBlocksWhenAnswerContainsPersonalInfo() {
void submitDoesNotBlockWhenAnswerContainsPersonalInfo() {
ProjectSession projectSession = projectSession();
InterviewSubmitRequest request = request(List.of(
answerRequest(1L, "제 이메일은 test@example.com 입니다."),
Expand All @@ -67,11 +64,10 @@ void submitBlocksWhenAnswerContainsPersonalInfo() {
when(interviewQuestionRepository.findByProjectSessionIdOrderByQuestionSequenceAsc(anyLong()))
.thenReturn(questions);

assertThatThrownBy(() -> interviewSubmissionService.submit(1L, request))
.isInstanceOf(PersonalInfoDetectedException.class)
.satisfies(e -> assertThat(((PersonalInfoDetectedException) e).getWarnings())
.extracting("type")
.containsExactly(com.example.trouble_log.domain.interview.type.PersonalInfoType.EMAIL));
InterviewSubmitResponse response = interviewSubmissionService.submit(1L, request);

assertThat(response.getSubmitted()).isTrue();
assertThat(response.getReportGenerationReady()).isTrue();
}

@Test
Expand Down
Loading
Loading