From fca7138eca307208f7e15eb5eecb34aa84940a27 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 3 Jun 2026 12:01:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(session):=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=9A=A9=20presigned=20URL=20=EB=8F=99=EB=B4=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 음성 면접 재생을 위해 ttsAudioPath/audioFilePath(원시 S3 키)를 조회 응답에서 presigned URL(ttsAudioUrl/audioFileUrl)로 변환. 문서 다운로드 presign 패턴과 동일, TTL 30분. - MessageResult/MessageResponse: ttsAudioUrl/audioFileUrl 필드 추가 - InterviewMessageService.list: ObjectStorageClient 주입, TTS SUCCEEDED 질문 + 음성 답변 원본을 presign (개별 try/catch) - openapi.json 재생성 Co-Authored-By: Claude Opus 4.8 --- backend/openapi.json | 6 ++++ .../application/InterviewMessageService.java | 34 ++++++++++++++++++- .../application/dto/MessageResult.java | 13 +++++-- .../presentation/dto/MessageResponse.java | 9 +++-- 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/backend/openapi.json b/backend/openapi.json index ca31a921..4772afe0 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2613,6 +2613,12 @@ }, "targetEvidence" : { "type" : "string" + }, + "ttsAudioUrl" : { + "type" : "string" + }, + "audioFileUrl" : { + "type" : "string" } } }, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java index 58e9944e..16bb145b 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -2,6 +2,7 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; import com.stackup.stackup.session.application.dto.MessageResult; import com.stackup.stackup.session.application.event.AnswerSubmittedEvent; import com.stackup.stackup.session.domain.InterviewMessage; @@ -11,8 +12,13 @@ import com.stackup.stackup.session.domain.MessageRole; import com.stackup.stackup.session.domain.MessageStatus; import com.stackup.stackup.session.domain.SessionStatus; +import com.stackup.stackup.session.domain.TtsStatus; +import java.net.URI; +import java.time.Duration; import java.util.List; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,17 +30,43 @@ @Transactional(readOnly = true) public class InterviewMessageService { + private static final Logger log = LoggerFactory.getLogger(InterviewMessageService.class); + private static final Duration AUDIO_URL_TTL = Duration.ofMinutes(30); + private final InterviewSessionRepository sessionRepository; private final InterviewMessageRepository messageRepository; + private final ObjectStorageClient storage; private final ApplicationEventPublisher events; public List list(Long userId, Long sessionId) { ownedSession(userId, sessionId); return messageRepository.findBySession_IdOrderBySequenceNumberAsc(sessionId).stream() - .map(MessageResult::of) + .map(this::toResultWithAudioUrls) .toList(); } + // 재생용 presigned URL 동봉: 질문 TTS(SUCCEEDED) + 음성 답변 원본. + // presign 실패가 메시지 조회 전체를 깨뜨리지 않도록 개별 try/catch. + private MessageResult toResultWithAudioUrls(InterviewMessage m) { + String ttsUrl = m.getTtsStatus() == TtsStatus.SUCCEEDED + ? presign(m.getTtsAudioPath()) : null; + String audioUrl = presign(m.getAudioFilePath()); + return MessageResult.of(m, ttsUrl, audioUrl); + } + + private String presign(String key) { + if (key == null || key.isBlank()) { + return null; + } + try { + URI url = storage.createPresignedGetUrl(key, AUDIO_URL_TTL); + return url == null ? null : url.toString(); + } catch (RuntimeException e) { + log.warn("presigned audio URL creation failed. key={}", key, e); + return null; + } + } + @Transactional public MessageResult submitAnswer(Long userId, Long sessionId, String content, String idempotencyKey) { InterviewSession session = ownedSession(userId, sessionId); 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 a4ef63cd..8a199e03 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 @@ -20,10 +20,17 @@ public record MessageResult( MessageStatus status, Instant createdAt, String category, - String targetEvidence + String targetEvidence, + // 재생용 presigned URL. 조회(list) 경로에서만 채우고, 그 외에는 null. + String ttsAudioUrl, + String audioFileUrl // expectedSignal 은 의도적으로 제외 — 정답 유출 방지(라이브 비노출). ) { public static MessageResult of(InterviewMessage m) { + return of(m, null, null); + } + + public static MessageResult of(InterviewMessage m, String ttsAudioUrl, String audioFileUrl) { return new MessageResult( m.getId(), m.getSession().getId(), @@ -38,7 +45,9 @@ public static MessageResult of(InterviewMessage m) { m.getStatus(), m.getCreatedAt(), m.getCategory(), - m.getTargetEvidence() + m.getTargetEvidence(), + ttsAudioUrl, + audioFileUrl ); } } 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 b2a9f9e5..67bbd03a 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 @@ -20,7 +20,10 @@ public record MessageResponse( MessageStatus status, Instant createdAt, String category, - String targetEvidence + String targetEvidence, + // 재생용 presigned URL (질문 TTS / 음성 답변 원본). 조회 응답에서만 채움. + String ttsAudioUrl, + String audioFileUrl // expectedSignal 은 노출하지 않음 — 자기연습 시 정답 유출 방지. ) { public static MessageResponse from(MessageResult r) { @@ -38,7 +41,9 @@ public static MessageResponse from(MessageResult r) { r.status(), r.createdAt(), r.category(), - r.targetEvidence() + r.targetEvidence(), + r.ttsAudioUrl(), + r.audioFileUrl() ); } } From fd5bae3fc36d8eac52e1a37816979672d704310a Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 3 Jun 2026 12:07:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(interview):=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=91=20=E2=80=94=20=EC=A7=88=EB=AC=B8=20TTS=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=20+=20=EC=9D=8C=EC=84=B1=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EB=85=B9=EC=9D=8C/=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드 음성 파이프라인(STT/TTS)이 이미 있었으나 프론트가 텍스트만 사용. 풀 음성 경험을 연결한다. 질문 음성 재생: - QuestionBubble: ttsStatus=SUCCEEDED + ttsAudioUrl 이면 "음성 듣기" 버튼 +