From df79d8396016f31f20aaa1be50ea3ec9814fed30 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:31:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20TTS=20=E3=84=B1=EC=85=9C=EA=B3=BC?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EB=B0=8F=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/TtsCallbackService.java | 129 ++++++++++++++++++ .../application/dto/MessageResult.java | 7 + .../application/dto/TtsCallbackEnvelope.java | 16 +++ .../application/dto/TtsCallbackPayload.java | 23 ++++ .../session/domain/InterviewMessage.java | 34 ++++- .../stackup/session/domain/TtsStatus.java | 8 ++ .../infrastructure/TtsCallbackHandler.java | 29 ++++ .../presentation/dto/MessageResponse.java | 7 + ...ts_result_fields_to_interview_messages.sql | 6 + 9 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackEnvelope.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/domain/TtsStatus.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/infrastructure/TtsCallbackHandler.java create mode 100644 backend/src/main/resources/db/migration/V7__add_tts_result_fields_to_interview_messages.sql diff --git a/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java new file mode 100644 index 00000000..84d5227f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/TtsCallbackService.java @@ -0,0 +1,129 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.TtsCallbackPayload; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.TtsStatus; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TtsCallbackService { + + private static final Logger log = LoggerFactory.getLogger(TtsCallbackService.class); + private static final String CONSUMER_NAME = "core.callback.tts"; + + private final InterviewMessageRepository messageRepository; + private final ProcessedMessageRepository processedMessageRepository; + private final SseEventPublisher sseEventPublisher; + + @Transactional + public void apply(TtsCallbackEnvelope envelope) { + if (envelope == null || envelope.payload() == null) { + log.warn("callback.tts envelope or payload is null - skip"); + return; + } + if (isProcessed(envelope.messageId())) { + log.info("callback.tts duplicate, skip. messageId={}", envelope.messageId()); + return; + } + + TtsCallbackPayload p = envelope.payload(); + if (p.messageId() == null || p.sessionId() == null) { + log.warn("callback.tts missing sessionId or messageId. messageId={}", envelope.messageId()); + markProcessed(envelope.messageId()); + return; + } + + InterviewMessage message = messageRepository.findById(p.messageId()).orElse(null); + if (message == null) { + log.warn("callback.tts message not found. id={}", p.messageId()); + markProcessed(envelope.messageId()); + return; + } + if (!p.sessionId().equals(message.getSession().getId()) || message.getRole() != MessageRole.INTERVIEWER) { + log.warn("callback.tts ignored non-question or mismatched message. sessionId={}, msg={}", + p.sessionId(), p.messageId()); + markProcessed(envelope.messageId()); + return; + } + + if (p.isSucceeded()) { + if (p.audioKey() == null || p.audioKey().isBlank()) { + message.failTts(); + publishNotice(message, "TTS_EMPTY_AUDIO"); + log.warn("callback.tts success without audio key. sessionId={}, msg={}", p.sessionId(), p.messageId()); + } else { + message.completeTts(p.audioKey(), p.durationSec()); + publishNotice(message, null); + log.info("callback.tts processed. sessionId={}, msg={}, durationSec={}", + p.sessionId(), p.messageId(), p.durationSec()); + } + } else if (p.isFailed()) { + message.failTts(); + publishNotice(message, p.errorCode()); + log.warn("callback.tts failed. sessionId={}, msg={}, code={}", + p.sessionId(), p.messageId(), p.errorCode()); + } else { + log.warn("callback.tts unknown status={}. sessionId={}, msg={}", + p.status(), p.sessionId(), p.messageId()); + } + + markProcessed(envelope.messageId()); + } + + private void publishNotice(InterviewMessage message, String errorCode) { + Long sessionId = message.getSession().getId(); + TtsUpdatedNotice notice = new TtsUpdatedNotice( + sessionId, + message.getId(), + message.getTtsStatus(), + message.getTtsAudioPath(), + message.getTtsDurationSec(), + errorCode + ); + sseEventPublisher.publishToSession(sessionId, SseEventType.SESSION_MESSAGE, notice); + sseEventPublisher.publishToUser(message.getSession().getUser().getId(), + SseEventType.SESSION_MESSAGE, notice); + } + + private boolean isProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) { + return false; + } + return processedMessageRepository.existsById(messageId); + } + + private void markProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) { + return; + } + try { + processedMessageRepository.save(ProcessedMessage.of(messageId, CONSUMER_NAME)); + } catch (DataIntegrityViolationException ignored) { + // race + } + } + + public record TtsUpdatedNotice( + Long sessionId, + Long messageId, + TtsStatus status, + String audioPath, + Double durationSec, + String errorCode + ) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java index 80a2394a..63cbb6e2 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/MessageResult.java @@ -3,6 +3,7 @@ import com.stackup.stackup.session.domain.InterviewMessage; import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.TtsStatus; import java.time.Instant; public record MessageResult( @@ -12,6 +13,9 @@ public record MessageResult( MessageRole role, String content, String audioFilePath, + String ttsAudioPath, + TtsStatus ttsStatus, + Double ttsDurationSec, Long parentMessageId, MessageStatus status, Instant createdAt @@ -24,6 +28,9 @@ public static MessageResult of(InterviewMessage m) { m.getRole(), m.getContent(), m.getAudioFilePath(), + m.getTtsAudioPath(), + m.getTtsStatus(), + m.getTtsDurationSec(), m.getParentMessage() == null ? null : m.getParentMessage().getId(), m.getStatus(), m.getCreatedAt() diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackEnvelope.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackEnvelope.java new file mode 100644 index 00000000..401bb875 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackEnvelope.java @@ -0,0 +1,16 @@ +package com.stackup.stackup.session.application.dto; + +import com.stackup.stackup.common.messaging.MessageContext; +import java.time.Instant; + +public record TtsCallbackEnvelope( + String messageId, + String messageType, + String version, + String traceId, + Instant publishedAt, + String publisher, + TtsCallbackPayload payload, + MessageContext context +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackPayload.java new file mode 100644 index 00000000..bf9933cd --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/TtsCallbackPayload.java @@ -0,0 +1,23 @@ +package com.stackup.stackup.session.application.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TtsCallbackPayload( + Long sessionId, + Long messageId, + String status, + @JsonAlias({"audioPath", "audioUrl"}) + String audioKey, + Double durationSec, + String errorCode +) { + public boolean isSucceeded() { + return "SUCCEEDED".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status); + } + + public boolean isFailed() { + return "FAILED".equalsIgnoreCase(status) || (errorCode != null && !errorCode.isBlank()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java index f36e9ce7..2f84cd8e 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessage.java @@ -58,6 +58,16 @@ public class InterviewMessage extends BaseTimeEntity { @Column(name = "audio_file_path", length = 1000) private String audioFilePath; + @Column(name = "tts_audio_path", length = 1000) + private String ttsAudioPath; + + @Column(name = "tts_status", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private TtsStatus ttsStatus = TtsStatus.NOT_REQUESTED; + + @Column(name = "tts_duration_sec") + private Double ttsDurationSec; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_message_id") private InterviewMessage parentMessage; @@ -91,14 +101,18 @@ private InterviewMessage(InterviewSession session, Integer sequenceNumber, Messa } public static InterviewMessage interviewer(InterviewSession session, int seq, String content) { - return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null, + InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, null, MessageStatus.CREATED, null); + m.markTtsPending(); + return m; } public static InterviewMessage followup(InterviewSession session, int seq, String content, InterviewMessage parent) { - return new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent, + InterviewMessage m = new InterviewMessage(session, seq, MessageRole.INTERVIEWER, content, parent, MessageStatus.CREATED, null); + m.markTtsPending(); + return m; } public static InterviewMessage interviewee(InterviewSession session, int seq, String content, @@ -126,6 +140,22 @@ public void attachAudio(String audioFilePath) { this.audioFilePath = audioFilePath; } + public void markTtsPending() { + this.ttsStatus = TtsStatus.PENDING; + } + + public void completeTts(String ttsAudioPath, Double ttsDurationSec) { + if (ttsAudioPath != null && !ttsAudioPath.isBlank()) { + this.ttsAudioPath = ttsAudioPath; + } + this.ttsDurationSec = ttsDurationSec; + this.ttsStatus = TtsStatus.SUCCEEDED; + } + + public void failTts() { + this.ttsStatus = TtsStatus.FAILED; + } + public void completeWithTranscript(String transcript) { if (transcript != null && !transcript.isBlank()) { this.content = transcript; diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/TtsStatus.java b/backend/src/main/java/com/stackup/stackup/session/domain/TtsStatus.java new file mode 100644 index 00000000..55f31f91 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/domain/TtsStatus.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.session.domain; + +public enum TtsStatus { + NOT_REQUESTED, + PENDING, + SUCCEEDED, + FAILED +} diff --git a/backend/src/main/java/com/stackup/stackup/session/infrastructure/TtsCallbackHandler.java b/backend/src/main/java/com/stackup/stackup/session/infrastructure/TtsCallbackHandler.java new file mode 100644 index 00000000..6caa40ec --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/infrastructure/TtsCallbackHandler.java @@ -0,0 +1,29 @@ +package com.stackup.stackup.session.infrastructure; + +import com.stackup.stackup.session.application.TtsCallbackService; +import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TtsCallbackHandler { + + private static final Logger log = LoggerFactory.getLogger(TtsCallbackHandler.class); + + private final TtsCallbackService callbackService; + + @RabbitListener(queues = "${app.messaging.rabbitmq.queues.names.core-callback-tts}") + public void handle(TtsCallbackEnvelope envelope) { + try { + callbackService.apply(envelope); + } catch (RuntimeException e) { + log.error("callback.tts processing failed. messageId={}", + envelope == null ? null : envelope.messageId(), e); + throw e; + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java index d62270c5..2a08c01b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/MessageResponse.java @@ -3,6 +3,7 @@ import com.stackup.stackup.session.application.dto.MessageResult; import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.TtsStatus; import java.time.Instant; public record MessageResponse( @@ -12,6 +13,9 @@ public record MessageResponse( MessageRole role, String content, String audioFilePath, + String ttsAudioPath, + TtsStatus ttsStatus, + Double ttsDurationSec, Long parentMessageId, MessageStatus status, Instant createdAt @@ -24,6 +28,9 @@ public static MessageResponse from(MessageResult r) { r.role(), r.content(), r.audioFilePath(), + r.ttsAudioPath(), + r.ttsStatus(), + r.ttsDurationSec(), r.parentMessageId(), r.status(), r.createdAt() diff --git a/backend/src/main/resources/db/migration/V7__add_tts_result_fields_to_interview_messages.sql b/backend/src/main/resources/db/migration/V7__add_tts_result_fields_to_interview_messages.sql new file mode 100644 index 00000000..ea87021a --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__add_tts_result_fields_to_interview_messages.sql @@ -0,0 +1,6 @@ +ALTER TABLE interview_messages + ADD COLUMN tts_audio_path VARCHAR(1000), + ADD COLUMN tts_status VARCHAR(20) NOT NULL DEFAULT 'NOT_REQUESTED', + ADD COLUMN tts_duration_sec DOUBLE PRECISION, + ADD CONSTRAINT chk_interview_messages_tts_status + CHECK (tts_status IN ('NOT_REQUESTED', 'PENDING', 'SUCCEEDED', 'FAILED')); From ee0912d691c1e7c454490e3924191b7cc1361e59 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:32:29 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20TTS=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=ED=81=90=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/properties/RabbitMqProperties.java | 4 ++- .../common/messaging/RabbitMqConfig.java | 15 +++++++-- backend/src/main/resources/application.yml | 2 ++ infra/rabbitmq/definitions.json | 31 +++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java index a4439f93..3076aab2 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -72,7 +72,8 @@ public record Names( @NotBlank String coreCallbackAnalysis, @NotBlank String coreCallbackQuestions, @NotBlank String coreCallbackFeedback, - @NotBlank String coreCallbackVoice + @NotBlank String coreCallbackVoice, + @NotBlank String coreCallbackTts ) { } } @@ -88,6 +89,7 @@ public record RoutingKeyProperties( @NotBlank String callbackQuestions, @NotBlank String callbackFeedback, @NotBlank String callbackVoice, + @NotBlank String callbackTts, @NotBlank String realtimeSessionNotify, @NotBlank String realtimeUserNotify, @NotBlank String realtimeDocumentNotify diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index 02484570..a6040d89 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -117,6 +117,11 @@ public Queue coreCallbackVoiceQueue() { return workQueue(properties.queues().names().coreCallbackVoice()); } + @Bean + public Queue coreCallbackTtsQueue() { + return workQueue(properties.queues().names().coreCallbackTts()); + } + @Bean public Declarables rabbitDeclarables( TopicExchange coreToAiExchange, @@ -132,7 +137,8 @@ public Declarables rabbitDeclarables( Queue coreCallbackAnalysisQueue, Queue coreCallbackQuestionsQueue, Queue coreCallbackFeedbackQueue, - Queue coreCallbackVoiceQueue + Queue coreCallbackVoiceQueue, + Queue coreCallbackTtsQueue ) { Queue dlqAiAnalyzeResume = dlq(properties.queues().names().aiAnalyzeResume()); Queue dlqAiAnalyzeRepository = dlq(properties.queues().names().aiAnalyzeRepository()); @@ -144,6 +150,7 @@ public Declarables rabbitDeclarables( Queue dlqCoreCallbackQuestions = dlq(properties.queues().names().coreCallbackQuestions()); Queue dlqCoreCallbackFeedback = dlq(properties.queues().names().coreCallbackFeedback()); Queue dlqCoreCallbackVoice = dlq(properties.queues().names().coreCallbackVoice()); + Queue dlqCoreCallbackTts = dlq(properties.queues().names().coreCallbackTts()); return new Declarables( coreToAiExchange, @@ -160,6 +167,7 @@ public Declarables rabbitDeclarables( coreCallbackQuestionsQueue, coreCallbackFeedbackQueue, coreCallbackVoiceQueue, + coreCallbackTtsQueue, dlqAiAnalyzeResume, dlqAiAnalyzeRepository, dlqAiGenerateQuestions, @@ -170,6 +178,7 @@ public Declarables rabbitDeclarables( dlqCoreCallbackQuestions, dlqCoreCallbackFeedback, dlqCoreCallbackVoice, + dlqCoreCallbackTts, BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeResume()), BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeRepository()), BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(properties.routingKeys().generateQuestions()), @@ -180,6 +189,7 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackQuestions()), BindingBuilder.bind(coreCallbackFeedbackQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackFeedback()), BindingBuilder.bind(coreCallbackVoiceQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackVoice()), + BindingBuilder.bind(coreCallbackTtsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackTts()), BindingBuilder.bind(dlqAiAnalyzeResume).to(deadLetterExchange).with(dlqAiAnalyzeResume.getName()), BindingBuilder.bind(dlqAiAnalyzeRepository).to(deadLetterExchange).with(dlqAiAnalyzeRepository.getName()), BindingBuilder.bind(dlqAiGenerateQuestions).to(deadLetterExchange).with(dlqAiGenerateQuestions.getName()), @@ -189,7 +199,8 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(dlqCoreCallbackAnalysis).to(deadLetterExchange).with(dlqCoreCallbackAnalysis.getName()), BindingBuilder.bind(dlqCoreCallbackQuestions).to(deadLetterExchange).with(dlqCoreCallbackQuestions.getName()), BindingBuilder.bind(dlqCoreCallbackFeedback).to(deadLetterExchange).with(dlqCoreCallbackFeedback.getName()), - BindingBuilder.bind(dlqCoreCallbackVoice).to(deadLetterExchange).with(dlqCoreCallbackVoice.getName()) + BindingBuilder.bind(dlqCoreCallbackVoice).to(deadLetterExchange).with(dlqCoreCallbackVoice.getName()), + BindingBuilder.bind(dlqCoreCallbackTts).to(deadLetterExchange).with(dlqCoreCallbackTts.getName()) // 주의: q.realtime.session.notify 큐 + binding (+ DLQ) 은 RealTime 서버 측에서 // infra/rabbitmq/definitions.json 으로 import 되므로 Core 는 exchange 만 declare. ); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 00c9ed56..df8031e3 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -100,6 +100,7 @@ app: core-callback-questions: core.callback.questions core-callback-feedback: core.callback.feedback core-callback-voice: core.callback.voice + core-callback-tts: core.callback.tts routing-keys: analyze-resume: analyze.resume analyze-repository: analyze.repository @@ -111,6 +112,7 @@ app: callback-questions: callback.questions callback-feedback: callback.feedback callback-voice: callback.voice + callback-tts: callback.tts realtime-session-notify: realtime.session.notify realtime-user-notify: realtime.user.notify realtime-document-notify: realtime.document.notify diff --git a/infra/rabbitmq/definitions.json b/infra/rabbitmq/definitions.json index 92d3b9e2..8ddcd475 100644 --- a/infra/rabbitmq/definitions.json +++ b/infra/rabbitmq/definitions.json @@ -160,6 +160,16 @@ "x-dead-letter-routing-key": "dlq.core.callback.voice" } }, + { + "name": "core.callback.tts", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": { + "x-dead-letter-exchange": "stackup.dlx", + "x-dead-letter-routing-key": "dlq.core.callback.tts" + } + }, { "name": "q.realtime.session.notify", "vhost": "/", @@ -247,6 +257,13 @@ "auto_delete": false, "arguments": {} }, + { + "name": "dlq.core.callback.tts", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, { "name": "dlq.q.realtime.session.notify", "vhost": "/", @@ -333,6 +350,13 @@ "destination_type": "queue", "routing_key": "callback.voice" }, + { + "source": "stackup.ai-to-core", + "vhost": "/", + "destination": "core.callback.tts", + "destination_type": "queue", + "routing_key": "callback.tts" + }, { "source": "stackup.realtime", "vhost": "/", @@ -431,6 +455,13 @@ "destination_type": "queue", "routing_key": "dlq.core.callback.voice" }, + { + "source": "stackup.dlx", + "vhost": "/", + "destination": "dlq.core.callback.tts", + "destination_type": "queue", + "routing_key": "dlq.core.callback.tts" + }, { "source": "stackup.dlx", "vhost": "/", From fd502ed4c19c442cf196e7c1447264b51dda86b2 Mon Sep 17 00:00:00 2001 From: pswaao88 Date: Tue, 2 Jun 2026 00:33:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20TTS=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=94=EC=84=B8=EC=A7=95=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messaging/RabbitMessagePublisherTest.java | 4 +- .../common/messaging/RabbitMqConfigTest.java | 6 +- .../SessionFeedbackRequesterTest.java | 1 + .../SessionQuestionsRequesterTest.java | 1 + .../application/TtsCallbackServiceTest.java | 142 ++++++++++++++++++ .../VoiceAnswerUploadServiceTest.java | 4 +- 6 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index a115d060..9085e434 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -97,7 +97,8 @@ private RabbitMqProperties rabbitMqProperties() { "core.callback.analysis", "core.callback.questions", "core.callback.feedback", - "core.callback.voice" + "core.callback.voice", + "core.callback.tts" ) ), new RabbitMqProperties.RoutingKeyProperties( @@ -111,6 +112,7 @@ private RabbitMqProperties rabbitMqProperties() { "callback.questions", "callback.feedback", "callback.voice", + "callback.tts", "realtime.session.notify", "realtime.user.notify", "realtime.document.notify" diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java index b1b4a2cc..6f56fa37 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java @@ -37,6 +37,8 @@ void allWorkQueues_routeToDeadLetterExchange() { .containsEntry("x-dead-letter-routing-key", "dlq.core.callback.analysis"); assertThat(config.coreCallbackQuestionsQueue().getArguments()) .containsEntry("x-dead-letter-routing-key", "dlq.core.callback.questions"); + assertThat(config.coreCallbackTtsQueue().getArguments()) + .containsEntry("x-dead-letter-routing-key", "dlq.core.callback.tts"); } private RabbitMqProperties rabbitMqProperties() { @@ -64,7 +66,8 @@ private RabbitMqProperties rabbitMqProperties() { "core.callback.analysis", "core.callback.questions", "core.callback.feedback", - "core.callback.voice" + "core.callback.voice", + "core.callback.tts" ) ), new RabbitMqProperties.RoutingKeyProperties( @@ -78,6 +81,7 @@ private RabbitMqProperties rabbitMqProperties() { "callback.questions", "callback.feedback", "callback.voice", + "callback.tts", "realtime.session.notify", "realtime.user.notify", "realtime.document.notify" diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index 674cbfc9..6c1d72c8 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -84,6 +84,7 @@ private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { "analyze.resume", "analyze.repository", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", + "callback.tts", "realtime.session.notify", "realtime.user.notify", "realtime.document.notify"); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java index 241f86e9..07d924e3 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java @@ -63,6 +63,7 @@ private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { "analyze.resume", "analyze.repository", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", + "callback.tts", "realtime.session.notify", "realtime.user.notify", "realtime.document.notify"); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java new file mode 100644 index 00000000..d2e40ade --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/TtsCallbackServiceTest.java @@ -0,0 +1,142 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope; +import com.stackup.stackup.session.application.dto.TtsCallbackPayload; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.domain.TtsStatus; +import com.stackup.stackup.user.domain.User; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class TtsCallbackServiceTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Mock InterviewMessageRepository messageRepository; + @Mock ProcessedMessageRepository processedMessageRepository; + @Mock SseEventPublisher sseEventPublisher; + @InjectMocks TtsCallbackService service; + + @Test + void apply_successStoresTtsFieldsWithoutChangingMessageStatus() { + InterviewSession session = sessionFixture(50L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q?"); + ReflectionTestUtils.setField(question, "id", 100L); + + when(processedMessageRepository.existsById("tts-1")).thenReturn(false); + when(messageRepository.findById(100L)).thenReturn(Optional.of(question)); + + service.apply(envelope("tts-1", new TtsCallbackPayload( + 50L, 100L, "SUCCEEDED", "interview/tts/50/100.mp3", 2.4, null + ))); + + assertThat(question.getTtsStatus()).isEqualTo(TtsStatus.SUCCEEDED); + assertThat(question.getTtsAudioPath()).isEqualTo("interview/tts/50/100.mp3"); + assertThat(question.getTtsDurationSec()).isEqualTo(2.4); + assertThat(MessageResult.of(question).ttsAudioPath()).isEqualTo("interview/tts/50/100.mp3"); + verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(sseEventPublisher).publishToUser(eq(1L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(processedMessageRepository).save(any(ProcessedMessage.class)); + } + + @Test + void apply_failedMarksOnlyTtsFailedAndLeavesQuestionUsable() { + InterviewSession session = sessionFixture(50L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q?"); + ReflectionTestUtils.setField(question, "id", 100L); + + when(processedMessageRepository.existsById("tts-fail")).thenReturn(false); + when(messageRepository.findById(100L)).thenReturn(Optional.of(question)); + + service.apply(envelope("tts-fail", new TtsCallbackPayload( + 50L, 100L, "FAILED", null, null, "TTS_PROVIDER_DOWN" + ))); + + assertThat(question.getTtsStatus()).isEqualTo(TtsStatus.FAILED); + assertThat(question.getContent()).isEqualTo("Q?"); + assertThat(question.getTtsAudioPath()).isNull(); + verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(processedMessageRepository).save(any(ProcessedMessage.class)); + } + + @Test + void apply_ignoresIntervieweeMessages() { + InterviewSession session = sessionFixture(50L); + InterviewMessage answer = InterviewMessage.interviewee(session, 2, "A", null, null); + ReflectionTestUtils.setField(answer, "id", 200L); + + when(processedMessageRepository.existsById("tts-answer")).thenReturn(false); + when(messageRepository.findById(200L)).thenReturn(Optional.of(answer)); + + service.apply(envelope("tts-answer", new TtsCallbackPayload( + 50L, 200L, "SUCCEEDED", "interview/tts/50/200.mp3", 1.1, null + ))); + + assertThat(answer.getTtsStatus()).isEqualTo(TtsStatus.NOT_REQUESTED); + verify(sseEventPublisher, never()).publishToSession(any(), any(), any()); + verify(processedMessageRepository).save(any(ProcessedMessage.class)); + } + + @Test + void apply_skipsDuplicateMessageId() { + when(processedMessageRepository.existsById("dup")).thenReturn(true); + + service.apply(envelope("dup", new TtsCallbackPayload( + 50L, 100L, "SUCCEEDED", "interview/tts/50/100.mp3", 2.4, null + ))); + + verify(messageRepository, never()).findById(any()); + } + + @Test + void payload_acceptsAudioUrlAliasAndLowercaseStatus() throws Exception { + TtsCallbackPayload payload = JSON.readValue(""" + { + "sessionId": 50, + "messageId": 100, + "status": "succeeded", + "audioUrl": "interview/tts/50/100.mp3", + "durationSec": 2.4 + } + """, TtsCallbackPayload.class); + + assertThat(payload.isSucceeded()).isTrue(); + assertThat(payload.audioKey()).isEqualTo("interview/tts/50/100.mp3"); + } + + private TtsCallbackEnvelope envelope(String messageId, TtsCallbackPayload payload) { + return new TtsCallbackEnvelope(messageId, "callback.tts", "1", "t", + null, "ai", payload, null); + } + + private InterviewSession sessionFixture(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, JobCategory.BACKEND, 5, 30); + ReflectionTestUtils.setField(s, "id", id); + return s; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java index cfabd95a..4a7c4d81 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java @@ -216,7 +216,8 @@ private RabbitMqProperties rabbitMqProperties() { "core.callback.analysis", "core.callback.questions", "core.callback.feedback", - "core.callback.voice" + "core.callback.voice", + "core.callback.tts" )), new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", @@ -229,6 +230,7 @@ private RabbitMqProperties rabbitMqProperties() { "callback.questions", "callback.feedback", "callback.voice", + "callback.tts", "session.notify", "realtime.user.notify", "realtime.document.notify"