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
@@ -1,5 +1,6 @@
package com.aibe.team2.domain.interview.controller;

import com.aibe.team2.domain.interview.dto.InterviewReportResponse;
import com.aibe.team2.domain.interview.dto.InterviewStartRequest;
import com.aibe.team2.domain.interview.dto.VoiceSessionResponse;
import com.aibe.team2.domain.interview.entity.InterviewSession;
Expand All @@ -8,10 +9,13 @@
import com.aibe.team2.domain.interview.repository.InterviewRepository;
import com.aibe.team2.domain.interview.service.ConversationManager;
import com.aibe.team2.domain.interview.service.InterviewManager;
import com.aibe.team2.domain.interview.service.InterviewReportService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
Expand All @@ -23,13 +27,18 @@ public class InterviewController {

private final ConversationManager conversationManager;
private final InterviewManager interviewManager;
private final InterviewRepository interviewRepository; // 세션 조회를 위해 Repository 의존성 추가
private final InterviewRepository interviewRepository;
private final InterviewReportService interviewReportService;

@PostMapping("/start")
public ResponseEntity<InterviewSession> startInterview(@RequestBody InterviewStartRequest request) {
public ResponseEntity<InterviewSession> startInterview(
@RequestBody InterviewStartRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
try {
Long authenticatedMemberId = (userDetails != null) ? getMemberIdFromUserDetails(userDetails) : request.getMemberId();

InterviewSession session = interviewManager.startInterview(
request.getMemberId(),
authenticatedMemberId,
request.getResumeId(),
request.getJobPostingId(),
InterviewMode.from(request.getInterviewMode()),
Expand All @@ -47,42 +56,33 @@ public ResponseEntity<InterviewSession> startInterview(@RequestBody InterviewSta
public SseEmitter streamTextInterview(
@PathVariable Long sessionId,
@RequestParam String answer,
@RequestParam Long memberId, // 프론트엔드 연동용 로그인 유저 ID
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) Long memberId, // 🚀 복구: 프론트엔드 연동 및 SSE 한계 우회용
@RequestParam(required = false, defaultValue = "gemini-flash-latest") String modelVariant,
@RequestParam(required = false, defaultValue = "NORMAL") String interviewMode) {

// 리뷰 반영: 전용 private 메서드로 조회 및 권한 검증 위임 (코드 중복 제거 및 보안 강화)
InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId);
InterviewSession session = validateAndGetSessionOwnership(sessionId, userDetails, memberId);

// [FR-INT-02] 상태 전이 로직
if (session.getStatus() == InterviewSessionStatus.CREATED) {
interviewManager.advanceStatus(sessionId, InterviewSessionStatus.IN_PROGRESS);
}

SseEmitter emitter = new SseEmitter(120000L);

try {
conversationManager.startTextStreaming(
sessionId,
answer,
modelVariant,
InterviewMode.from(interviewMode),
emitter
);
conversationManager.startTextStreaming(session, answer, modelVariant, InterviewMode.from(interviewMode), emitter);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
}

return emitter;
}

@PostMapping("/{sessionId}/voice/start")
public VoiceSessionResponse startVoiceInterview(
@PathVariable Long sessionId,
@RequestParam Long memberId) {
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) Long memberId) { // 🚀 복구

// 두 번째 리뷰 반영: startVoiceInterview 엔드포인트 역시 동일한 검증 위임
InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId);
InterviewSession session = validateAndGetSessionOwnership(sessionId, userDetails, memberId);

if (session.getStatus() == InterviewSessionStatus.CREATED) {
interviewManager.advanceStatus(sessionId, InterviewSessionStatus.IN_PROGRESS);
Expand All @@ -94,19 +94,50 @@ public VoiceSessionResponse startVoiceInterview(
@PatchMapping("/{sessionId}/end")
public ResponseEntity<Void> endInterview(
@PathVariable Long sessionId,
@RequestParam Long memberId) {
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) Long memberId) { // 🚀 복구

// 세 번째 리뷰 반영: 면접 종료 엔드포인트 역시 동일한 검증 위임
validateAndGetSessionOwnership(sessionId, memberId);
validateAndGetSessionOwnership(sessionId, userDetails, memberId);

interviewManager.advanceStatus(sessionId, InterviewSessionStatus.DONE);
return ResponseEntity.ok().build();
}

// 보안 리뷰 및 팀원 피드백 반영: IDOR 취약점 방지 및 중복 로직 제거를 위한 전용 검증 메서드 (streamTextInterview, startVoiceInterview, endInterview 모든 엔드포인트에 공통 적용됨)
// 현재는 프론트엔드 연동을 위해 @RequestParam 으로 넘겨받은 memberId를 검증에 사용 중
// 추후 Spring Security 적용 시, 컨트롤러 파라미터를 삭제하고 @AuthenticationPrincipal SecurityContext에서 추출한 실제 로그인 사용자의 ID로 변경하여 완벽한 인가 처리를 수행
private InterviewSession validateAndGetSessionOwnership(Long sessionId, Long authenticatedMemberId) {
@GetMapping("/{sessionId}/report")
public ResponseEntity<InterviewReportResponse> getInterviewReport(
@PathVariable Long sessionId,
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) Long memberId) { // 🚀 복구

InterviewSession session = validateAndGetSessionOwnership(sessionId, userDetails, memberId);

try {
InterviewReportResponse report = interviewReportService.getReport(session);
return ResponseEntity.ok(report);
} catch (IllegalStateException e) {
throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage());
}
Comment on lines +117 to +119
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

IllegalStateException이 발생했을 때 HttpStatus.FORBIDDEN (403)을 반환하도록 구현하셨습니다. 하지만 403은 인증된 사용자가 리소스에 접근할 권한이 없을 때 사용하는 상태 코드입니다. 이 경우, 사용자는 자신의 면접 리포트에 접근할 권한이 있지만, 면접이 아직 완료되지 않아 리소스의 상태가 요청을 처리하기에 적절하지 않은 상황입니다.
이러한 "상태 충돌" 상황에는 HttpStatus.CONFLICT (409)가 더 적합한 상태 코드입니다. API의 의미를 더 명확하게 전달하기 위해 상태 코드를 변경하는 것을 고려해 보세요.

Suggested change
} catch (IllegalStateException e) {
// 상태가 DONE이 아닐 경우 403 Forbidden 에러 반환
throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage());
}
} catch (IllegalStateException e) {
// 상태가 DONE이 아닐 경우 409 Conflict 에러 반환
throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage());
}

}

/**
* 🚀 보안 리뷰 반영 및 SSE 우회: Security 인증 객체와 fallbackMemberId를 유연하게 처리
*/
private InterviewSession validateAndGetSessionOwnership(Long sessionId, UserDetails userDetails, Long fallbackMemberId) {
Long authenticatedMemberId;

// 1. Security Context에 정보가 있으면 우선 사용
if (userDetails != null) {
authenticatedMemberId = getMemberIdFromUserDetails(userDetails);
}
// 2. EventSource(SSE) 헤더 누락 및 로컬 테스트 환경을 위해 파라미터 값으로 우회(Fallback) 허용
else if (fallbackMemberId != null) {
authenticatedMemberId = fallbackMemberId;
}
// 3. 둘 다 없으면 401 에러
else {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요한 서비스입니다.");
}

InterviewSession session = interviewRepository.findById(sessionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "면접 세션을 찾을 수 없습니다."));

Expand All @@ -116,4 +147,15 @@ private InterviewSession validateAndGetSessionOwnership(Long sessionId, Long aut

return session;
}

/**
* UserDetails에서 MemberId 추출을 돕는 유틸 메서드
*/
private Long getMemberIdFromUserDetails(UserDetails userDetails) {
try {
return Long.valueOf(userDetails.getUsername());
} catch (NumberFormatException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "회원 식별자를 처리할 수 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.aibe.team2.domain.interview.dto;

import com.aibe.team2.domain.interview.entity.InterviewSession;
import com.aibe.team2.domain.interview.enums.InterviewMode;
import com.aibe.team2.domain.interview.enums.InterviewSessionStatus;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@Builder
public class InterviewReportResponse {
private Long sessionId;
private InterviewMode interviewMode;
private String interviewType;
private InterviewSessionStatus status;
private Integer finalScore;
private LocalDateTime createdAt;

// 연관된 이력서와 공고의 '제목'을 프론트엔드에 전달하기 위한 필드
private String resumeTitle;
private String jobTitle;

public static InterviewReportResponse of(InterviewSession session, String resumeTitle, String jobTitle) {
return InterviewReportResponse.builder()
.sessionId(session.getId())
.interviewMode(session.getInterviewMode())
.interviewType(session.getInterviewType())
.status(session.getStatus())
.finalScore(session.getFinalScore())
.createdAt(session.getCreatedAt())
.resumeTitle(resumeTitle)
.jobTitle(jobTitle)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ public class InterviewRequestDto {
private String message;
private String modelVariant;
private InterviewMode interviewMode;

// [FR-INT-06, 07] 프롬프트에 동적 주입할 컨텍스트 데이터 필드 추가
private String resumeContent;
private String jobDescription;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,55 @@

import com.aibe.team2.domain.interview.dto.InterviewRequestDto;
import com.aibe.team2.domain.interview.dto.VoiceSessionResponse;
import com.aibe.team2.domain.interview.entity.InterviewSession;
import com.aibe.team2.domain.interview.enums.InterviewMode;
import com.aibe.team2.domain.interview.enums.InterviewSessionStatus;
import com.aibe.team2.domain.jobposting.entity.JobPosting;
import com.aibe.team2.domain.jobposting.repository.JobPostingRepository;
import com.aibe.team2.domain.resume.entity.Resume;
import com.aibe.team2.domain.resume.repository.ResumeRepository;
import com.aibe.team2.global.redis.lock.DistributedLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;

@Slf4j
@Service
@RequiredArgsConstructor
public class ConversationManager {

private final GeminiService geminiService;
private final RetellService retellService;
private final InterviewManager interviewManager; // 추가: 상태 변경을 위한 Manager 주입
private final InterviewManager interviewManager; // 상태 변경을 위한 Manager 주입

// @DistributedLock(key = "text-streaming", waitTime = 1, leaseTime = 20) <- 추후 분산 락 구현시 주석 해제
public void startTextStreaming(Long sessionId, String answer, String modelVariant, InterviewMode interviewMode, SseEmitter emitter) {
InterviewRequestDto request = new InterviewRequestDto(answer, modelVariant, interviewMode);
private final ResumeRepository resumeRepository; // [FR-INT-06] 이력서 조회를 위한 의존성 추가
private final JobPostingRepository jobPostingRepository; // [FR-INT-07] 채용 공고 조회를 위한 의존성 추가

geminiService.streamQuestion(String.valueOf(sessionId), request).subscribe(
@DistributedLock(key = "text-streaming", waitTime = 1, leaseTime = 20)
public void startTextStreaming(InterviewSession session, String answer, String modelVariant, InterviewMode interviewMode, SseEmitter emitter) {

// [FR-INT-06] 자기소개서 내용 안전하게 조회 및 추출
String resumeContent = null;
if (session.getResumeId() != null) {
resumeContent = resumeRepository.findByIdAndMemberId(session.getResumeId(), session.getMemberId())
.map(Resume::getContent)
.orElse(null); // 권한이 없거나 찾을 수 없으면 주입하지 않음
}

// [FR-INT-07] 채용 공고 내용 안전하게 조회 및 추출
String jobDescription = null;
if (session.getJobPostingId() != null) {
jobDescription = jobPostingRepository.findByIdAndMemberId(session.getJobPostingId(), session.getMemberId())
.map(JobPosting::getJobDescription)
.orElse(null);
}

// DTO 생성 시 추출한 이력서 및 공고 데이터 모두 포함
InterviewRequestDto request = new InterviewRequestDto(answer, modelVariant, interviewMode, resumeContent, jobDescription);

geminiService.streamQuestion(String.valueOf(session.getId()), request).subscribe(
data -> {
try {
emitter.send(SseEmitter.event().name("message").data(data));
Expand All @@ -30,8 +59,9 @@ public void startTextStreaming(Long sessionId, String answer, String modelVarian
}
},
error -> {
// 추가: 스트리밍 중 에러 발생 시 세션을 ABORTED 상태로 전환
interviewManager.advanceStatus(sessionId, InterviewSessionStatus.ABORTED);
log.error("스트리밍 중 에러 발생. 세션을 ABORTED 상태로 전환합니다. SessionID: {}", session.getId(), error);
// 통계 제외를 위해 에러 발생 시 상태를 ABORTED로 변경
interviewManager.advanceStatus(session.getId(), InterviewSessionStatus.ABORTED);
emitter.completeWithError(error);
},
emitter::complete
Expand Down
Loading