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
9 changes: 9 additions & 0 deletions api/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ include::{snippetsDir}/resume-based-interview-start-voice-mode/http-response.ado
include::{snippetsDir}/resume-based-interview-start-voice-mode/response-fields.adoc[]
include::{snippetsDir}/resume-based-interview-start-voice-mode/curl-request.adoc[]

=== 이력서 기반 질문 생성 기록 목록 조회

include::{snippetsDir}/resume-question-generation-list/http-request.adoc[]
include::{snippetsDir}/resume-question-generation-list/request-headers.adoc[]
include::{snippetsDir}/resume-question-generation-list/query-parameters.adoc[]
include::{snippetsDir}/resume-question-generation-list/http-response.adoc[]
include::{snippetsDir}/resume-question-generation-list/response-fields.adoc[]
include::{snippetsDir}/resume-question-generation-list/curl-request.adoc[]

=== 인터뷰 시작 텍스트모드

include::{snippetsDir}/interview-startInterview-text-mode/http-request.adoc[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,31 @@
import com.samhap.kokomen.global.annotation.Authentication;
import com.samhap.kokomen.global.dto.MemberAuth;
import com.samhap.kokomen.global.exception.BadRequestException;
import com.samhap.kokomen.interview.domain.ResumeQuestionGenerationState;
import com.samhap.kokomen.interview.service.InterviewFacadeService;
import com.samhap.kokomen.interview.service.ResumeBasedInterviewService;
import com.samhap.kokomen.interview.service.dto.GeneratedQuestionsResponse;
import com.samhap.kokomen.interview.service.dto.QuestionGenerationStatusResponse;
import com.samhap.kokomen.interview.service.dto.QuestionGenerationStateResponse;
import com.samhap.kokomen.interview.service.dto.QuestionGenerationSubmitResponse;
import com.samhap.kokomen.interview.service.dto.ResumeBasedInterviewStartRequest;
import com.samhap.kokomen.interview.service.dto.ResumeBasedQuestionGenerateRequest;
import com.samhap.kokomen.interview.service.dto.ResumeQuestionGenerationPageResponse;
import com.samhap.kokomen.interview.service.dto.ResumeQuestionUsageStatusResponse;
import com.samhap.kokomen.interview.service.dto.start.InterviewStartResponse;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
Expand Down Expand Up @@ -68,6 +74,20 @@ public ResponseEntity<ResumeQuestionUsageStatusResponse> getUsageStatus(
return ResponseEntity.ok(response);
}

@GetMapping("/questions/generations")
public ResponseEntity<ResumeQuestionGenerationPageResponse> findMyQuestionGenerations(
@RequestParam(required = false) ResumeQuestionGenerationState state,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
@Authentication MemberAuth memberAuth
) {
ResumeQuestionGenerationPageResponse response = resumeBasedInterviewService.findMyQuestionGenerations(
memberAuth.memberId(),
state,
pageable
);
return ResponseEntity.ok(response);
}

private Long parseIdOrNull(String idStr) {
if (idStr == null || idStr.isBlank()) {
return null;
Expand All @@ -80,11 +100,11 @@ private Long parseIdOrNull(String idStr) {
}

@GetMapping("/{resumeBasedInterviewResultId}/check")
public ResponseEntity<QuestionGenerationStatusResponse> getGenerationStatus(
public ResponseEntity<QuestionGenerationStateResponse> getGenerationStatus(
@PathVariable Long resumeBasedInterviewResultId,
@Authentication MemberAuth memberAuth
) {
QuestionGenerationStatusResponse response = resumeBasedInterviewService.getQuestionGenerationStatus(
QuestionGenerationStateResponse response = resumeBasedInterviewService.getQuestionGenerationStatus(
resumeBasedInterviewResultId,
memberAuth.memberId()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import com.samhap.kokomen.global.exception.UnauthorizedException;
import com.samhap.kokomen.interview.domain.GeneratedQuestion;
import com.samhap.kokomen.interview.domain.ResumeQuestionGeneration;
import com.samhap.kokomen.interview.domain.ResumeQuestionGenerationState;
import com.samhap.kokomen.interview.repository.GeneratedQuestionRepository;
import com.samhap.kokomen.interview.repository.ResumeQuestionGenerationRepository;
import com.samhap.kokomen.interview.service.dto.GeneratedQuestionsResponse;
import com.samhap.kokomen.interview.service.dto.QuestionGenerationStatusResponse;
import com.samhap.kokomen.interview.service.dto.QuestionGenerationStateResponse;
import com.samhap.kokomen.interview.service.dto.QuestionGenerationSubmitResponse;
import com.samhap.kokomen.interview.service.dto.ResumeBasedQuestionGenerateRequest;
import com.samhap.kokomen.interview.service.dto.ResumeQuestionGenerationPageResponse;
import com.samhap.kokomen.interview.service.dto.ResumeQuestionGenerationResponse;
import com.samhap.kokomen.interview.service.dto.ResumeQuestionUsageStatusResponse;
import com.samhap.kokomen.member.domain.Member;
import com.samhap.kokomen.member.repository.MemberRepository;
Expand All @@ -22,6 +25,8 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -31,6 +36,10 @@
public class ResumeBasedInterviewService {

private static final int RESUME_QUESTION_GENERATION_TOKEN_COST = 5;
private static final List<ResumeQuestionGenerationState> DEFAULT_FILTER_STATES = List.of(
ResumeQuestionGenerationState.PENDING,
ResumeQuestionGenerationState.COMPLETED
);

private final ResumeQuestionGenerationRepository resumeQuestionGenerationRepository;
private final GeneratedQuestionRepository generatedQuestionRepository;
Expand Down Expand Up @@ -70,19 +79,67 @@ public QuestionGenerationSubmitResponse submitQuestionGeneration(
return new QuestionGenerationSubmitResponse(savedGeneration.getId());
}

private Member readMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new UnauthorizedException("존재하지 않는 회원입니다."));
}

private MemberResume findMemberResume(Long memberId, Long resumeId) {
if (resumeId == null) {
return null;
}
return memberResumeRepository.findByIdAndMemberId(resumeId, memberId).orElse(null);
}

private MemberPortfolio findMemberPortfolio(Long memberId, Long portfolioId) {
if (portfolioId == null) {
return null;
}
return memberPortfolioRepository.findByIdAndMemberId(portfolioId, memberId).orElse(null);
}
Comment on lines +87 to +99
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

잘못된 resume_id/portfolio_id가 조용히 무시됩니다.
ID가 제공됐는데 소유/존재 확인이 실패하면 null로 처리되어 요청이 성공 처리될 수 있습니다. 명시적으로 400을 반환하는 것이 안전합니다.

🔧 수정 제안
-        return memberResumeRepository.findByIdAndMemberId(resumeId, memberId).orElse(null);
+        return memberResumeRepository.findByIdAndMemberId(resumeId, memberId)
+                .orElseThrow(() -> new BadRequestException("존재하지 않는 이력서입니다."));
...
-        return memberPortfolioRepository.findByIdAndMemberId(portfolioId, memberId).orElse(null);
+        return memberPortfolioRepository.findByIdAndMemberId(portfolioId, memberId)
+                .orElseThrow(() -> new BadRequestException("존재하지 않는 포트폴리오입니다."));
🤖 Prompt for AI Agents
In
`@api/src/main/java/com/samhap/kokomen/interview/service/ResumeBasedInterviewService.java`
around lines 83 - 95, The helper methods findMemberResume and
findMemberPortfolio currently return null when an ID is provided but the entity
isn't found, silently allowing success; change them to throw a 400-level
exception instead (e.g., ResponseStatusException(HttpStatus.BAD_REQUEST, ...) or
your project's BadRequestException) when resumeId/portfolioId is non-null and
repository.findByIdAndMemberId(...).isEmpty(), and include a clear message like
"resume_id not found or not owned by member" / "portfolio_id not found or not
owned by member" so callers stop processing invalid IDs.


@Transactional(readOnly = true)
public ResumeQuestionUsageStatusResponse getUsageStatus(Long memberId) {
return ResumeQuestionUsageStatusResponse.of(isFirstUse(memberId));
}

private boolean isFirstUse(Long memberId) {
return !resumeQuestionGenerationRepository.existsByMemberId(memberId);
}

@Transactional(readOnly = true)
public ResumeQuestionGenerationPageResponse findMyQuestionGenerations(
Long memberId,
ResumeQuestionGenerationState state,
Pageable pageable
) {
Page<ResumeQuestionGeneration> page = getResumeQuestionGenerations(memberId, state, pageable);

List<ResumeQuestionGenerationResponse> data = page.getContent().stream()
.map(ResumeQuestionGenerationResponse::from)
.toList();
return ResumeQuestionGenerationPageResponse.of(data, page);
}

private Page<ResumeQuestionGeneration> getResumeQuestionGenerations(
Long memberId,
ResumeQuestionGenerationState state,
Pageable pageable
) {
if (state == null) {
return resumeQuestionGenerationRepository.findByMemberIdAndStateIn(memberId, DEFAULT_FILTER_STATES, pageable);
}
return resumeQuestionGenerationRepository.findByMemberIdAndState(memberId, state, pageable);
}

@Transactional(readOnly = true)
public QuestionGenerationStatusResponse getQuestionGenerationStatus(Long generationId, Long memberId) {
public QuestionGenerationStateResponse getQuestionGenerationStatus(Long generationId, Long memberId) {
ResumeQuestionGeneration generation = resumeQuestionGenerationRepository.findById(generationId)
.orElseThrow(() -> new BadRequestException("존재하지 않는 질문 생성 요청입니다."));
if (!generation.isOwner(memberId)) {
throw new ForbiddenException("본인의 질문 생성 요청만 조회할 수 있습니다.");
}
return QuestionGenerationStatusResponse.of(generation.getState());
return QuestionGenerationStateResponse.of(generation.getState());
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -118,27 +175,4 @@ public GeneratedQuestion readGeneratedQuestion(Long questionId, Long generationI
}
return question;
}

private Member readMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new UnauthorizedException("존재하지 않는 회원입니다."));
}

private MemberResume findMemberResume(Long memberId, Long resumeId) {
if (resumeId == null) {
return null;
}
return memberResumeRepository.findByIdAndMemberId(resumeId, memberId).orElse(null);
}

private MemberPortfolio findMemberPortfolio(Long memberId, Long portfolioId) {
if (portfolioId == null) {
return null;
}
return memberPortfolioRepository.findByIdAndMemberId(portfolioId, memberId).orElse(null);
}

private boolean isFirstUse(Long memberId) {
return !resumeQuestionGenerationRepository.existsByMemberId(memberId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.samhap.kokomen.interview.service.dto;

import com.samhap.kokomen.resume.domain.MemberPortfolio;

public record PortfolioInfo(String name, String url) {

public static PortfolioInfo fromNullable(MemberPortfolio portfolio) {
if (portfolio == null) {
return null;
}
return new PortfolioInfo(portfolio.getTitle(), portfolio.getPortfolioUrl());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.samhap.kokomen.interview.service.dto;

import com.samhap.kokomen.interview.domain.ResumeQuestionGenerationState;

public record QuestionGenerationStateResponse(
ResumeQuestionGenerationState state
) {
public static QuestionGenerationStateResponse of(ResumeQuestionGenerationState state) {
return new QuestionGenerationStateResponse(state);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.samhap.kokomen.interview.service.dto;

import com.samhap.kokomen.resume.domain.MemberResume;

public record ResumeInfo(String name, String url) {

public static ResumeInfo fromNullable(MemberResume resume) {
if (resume == null) {
return null;
}
return new ResumeInfo(resume.getTitle(), resume.getResumeUrl());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.samhap.kokomen.interview.service.dto;

import java.util.List;
import org.springframework.data.domain.Page;

public record ResumeQuestionGenerationPageResponse(
List<ResumeQuestionGenerationResponse> data,
int currentPage,
long totalCount,
int totalPages,
boolean hasNext
) {
public static ResumeQuestionGenerationPageResponse of(
List<ResumeQuestionGenerationResponse> data,
Page<?> page
) {
return new ResumeQuestionGenerationPageResponse(
data,
page.getNumber(),
page.getTotalElements(),
page.getTotalPages(),
page.hasNext()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.samhap.kokomen.interview.service.dto;

import com.samhap.kokomen.interview.domain.ResumeQuestionGeneration;
import com.samhap.kokomen.interview.domain.ResumeQuestionGenerationState;
import java.time.LocalDateTime;

public record ResumeQuestionGenerationResponse(
Long id,
String jobCareer,
ResumeQuestionGenerationState state,
LocalDateTime createdAt,
ResumeInfo resume,
PortfolioInfo portfolio
) {

public static ResumeQuestionGenerationResponse from(ResumeQuestionGeneration generation) {
return new ResumeQuestionGenerationResponse(
generation.getId(),
generation.getJobCareer(),
generation.getState(),
generation.getCreatedAt(),
ResumeInfo.fromNullable(generation.getMemberResume()),
PortfolioInfo.fromNullable(generation.getMemberPortfolio())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.samhap.kokomen.global.fixture.interview;

import com.samhap.kokomen.interview.domain.ResumeQuestionGeneration;
import com.samhap.kokomen.interview.domain.ResumeQuestionGenerationState;
import com.samhap.kokomen.member.domain.Member;
import com.samhap.kokomen.resume.domain.MemberPortfolio;
import com.samhap.kokomen.resume.domain.MemberResume;

public class ResumeQuestionGenerationFixtureBuilder {

private Member member;
private MemberResume memberResume;
private MemberPortfolio memberPortfolio;
private String jobCareer;
private ResumeQuestionGenerationState state;

public static ResumeQuestionGenerationFixtureBuilder builder() {
return new ResumeQuestionGenerationFixtureBuilder();
}

public ResumeQuestionGenerationFixtureBuilder member(Member member) {
this.member = member;
return this;
}

public ResumeQuestionGenerationFixtureBuilder memberResume(MemberResume memberResume) {
this.memberResume = memberResume;
return this;
}

public ResumeQuestionGenerationFixtureBuilder memberPortfolio(MemberPortfolio memberPortfolio) {
this.memberPortfolio = memberPortfolio;
return this;
}

public ResumeQuestionGenerationFixtureBuilder jobCareer(String jobCareer) {
this.jobCareer = jobCareer;
return this;
}

public ResumeQuestionGenerationFixtureBuilder state(ResumeQuestionGenerationState state) {
this.state = state;
return this;
}

public ResumeQuestionGeneration build() {
ResumeQuestionGeneration generation = new ResumeQuestionGeneration(
member,
memberResume,
memberPortfolio,
jobCareer != null ? jobCareer : "신입"
);

if (state == ResumeQuestionGenerationState.COMPLETED) {
generation.complete();
} else if (state == ResumeQuestionGenerationState.FAILED) {
generation.fail();
}
Comment on lines +46 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

필수 필드 기본값 또는 명시적 검증 추가를 고려해 주세요.
member(또는 memberResume)가 도메인에서 필수라면, 현재 builder는 null로 생성되어 테스트가 애매하게 실패할 수 있습니다. 다른 fixture builder처럼 기본값을 넣거나 requireNonNull로 빠른 실패를 유도하는 편이 안전합니다.

🤖 Prompt for AI Agents
In
`@api/src/test/java/com/samhap/kokomen/global/fixture/interview/ResumeQuestionGenerationFixtureBuilder.java`
around lines 46 - 58, The builder ResumeQuestionGenerationFixtureBuilder.build()
currently allows null for required fields like member and memberResume; either
provide sensible defaults (as other fixture builders do) for member and
memberResume inside the builder or add explicit null-checks (e.g.,
requireNonNull) at the start of build() to fail fast; update the build() method
to validate member and memberResume before constructing ResumeQuestionGeneration
and reference the class/method names
(ResumeQuestionGenerationFixtureBuilder.build, member, memberResume,
ResumeQuestionGeneration) when making the change.


return generation;
}
}
Loading