Skip to content
22 changes: 22 additions & 0 deletions docs/interview-answer-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 면접 답변 방식 계약 (#107)

면접 생성의 선택 필드 `answerMode`는 `TEXT` 또는 `VOICE`입니다. `VOICE`는 현재 웹의 영상/음성 답변을 뜻합니다. 생략·null은 기존 클라이언트 호환을 위해 허용하고 기록에도 null로 저장합니다. 기존 기록에 답변 방식을 추정해 채우지 않습니다.

```json
{"personaId": 1, "answerMode": "TEXT"}
```

- SOLO와 MULTI 생성 모두 저장합니다.
- 생성 응답 및 단건·목록 면접 조회의 `answerMode`로 그대로 반환합니다.
- 완료 기록 저장·피드백 재채점은 답변 방식을 변경하지 않습니다.
- 기존 요청의 나머지 필드는 그대로이며 잘못된 enum 문자열은 400입니다.
- `Idempotency-Key`가 같은데 TEXT↔VOICE를 바꾸면 409 `IDEMPOTENCY_KEY_REUSED`입니다.
- 필드가 없거나 null인 요청의 canonical 해시는 기존과 같습니다. 이미 제출한 요청은 본문을 바꾸지 않고 재전송해야 합니다.
- `V37__add_interview_answer_mode.sql`은 nullable 컬럼과 값 제약만 추가합니다. 선행 기업 계약 PR #106의 V36 뒤에 적용합니다.
- Chat·Analysis 요청/콜백 및 점수 산식·음성 분석 디스패치에는 영향이 없습니다.

웹은 TEXT의 음성 섹션을 숨기고, VOICE에는 녹음 실패 상태도 보여 줍니다. 기존 null 기록은 음성 조회의 녹음 유무로 표시를 보완하며 조회 장애를 TEXT로 단정하지 않습니다.

## 검증

격리 PostgreSQL 16에서 `./gradlew test` 877개 통과(실패·오류·건너뜀 0). 추가 회귀는 SOLO/MULTI × TEXT/VOICE의 생성·멱등 재전송·완료 후 단건/목록 조회·실제 컬럼 저장, 변경된 답변 방식의 409, 변경 전 해시 재현과 null 유지, JSON enum의 허용/거절·문자열 응답을 확인합니다. V37 SQL을 이전 스키마의 기존 행에 적용해 null이 유지되는 것도 격리 스키마에서 확인했습니다.
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode;

import java.util.List;

/**
* 면접 생성 요청.
*
* <p>면접이 고르는 것은 면접관뿐이라 그것만 받는다. 예전에는 페르소나 등록용 DTO를 그대로 써서
* <p>면접관과 기업 선택, 답변 방식을 받는다. 예전에는 페르소나 등록용 DTO를 그대로 써서
* 전공·타입·경력·성별까지 받는 것처럼 보였지만, 실제로는 이름만 쓰고 나머지는 버렸다.
*
* <p>{@code personaId}가 있으면 그것을 쓰고, 없으면 {@code personaName}으로 찾는다. 이름은 바뀔 수 있는
Expand All @@ -33,4 +34,10 @@ public class CreateInterviewRequest {
// 순서를 그대로 쓴다.
private List<Long> personaIds;
private Long companyId;
// 생략한 기존 클라이언트는 null로 남긴다.
private AnswerMode answerMode;

public CreateInterviewRequest(Long personaId, String personaName, List<Long> personaIds, Long companyId) {
this(personaId, personaName, personaIds, companyId, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package repit.repit_api_server.domain.userdata.interview.dto.response;

import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity;
import repit.repit_api_server.domain.userdata.persona.entity.enums.InterviewTone;
import repit.repit_api_server.domain.userdata.persona.entity.enums.Level;
import repit.repit_api_server.domain.userdata.persona.entity.enums.Role;
import repit.repit_api_server.domain.userdata.persona.entity.enums.Type;

/**
* 면접 응답에 붙는 대표 면접관. 목록·상세 화면이 성향·난이도·면접관 이름을 여기서 읽는다.
*
* <p>1:1은 그 면접의 면접관, N:1은 진행 순서 맨 앞 면접관이다. 피드백 응답의 성향·난이도와
* 같은 규칙이다. 이름은 저장된 값 그대로다 — 1:1 면접관 이름 끝의 유일성 접미사는 화면이 뗀다.
*/
public record InterviewPersonaSummary(
Long id,
String personaName,
Role personaRole,
Type type,
Level level,
InterviewTone tone,
String imageUrl
) {

/** 면접관이 없으면(지워졌거나 걸려 있지 않으면) null이다. */
public static InterviewPersonaSummary of(PersonaEntity persona) {
if (persona == null) {
return null;
}
return new InterviewPersonaSummary(
persona.getPersonaId(),
persona.getPersonaName(),
persona.getRole(),
persona.getType(),
persona.getLevel(),
persona.getTone(),
persona.getImageUrl()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import repit.repit_api_server.domain.company.dto.CompanyBadge;
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.AnswerMode;
import repit.repit_api_server.domain.userdata.interview.entity.enums.Status;
import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity;

import java.time.LocalDateTime;
import java.util.List;
Expand All @@ -24,27 +26,32 @@ public class InterviewResponse {
private Long personaId;
// N:1 면접관 목록. 진행 순서대로다.
private List<Long> personaIds;
// 대표 면접관. 1:1은 그 면접관, N:1은 진행 순서 맨 앞 면접관이다. 면접관이 없으면 비어 있다.
private InterviewPersonaSummary persona;
private String sessionId;
private Status status;
private LocalDateTime createdAt;
// 면접을 만들 때 고른 기업. 고르지 않았으면 비어 있다.
private CompanyBadge company;
private AnswerMode answerMode;

public static InterviewResponse from(InterviewEntity interview) {
return from(interview, List.of());
}

public static InterviewResponse from(InterviewEntity interview, List<Long> personaIds) {
/**
* @param personaIds N:1 면접관 목록. 1:1은 빈 목록이다.
* @param persona 대표 면접관. 없으면 null이다.
*/
public static InterviewResponse from(InterviewEntity interview, List<Long> personaIds, PersonaEntity persona) {
return new InterviewResponse(
interview.getInterviewId(),
interview.getUserId(),
interview.getMode(),
interview.getPersonaId(),
personaIds,
InterviewPersonaSummary.of(persona),
interview.getSessionId(),
interview.getStatus(),
interview.getCreatedAt(),
CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot())
CompanyBadge.of(interview.getCompanyId(), interview.getCompanySnapshot()),
interview.getAnswerMode()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.hibernate.type.SqlTypes;
import repit.repit_api_server.domain.company.dto.CompanySnapshot;
import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode;
import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode;
import repit.repit_api_server.domain.userdata.interview.entity.enums.Status;

import java.time.LocalDateTime;
Expand Down Expand Up @@ -35,6 +36,10 @@ public class InterviewEntity {
@Builder.Default
private InterviewMode mode = InterviewMode.SOLO;

@Enumerated(EnumType.STRING)
@Column(length = 16)
private AnswerMode answerMode;

@Column(nullable = false, unique = true, length = 64)
private String sessionId;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package repit.repit_api_server.domain.userdata.interview.entity.enums;

/** 면접에서 선택한 답변 방식. VOICE는 영상/음성 답변을 함께 뜻한다. */
public enum AnswerMode {
TEXT,
VOICE
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ static String of(CreateInterviewRequest request) {
canonical.put("personaName", request.getPersonaName());
}
canonical.put("companyId", request.getCompanyId());
// 필드 없는 기존 제출의 해시는 바꾸지 않아 재전송을 보존한다.
if (request.getAnswerMode() != null) canonical.put("answerMode", request.getAnswerMode().name());
return sha256(JsonMapper.shared().writeValueAsString(canonical));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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.interview.entity.enums.InterviewMode;
import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode;
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;
Expand Down Expand Up @@ -77,14 +78,15 @@ public class InterviewService {
*/
public InterviewResponse createInterview(Long userId, CreateInterviewRequest request) {
if (request.getPersonaIds() != null && !request.getPersonaIds().isEmpty()) {
return createMultiInterview(userId, request.getPersonaIds(), request.getCompanyId());
return createMultiInterview(userId, request.getPersonaIds(), request.getCompanyId(), request.getAnswerMode());
}

PersonaEntity persona = findPersona(request);

InterviewEntity interview = InterviewEntity.builder()
.userId(userId)
.mode(InterviewMode.SOLO)
.answerMode(request.getAnswerMode())
.personaId(persona.getPersonaId())
.status(Status.IN_PROGRESS)
.sessionId(UUID.randomUUID().toString())
Expand All @@ -93,7 +95,7 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req
.build();

InterviewEntity saved = interviewRepository.save(interview);
return InterviewResponse.from(saved);
return InterviewResponse.from(saved, List.of(), persona);
}

/**
Expand All @@ -103,7 +105,7 @@ public InterviewResponse createInterview(Long userId, CreateInterviewRequest req
* 이 순서를 따르고, 꼬리질문이 부모 질문 바로 뒤에 삽입되므로 한 면접관의 질문 묶음이 끝나야
* 다음 면접관으로 넘어간다.
*/
private InterviewResponse createMultiInterview(Long userId, List<Long> requestedIds, Long companyId) {
private InterviewResponse createMultiInterview(Long userId, List<Long> requestedIds, Long companyId, AnswerMode answerMode) {
List<Long> personaIds = new ArrayList<>(new LinkedHashSet<>(requestedIds));
if (personaIds.size() != requestedIds.size()) {
throw BusinessException.unprocessable("같은 면접관을 두 번 지정할 수 없습니다.");
Expand All @@ -122,6 +124,7 @@ private InterviewResponse createMultiInterview(Long userId, List<Long> requested
InterviewEntity saved = interviewRepository.save(InterviewEntity.builder()
.userId(userId)
.mode(InterviewMode.MULTI)
.answerMode(answerMode)
.status(Status.IN_PROGRESS)
.sessionId(UUID.randomUUID().toString())
.companyId(companyId)
Expand All @@ -138,7 +141,9 @@ private InterviewResponse createMultiInterview(Long userId, List<Long> requested
}
interviewPersonaRepository.saveAll(members);

return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList());
// 대표 면접관은 진행 순서 맨 앞이다. 조회 응답과 같은 규칙이다.
return InterviewResponse.from(saved, ordered.stream().map(PersonaEntity::getPersonaId).toList(),
ordered.getFirst());
}

/**
Expand Down Expand Up @@ -261,17 +266,73 @@ private static void requireSupportedJob(AuthUser authUser) {
}
}

/**
* 사용자의 면접 목록. 면접마다 대표 면접관을 붙인다.
*
* <p>N:1 면접관 명단과 면접관은 면접마다 따로 읽지 않고 한 번에 읽어 면접별로 나눈다.
* 그러지 않으면 목록이 길어지는 만큼 쿼리가 늘어난다.
*/
public List<InterviewResponse> getAllInterviewsByUserId(Long userId) {
return interviewRepository.findAllByUserId(userId).stream()
.map(interview -> InterviewResponse.from(interview, personaIdsOf(interview)))
List<InterviewEntity> interviews = interviewRepository.findAllByUserId(userId);

List<Long> multiInterviewIds = interviews.stream()
.filter(interview -> interview.getMode() == InterviewMode.MULTI)
.map(InterviewEntity::getInterviewId)
.toList();
Map<Long, List<Long>> personaIdsByInterview = multiInterviewIds.isEmpty()
? Map.of()
: interviewPersonaRepository
.findAllByInterviewIdInOrderByInterviewIdAscPersonaOrderAsc(multiInterviewIds).stream()
.collect(Collectors.groupingBy(InterviewPersonaEntity::getInterviewId,
Collectors.mapping(InterviewPersonaEntity::getPersonaId, Collectors.toList())));

Map<Long, Long> representativeIdByInterview = new HashMap<>();
for (InterviewEntity interview : interviews) {
Long personaId = representativePersonaId(interview,
personaIdsByInterview.getOrDefault(interview.getInterviewId(), List.of()));
if (personaId != null) {
representativeIdByInterview.put(interview.getInterviewId(), personaId);
}
}
Map<Long, PersonaEntity> personaById = representativeIdByInterview.isEmpty()
? Map.of()
: personaRepository.findAllById(new LinkedHashSet<>(representativeIdByInterview.values())).stream()
.collect(Collectors.toMap(PersonaEntity::getPersonaId, Function.identity()));

return interviews.stream()
.map(interview -> {
Long representativeId = representativeIdByInterview.get(interview.getInterviewId());
return InterviewResponse.from(
interview,
personaIdsByInterview.getOrDefault(interview.getInterviewId(), List.of()),
representativeId == null ? null : personaById.get(representativeId));
})
.toList();
}

public InterviewResponse getInterviewById(Long userId, Long interviewId) {
InterviewEntity interview = interviewRepository.findById(interviewId)
.orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다"));
verifyOwner(interview, userId);
return InterviewResponse.from(interview, personaIdsOf(interview));
List<Long> personaIds = personaIdsOf(interview);
Long representativeId = representativePersonaId(interview, personaIds);
PersonaEntity persona = representativeId == null
? null
: personaRepository.findById(representativeId).orElse(null);
return InterviewResponse.from(interview, personaIds, persona);
}

/**
* 면접을 대표하는 면접관 id. 1:1은 그 면접의 면접관, N:1은 진행 순서 맨 앞 면접관이다.
* 피드백 응답이 성향·난이도를 대표 한 명으로 읽는 것과 같은 규칙이다. 걸린 면접관이 없으면 null이다.
*
* @param multiPersonaIds 진행 순서대로 정렬된 N:1 면접관 명단. 1:1은 빈 목록이다.
*/
private static Long representativePersonaId(InterviewEntity interview, List<Long> multiPersonaIds) {
if (interview.getMode() == InterviewMode.MULTI) {
return multiPersonaIds.isEmpty() ? null : multiPersonaIds.getFirst();
}
return interview.getPersonaId();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE interview ADD COLUMN answer_mode varchar(16);
ALTER TABLE interview ADD CONSTRAINT ck_interview_answer_mode
CHECK (answer_mode IS NULL OR answer_mode IN ('TEXT', 'VOICE'));
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,21 @@ void tearDown() {
}

private static InterviewResponse response(Long interviewId) {
return new InterviewResponse(interviewId, USER_ID, InterviewMode.SOLO, 1L, List.of(), "session",
Status.IN_PROGRESS, LocalDateTime.now(), new CompanyBadge(12L, "샘플커머스"));
return new InterviewResponse(interviewId, USER_ID, InterviewMode.SOLO, 1L, List.of(), null, "session",
Status.IN_PROGRESS, LocalDateTime.now(), new CompanyBadge(12L, "샘플커머스"), null);
}

@Test
void 답변방식_enum을_받고_잘못된_값은_거절한다() throws Exception {
for (String mode : List.of("TEXT", "VOICE")) {
mockMvc.perform(create("{\"personaId\":1,\"answerMode\":\"" + mode + "\"}"))
.andExpect(status().isOk());
}
mockMvc.perform(create("{\"personaId\":1,\"answerMode\":null}"))
.andExpect(status().isOk());
mockMvc.perform(create("{\"personaId\":1,\"answerMode\":\"INVALID\"}"))
.andExpect(status().isBadRequest());

}

private static MockHttpServletRequestBuilder create(String body) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import tools.jackson.databind.ObjectMapper;
import repit.repit_api_server.domain.userdata.interview.entity.enums.AnswerMode;
import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity;
import repit.repit_api_server.domain.userdata.interview.dto.response.InterviewResponse;

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

Expand Down Expand Up @@ -36,6 +39,21 @@ class CreateInterviewRequestDeserializationTest {
});
}

@Test
void 답변방식은_문자열로_왕복하고_생략한_옛_요청은_null이다() {
contextRunner.run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
for (AnswerMode mode : AnswerMode.values()) {
CreateInterviewRequest request = mapper.readValue("{\"personaId\":1,\"answerMode\":\"" + mode + "\"}", CreateInterviewRequest.class);
assertThat(request.getAnswerMode()).isEqualTo(mode);
InterviewResponse response = InterviewResponse.from(InterviewEntity.builder().answerMode(mode).build(), java.util.List.of(), null);
assertThat(mapper.readTree(mapper.writeValueAsString(response)).get("answerMode").asText()).isEqualTo(mode.name());
}
assertThat(mapper.readValue("{\"personaId\":1}", CreateInterviewRequest.class).getAnswerMode()).isNull();
assertThat(mapper.readValue("{\"personaId\":1,\"answerMode\":null}", CreateInterviewRequest.class).getAnswerMode()).isNull();
});
}

@Test
void personaId만_보내는_요청도_받는다() {
contextRunner.run(context -> {
Expand Down
Loading
Loading