-
Notifications
You must be signed in to change notification settings - Fork 2
Feat/130 fr int 06 generate follow up questions #138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||||||||||
|
|
@@ -8,6 +9,7 @@ | |||||||||||||||||
| 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; | ||||||||||||||||||
|
|
@@ -23,7 +25,10 @@ public class InterviewController { | |||||||||||||||||
|
|
||||||||||||||||||
| private final ConversationManager conversationManager; | ||||||||||||||||||
| private final InterviewManager interviewManager; | ||||||||||||||||||
| private final InterviewRepository interviewRepository; // 세션 조회를 위해 Repository 의존성 추가 | ||||||||||||||||||
| private final InterviewRepository interviewRepository; | ||||||||||||||||||
|
|
||||||||||||||||||
| // [FR-INT-08] 리포트 조회를 위한 서비스 의존성 추가 | ||||||||||||||||||
| private final InterviewReportService interviewReportService; | ||||||||||||||||||
|
|
||||||||||||||||||
| @PostMapping("/start") | ||||||||||||||||||
| public ResponseEntity<InterviewSession> startInterview(@RequestBody InterviewStartRequest request) { | ||||||||||||||||||
|
|
@@ -47,32 +52,24 @@ public ResponseEntity<InterviewSession> startInterview(@RequestBody InterviewSta | |||||||||||||||||
| public SseEmitter streamTextInterview( | ||||||||||||||||||
| @PathVariable Long sessionId, | ||||||||||||||||||
| @RequestParam String answer, | ||||||||||||||||||
| @RequestParam Long memberId, // 프론트엔드 연동용 로그인 유저 ID | ||||||||||||||||||
| @RequestParam Long memberId, | ||||||||||||||||||
| @RequestParam(required = false, defaultValue = "gemini-flash-latest") String modelVariant, | ||||||||||||||||||
| @RequestParam(required = false, defaultValue = "NORMAL") String interviewMode) { | ||||||||||||||||||
|
|
||||||||||||||||||
| // 리뷰 반영: 전용 private 메서드로 조회 및 권한 검증 위임 (코드 중복 제거 및 보안 강화) | ||||||||||||||||||
| // 리뷰 반영: 전용 private 메서드로 조회 및 권한 검증 위임 (코드 중복 제거) | ||||||||||||||||||
| InterviewSession session = validateAndGetSessionOwnership(sessionId, 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 | ||||||||||||||||||
| ); | ||||||||||||||||||
| // 리뷰 반영: sessionId(Long) 대신 검증이 끝난 session 객체를 통째로 넘기도록 수정 | ||||||||||||||||||
| conversationManager.startTextStreaming(session, answer, modelVariant, InterviewMode.from(interviewMode), emitter); | ||||||||||||||||||
| } catch (IllegalArgumentException e) { | ||||||||||||||||||
| throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return emitter; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -81,7 +78,7 @@ public VoiceSessionResponse startVoiceInterview( | |||||||||||||||||
| @PathVariable Long sessionId, | ||||||||||||||||||
| @RequestParam Long memberId) { | ||||||||||||||||||
|
|
||||||||||||||||||
| // 두 번째 리뷰 반영: startVoiceInterview 엔드포인트 역시 동일한 검증 위임 | ||||||||||||||||||
| // 리뷰 반영: 전용 private 메서드로 위임 (코드 중복 제거) | ||||||||||||||||||
| InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (session.getStatus() == InterviewSessionStatus.CREATED) { | ||||||||||||||||||
|
|
@@ -96,16 +93,33 @@ public ResponseEntity<Void> endInterview( | |||||||||||||||||
| @PathVariable Long sessionId, | ||||||||||||||||||
| @RequestParam Long memberId) { | ||||||||||||||||||
|
|
||||||||||||||||||
| // 세 번째 리뷰 반영: 면접 종료 엔드포인트 역시 동일한 검증 위임 | ||||||||||||||||||
| // 리뷰 반영: 전용 private 메서드로 위임 (코드 중복 제거) | ||||||||||||||||||
| validateAndGetSessionOwnership(sessionId, memberId); | ||||||||||||||||||
|
|
||||||||||||||||||
| interviewManager.advanceStatus(sessionId, InterviewSessionStatus.DONE); | ||||||||||||||||||
| return ResponseEntity.ok().build(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // 보안 리뷰 및 팀원 피드백 반영: IDOR 취약점 방지 및 중복 로직 제거를 위한 전용 검증 메서드 (streamTextInterview, startVoiceInterview, endInterview 모든 엔드포인트에 공통 적용됨) | ||||||||||||||||||
| // 현재는 프론트엔드 연동을 위해 @RequestParam 으로 넘겨받은 memberId를 검증에 사용 중 | ||||||||||||||||||
| // 추후 Spring Security 적용 시, 컨트롤러 파라미터를 삭제하고 @AuthenticationPrincipal SecurityContext에서 추출한 실제 로그인 사용자의 ID로 변경하여 완벽한 인가 처리를 수행 | ||||||||||||||||||
| // [FR-INT-08] 면접 결과 리포트 조회 엔드포인트 추가 | ||||||||||||||||||
| @GetMapping("/{sessionId}/report") | ||||||||||||||||||
| public ResponseEntity<InterviewReportResponse> getInterviewReport( | ||||||||||||||||||
| @PathVariable Long sessionId, | ||||||||||||||||||
| @RequestParam Long memberId) { | ||||||||||||||||||
|
|
||||||||||||||||||
| // 1. 소유권 검증: 내 면접이 맞는지 확인 (IDOR 취약점 방어) | ||||||||||||||||||
| InterviewSession session = validateAndGetSessionOwnership(sessionId, memberId); | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| // 2. 리포트 서비스 호출 (이 안에서 DONE 상태인지 검증) | ||||||||||||||||||
| InterviewReportResponse report = interviewReportService.getReport(session); | ||||||||||||||||||
| return ResponseEntity.ok(report); | ||||||||||||||||||
| } catch (IllegalStateException e) { | ||||||||||||||||||
| // 상태가 DONE이 아닐 경우 403 Forbidden 에러 반환 | ||||||||||||||||||
| throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage()); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+117
to
+119
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| //보안 리뷰 반영: IDOR 취약점 방지 및 중복 로직 제거를 위한 전용 검증 메서드 현재는 프론트엔드 연동을 위해 @RequestParam 으로 넘겨받은 memberId를 검증에 사용 중 / 추후 Spring Security 적용 시, 파라미터를 삭제하고 @AuthenticationPrincipal 또는 SecurityContext에서 추출한 실제 로그인 사용자의 ID로 authenticatedMemberId를 주입받도록 수정 | ||||||||||||||||||
| private InterviewSession validateAndGetSessionOwnership(Long sessionId, Long authenticatedMemberId) { | ||||||||||||||||||
| InterviewSession session = interviewRepository.findById(sessionId) | ||||||||||||||||||
| .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "면접 세션을 찾을 수 없습니다.")); | ||||||||||||||||||
|
|
||||||||||||||||||
| 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 |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package com.aibe.team2.domain.interview.service; | ||
|
|
||
| import com.aibe.team2.domain.interview.dto.InterviewRequestDto; | ||
| import com.aibe.team2.domain.interview.enums.InterviewMode; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.core.io.Resource; | ||
|
|
@@ -13,6 +14,7 @@ | |
| import java.io.IOException; | ||
| import java.net.URI; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.Arrays; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
|
|
@@ -43,13 +45,26 @@ public Flux<String> streamQuestion(String sessionId, InterviewRequestDto request | |
|
|
||
| String fullUrl = String.format("%s/v1beta/models/%s:streamGenerateContent?alt=sse", rootUrl, model); | ||
|
|
||
| // interviewMode.name()을 파일명으로 사용하여 분위기 프롬프트 로드 (NORMAL, FOLLOW_UP, STRESS) | ||
| String atmospherePrompt = loadPromptFile(request.getInterviewMode().name()); | ||
| String constraints = loadPromptFile("constraints"); | ||
| String finalPrompt = String.format("%s\n\n%s\n\n[Candidate Answer]\n%s", | ||
| atmospherePrompt, constraints, request.getMessage()); | ||
|
|
||
| log.info("[Gemini-Success] Session: {}, Mode: {}", sessionId, request.getInterviewMode()); | ||
| // [FR-INT-06] 이력서 기반 꼬리 질문 생성을 위한 컨텍스트 동적 주입 | ||
| String resumeContext = ""; | ||
| if (request.getResumeContent() != null && !request.getResumeContent().isBlank()) { | ||
| resumeContext = "\n\n[Candidate's Resume]\n다음은 지원자의 자기소개서 내용입니다. 이를 바탕으로 지원자의 경험을 묻는 꼬리 질문을 생성하세요.\n" + request.getResumeContent(); | ||
| } | ||
|
|
||
| // [FR-INT-07] 채용 공고 기반 맞춤형 질문 생성을 위한 컨텍스트 동적 주입 | ||
| String jobContext = ""; | ||
| if (request.getJobDescription() != null && !request.getJobDescription().isBlank()) { | ||
| jobContext = "\n\n[Job Posting Requirements]\n다음은 지원자가 지원한 채용 공고의 상세 내용(요구 역량 및 주요 업무)입니다. 이를 바탕으로 직무 적합성을 검증하는 질문을 생성하세요.\n" + request.getJobDescription(); | ||
| } | ||
|
Comment on lines
+51
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| // 최종 프롬프트 조립 (분위기 + 이력서 + 채용공고 + 제약조건 + 사용자 답변) | ||
| String finalPrompt = String.format("%s%s%s\n\n%s\n\n[Candidate Answer]\n%s", | ||
| atmospherePrompt, resumeContext, jobContext, constraints, request.getMessage()); | ||
|
||
|
|
||
| log.info("[Gemini-Streaming] Session: {}, Mode: {}", sessionId, request.getInterviewMode()); | ||
|
|
||
| return webClient.post() | ||
| .uri(URI.create(fullUrl)) | ||
|
|
@@ -64,15 +79,31 @@ public Flux<String> streamQuestion(String sessionId, InterviewRequestDto request | |
| .doOnError(e -> log.error("=== 🚨 Gemini API 호출 에러: {} ===", e.getMessage())); | ||
| } | ||
|
|
||
| //보안 리뷰 반영 (Remediation): Path Traversal 방어를 위한 허용 목록(Allow-list) 검증 | ||
| private String loadPromptFile(String fileName) { | ||
| final String currentFileName = fileName; | ||
|
|
||
| // 허용 목록 검증: Enum 상수에 있거나 'constraints'인 경우만 허용 | ||
| boolean isAllowed = Arrays.stream(InterviewMode.values()) | ||
| .anyMatch(mode -> mode.name().equals(currentFileName)) || "constraints".equals(currentFileName); | ||
|
|
||
| String targetFileName = fileName; | ||
| if (!isAllowed) { | ||
| log.error("보안 위협: 허용되지 않은 파일 이름 접근 시도 - {}", fileName); | ||
| targetFileName = "NORMAL"; | ||
| } | ||
|
|
||
| try { | ||
| Resource resource = resourceLoader.getResource("classpath:prompts/" + fileName + ".txt"); | ||
| if (!resource.exists()) resource = resourceLoader.getResource("classpath:prompts/NORMAL.txt"); | ||
| Resource resource = resourceLoader.getResource("classpath:prompts/" + targetFileName + ".txt"); | ||
| if (!resource.exists()) { | ||
| log.warn("프롬프트 파일이 존재하지 않아 기본 설정을 로드합니다: {}", targetFileName); | ||
| resource = resourceLoader.getResource("classpath:prompts/NORMAL.txt"); | ||
| } | ||
| return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); | ||
| } catch (IOException e) { | ||
| // 리뷰 반영: 예외 발생 시 로그를 남겨 근본 원인 파악이 가능하도록 수정 | ||
| log.error("Failed to load prompt file: {}", fileName, e); | ||
| return "전문 면접관으로서 지원자에게 질문을 던져주세요."; | ||
| // 리뷰 반영: 에러 로깅 시 실제 원인(e) 기록 | ||
| log.error("❌ 프롬프트 파일 로드 실패 [파일명: {}]: ", targetFileName, e); | ||
| return "면접관으로서 질문을 생성하세요."; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,47 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.aibe.team2.domain.interview.service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.aibe.team2.domain.interview.dto.InterviewReportResponse; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.aibe.team2.domain.interview.entity.InterviewSession; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Transactional(readOnly = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class InterviewReportService { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final ResumeRepository resumeRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final JobPostingRepository jobPostingRepository; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public InterviewReportResponse getReport(InterviewSession session) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 🚀 [FR-INT-08] 핵심: DONE 상태 세션만 리포트 조회가 가능해야 한다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (session.getStatus() != InterviewSessionStatus.DONE) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new IllegalStateException("면접이 정상적으로 완료된(DONE) 세션만 리포트를 조회할 수 있습니다. 현재 상태: " + session.getStatus()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 이력서 제목 조회 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String resumeTitle = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (session.getResumeId() != null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resumeTitle = resumeRepository.findById(session.getResumeId()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(Resume::getTitle) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .orElse("삭제된 이력서"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 채용 공고 제목 조회 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String jobTitle = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (session.getJobPostingId() != null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| jobTitle = jobPostingRepository.findById(session.getJobPostingId()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map(JobPosting::getJobTitle) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .orElse("삭제된 공고"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+31
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 리포트 생성 시 이력서와 채용 공고 제목을 조회할 때
Suggested change
References
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수정했습니다 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return InterviewReportResponse.of(session, resumeTitle, jobTitle); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
getInterviewReportendpoint (and others usingvalidateAndGetSessionOwnership) is vulnerable to Insecure Direct Object Reference (IDOR). ThememberIdused for ownership verification is taken directly from the request parameters (@RequestParam Long memberId) without being verified against the actual authenticated user. An attacker can access any user's interview report by providing that user'smemberIdand the correspondingsessionId. As noted in the code comments, this should be replaced with a secure authentication mechanism (e.g.,@AuthenticationPrincipalfrom Spring Security) to ensure thememberIdcorresponds to the logged-in user.References
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
수정했습니다.