Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import repit.repit_api_server.global.auth.AuthUser;
import repit.repit_api_server.global.common.ApiResponse;
import repit.repit_api_server.domain.userdata.persona.dto.request.PersonaRequest;
import repit.repit_api_server.domain.userdata.persona.dto.response.PersonaResponse;
Expand All @@ -20,8 +22,9 @@ public class PersonaController {
@PostMapping("/save")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<PersonaResponse> savePersona(
@AuthenticationPrincipal AuthUser authUser,
@RequestBody PersonaRequest request) {
return ApiResponse.created(personaService.createPersona(request));
return ApiResponse.created(personaService.createPersona(request, authUser.user().getMajor()));
}

@GetMapping("/getById")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class PersonaRequest {
// 필수. TECH / HR / CEO.
private Role role;

// 기술 면접관에게만 필요하다. 인사팀·CEO는 비워 보낸다.
// 읽지 않는다. 기술 면접관 전공은 로그인 사용자의 직무로 정한다(구버전 Web 호환용으로만 남김).
private Major major;

private Type type;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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.domain.userdata.persona.support.UserJobMajors;
import repit.repit_api_server.global.exception.BusinessException;

import java.util.List;
Expand All @@ -17,7 +18,10 @@ public class PersonaService {

private final PersonaRepository personaRepository;

public PersonaResponse createPersona(PersonaRequest request) {
/**
* @param userMajor 로그인 사용자의 직무. 기술 면접관 전공은 요청값이 아니라 이 값으로 정한다.
*/
public PersonaResponse createPersona(PersonaRequest request, String userMajor) {
// 직책과 난이도에는 기본값을 두지 않는다. 조용히 채워 넣으면 인사·CEO 면접관이
// 기술 면접관으로 저장되고, 저장된 뒤에는 무엇이 요청값이고 무엇이 기본값인지 구분할 방법이 없다.
if (request.getRole() == null) {
Expand All @@ -31,10 +35,6 @@ public PersonaResponse createPersona(PersonaRequest request) {
throw BusinessException.unprocessable("면접관 어조(tone)를 지정해주세요.");
}
Role role = request.getRole();
// 전공은 기술 면접관에게만 있는 값이다. DB CHECK와 같은 규칙을 여기서 먼저 걸러 메시지를 남긴다.
if (role == Role.TECH && request.getMajor() == null) {
throw BusinessException.unprocessable("기술 면접관에게는 전공(major)이 필요합니다.");
}
// DB CHECK와 같은 범위. 번호와 성별의 관계는 Web과 채팅 서버의 카탈로그가 맞추므로 보지 않는다.
Integer voiceIndex = request.getVoiceIndex();
if (voiceIndex != null && (voiceIndex < 1 || voiceIndex > 5)) {
Expand All @@ -47,7 +47,7 @@ public PersonaResponse createPersona(PersonaRequest request) {
.type(request.getType())
.tone(request.getTone())
.level(request.getLevel())
.major(role == Role.TECH ? request.getMajor() : null)
.major(role == Role.TECH ? UserJobMajors.toPersonaMajor(userMajor) : null)
.career(request.getCareer())
.gender(request.getGender())
.imageUrl(blankToNull(request.getImageUrl()))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package repit.repit_api_server.domain.userdata.persona.support;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import repit.repit_api_server.domain.userdata.persona.entity.enums.Major;

import java.util.Locale;

/**
* 인증 서버의 사용자 직무 값을 다룬다.
*
* <p>기술 면접관의 전공은 사용자가 고르지 않고 사용자 직무를 따른다. 면접관 전공은
* BACKEND / FRONTEND 두 가지뿐이라, 그 밖의 직무는 가까운 값이나 대체값으로 맞춘다.
*/
public final class UserJobMajors {

private static final Logger log = LoggerFactory.getLogger(UserJobMajors.class);

// 인증 서버의 직무 값 앞에 붙는 접두사(MAJOR_BACKEND).
private static final String AUTH_MAJOR_PREFIX = "MAJOR_";

private UserJobMajors() {}

/** MAJOR_ 접두사를 떼고 앞뒤 공백을 지운 뒤 대문자로 맞춘다. 비어 있으면 null. */
public static String normalize(String userMajor) {
if (userMajor == null) {
return null;
}
String value = userMajor.strip().toUpperCase(Locale.ROOT);
if (value.startsWith(AUTH_MAJOR_PREFIX)) {
value = value.substring(AUTH_MAJOR_PREFIX.length());
}
return value.isEmpty() ? null : value;
}

/**
* 기술 면접관 전공. null을 돌려주지 않는다.
*
* <p>WEB_PUBLISHER는 화면 개발 직무라 FRONTEND로 맞춘다. 직무가 없거나 옮길 수 없는 값은
* BACKEND로 둔다. 마이페이지 직무 저장이 아직 없어 직무 없는 사용자가 정상 경로에 있으므로 막지 않는다.
*/
public static Major toPersonaMajor(String userMajor) {
String job = normalize(userMajor);
if (job == null) {
log.info("사용자 직무가 없어 기술 면접관 전공을 BACKEND로 둡니다.");
return Major.BACKEND;
}
return switch (job) {
case "BACKEND" -> Major.BACKEND;
case "FRONTEND", "WEB_PUBLISHER" -> Major.FRONTEND;
default -> {
log.info("면접관 전공으로 옮길 수 없는 직무라 BACKEND로 둡니다. userMajor={}", userMajor);
yield Major.BACKEND;
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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.domain.userdata.persona.support.UserJobMajors;
import repit.repit_api_server.domain.userdata.question.dto.request.QuestionCycleCallbackRequest;
import repit.repit_api_server.domain.userdata.question.dto.request.QuestionTailorCallbackRequest;
import repit.repit_api_server.domain.userdata.question.dto.request.QuestionTailorMultiCallbackRequest;
Expand Down Expand Up @@ -67,8 +68,6 @@ public class QuestionTailorService {
private static final String CALLBACK_PATH = "/api/questions/tailor/callback";
private static final String MULTI_CALLBACK_PATH = "/api/questions/tailor/multi/callback";
private static final String STATUS_SUCCEEDED = "succeeded";
// 인증 서버의 전공 값 앞에 붙는 접두사. 떼면 면접관 전공(Major)과 같은 형식이 된다.
private static final String AUTH_MAJOR_PREFIX = "MAJOR_";

// 기술 외 면접관 한 명이 맡을 문항 수. 분석 서버 기본값과 같다. 면접관이 늘면 이만큼씩 늘어난다.
private static final int OTHER_QUESTION_COUNT = 2;
Expand Down Expand Up @@ -245,7 +244,7 @@ private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interv
return aiServerClient.tailorQuestionsMulti(QuestionTailorMultiRequest.builder()
.interviewId(String.valueOf(interview.getInterviewId()))
.userId(String.valueOf(interview.getUserId()))
.jobRole(resolveJobRole(major, tech))
.jobRole(resolveJobRole(major))
// persona.career는 면접관 설정이지 지원자 경력이 아니다. 지원자 경력은 아직 수집하지 않는다.
.experienceLevel(null)
.techPersona(toRequestPersona(tech, techQuestions.size()))
Expand Down Expand Up @@ -403,25 +402,14 @@ private QuestionTailorMultiRequest.ProjectSummary toRequestProjectSummary(Questi
}

/**
* 재작성 개인화 축. 면접 설정에서 고른 면접관의 전공이 먼저고, 없으면 사용자 전공을 쓴다.
* 재작성 개인화 축. 사용자 직무만 본다.
*
* <p>사용자 전공은 회원가입 때 고정값으로 들어간 것이라 사용자가 고른 적이 없다. 이것을 먼저
* 보면 면접 설정에서 프론트엔드를 골라도 질문은 백엔드 기준으로 다시 쓰인다.
* <p>면접관 전공은 사용자 직무에서 나온 값이라 따로 볼 이유가 없다. 예전에는 면접관 전공을
* 먼저 봐서, 면접 설정에서 고른 면접관에 따라 질문이 다른 직무 기준으로 다시 쓰였다.
* 면접관 전공으로 좁히지 않고 직무 원문을 보낸다(WEB_PUBLISHER 등).
*/
private String resolveJobRole(String userMajor, PersonaEntity persona) {
if (persona != null && persona.getMajor() != null) {
return persona.getMajor().name();
}
return normalizeMajor(userMajor);
}

/** 인증 서버는 MAJOR_BACKEND 형식으로 내려준다. 면접관 전공과 같은 BACKEND 형식으로 맞춘다. */
private String normalizeMajor(String major) {
String value = blankToNull(major);
if (value == null) {
return null;
}
return value.startsWith(AUTH_MAJOR_PREFIX) ? value.substring(AUTH_MAJOR_PREFIX.length()) : value;
private String resolveJobRole(String userMajor) {
return UserJobMajors.normalize(userMajor);
}

private QuestionTailorRequest.Question toRequestQuestion(TailoredQuestionResponse question) {
Expand All @@ -442,7 +430,7 @@ private QuestionTailorRequest.Question toRequestQuestion(TailoredQuestionRespons
private QuestionTailorRequest.Profile resolveProfile(String userMajor, Long personaId) {
PersonaEntity persona = personaRepository.findById(personaId).orElse(null);

String jobRole = resolveJobRole(userMajor, persona);
String jobRole = resolveJobRole(userMajor);
String personaType = persona == null ? null : enumName(persona.getType());
String personaTone = persona == null ? null : enumName(persona.getTone());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
Expand Down Expand Up @@ -65,7 +66,7 @@ private PersonaRequest request(Role role, Major major) {

@Test
void 요청한_직책이_그대로_저장된다() {
service.createPersona(request(Role.HR, null));
service.createPersona(request(Role.HR, null), "MAJOR_BACKEND");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getRole()).isEqualTo(Role.HR);
Expand All @@ -75,7 +76,7 @@ private PersonaRequest request(Role role, Major major) {

@Test
void 직책을_안_보내면_422다() {
assertThatThrownBy(() -> service.createPersona(request(null, Major.BACKEND)))
assertThatThrownBy(() -> service.createPersona(request(null, Major.BACKEND), "MAJOR_BACKEND"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getStatus())
.isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT);
Expand All @@ -84,34 +85,61 @@ private PersonaRequest request(Role role, Major major) {
verify(personaRepository, never()).save(any());
}

/** Web이 보낸 전공이 사용자 직무를 덮어쓰면 엉뚱한 직무 기준으로 면접이 진행된다. */
@Test
void 기술_면접관은_전공이_없으면_422다() {
assertThatThrownBy(() -> service.createPersona(request(Role.TECH, null)))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getStatus())
.isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT);
void 기술_면접관_전공은_요청값이_아니라_사용자_직무다() {
service.createPersona(request(Role.TECH, Major.BACKEND), "MAJOR_FRONTEND");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getRole()).isEqualTo(Role.TECH);
assertThat(saved.getValue().getMajor()).isEqualTo(Major.FRONTEND);
}

@Test
void 기술_면접관은_전공을_유지한다() {
service.createPersona(request(Role.TECH, Major.BACKEND));
void 기술_면접관은_전공을_안_보내도_저장된다() {
service.createPersona(request(Role.TECH, null), "MAJOR_BACKEND");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getMajor()).isEqualTo(Major.BACKEND);
}

@Test
void 웹퍼블리셔_사용자의_기술_면접관은_프론트엔드다() {
service.createPersona(request(Role.TECH, null), "WEB_PUBLISHER");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getMajor()).isEqualTo(Major.FRONTEND);
}

@ParameterizedTest
@NullSource
@ValueSource(strings = "DATA_ANALYST")
void 직무가_없거나_옮길_수_없으면_백엔드다(String userMajor) {
service.createPersona(request(Role.TECH, null), userMajor);

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.HR, null), "MAJOR_FRONTEND");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getMajor()).isNull();
}

@Test
void 요청한_난이도가_그대로_저장된다() {
service.createPersona(request(Role.CEO, null));
service.createPersona(request(Role.CEO, null), "MAJOR_BACKEND");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getLevel()).isEqualTo(Level.HARD);
}

@Test
void 요청한_어조가_그대로_저장된다() {
service.createPersona(request(Role.CEO, null));
service.createPersona(request(Role.CEO, null), "MAJOR_BACKEND");

verify(personaRepository).save(saved.capture());
// 성향이 FRIENDLY라고 어조까지 부드럽게 채워지면 안 된다. 두 축은 독립이다.
Expand All @@ -123,7 +151,7 @@ private PersonaRequest request(Role role, Major major) {
PersonaRequest request = request(Role.CEO, null);
request.setTone(null);

assertThatThrownBy(() -> service.createPersona(request))
assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getStatus())
.isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT);
Expand All @@ -137,15 +165,15 @@ private PersonaRequest request(Role role, Major major) {
PersonaRequest request = request(Role.HR, null);
request.setVoiceIndex(voiceIndex);

assertThat(service.createPersona(request).getVoiceIndex()).isEqualTo(voiceIndex);
assertThat(service.createPersona(request, "MAJOR_BACKEND").getVoiceIndex()).isEqualTo(voiceIndex);
verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getVoiceIndex()).isEqualTo(voiceIndex);
}

/** 음성 번호는 선택이다. 비우면 채팅 서버가 기본 음성을 쓴다. */
@Test
void 음성_번호_없이도_저장된다() {
service.createPersona(request(Role.HR, null));
service.createPersona(request(Role.HR, null), "MAJOR_BACKEND");

verify(personaRepository).save(saved.capture());
assertThat(saved.getValue().getVoiceIndex()).isNull();
Expand All @@ -157,7 +185,7 @@ private PersonaRequest request(Role role, Major major) {
PersonaRequest request = request(Role.HR, null);
request.setVoiceIndex(voiceIndex);

assertThatThrownBy(() -> service.createPersona(request))
assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getStatus())
.isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT);
Expand All @@ -170,7 +198,7 @@ private PersonaRequest request(Role role, Major major) {
PersonaRequest request = request(Role.CEO, null);
request.setLevel(null);

assertThatThrownBy(() -> service.createPersona(request))
assertThatThrownBy(() -> service.createPersona(request, "MAJOR_BACKEND"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getStatus())
.isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package repit.repit_api_server.domain.userdata.persona.support;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import repit.repit_api_server.domain.userdata.persona.entity.enums.Major;

import static org.assertj.core.api.Assertions.assertThat;

class UserJobMajorsTest {

@ParameterizedTest
@CsvSource({
"MAJOR_BACKEND, BACKEND, BACKEND",
"MAJOR_FRONTEND, FRONTEND, FRONTEND",
"FRONTEND, FRONTEND, FRONTEND",
"' major_frontend ', FRONTEND, FRONTEND",
"WEB_PUBLISHER, WEB_PUBLISHER, FRONTEND",
"DATA_ANALYST, DATA_ANALYST, BACKEND",
})
void 직무를_정규화하고_면접관_전공으로_옮긴다(String userMajor, String normalized, Major major) {
assertThat(UserJobMajors.normalize(userMajor)).isEqualTo(normalized);
assertThat(UserJobMajors.toPersonaMajor(userMajor)).isEqualTo(major);
}

/** 직무가 없는 사용자도 면접을 시작할 수 있어야 한다. 예외 대신 BACKEND로 둔다. */
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", " "})
void 직무가_없으면_정규화는_null이고_전공은_백엔드다(String userMajor) {
assertThat(UserJobMajors.normalize(userMajor)).isNull();
assertThat(UserJobMajors.toPersonaMajor(userMajor)).isEqualTo(Major.BACKEND);
}
}
Loading
Loading