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 @@ -34,6 +34,8 @@ public enum ApiErrorCode {
SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "세션을 찾을 수 없습니다."),
SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "세션에 접근할 수 없습니다."),
FEEDBACK_NOT_READY(HttpStatus.NOT_FOUND, "피드백이 아직 생성되지 않았습니다."),
VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."),
VOICE_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "음성 파일 크기가 너무 큽니다."),
VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."),
VOICE_MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "음성 메시지를 찾을 수 없습니다."),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.MessageRole;
import com.stackup.stackup.session.domain.MessageStatus;
import com.stackup.stackup.session.domain.SessionStatus;
import java.util.List;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -51,22 +53,33 @@ public MessageResult submitAnswer(Long userId, Long sessionId, String content, S
InterviewMessage latest = messageRepository
.findFirstBySession_IdOrderBySequenceNumberDesc(sessionId)
.orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_INVALID_STATE));
if (latest.getRole() != com.stackup.stackup.session.domain.MessageRole.INTERVIEWER) {
// 직전 메시지가 질문이 아니면 답변 불가
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
}
InterviewMessage parentQuestion = resolveAnswerParent(latest);
int nextSeq = latest.getSequenceNumber() + 1;
InterviewMessage answer = messageRepository.save(
InterviewMessage.interviewee(session, nextSeq, content, latest,
InterviewMessage.interviewee(session, nextSeq, content, parentQuestion,
idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null)
);

events.publishEvent(new AnswerSubmittedEvent(
userId, sessionId, latest.getId(), answer.getId()
userId, sessionId, parentQuestion.getId(), answer.getId()
));
return MessageResult.of(answer);
}

private InterviewMessage resolveAnswerParent(InterviewMessage latest) {
if (latest.getRole() == MessageRole.INTERVIEWER) {
return latest;
}
if (latest.getRole() == MessageRole.INTERVIEWEE
&& latest.getStatus() == MessageStatus.FAILED
&& latest.getAudioFilePath() != null
&& latest.getParentMessage() != null
&& latest.getParentMessage().getRole() == MessageRole.INTERVIEWER) {
return latest.getParentMessage();
}
throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE);
}

private InterviewSession ownedSession(Long userId, Long sessionId) {
return sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,19 @@ public MessageResult submit(Long userId, Long sessionId, VoiceAnswerUploadComman
}

private void validate(VoiceAnswerUploadCommand cmd) {
if (cmd.size() <= 0 || cmd.size() > MAX_BYTES) {
throw new DomainException(ApiErrorCode.RESUME_FILE_TOO_LARGE);
if (cmd == null || cmd.content() == null || cmd.size() <= 0) {
throw new DomainException(ApiErrorCode.VOICE_EMPTY_FILE);
}
if (cmd.contentType() == null || !ALLOWED_CONTENT_TYPES.contains(cmd.contentType().toLowerCase())) {
if (cmd.size() > MAX_BYTES) {
throw new DomainException(ApiErrorCode.VOICE_FILE_TOO_LARGE);
}
if (cmd.contentType() == null || !ALLOWED_CONTENT_TYPES.contains(cmd.contentType().trim().toLowerCase())) {
throw new DomainException(ApiErrorCode.VOICE_INVALID_CONTENT_TYPE);
}
}

private static String buildKey(Long sessionId, Long messageId, String contentType) {
String ext = switch (contentType.toLowerCase()) {
String ext = switch (contentType.trim().toLowerCase()) {
case "audio/webm" -> "webm";
case "audio/ogg" -> "ogg";
case "audio/mpeg" -> "mp3";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import com.stackup.stackup.session.application.event.AnswerSubmittedEvent;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.MessageStatus;
import com.stackup.stackup.session.domain.MessageVoiceAnalysis;
import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -64,14 +63,19 @@ public void apply(VoiceCallbackEnvelope envelope) {
}

if (p.errorCode() != null && !p.errorCode().isBlank()) {
message.markStatus(MessageStatus.FAILED);
sseEventPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE,
new VoiceFailedNotice(p.sessionId(), message.getId(), p.errorCode()));
failVoiceMessage(p.sessionId(), message, p.errorCode());
markProcessed(envelope.messageId());
log.warn("callback.voice STT failed. sessionId={}, msg={}, code={}",
p.sessionId(), message.getId(), p.errorCode());
return;
}
if (p.transcript() == null || p.transcript().isBlank()) {
failVoiceMessage(p.sessionId(), message, "STT_EMPTY_TRANSCRIPT");
markProcessed(envelope.messageId());
log.warn("callback.voice STT empty transcript. sessionId={}, msg={}",
p.sessionId(), message.getId());
return;
}

message.completeWithTranscript(p.transcript());

Expand Down Expand Up @@ -113,7 +117,20 @@ public void apply(VoiceCallbackEnvelope envelope) {
public record VoiceTranscribedNotice(Long sessionId, Long messageId, String transcript) {
}

public record VoiceFailedNotice(Long sessionId, Long messageId, String errorCode) {
public record VoiceFailedNotice(Long sessionId, Long messageId, String errorCode, String fallbackText) {
}

private void failVoiceMessage(Long sessionId, InterviewMessage message, String errorCode) {
message.failVoiceTranscription();
VoiceFailedNotice notice = new VoiceFailedNotice(
sessionId,
message.getId(),
errorCode,
message.getContent()
);
sseEventPublisher.publishToSession(sessionId, SseEventType.SESSION_MESSAGE, notice);
sseEventPublisher.publishToUser(message.getSession().getUser().getId(),
SseEventType.SESSION_MESSAGE, notice);
}

private String fillerToJson(java.util.Map<String, Integer> map) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public record MessageResult(
Integer sequenceNumber,
MessageRole role,
String content,
String audioFilePath,
Long parentMessageId,
MessageStatus status,
Instant createdAt
Expand All @@ -22,6 +23,7 @@ public static MessageResult of(InterviewMessage m) {
m.getSequenceNumber(),
m.getRole(),
m.getContent(),
m.getAudioFilePath(),
m.getParentMessage() == null ? null : m.getParentMessage().getId(),
m.getStatus(),
m.getCreatedAt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class InterviewMessage extends BaseTimeEntity {

public static final String VOICE_TRANSCRIPTION_PENDING_TEXT = "(transcribing)";
public static final String VOICE_TRANSCRIPTION_FAILED_TEXT =
"음성 인식에 실패했습니다. 텍스트로 다시 답변해 주세요.";

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -106,7 +110,8 @@ public static InterviewMessage interviewee(InterviewSession session, int seq, St
// 음성 답변 placeholder. content 는 STT 완료 후 채움. 생성 시 CHECK 제약 만족을 위해 임시 공백 + audio_file_path 후속 갱신.
public static InterviewMessage voiceInterviewee(InterviewSession session, int seq,
InterviewMessage parent, String idempotencyKey) {
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWEE, "(transcribing)",
InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWEE,
VOICE_TRANSCRIPTION_PENDING_TEXT,
parent, MessageStatus.CREATED, idempotencyKey);
return m;
}
Expand All @@ -127,4 +132,9 @@ public void completeWithTranscript(String transcript) {
}
this.status = MessageStatus.COMPLETED;
}

public void failVoiceTranscription() {
this.content = VOICE_TRANSCRIPTION_FAILED_TEXT;
this.status = MessageStatus.FAILED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public record MessageResponse(
Integer sequenceNumber,
MessageRole role,
String content,
String audioFilePath,
Long parentMessageId,
MessageStatus status,
Instant createdAt
Expand All @@ -22,6 +23,7 @@ public static MessageResponse from(MessageResult r) {
r.sequenceNumber(),
r.role(),
r.content(),
r.audioFilePath(),
r.parentMessageId(),
r.status(),
r.createdAt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
Expand Down Expand Up @@ -84,6 +85,35 @@ void submitAnswer_rejectsWhenPreviousMessageNotInterviewer() {
.isInstanceOf(DomainException.class);
}

@Test
void submitAnswer_allowsTextRetryAfterFailedVoiceAnswer() {
InterviewSession session = sessionInProgress(10L);
InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?");
ReflectionTestUtils.setField(question, "id", 100L);
InterviewMessage failedVoice = InterviewMessage.voiceInterviewee(session, 2, question, null);
ReflectionTestUtils.setField(failedVoice, "id", 200L);
failedVoice.attachAudio("interview/voice/raw/10/200.webm");
failedVoice.failVoiceTranscription();

when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session));
when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(failedVoice));
when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> {
InterviewMessage m = inv.getArgument(0);
ReflectionTestUtils.setField(m, "id", 201L);
return m;
});

MessageResult result = service.submitAnswer(1L, 10L, "retry as text", null);

assertThat(result.id()).isEqualTo(201L);
assertThat(result.sequenceNumber()).isEqualTo(3);
assertThat(result.parentMessageId()).isEqualTo(100L);
ArgumentCaptor<AnswerSubmittedEvent> eventCaptor = ArgumentCaptor.forClass(AnswerSubmittedEvent.class);
verify(events).publishEvent(eventCaptor.capture());
assertThat(eventCaptor.getValue().parentQuestionMessageId()).isEqualTo(100L);
assertThat(eventCaptor.getValue().answerMessageId()).isEqualTo(201L);
}

private InterviewSession sessionInProgress(Long id) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
Expand Down
Loading