diff --git a/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java b/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java index ccea4e9..6bfa3e2 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/controller/AiMetaDataController.java @@ -1,6 +1,8 @@ package repit.repit_api_server.domain.metadata.controller; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -18,6 +20,7 @@ import repit.repit_api_server.global.client.AiServerClient; import repit.repit_api_server.global.client.AuthServerClient; import repit.repit_api_server.global.common.ApiResponse; +import repit.repit_api_server.global.exception.ExternalApiException; import repit.repit_api_server.global.response.UserResponse; import java.io.IOException; @@ -27,6 +30,9 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/ai") public class AiMetaDataController { + + private static final Logger log = LoggerFactory.getLogger(AiMetaDataController.class); + private static final long SSE_TIMEOUT = 10 * 60 * 1000L; // 10분 @Value("${app.callback-base-url}") @@ -42,6 +48,16 @@ public class AiMetaDataController { @GetMapping("/subscribe/{jobId}") public SseEmitter subscribe(@PathVariable String jobId) { SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); + + // 콜백이 구독보다 먼저 도착했을 수 있다. 그때는 붙는 즉시 결과를 돌려주고 끝낸다. + // 되짚어주지 않으면 이미 끝난 작업을 구독한 클라이언트는 아무것도 받지 못한 채 타임아웃까지 + // 매달려 있고, EventSource가 그때마다 다시 붙어 재연결만 반복한다. + CallbackSuccessResponse finished = aiMetaDataService.findFinished(jobId); + if (finished != null) { + sendCompletionEvent(emitter, jobId, finished); + return emitter; + } + sseEmitterRepository.save(jobId, emitter); emitter.onCompletion(() -> sseEmitterRepository.remove(jobId)); @@ -103,13 +119,35 @@ public ResponseEntity generateMock( return ResponseEntity.ok(response); } - // 이후 채팅 서버 요청에서 jobId를 서버가 직접 찾을 수 있도록 소유자를 기록해둔다. + /** + * 이후 채팅 서버 요청에서 jobId를 서버가 직접 찾을 수 있도록 소유자를 기록해둔다. + * + *

소유자를 남기지 못하면 이 분석 결과는 사용자로 되찾을 수 없어 면접 질문 재작성이 + * 예전 결과를 집어 든다. 그래서 실패를 조용히 넘기지 않고 반드시 로그로 남긴다. + * + *

다만 이 시점에는 분석 서버가 이미 작업을 접수한 뒤다. 소유자 기록이 실패했다고 요청 + * 전체를 실패시키면 클라이언트가 jobId를 받지 못해 결과를 영영 조회할 수 없게 되므로, + * 기록 실패는 예외로 번지지 않게 막는다. + */ private void registerJobOwner(String authorization, GenerateResponse response) { - UserResponse user = authServerClient.getUser(authorization); - if (user == null) { - return; + if (response == null || response.getJob_id() == null) { + // jobId가 없으면 구독도 조회도 할 수 없다. 성공으로 돌려주면 원인을 찾을 수 없다. + log.error("분석 서버 응답에 job_id가 없습니다. status={}, message={}", + response == null ? null : response.getStatus(), + response == null ? null : response.getMessage()); + throw new ExternalApiException("분석 서버가 작업 번호를 돌려주지 않았습니다.", null, null); + } + + try { + UserResponse user = authServerClient.getUser(authorization); + if (user == null || user.getId() == null) { + log.error("분석 작업의 소유자를 확인하지 못했습니다. jobId={}", response.getJob_id()); + return; + } + aiMetaDataService.registerJob(response.getJob_id(), user.getId()); + } catch (RuntimeException e) { + log.error("분석 작업의 소유자를 기록하지 못했습니다. jobId={}", response.getJob_id(), e); } - aiMetaDataService.registerJob(response.getJob_id(), user.getId()); } @PostMapping("/callback") @@ -132,9 +170,13 @@ public ApiResponse callback( private void sendCompletionEvent(String jobId, CallbackSuccessResponse response) { SseEmitter emitter = sseEmitterRepository.get(jobId); if (emitter == null) { + // 아직 아무도 구독하지 않았다. 결과는 DB에 있으니 구독이 붙을 때 되짚어 보낸다. return; } + sendCompletionEvent(emitter, jobId, response); + } + private void sendCompletionEvent(SseEmitter emitter, String jobId, CallbackSuccessResponse response) { String eventName = "succeeded".equalsIgnoreCase(response.getStatus()) ? "question-generated" : "question-generation-failed"; diff --git a/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java b/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java index 8f92fcd..64ae189 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/entity/AnalysisDataEntity.java @@ -2,6 +2,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AllArgsConstructor; @@ -12,6 +14,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import repit.repit_api_server.domain.metadata.entity.enums.AnalysisStatus; import java.time.LocalDateTime; @@ -30,10 +33,22 @@ public class AnalysisDataEntity { @Column(name = "user_id") private Long userId; + // 콜백이 오기 전에는 PENDING이다. 실패한 작업과 아직 끝나지 않은 작업을 구분하려면 이 값이 필요하다. + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private AnalysisStatus status = AnalysisStatus.PENDING; + @JdbcTypeCode(SqlTypes.JSON) @Column(columnDefinition = "jsonb") private Object result; + // 실패 콜백에만 채워진다. + private Integer errorStatusCode; + + @Column(columnDefinition = "TEXT") + private String errorMessage; + @CreationTimestamp @Column(nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/repit/repit_api_server/domain/metadata/entity/enums/AnalysisStatus.java b/src/main/java/repit/repit_api_server/domain/metadata/entity/enums/AnalysisStatus.java new file mode 100644 index 0000000..effbf2b --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/entity/enums/AnalysisStatus.java @@ -0,0 +1,8 @@ +package repit.repit_api_server.domain.metadata.entity.enums; + +/** 분석 작업의 상태. 콜백이 오기 전까지는 PENDING이다. */ +public enum AnalysisStatus { + PENDING, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java b/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java index 59d5286..f61dd13 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/repository/AnalysisDataRepository.java @@ -1,6 +1,9 @@ package repit.repit_api_server.domain.metadata.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import repit.repit_api_server.domain.metadata.entity.AnalysisDataEntity; import java.util.Optional; @@ -9,4 +12,9 @@ public interface AnalysisDataRepository extends JpaRepository findTopByUserIdAndResultIsNotNullOrderByCreatedAtDesc(Long userId); + + // 소유자만 갱신한다. 엔티티를 통째로 저장하면 콜백이 먼저 채워둔 result를 덮어쓸 수 있다. + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update AnalysisDataEntity a set a.userId = :userId where a.jobId = :jobId") + int updateUserId(@Param("jobId") String jobId, @Param("userId") Long userId); } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java index ab76e89..54cd5d0 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/AiMetaDataService.java @@ -1,39 +1,128 @@ package repit.repit_api_server.domain.metadata.service; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import repit.repit_api_server.domain.metadata.dto.request.CallbackSuccessRequest; +import repit.repit_api_server.domain.metadata.dto.response.CallbackSuccessResponse; import repit.repit_api_server.domain.metadata.dto.response.ResultResponse; import repit.repit_api_server.domain.metadata.entity.AnalysisDataEntity; +import repit.repit_api_server.domain.metadata.entity.enums.AnalysisStatus; import repit.repit_api_server.domain.metadata.repository.AnalysisDataRepository; @Service @RequiredArgsConstructor public class AiMetaDataService { + private static final Logger log = LoggerFactory.getLogger(AiMetaDataService.class); + + private static final String STATUS_SUCCEEDED = "succeeded"; + private static final String STATUS_FAILED = "failed"; + private final AnalysisDataRepository analysisDataRepository; - // 분석 요청 시점에 작업 소유자를 먼저 기록해둔다. 결과는 콜백에서 채워진다. + /** + * 분석 요청 시점에 작업 소유자를 먼저 기록해둔다. 결과는 콜백에서 채워진다. + * + *

분석 서버가 빠르면 이 메서드보다 콜백이 먼저 도착할 수 있다. 그때 행 전체를 저장하면 + * 아직 비어 있는 result로 이미 받아둔 결과를 덮어쓰게 되므로, 소유자 컬럼만 갱신하고 + * 행이 없을 때만 새로 만든다. + */ + @Transactional public void registerJob(String jobId, Long userId) { if (jobId == null || userId == null) { return; } - AnalysisDataEntity data = analysisDataRepository.findById(jobId) - .orElseGet(() -> AnalysisDataEntity.builder().jobId(jobId).build()); - data.setUserId(userId); - analysisDataRepository.save(data); + if (analysisDataRepository.updateUserId(jobId, userId) == 0) { + analysisDataRepository.save(AnalysisDataEntity.builder() + .jobId(jobId) + .userId(userId) + .build()); + } } + /** + * 분석 결과 콜백을 저장한다. 재전송이 있을 수 있어 두 번 받아도 안전해야 한다. + * + *

실패 콜백에는 result가 없다. status를 보지 않고 그대로 덮어쓰면 먼저 받아둔 성공 + * 결과까지 지워지므로, 성공 콜백일 때만 결과를 저장한다. + */ @Transactional public void saveResult(CallbackSuccessRequest request) { + String jobId = request.getJob_id(); + if (jobId == null) { + log.warn("job_id 없는 분석 콜백을 받았습니다. status={}", request.getStatus()); + return; + } + // registerJob으로 이미 저장된 행이 있으면 userId를 유지한 채 결과만 채운다. - AnalysisDataEntity data = analysisDataRepository.findById(request.getJob_id()) - .orElseGet(() -> AnalysisDataEntity.builder().jobId(request.getJob_id()).build()); - data.setResult(request.getResult()); + AnalysisDataEntity data = analysisDataRepository.findById(jobId) + .orElseGet(() -> AnalysisDataEntity.builder().jobId(jobId).build()); + + if (STATUS_SUCCEEDED.equalsIgnoreCase(request.getStatus()) && request.getResult() != null) { + data.setStatus(AnalysisStatus.SUCCEEDED); + data.setResult(request.getResult()); + data.setErrorStatusCode(null); + data.setErrorMessage(null); + analysisDataRepository.save(data); + return; + } + + // 이미 결과를 받아둔 작업이라면 뒤늦은 실패 콜백에 그 결과를 잃을 이유가 없다. + if (data.getStatus() == AnalysisStatus.SUCCEEDED) { + log.warn("이미 성공한 분석에 실패 콜백이 도착해 무시합니다. jobId={}, error={}", + jobId, describeError(request.getError())); + return; + } + + log.warn("분석에 실패했습니다. jobId={}, status={}, error={}", + jobId, request.getStatus(), describeError(request.getError())); + data.setStatus(AnalysisStatus.FAILED); + if (request.getError() != null) { + data.setErrorStatusCode(request.getError().getStatus_code()); + data.setErrorMessage(request.getError().getMessage()); + } analysisDataRepository.save(data); } + /** + * 이미 끝난 작업이면 콜백과 같은 모양으로 돌려준다. 아직 진행 중이거나 모르는 작업이면 null. + * + *

구독이 콜백보다 늦게 붙는 경우가 있다. 그때 SSE로 흘릴 것이 없으면 클라이언트는 + * 영영 아무것도 받지 못하므로, 저장해둔 결과로 되짚어준다. + */ + @Transactional(readOnly = true) + public CallbackSuccessResponse findFinished(String jobId) { + return analysisDataRepository.findById(jobId) + .filter(data -> data.getStatus() != AnalysisStatus.PENDING) + .map(data -> CallbackSuccessResponse.builder() + .job_id(data.getJobId()) + .status(data.getStatus() == AnalysisStatus.SUCCEEDED ? STATUS_SUCCEEDED : STATUS_FAILED) + .result(data.getResult()) + .error(toError(data)) + .build()) + .orElse(null); + } + + private CallbackSuccessRequest.Error toError(AnalysisDataEntity data) { + if (data.getErrorStatusCode() == null && data.getErrorMessage() == null) { + return null; + } + return CallbackSuccessRequest.Error.builder() + .status_code(data.getErrorStatusCode()) + .message(data.getErrorMessage()) + .build(); + } + + private String describeError(CallbackSuccessRequest.Error error) { + if (error == null) { + return null; + } + return error.getStatus_code() + " " + error.getMessage(); + } + /** * 저장된 분석 결과를 원형 그대로 돌려준다. * 면접에 쓸 질문은 재작성이 끝나는 시점에 이 서버가 채팅 서버로 직접 넘기므로, diff --git a/src/main/java/repit/repit_api_server/domain/userdata/answer/entity/AnswerEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/answer/entity/AnswerEntity.java index ae09a98..1fa4894 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/answer/entity/AnswerEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/answer/entity/AnswerEntity.java @@ -33,7 +33,8 @@ public class AnswerEntity { @Column(nullable = false) private int responseTime; - @Column(nullable = false) + // 모의면접 답변은 길다. 255자로 자르면 그대로 피드백 품질이 깎인다. + @Column(nullable = false, columnDefinition = "TEXT") private String content; @CreationTimestamp diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java index 16dccb3..a3c012e 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequest.java @@ -25,6 +25,8 @@ public class FeedbackCallbackRequest { @AllArgsConstructor public static class Result { private Overall overall; + // N:1 면접에만 실린다. 1:1 콜백에는 없다. + private List personas; private List feedbacks; } @@ -43,11 +45,28 @@ public static class Overall { private Integer questionCount; } + /** 면접관별 종합. 문항이 2~3개뿐이라 점수는 하나만 온다. */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class Persona { + private Long personaId; + private String personaRole; + private Integer score; + private String comment; + private List strengths; + private List improvements; + private Integer answeredCount; + private Integer questionCount; + } + @Getter @NoArgsConstructor @AllArgsConstructor public static class Item { private String questionId; + // N:1에서 이 질문을 던진 면접관. 1:1 콜백에는 없다. + private Long personaId; private String questionContent; private String intention; private String userAnswer; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java index 4902f70..5c01fd8 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackItemResponse.java @@ -14,6 +14,7 @@ @AllArgsConstructor public class FeedbackItemResponse { private String questionId; + private Long personaId; private String questionContent; private String intention; private String userAnswer; @@ -25,6 +26,7 @@ public class FeedbackItemResponse { public static FeedbackItemResponse from(FeedbackItemEntity item) { return FeedbackItemResponse.builder() .questionId(item.getQuestionId()) + .personaId(item.getPersonaId()) .questionContent(item.getQuestionContent()) .intention(item.getIntention()) .userAnswer(item.getUserAnswer()) diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackPersonaResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackPersonaResponse.java new file mode 100644 index 0000000..93a0397 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackPersonaResponse.java @@ -0,0 +1,37 @@ +package repit.repit_api_server.domain.userdata.feedback.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackPersonaEntity; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FeedbackPersonaResponse { + private Long personaId; + private String personaRole; + private Integer score; + private String comment; + private List strengths; + private List improvements; + private Integer answeredCount; + private Integer questionCount; + + public static FeedbackPersonaResponse from(FeedbackPersonaEntity persona) { + return FeedbackPersonaResponse.builder() + .personaId(persona.getPersonaId()) + .personaRole(persona.getPersonaRole()) + .score(persona.getScore()) + .comment(persona.getComment()) + .strengths(persona.getStrengths()) + .improvements(persona.getImprovements()) + .answeredCount(persona.getAnsweredCount()) + .questionCount(persona.getQuestionCount()) + .build(); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java index bb813a5..939c6c4 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/response/FeedbackResponse.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackItemEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackPersonaEntity; import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; import java.time.LocalDateTime; @@ -34,10 +35,15 @@ public class FeedbackResponse { // 실패한 경우에만 채워진다. private String errorMessage; + // 면접관별 종합. N:1 면접에만 채워지고 1:1은 비어 있다. + private List personas; + private List feedbacks; private LocalDateTime createdAt; - public static FeedbackResponse of(FeedbackEntity feedback, List items) { + public static FeedbackResponse of(FeedbackEntity feedback, + List personas, + List items) { return FeedbackResponse.builder() .feedbackId(feedback.getFeedbackId()) .interviewId(feedback.getInterviewId()) @@ -53,6 +59,7 @@ public static FeedbackResponse of(FeedbackEntity feedback, List면접관당 문항은 원질문 2개 + 꼬리질문 최대 1개다. 그 정도로는 "답변끼리 모순이 없는가"를 + * 판단할 수 없으므로 3지표(total·intent·reliability)는 전체 계층에만 두고 여기는 점수 하나만 둔다. + */ +@Entity +@Table(name = "feedback_persona") +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FeedbackPersonaEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "feedback_persona_id") + private Long feedbackPersonaId; + + @Column(nullable = false) + private Long feedbackId; + + private Long personaId; + + // 분석 서버가 주는 직책 문자열(TECH/HR/CEO). 값 집합이 서버 간에 어긋나도 저장은 실패하지 않게 문자열로 둔다. + private String personaRole; + + // 분석 서버가 보낸 personas 순서. 그대로가 면접 진행 순서다. + @Column(nullable = false) + private Integer sortOrder; + + private Integer score; + + @Column(columnDefinition = "TEXT") + private String comment; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private List strengths; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private List improvements; + + private Integer answeredCount; + + private Integer questionCount; +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackPersonaRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackPersonaRepository.java new file mode 100644 index 0000000..66e06ac --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/repository/FeedbackPersonaRepository.java @@ -0,0 +1,13 @@ +package repit.repit_api_server.domain.userdata.feedback.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackPersonaEntity; + +import java.util.List; + +public interface FeedbackPersonaRepository extends JpaRepository { + + List findAllByFeedbackIdOrderBySortOrderAsc(Long feedbackId); + + void deleteAllByFeedbackId(Long feedbackId); +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java index d977fe4..ac10ed6 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java @@ -12,8 +12,10 @@ import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackResponse; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackItemEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackPersonaEntity; import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackItemRepository; +import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackPersonaRepository; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackRepository; import repit.repit_api_server.domain.userdata.interview.dto.response.ChatAnswerResponse; import repit.repit_api_server.domain.userdata.interview.dto.response.ChatInterviewAllResponse; @@ -48,6 +50,7 @@ public class FeedbackService { private final FeedbackRepository feedbackRepository; private final FeedbackItemRepository feedbackItemRepository; + private final FeedbackPersonaRepository feedbackPersonaRepository; private final InterviewRepository interviewRepository; private final ChatServerClient chatServerClient; private final AiServerClient aiServerClient; @@ -238,10 +241,33 @@ private FeedbackEntity findTarget(FeedbackCallbackRequest request) { return byJobId; } } - if (request.getSessionId() != null) { - return feedbackRepository.findTopBySessionIdOrderByCreatedAtDesc(request.getSessionId()).orElse(null); + if (request.getSessionId() == null) { + return null; + } + + FeedbackEntity bySession = + feedbackRepository.findTopBySessionIdOrderByCreatedAtDesc(request.getSessionId()).orElse(null); + if (bySession != null) { + return bySession; } - return null; + return createFromSession(request.getSessionId()); + } + + /** + * N:1 면접은 채팅 서버가 면접 종료 시점에 분석 서버를 직접 호출한다. + * 이 서버는 요청을 접수한 적이 없어 대응하는 행이 없으므로, 세션으로 면접을 찾아 그때 만든다. + */ + private FeedbackEntity createFromSession(String sessionId) { + InterviewEntity interview = interviewRepository.findBySessionId(sessionId).orElse(null); + if (interview == null) { + return null; + } + return feedbackRepository.save(FeedbackEntity.builder() + .interviewId(interview.getInterviewId()) + .userId(interview.getUserId()) + .sessionId(sessionId) + .status(FeedbackStatus.PENDING) + .build()); } private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Result result) { @@ -261,9 +287,12 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul feedback.setErrorStatusCode(null); feedback.setErrorMessage(null); - // 콜백이 재전송되어도 문항이 중복되지 않도록 기존 것을 지우고 다시 넣는다. + // 콜백이 재전송되어도 문항·면접관이 중복되지 않도록 기존 것을 지우고 다시 넣는다. + feedbackPersonaRepository.deleteAllByFeedbackId(feedback.getFeedbackId()); feedbackItemRepository.deleteAllByFeedbackId(feedback.getFeedbackId()); + savePersonas(feedback, result.getPersonas()); + List items = result.getFeedbacks() == null ? List.of() : result.getFeedbacks(); List entities = new ArrayList<>(); for (int i = 0; i < items.size(); i++) { @@ -272,6 +301,7 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul .feedbackId(feedback.getFeedbackId()) .questionId(Objects.toString(item.getQuestionId(), "")) .sortOrder(i) + .personaId(item.getPersonaId()) .questionContent(item.getQuestionContent()) .intention(item.getIntention()) .userAnswer(item.getUserAnswer()) @@ -284,6 +314,31 @@ private void applySuccess(FeedbackEntity feedback, FeedbackCallbackRequest.Resul feedbackItemRepository.saveAll(entities); } + /** 1:1 콜백에는 personas가 없다. 없으면 아무것도 저장하지 않는다. */ + private void savePersonas(FeedbackEntity feedback, List personas) { + if (personas == null || personas.isEmpty()) { + return; + } + + List entities = new ArrayList<>(); + for (int i = 0; i < personas.size(); i++) { + FeedbackCallbackRequest.Persona persona = personas.get(i); + entities.add(FeedbackPersonaEntity.builder() + .feedbackId(feedback.getFeedbackId()) + .personaId(persona.getPersonaId()) + .personaRole(persona.getPersonaRole()) + .sortOrder(i) + .score(persona.getScore()) + .comment(persona.getComment()) + .strengths(persona.getStrengths()) + .improvements(persona.getImprovements()) + .answeredCount(persona.getAnsweredCount()) + .questionCount(persona.getQuestionCount()) + .build()); + } + feedbackPersonaRepository.saveAll(entities); + } + private void applyFailure(FeedbackEntity feedback, FeedbackCallbackRequest.Error error) { feedback.setStatus(FeedbackStatus.FAILED); feedback.setErrorStatusCode(error == null ? null : error.getStatusCode()); @@ -303,9 +358,11 @@ public FeedbackResponse getFeedback(String authorization, Long interviewId) { // 폴링하는 클라이언트가 PENDING에 갇히지 않도록 조회 시점에도 판정한다. expireIfTimedOut(feedback); + List personas = + feedbackPersonaRepository.findAllByFeedbackIdOrderBySortOrderAsc(feedback.getFeedbackId()); List items = feedbackItemRepository.findAllByFeedbackIdOrderBySortOrderAsc(feedback.getFeedbackId()); - return FeedbackResponse.of(feedback, items); + return FeedbackResponse.of(feedback, personas, items); } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java index fd4e6bd..f34bee9 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/ChatInterviewPrepareRequest.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; @@ -41,6 +42,8 @@ public static class Persona { private String personaName; private Major major; private Type type; + // 난이도. 채팅 서버가 꼬리질문을 얼마나 깊게 파고들지 정할 때 쓴다. + private Level level; private int career; private Gender gender; } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java index 404a159..28938e9 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/request/CreateInterviewRequest.java @@ -5,14 +5,18 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.List; + /** * 면접 생성 요청. * - *

면접이 고르는 것은 페르소나 하나뿐이라 그것만 받는다. 예전에는 페르소나 등록용 DTO를 그대로 써서 + *

면접이 고르는 것은 면접관뿐이라 그것만 받는다. 예전에는 페르소나 등록용 DTO를 그대로 써서 * 전공·타입·경력·성별까지 받는 것처럼 보였지만, 실제로는 이름만 쓰고 나머지는 버렸다. * *

{@code personaId}가 있으면 그것을 쓰고, 없으면 {@code personaName}으로 찾는다. 이름은 바뀔 수 있는 * 값이라 id 쪽이 안전하지만, 웹이 이름으로 보내던 기존 방식도 그대로 받는다. + * + *

N:1 면접은 면접관이 셋이라 {@code personaIds}로 보낸다. 이 값이 있으면 위 두 값은 보지 않는다. */ @Getter @Setter @@ -21,4 +25,6 @@ public class CreateInterviewRequest { private Long personaId; private String personaName; + // N:1 면접관 3인. 기술·인사·CEO를 한 명씩 담는다. 순서는 서버가 직책 기준으로 정한다. + private List personaIds; } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java index ad94020..7f0b5f0 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/dto/response/InterviewResponse.java @@ -5,9 +5,11 @@ import lombok.NoArgsConstructor; import lombok.Setter; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import java.time.LocalDateTime; +import java.util.List; @Getter @Setter @@ -16,16 +18,26 @@ public class InterviewResponse { private Long interviewId; private Long userId; + private InterviewMode mode; + // 1:1이면 면접관 하나, N:1이면 비어 있고 personaIds가 채워진다. private Long personaId; + // N:1 면접관 목록. 진행 순서대로다. + private List personaIds; private String sessionId; private Status status; private LocalDateTime createdAt; public static InterviewResponse from(InterviewEntity interview) { + return from(interview, List.of()); + } + + public static InterviewResponse from(InterviewEntity interview, List personaIds) { return new InterviewResponse( interview.getInterviewId(), interview.getUserId(), + interview.getMode(), interview.getPersonaId(), + personaIds, interview.getSessionId(), interview.getStatus(), interview.getCreatedAt() diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java index b45adda..7f7fc48 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewEntity.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; import java.time.LocalDateTime; @@ -23,9 +24,14 @@ public class InterviewEntity { @Column(nullable = false) private Long userId; - @Column(nullable = false) + // 1:1 면접의 면접관. N:1은 면접관이 여럿이라 비어 있고, interview_persona가 대신한다. private Long personaId; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private InterviewMode mode = InterviewMode.SOLO; + @Column(nullable = false, unique = true, length = 64) private String sessionId; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewPersonaEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewPersonaEntity.java new file mode 100644 index 0000000..1321bf0 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/InterviewPersonaEntity.java @@ -0,0 +1,34 @@ +package repit.repit_api_server.domain.userdata.interview.entity; + +import jakarta.persistence.*; +import lombok.*; + +/** + * 면접에 들어가는 면접관 한 명. + * + *

N:1 면접은 면접관이 여럿이라 {@code interview.persona_id} 한 칸으로는 담을 수 없다. + * {@code personaOrder}가 면접 진행 순서이고, 질문 배열도 이 순서를 따른다. + */ +@Entity +@Table(name = "interview_persona") +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class InterviewPersonaEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "interview_persona_id") + private Long interviewPersonaId; + + @Column(nullable = false) + private Long interviewId; + + @Column(nullable = false) + private Long personaId; + + // 0부터 시작하는 진행 순서. 기술 -> 인사 -> CEO. + @Column(nullable = false) + private Integer personaOrder; +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/enums/InterviewMode.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/enums/InterviewMode.java new file mode 100644 index 0000000..48b86d8 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/entity/enums/InterviewMode.java @@ -0,0 +1,13 @@ +package repit.repit_api_server.domain.userdata.interview.entity.enums; + +/** + * 면접 방식. + * + *

{@link #SOLO}는 면접관 한 명이 처음부터 끝까지 진행한다. + * {@link #MULTI}는 한 세션 안에서 면접관 세 명(기술·인사·CEO)이 차례로 교대한다. + * 세션을 나누지 않는 이유는 면접관이 바뀐 뒤 진술이 달라지는지를 한 번에 판단하기 위해서다. + */ +public enum InterviewMode { + SOLO, + MULTI +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewPersonaRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewPersonaRepository.java new file mode 100644 index 0000000..efc0369 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewPersonaRepository.java @@ -0,0 +1,11 @@ +package repit.repit_api_server.domain.userdata.interview.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewPersonaEntity; + +import java.util.List; + +public interface InterviewPersonaRepository extends JpaRepository { + + List findAllByInterviewIdOrderByPersonaOrderAsc(Long interviewId); +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java index f6746eb..402bd28 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/repository/InterviewRepository.java @@ -1,11 +1,13 @@ package repit.repit_api_server.domain.userdata.interview.repository; import org.springframework.data.jpa.repository.JpaRepository; -import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; import java.util.List; +import java.util.Optional; public interface InterviewRepository extends JpaRepository { - List findAllByUserId(Long UserId); + List findAllByUserId(Long userId); + + Optional findBySessionId(String sessionId); } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java index bac2a7f..0866321 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffService.java @@ -55,6 +55,7 @@ private ChatInterviewPrepareRequest.Persona toPersona(PersonaEntity persona) { .personaName(persona.getPersonaName()) .major(persona.getMajor()) .type(persona.getType()) + .level(persona.getLevel()) .career(persona.getCareer()) .gender(persona.getGender()) .build(); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java index 95bb90e..b0f0b9a 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java @@ -8,8 +8,12 @@ import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewPrepareResponse; import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewPersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; @@ -22,13 +26,22 @@ import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.response.UserResponse; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class InterviewService { + // N:1 면접은 직책마다 한 명씩, 이 순서로 진행한다. + private static final List MULTI_ROLES = List.of(Role.TECH, Role.HR, Role.CEO); + private final InterviewRepository interviewRepository; private final QuestionRepository questionRepository; private final ChatServerClient chatServerClient; @@ -36,13 +49,20 @@ public class InterviewService { private final AnswerRepository answerRepository; private final PersonaRepository personaRepository; private final QuestionTailorService questionTailorService; + private final InterviewPersonaRepository interviewPersonaRepository; public InterviewResponse createInterview(String authorization, CreateInterviewRequest request) { UserResponse user = currentUser(authorization); + + if (request.getPersonaIds() != null && !request.getPersonaIds().isEmpty()) { + return createMultiInterview(user, request.getPersonaIds()); + } + PersonaEntity persona = findPersona(request); InterviewEntity interview = InterviewEntity.builder() .userId(user.getId()) + .mode(InterviewMode.SOLO) .personaId(persona.getPersonaId()) .status(Status.IN_PROGRESS) .sessionId(UUID.randomUUID().toString()) @@ -52,6 +72,67 @@ public InterviewResponse createInterview(String authorization, CreateInterviewRe return InterviewResponse.from(saved); } + /** + * N:1 면접 생성. 면접관은 기술·인사·CEO 한 명씩이다. + * + *

진행 순서는 요청 순서가 아니라 직책 순서로 정한다. 질문 배열도 이 순서를 따르고, + * 꼬리질문이 부모 질문 바로 뒤에 삽입되므로 한 면접관의 질문 묶음이 끝나야 다음 면접관으로 넘어간다. + */ + private InterviewResponse createMultiInterview(UserResponse user, List requestedIds) { + List personaIds = new ArrayList<>(new LinkedHashSet<>(requestedIds)); + if (personaIds.size() != requestedIds.size()) { + throw BusinessException.unprocessable("같은 면접관을 두 번 지정할 수 없습니다."); + } + + Map personas = personaRepository.findAllById(personaIds).stream() + .collect(Collectors.toMap(PersonaEntity::getPersonaId, Function.identity())); + for (Long personaId : personaIds) { + if (!personas.containsKey(personaId)) { + throw BusinessException.notFound("페르소나를 찾을 수 없습니다: " + personaId); + } + } + + List ordered = orderByRole(personas.values()); + + InterviewEntity saved = interviewRepository.save(InterviewEntity.builder() + .userId(user.getId()) + .mode(InterviewMode.MULTI) + .status(Status.IN_PROGRESS) + .sessionId(UUID.randomUUID().toString()) + .build()); + + List members = new ArrayList<>(); + for (int order = 0; order < ordered.size(); order++) { + members.add(InterviewPersonaEntity.builder() + .interviewId(saved.getInterviewId()) + .personaId(ordered.get(order).getPersonaId()) + .personaOrder(order) + .build()); + } + interviewPersonaRepository.saveAll(members); + + return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList()); + } + + /** 직책이 하나라도 비거나 겹치면 면접이 성립하지 않으므로 생성 시점에 막는다. */ + private List orderByRole(Iterable personas) { + Map> byRole = new EnumMap<>(Role.class); + for (PersonaEntity persona : personas) { + byRole.computeIfAbsent(persona.getRole(), role -> new ArrayList<>()).add(persona); + } + + List ordered = new ArrayList<>(); + for (Role role : MULTI_ROLES) { + List matched = byRole.get(role); + if (matched == null || matched.size() != 1) { + throw BusinessException.unprocessable( + "N:1 면접은 기술·인사·CEO 면접관을 한 명씩 지정해야 합니다."); + } + ordered.add(matched.getFirst()); + } + return ordered; + } + /** * 면접이 고르는 것은 페르소나 하나다. * id가 오면 그것을 쓰고, 없으면 이름으로 찾는다 — 이름은 바뀔 수 있어 id 쪽이 안전하지만, @@ -93,6 +174,11 @@ public InterviewPrepareResponse prepareInterview(String authorization, Long inte if (!user.getId().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 시작할 수 있습니다."); } + // N:1은 질문 재작성이 아니라 신규 생성이 섞인 multi tailor를 써야 한다. 분석 서버 스펙이 확정되기 전까지는 + // 1:1용 재작성을 태우면 인사·CEO 질문 없이 면접이 열리므로 아예 막는다. + if (interview.getMode() == InterviewMode.MULTI) { + throw BusinessException.unprocessable("N:1 면접 시작은 아직 준비 중입니다."); + } QuestionTailorEntity tailor = questionTailorService.requestTailor(interview, user); return InterviewPrepareResponse.of(tailor, interview.getSessionId(), prepareMessage(tailor)); @@ -111,13 +197,26 @@ private String prepareMessage(QuestionTailorEntity tailor) { public List getAllInterviewsByUserId(String authorization) { UserResponse user = authServerClient.getUser(authorization); - return interviewRepository.findAllByUserId(user.getId()); + return interviewRepository.findAllByUserId(user.getId()).stream() + .map(interview -> InterviewResponse.from(interview, personaIdsOf(interview))) + .toList(); } public InterviewResponse getInterviewById(Long interviewId) { InterviewEntity interview = interviewRepository.findById(interviewId).orElse(null); assert interview != null; - return InterviewResponse.from(interview); + return InterviewResponse.from(interview, personaIdsOf(interview)); + } + + /** N:1 면접관 목록. 1:1은 interview.personaId 하나로 끝나므로 조회하지 않는다. */ + private List personaIdsOf(InterviewEntity interview) { + if (interview.getMode() != InterviewMode.MULTI) { + return List.of(); + } + return interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(interview.getInterviewId()) + .stream() + .map(InterviewPersonaEntity::getPersonaId) + .toList(); } public ChatInterviewAllResponse getChatInterview(Long interviewId) { diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/request/PersonaRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/request/PersonaRequest.java index f5644b0..bb23288 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/request/PersonaRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/request/PersonaRequest.java @@ -5,7 +5,9 @@ import lombok.NoArgsConstructor; import lombok.Setter; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; @Getter @@ -16,10 +18,17 @@ public class PersonaRequest { private String personaName; + // 필수. TECH / HR / CEO. + private Role role; + + // 기술 면접관에게만 필요하다. 인사팀·CEO는 비워 보낸다. private Major major; private Type type; + // 필수. EASY / NORMAL / HARD. + private Level level; + private int career; private Gender gender; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java index 7dbc774..0440428 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/dto/response/PersonaResponse.java @@ -3,7 +3,9 @@ import lombok.*; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; @Getter @@ -13,8 +15,10 @@ public class PersonaResponse { private Long personaId; private String personaName; + private Role role; private Major major; private Type type; + private Level level; private int career; private Gender gender; @@ -23,8 +27,10 @@ public static PersonaResponse from(PersonaEntity persona) { return new PersonaResponse( persona.getPersonaId(), persona.getPersonaName(), + persona.getRole(), persona.getMajor(), persona.getType(), + persona.getLevel(), persona.getCareer(), persona.getGender() ); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java index 24747c8..f4529e3 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/PersonaEntity.java @@ -6,7 +6,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; @Entity @@ -24,14 +26,24 @@ public class PersonaEntity { @Column(name = "name", nullable = false, unique = true) private String personaName; + // 직책. 기본값을 두지 않는다 — 안 채우고 저장하면 TECH로 묻히는 대신 NOT NULL로 걸린다. @Enumerated(EnumType.STRING) @Column(nullable = false) + private Role role; + + // 기술 면접관의 세부 전공. 인사팀·CEO에게는 해당 값이 없어 비어 있다. + @Enumerated(EnumType.STRING) private Major major; @Enumerated(EnumType.STRING) @Column(nullable = false) private Type type; + // 난이도. 직책과 마찬가지로 기본값을 두지 않는다. + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Level level; + @Column(nullable = false) private int career; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Level.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Level.java new file mode 100644 index 0000000..4ce932d --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Level.java @@ -0,0 +1,13 @@ +package repit.repit_api_server.domain.userdata.persona.entity.enums; + +/** + * 면접 난이도. 말투(type)·직책(role)과는 독립된 축이다. + * + *

같은 직책이라도 얼마나 깊게 파고드는지가 달라진다 — 압박형 CEO가 쉬울 수도, 친화형 기술 면접관이 + * 어려울 수도 있어야 한다. + */ +public enum Level { + EASY, + NORMAL, + HARD +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Role.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Role.java new file mode 100644 index 0000000..94f724c --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/entity/enums/Role.java @@ -0,0 +1,13 @@ +package repit.repit_api_server.domain.userdata.persona.entity.enums; + +/** + * 면접관의 직책. 말투(type)와는 독립된 축이다. + * + *

1:1 면접은 기술 면접관 한 명뿐이라 전부 {@link #TECH}다. N:1 면접은 세 직책이 한 명씩 들어간다. + * 직책은 채점 관점에 반영된다 — 기술은 근거와 트레이드오프, 인사는 동기의 구체성, CEO는 우선순위 판단. + */ +public enum Role { + TECH, + HR, + CEO +} diff --git a/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java b/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java index 72ffb57..4938477 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/persona/service/PersonaService.java @@ -5,7 +5,9 @@ import repit.repit_api_server.domain.userdata.persona.dto.request.PersonaRequest; import repit.repit_api_server.domain.userdata.persona.dto.response.PersonaResponse; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.global.exception.BusinessException; import java.util.List; @@ -16,10 +18,26 @@ public class PersonaService { private final PersonaRepository personaRepository; public PersonaResponse createPersona(PersonaRequest request) { + // 직책과 난이도에는 기본값을 두지 않는다. 조용히 채워 넣으면 인사·CEO 면접관이 + // 기술 면접관으로 저장되고, 저장된 뒤에는 무엇이 요청값이고 무엇이 기본값인지 구분할 방법이 없다. + if (request.getRole() == null) { + throw BusinessException.unprocessable("면접관 직책(role)을 지정해주세요."); + } + if (request.getLevel() == null) { + throw BusinessException.unprocessable("면접 난이도(level)를 지정해주세요."); + } + Role role = request.getRole(); + // 전공은 기술 면접관에게만 있는 값이다. DB CHECK와 같은 규칙을 여기서 먼저 걸러 메시지를 남긴다. + if (role == Role.TECH && request.getMajor() == null) { + throw BusinessException.unprocessable("기술 면접관에게는 전공(major)이 필요합니다."); + } + PersonaEntity persona = PersonaEntity.builder() .personaName(request.getPersonaName()) + .role(role) .type(request.getType()) - .major(request.getMajor()) + .level(request.getLevel()) + .major(role == Role.TECH ? request.getMajor() : null) .career(request.getCareer()) .gender(request.getGender()) .build(); diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java index f63d13c..9cf1263 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionEntity.java @@ -29,6 +29,9 @@ public class QuestionEntity { @Column() private Long parentId; + // 이 질문을 던진 면접관. 1:1은 면접관이 하나뿐이라 비어 있다. + private Long personaId; + @Enumerated(EnumType.STRING) @Column(nullable = false) private Type type; diff --git a/src/main/resources/db/migration/V10__add_analysis_data_status.sql b/src/main/resources/db/migration/V10__add_analysis_data_status.sql new file mode 100644 index 0000000..7a147bd --- /dev/null +++ b/src/main/resources/db/migration/V10__add_analysis_data_status.sql @@ -0,0 +1,16 @@ +-- 분석 작업의 상태와 실패 사유를 남긴다. +-- 지금까지는 result 유무로만 짐작할 수 있어 "실패한 작업"과 "아직 끝나지 않은 작업"이 구분되지 않았다. +-- 구독이 콜백보다 늦게 붙어도 결과를 돌려주려면 이 구분이 필요하다. +-- 기본값을 두어 상태를 모르는 구버전이 콜백을 받아도 저장이 깨지지 않게 한다. +ALTER TABLE analysis_data + ADD COLUMN status VARCHAR(255) NOT NULL DEFAULT 'PENDING', + ADD COLUMN error_status_code INTEGER, + ADD COLUMN error_message TEXT; + +-- 결과가 이미 들어 있는 기존 행은 성공한 작업이다. +UPDATE analysis_data +SET status = 'SUCCEEDED' +WHERE result IS NOT NULL; + +ALTER TABLE analysis_data + ADD CONSTRAINT analysis_data_status_check CHECK (status IN ('PENDING', 'SUCCEEDED', 'FAILED')); diff --git a/src/main/resources/db/migration/V7__drop_persona_level.sql b/src/main/resources/db/migration/V7__drop_persona_level.sql new file mode 100644 index 0000000..bf37306 --- /dev/null +++ b/src/main/resources/db/migration/V7__drop_persona_level.sql @@ -0,0 +1,4 @@ +-- 페르소나에서 더 이상 쓰지 않는 난이도(level) 컬럼을 제거한다. +-- 베이스라인(V1) 이전에 생성된 DB에만 남아 있을 수 있어 IF EXISTS로 처리한다. +ALTER TABLE persona + DROP COLUMN IF EXISTS level; diff --git a/src/main/resources/db/migration/V8__add_multi_interview.sql b/src/main/resources/db/migration/V8__add_multi_interview.sql new file mode 100644 index 0000000..814ac12 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_multi_interview.sql @@ -0,0 +1,76 @@ +-- N:1 면접(면접관 3인)을 담기 위한 스키마 변경. +-- 1:1 면접은 그대로 돌아가야 하므로 기존 컬럼은 지우지 않고 제약만 완화한다. + +-- 페르소나에 직책 축을 신설한다. +-- 말투(type: FRIENDLY/NEUTRAL/STRESS)와 직책(role)은 독립이다. CEO가 압박형일 수도 친화형일 수도 있어야 한다. +ALTER TABLE persona + ADD COLUMN role VARCHAR(255) NOT NULL DEFAULT 'TECH'; + +ALTER TABLE persona + ADD CONSTRAINT persona_role_check CHECK (role IN ('TECH', 'HR', 'CEO')); + +-- major(BACKEND/FRONTEND)는 기술 면접관의 세부 전공이다. 인사팀·CEO에게는 해당 값이 없다. +ALTER TABLE persona + ALTER COLUMN major DROP NOT NULL; + +ALTER TABLE persona + ADD CONSTRAINT persona_tech_major_check CHECK (role <> 'TECH' OR major IS NOT NULL); + +-- 면접 방식. 기존 면접은 전부 1:1이다. +ALTER TABLE interview + ADD COLUMN mode VARCHAR(255) NOT NULL DEFAULT 'SOLO'; + +ALTER TABLE interview + ADD CONSTRAINT interview_mode_check CHECK (mode IN ('SOLO', 'MULTI')); + +-- N:1은 면접관이 여럿이라 단일 컬럼으로 표현할 수 없다. 1:1 호환을 위해 컬럼은 남기고 NOT NULL만 푼다. +ALTER TABLE interview + ALTER COLUMN persona_id DROP NOT NULL; + +-- 면접에 참여하는 면접관 목록. persona_order가 면접 진행 순서다(TECH -> HR -> CEO). +CREATE TABLE interview_persona ( + interview_persona_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + interview_id BIGINT NOT NULL, + persona_id BIGINT NOT NULL, + persona_order INTEGER NOT NULL, + CONSTRAINT interview_persona_pkey PRIMARY KEY (interview_persona_id), + CONSTRAINT interview_persona_interview_fk FOREIGN KEY (interview_id) + REFERENCES interview (interview_id) ON DELETE CASCADE, + CONSTRAINT interview_persona_order_unique UNIQUE (interview_id, persona_order), + CONSTRAINT interview_persona_unique UNIQUE (interview_id, persona_id) +); + +CREATE INDEX interview_persona_interview_id_idx ON interview_persona (interview_id); + +-- 어느 면접관이 던진 질문인지. 1:1 질문에는 값이 없다. +ALTER TABLE interview_question + ADD COLUMN persona_id BIGINT; + +-- 모의면접 답변으로 255자는 짧다. 잘린 답변은 그대로 피드백 품질로 이어진다. +ALTER TABLE interview_answer + ALTER COLUMN content TYPE TEXT; + +-- 면접관별 종합 피드백. N:1에만 생기는 계층이라 별도 테이블로 둔다. +-- 문항이 페르소나당 2~3개뿐이라 3지표(total/intent/reliability)는 두지 않고 점수 하나만 둔다. +CREATE TABLE feedback_persona ( + feedback_persona_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + feedback_id BIGINT NOT NULL, + persona_id BIGINT, + persona_role VARCHAR(255), + sort_order INTEGER NOT NULL, + score INTEGER, + comment TEXT, + strengths JSONB, + improvements JSONB, + answered_count INTEGER, + question_count INTEGER, + CONSTRAINT feedback_persona_pkey PRIMARY KEY (feedback_persona_id), + CONSTRAINT feedback_persona_feedback_fk FOREIGN KEY (feedback_id) + REFERENCES feedback (feedback_id) ON DELETE CASCADE +); + +CREATE INDEX feedback_persona_feedback_id_idx ON feedback_persona (feedback_id); + +-- 문항별 피드백이 어느 면접관 것인지. 1:1 피드백에는 값이 없다. +ALTER TABLE feedback_item + ADD COLUMN persona_id BIGINT; diff --git a/src/main/resources/db/migration/V9__add_persona_level.sql b/src/main/resources/db/migration/V9__add_persona_level.sql new file mode 100644 index 0000000..fcdc867 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_persona_level.sql @@ -0,0 +1,7 @@ +-- 페르소나에 난이도를 다시 넣는다. +-- V7에서 제거한 자유 문자열 컬럼과 달리, 이번에는 값 집합을 EASY/NORMAL/HARD로 고정한다. +ALTER TABLE persona + ADD COLUMN level VARCHAR(255) NOT NULL DEFAULT 'NORMAL'; + +ALTER TABLE persona + ADD CONSTRAINT persona_level_check CHECK (level IN ('EASY', 'NORMAL', 'HARD')); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java new file mode 100644 index 0000000..8a18cbe --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackCallbackRequestDeserializationTest.java @@ -0,0 +1,114 @@ +package repit.repit_api_server.domain.userdata.feedback.dto.request; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import tools.jackson.databind.ObjectMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +/** N:1 콜백은 1:1과 같은 엔드포인트로 들어온다. 늘어난 personas 계층이 그대로 읽히는지 확인한다. */ +class FeedbackCallbackRequestDeserializationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)); + + private static final String MULTI_CALLBACK = """ + { + "jobId": "job-1", + "sessionId": "sess-1", + "status": "succeeded", + "result": { + "overall": { + "totalScore": 72, + "intentAlignmentScore": 80, + "reliabilityScore": 61, + "summary": "기술 질문에는 근거를 들어 답했으나, 면접관이 바뀐 뒤 설명이 달라졌습니다.", + "strengths": ["캐시 도입 배경을 트래픽 수치와 함께 설명함"], + "improvements": ["기술 부문에서는 팀 합의로, CEO 질문에서는 개인 판단으로 설명이 엇갈림"], + "frequentWords": [{ "word": "성능", "count": 7 }], + "answeredCount": 6, + "questionCount": 7 + }, + "personas": [ + { + "personaId": 11, + "personaRole": "TECH", + "score": 78, + "comment": "선택 근거는 분명하나 대안 검토가 얕습니다.", + "strengths": ["측정값을 근거로 제시함"], + "improvements": ["고려했다 밝힌 대안의 탈락 이유가 없음"], + "answeredCount": 3, + "questionCount": 3 + }, + { "personaId": 12, "personaRole": "HR", "score": 70 }, + { "personaId": 13, "personaRole": "CEO", "score": 64 } + ], + "feedbacks": [ + { + "questionId": "2", + "personaId": 11, + "questionContent": "주문 API에서 Redis를 캐시로 두신 이유는?", + "intention": "기술 선택의 근거와 트레이드오프 인식", + "userAnswer": "조회가 쓰기보다 많아서 앞단에 뒀습니다.", + "modelAnswer": "조회 비중과 응답 지연을 수치로 제시하고 무효화 전략까지 밝힌다.", + "strengths": ["p99 지연을 근거로 든 점"], + "improvements": ["캐시 무효화 전략을 언급하지 않음"], + "comment": "선택 이유는 설득력 있으나 운영 시 부작용까지는 못 짚었습니다." + } + ] + } + } + """; + + // 1:1 콜백에는 personas가 아예 없다. 그래도 같은 DTO로 읽혀야 한다. + private static final String SOLO_CALLBACK = """ + { + "jobId": "job-2", + "sessionId": "sess-2", + "status": "succeeded", + "result": { + "overall": { "totalScore": 55, "answeredCount": 5, "questionCount": 7 }, + "feedbacks": [{ "questionId": "1", "comment": "근거가 부족합니다." }] + } + } + """; + + @Test + void 면접관별_종합과_문항의_면접관을_읽는다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + FeedbackCallbackRequest request = + objectMapper.readValue(MULTI_CALLBACK, FeedbackCallbackRequest.class); + + assertThat(request.getResult().getOverall().getReliabilityScore()).isEqualTo(61); + + assertThat(request.getResult().getPersonas()).hasSize(3); + FeedbackCallbackRequest.Persona tech = request.getResult().getPersonas().getFirst(); + assertThat(tech.getPersonaId()).isEqualTo(11L); + assertThat(tech.getPersonaRole()).isEqualTo("TECH"); + assertThat(tech.getScore()).isEqualTo(78); + assertThat(tech.getStrengths()).containsExactly("측정값을 근거로 제시함"); + assertThat(tech.getQuestionCount()).isEqualTo(3); + + FeedbackCallbackRequest.Item item = request.getResult().getFeedbacks().getFirst(); + assertThat(item.getQuestionId()).isEqualTo("2"); + assertThat(item.getPersonaId()).isEqualTo(11L); + }); + } + + @Test + void 일대일_콜백은_personas_없이도_읽힌다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + FeedbackCallbackRequest request = + objectMapper.readValue(SOLO_CALLBACK, FeedbackCallbackRequest.class); + + assertThat(request.getResult().getPersonas()).isNull(); + assertThat(request.getResult().getFeedbacks().getFirst().getPersonaId()).isNull(); + }); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java new file mode 100644 index 0000000..662ba42 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceMultiCallbackTest.java @@ -0,0 +1,181 @@ +package repit.repit_api_server.domain.userdata.feedback.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackCallbackRequest; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackItemEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackPersonaEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; +import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackItemRepository; +import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackPersonaRepository; +import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackRepository; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.global.client.AiServerClient; +import repit.repit_api_server.global.client.AuthServerClient; +import repit.repit_api_server.global.client.ChatServerClient; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * N:1 피드백 콜백 저장. + * + *

N:1은 채팅 서버가 면접 종료 시점에 분석 서버를 직접 호출한다. 이 서버는 요청을 접수한 적이 없어 + * 대응하는 행이 없는 상태로 콜백을 받는다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FeedbackServiceMultiCallbackTest { + + @Mock + private FeedbackRepository feedbackRepository; + @Mock + private FeedbackItemRepository feedbackItemRepository; + @Mock + private FeedbackPersonaRepository feedbackPersonaRepository; + @Mock + private InterviewRepository interviewRepository; + @Mock + private ChatServerClient chatServerClient; + @Mock + private AiServerClient aiServerClient; + @Mock + private AuthServerClient authServerClient; + + @Captor + private ArgumentCaptor> savedPersonas; + @Captor + private ArgumentCaptor> savedItems; + + private FeedbackService service; + + @BeforeEach + void setUp() { + service = new FeedbackService(feedbackRepository, feedbackItemRepository, feedbackPersonaRepository, + interviewRepository, chatServerClient, aiServerClient, authServerClient); + + when(feedbackRepository.save(any(FeedbackEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + private FeedbackCallbackRequest callback() { + FeedbackCallbackRequest.Overall overall = new FeedbackCallbackRequest.Overall( + 72, 80, 61, "면접관이 바뀐 뒤 설명이 달라졌습니다.", + List.of("캐시 도입 배경을 수치와 함께 설명함"), + List.of("설명이 엇갈림"), List.of(), 6, 7); + + FeedbackCallbackRequest.Persona tech = new FeedbackCallbackRequest.Persona( + 11L, "TECH", 78, "대안 검토가 얕습니다.", + List.of("측정값을 근거로 제시함"), List.of("탈락 이유가 없음"), 3, 3); + FeedbackCallbackRequest.Persona hr = new FeedbackCallbackRequest.Persona( + 12L, "HR", 70, "동기가 추상적입니다.", List.of(), List.of(), 2, 2); + FeedbackCallbackRequest.Persona ceo = new FeedbackCallbackRequest.Persona( + 13L, "CEO", 64, "우선순위 근거가 약합니다.", List.of(), List.of(), 2, 2); + + FeedbackCallbackRequest.Item item = new FeedbackCallbackRequest.Item( + "2", 11L, "Redis를 캐시로 두신 이유는?", "기술 선택의 근거", + "조회가 쓰기보다 많아서요.", "지연을 수치로 제시한다.", + List.of("p99 지연을 근거로 든 점"), List.of("무효화 전략 미언급"), "부작용까지는 못 짚었습니다."); + + return new FeedbackCallbackRequest("job-1", "sess-1", "succeeded", + new FeedbackCallbackRequest.Result(overall, List.of(tech, hr, ceo), List.of(item)), null); + } + + @Test + void 접수한_적_없는_세션이면_면접을_찾아_행을_만든다() { + when(feedbackRepository.findByJobId("job-1")).thenReturn(Optional.empty()); + when(feedbackRepository.findTopBySessionIdOrderByCreatedAtDesc("sess-1")).thenReturn(Optional.empty()); + when(interviewRepository.findBySessionId("sess-1")).thenReturn(Optional.of(InterviewEntity.builder() + .interviewId(3L) + .userId(7L) + .mode(InterviewMode.MULTI) + .sessionId("sess-1") + .status(Status.COMPLETED) + .build())); + + service.handleCallback(callback()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(FeedbackEntity.class); + verify(feedbackRepository, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); + + FeedbackEntity feedback = saved.getValue(); + assertThat(feedback.getInterviewId()).isEqualTo(3L); + assertThat(feedback.getUserId()).isEqualTo(7L); + assertThat(feedback.getJobId()).isEqualTo("job-1"); + assertThat(feedback.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED); + assertThat(feedback.getReliabilityScore()).isEqualTo(61); + } + + @Test + void 면접관별_종합과_문항의_면접관을_저장한다() { + when(feedbackRepository.findByJobId("job-1")).thenReturn(Optional.of(FeedbackEntity.builder() + .feedbackId(5L) + .interviewId(3L) + .userId(7L) + .sessionId("sess-1") + .jobId("job-1") + .status(FeedbackStatus.PENDING) + .build())); + + service.handleCallback(callback()); + + verify(feedbackPersonaRepository).saveAll(savedPersonas.capture()); + List personas = savedPersonas.getValue(); + assertThat(personas).hasSize(3); + assertThat(personas).extracting(FeedbackPersonaEntity::getPersonaRole) + .containsExactly("TECH", "HR", "CEO"); + // 분석 서버가 보낸 순서가 곧 면접 진행 순서다. + assertThat(personas).extracting(FeedbackPersonaEntity::getSortOrder).containsExactly(0, 1, 2); + assertThat(personas.getFirst().getFeedbackId()).isEqualTo(5L); + assertThat(personas.getFirst().getScore()).isEqualTo(78); + assertThat(personas.getFirst().getStrengths()).containsExactly("측정값을 근거로 제시함"); + + verify(feedbackItemRepository).saveAll(savedItems.capture()); + assertThat(savedItems.getValue().getFirst().getPersonaId()).isEqualTo(11L); + } + + @Test + void 콜백이_다시_와도_면접관_종합이_중복되지_않는다() { + when(feedbackRepository.findByJobId("job-1")).thenReturn(Optional.of(FeedbackEntity.builder() + .feedbackId(5L) + .interviewId(3L) + .userId(7L) + .sessionId("sess-1") + .jobId("job-1") + .status(FeedbackStatus.SUCCEEDED) + .build())); + + service.handleCallback(callback()); + + verify(feedbackPersonaRepository).deleteAllByFeedbackId(5L); + } + + @Test + void 세션도_면접도_모르면_아무것도_저장하지_않는다() { + when(feedbackRepository.findByJobId("job-1")).thenReturn(Optional.empty()); + when(feedbackRepository.findTopBySessionIdOrderByCreatedAtDesc("sess-1")).thenReturn(Optional.empty()); + when(interviewRepository.findBySessionId("sess-1")).thenReturn(Optional.empty()); + + service.handleCallback(callback()); + + verify(feedbackRepository, never()).save(any()); + verify(feedbackPersonaRepository, never()).saveAll(any()); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java index 037fd5d..ebce2fb 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/ChatInterviewHandoffServiceTest.java @@ -15,6 +15,7 @@ import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; @@ -63,6 +64,7 @@ void setUp() { .personaName("압박 면접관") .major(Major.BACKEND) .type(Type.STRESS) + .level(Level.HARD) .career(10) .gender(Gender.MALE) .build())); @@ -108,6 +110,8 @@ private TailoredQuestionResponse question(int id, String content) { assertThat(sent.getPersona().getPersonaName()).isEqualTo("압박 면접관"); assertThat(sent.getPersona().getType()).isEqualTo(Type.STRESS); + // 난이도를 안 넘기면 채팅 서버가 꼬리질문 깊이를 판단할 근거가 없다. + assertThat(sent.getPersona().getLevel()).isEqualTo(Level.HARD); assertThat(sent.getPersona().getCareer()).isEqualTo(10); ChatInterviewPrepareRequest.Question question = sent.getQuestions().getFirst(); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java index f15f03b..74d9651 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceCreateTest.java @@ -12,6 +12,7 @@ import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; @@ -54,13 +55,16 @@ class InterviewServiceCreateTest { private PersonaRepository personaRepository; @Mock private QuestionTailorService questionTailorService; + @Mock + private InterviewPersonaRepository interviewPersonaRepository; private InterviewService service; @BeforeEach void setUp() { service = new InterviewService(interviewRepository, questionRepository, chatServerClient, - authServerClient, answerRepository, personaRepository, questionTailorService); + authServerClient, answerRepository, personaRepository, questionTailorService, + interviewPersonaRepository); UserResponse user = mock(UserResponse.class); when(user.getId()).thenReturn(7L); @@ -86,7 +90,7 @@ private PersonaEntity persona() { @Test void personaId가_있으면_이름은_보지_않는다() { InterviewResponse response = service.createInterview("Bearer t", - new CreateInterviewRequest(1L, "무시되는 이름")); + new CreateInterviewRequest(1L, "무시되는 이름", null)); assertThat(response.getPersonaId()).isEqualTo(1L); assertThat(response.getUserId()).isEqualTo(7L); @@ -96,7 +100,7 @@ private PersonaEntity persona() { @Test void personaId가_없으면_이름으로_찾는다() { InterviewResponse response = service.createInterview("Bearer t", - new CreateInterviewRequest(null, "압박 면접관")); + new CreateInterviewRequest(null, "압박 면접관", null)); assertThat(response.getPersonaId()).isEqualTo(1L); verify(personaRepository).findByPersonaName("압박 면접관"); @@ -105,7 +109,7 @@ private PersonaEntity persona() { @Test void 페르소나를_아예_지정하지_않으면_422다() { assertThatThrownBy(() -> service.createInterview("Bearer t", - new CreateInterviewRequest(null, " "))) + new CreateInterviewRequest(null, " ", null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); @@ -116,7 +120,7 @@ private PersonaEntity persona() { when(personaRepository.findByPersonaName("없는 면접관")).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.createInterview("Bearer t", - new CreateInterviewRequest(null, "없는 면접관"))) + new CreateInterviewRequest(null, "없는 면접관", null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.NOT_FOUND); @@ -127,7 +131,7 @@ private PersonaEntity persona() { when(authServerClient.getUser("Bearer bad")).thenReturn(null); assertThatThrownBy(() -> service.createInterview("Bearer bad", - new CreateInterviewRequest(1L, null))) + new CreateInterviewRequest(1L, null, null))) .isInstanceOf(BusinessException.class) .extracting(e -> ((BusinessException) e).getStatus()) .isEqualTo(HttpStatus.UNAUTHORIZED); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java new file mode 100644 index 0000000..cc2a1c8 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServiceMultiCreateTest.java @@ -0,0 +1,171 @@ +package repit.repit_api_server.domain.userdata.interview.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; +import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; +import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewPersonaEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository; +import repit.repit_api_server.domain.userdata.question.service.QuestionTailorService; +import repit.repit_api_server.global.client.AuthServerClient; +import repit.repit_api_server.global.client.ChatServerClient; +import repit.repit_api_server.global.exception.BusinessException; +import repit.repit_api_server.global.response.UserResponse; + +import java.util.List; + +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.anyIterable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** N:1 면접은 기술·인사·CEO 한 명씩이고, 진행 순서는 요청 순서가 아니라 직책 순서다. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class InterviewServiceMultiCreateTest { + + @Mock + private InterviewRepository interviewRepository; + @Mock + private QuestionRepository questionRepository; + @Mock + private ChatServerClient chatServerClient; + @Mock + private AuthServerClient authServerClient; + @Mock + private AnswerRepository answerRepository; + @Mock + private PersonaRepository personaRepository; + @Mock + private QuestionTailorService questionTailorService; + @Mock + private InterviewPersonaRepository interviewPersonaRepository; + + @Captor + private ArgumentCaptor> savedMembers; + + private InterviewService service; + + @BeforeEach + void setUp() { + service = new InterviewService(interviewRepository, questionRepository, chatServerClient, + authServerClient, answerRepository, personaRepository, questionTailorService, + interviewPersonaRepository); + + UserResponse user = mock(UserResponse.class); + when(user.getId()).thenReturn(7L); + when(authServerClient.getUser("Bearer t")).thenReturn(user); + + when(interviewRepository.save(any(InterviewEntity.class))).thenAnswer(invocation -> { + InterviewEntity interview = invocation.getArgument(0); + interview.setInterviewId(3L); + return interview; + }); + } + + private PersonaEntity persona(long id, Role role) { + return PersonaEntity.builder() + .personaId(id) + .personaName("면접관 " + id) + .role(role) + .major(role == Role.TECH ? Major.BACKEND : null) + .type(Type.NEUTRAL) + .career(8) + .gender(Gender.FEMALE) + .build(); + } + + @Test + void 면접관은_직책_순서로_배치된다() { + // 요청은 CEO -> 기술 -> 인사 순서로 왔다. + when(personaRepository.findAllById(List.of(13L, 11L, 12L))).thenReturn(List.of( + persona(13L, Role.CEO), persona(11L, Role.TECH), persona(12L, Role.HR))); + + InterviewResponse response = service.createInterview("Bearer t", + new CreateInterviewRequest(null, null, List.of(13L, 11L, 12L))); + + assertThat(response.getMode()).isEqualTo(InterviewMode.MULTI); + // 면접관이 여럿이라 단일 personaId는 비워 둔다. + assertThat(response.getPersonaId()).isNull(); + assertThat(response.getPersonaIds()).containsExactly(11L, 12L, 13L); + + verify(interviewPersonaRepository).saveAll(savedMembers.capture()); + assertThat(savedMembers.getValue()).extracting(InterviewPersonaEntity::getPersonaId) + .containsExactly(11L, 12L, 13L); + assertThat(savedMembers.getValue()).extracting(InterviewPersonaEntity::getPersonaOrder) + .containsExactly(0, 1, 2); + assertThat(savedMembers.getValue().getFirst().getInterviewId()).isEqualTo(3L); + } + + @Test + void 직책이_빠지면_422다() { + when(personaRepository.findAllById(List.of(11L, 12L))).thenReturn(List.of( + persona(11L, Role.TECH), persona(12L, Role.HR))); + + assertThatThrownBy(() -> service.createInterview("Bearer t", + new CreateInterviewRequest(null, null, List.of(11L, 12L)))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + + verify(interviewRepository, never()).save(any()); + } + + @Test + void 같은_직책이_둘이면_422다() { + when(personaRepository.findAllById(List.of(11L, 14L, 12L))).thenReturn(List.of( + persona(11L, Role.TECH), persona(14L, Role.TECH), persona(12L, Role.HR))); + + assertThatThrownBy(() -> service.createInterview("Bearer t", + new CreateInterviewRequest(null, null, List.of(11L, 14L, 12L)))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + } + + @Test + void 같은_면접관을_두_번_지정하면_422다() { + assertThatThrownBy(() -> service.createInterview("Bearer t", + new CreateInterviewRequest(null, null, List.of(11L, 11L, 12L)))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + + verify(personaRepository, never()).findAllById(anyIterable()); + } + + @Test + void 없는_면접관을_지정하면_404다() { + when(personaRepository.findAllById(List.of(11L, 12L, 99L))).thenReturn(List.of( + persona(11L, Role.TECH), persona(12L, Role.HR))); + + assertThatThrownBy(() -> service.createInterview("Bearer t", + new CreateInterviewRequest(null, null, List.of(11L, 12L, 99L)))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java b/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java new file mode 100644 index 0000000..743c34c --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/persona/service/PersonaServiceCreateTest.java @@ -0,0 +1,120 @@ +package repit.repit_api_server.domain.userdata.persona.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import repit.repit_api_server.domain.userdata.persona.dto.request.PersonaRequest; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Gender; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Level; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Major; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.global.exception.BusinessException; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 직책과 난이도는 저장되고 나면 되돌릴 근거가 없다. 요청에 있는 값이 그대로 남는지 확인한다. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PersonaServiceCreateTest { + + @Mock + private PersonaRepository personaRepository; + + @Captor + private ArgumentCaptor saved; + + private PersonaService service; + + @BeforeEach + void setUp() { + service = new PersonaService(personaRepository); + when(personaRepository.save(any(PersonaEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + private PersonaRequest request(Role role, Major major) { + PersonaRequest request = new PersonaRequest(); + request.setPersonaName("면접관"); + request.setRole(role); + request.setMajor(major); + request.setLevel(Level.HARD); + request.setType(Type.FRIENDLY); + request.setCareer(5); + request.setGender(Gender.FEMALE); + return request; + } + + @Test + void 요청한_직책이_그대로_저장된다() { + service.createPersona(request(Role.HR, null)); + + verify(personaRepository).save(saved.capture()); + assertThat(saved.getValue().getRole()).isEqualTo(Role.HR); + // 전공은 기술 면접관에게만 있는 값이다. + assertThat(saved.getValue().getMajor()).isNull(); + } + + @Test + void 직책을_안_보내면_422다() { + assertThatThrownBy(() -> service.createPersona(request(null, Major.BACKEND))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + + // 조용히 TECH로 저장되면 인사·CEO 면접관이 기술 면접관으로 묻힌다. + verify(personaRepository, never()).save(any()); + } + + @Test + void 기술_면접관은_전공이_없으면_422다() { + assertThatThrownBy(() -> service.createPersona(request(Role.TECH, null))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + } + + @Test + void 기술_면접관은_전공을_유지한다() { + service.createPersona(request(Role.TECH, Major.BACKEND)); + + verify(personaRepository).save(saved.capture()); + assertThat(saved.getValue().getRole()).isEqualTo(Role.TECH); + assertThat(saved.getValue().getMajor()).isEqualTo(Major.BACKEND); + } + + @Test + void 요청한_난이도가_그대로_저장된다() { + service.createPersona(request(Role.CEO, null)); + + verify(personaRepository).save(saved.capture()); + assertThat(saved.getValue().getLevel()).isEqualTo(Level.HARD); + } + + @Test + void 난이도를_안_보내면_422다() { + PersonaRequest request = request(Role.CEO, null); + request.setLevel(null); + + assertThatThrownBy(() -> service.createPersona(request)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getStatus()) + .isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + + verify(personaRepository, never()).save(any()); + } +}