diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/extern/ParagraphAdapter.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/extern/ParagraphAdapter.java new file mode 100644 index 0000000..ae5781c --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/extern/ParagraphAdapter.java @@ -0,0 +1,19 @@ +package com.capstone.kkumteul.domain.fairytale.extern; + +import com.capstone.kkumteul.domain.fairytale.exception.ParagraphNotFoundException; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ParagraphAdapter implements ParagraphPort { + + private final ParagraphRepository paragraphRepository; + + @Override + public int getPageNoByParagraphId(Long paragraphId) { + return paragraphRepository.findById(paragraphId) + .orElseThrow(ParagraphNotFoundException::new).getPage(); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/extern/ParagraphPort.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/extern/ParagraphPort.java new file mode 100644 index 0000000..13cc2a8 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/extern/ParagraphPort.java @@ -0,0 +1,6 @@ +package com.capstone.kkumteul.domain.fairytale.extern; + +public interface ParagraphPort { + + int getPageNoByParagraphId(Long paragraphId); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java index 8725643..43fc11b 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java @@ -9,4 +9,6 @@ public interface FairytaleCheckService { boolean isBothDone(Long fairytaleId, int page); void markTotalPages(Long fairytaleId, int totalPages); + + void markTtsFileDone(Long fairytaleId, int pageNo, String ttsUrl); } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index 5988c92..59fa61d 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -6,6 +6,7 @@ import com.capstone.kkumteul.domain.fairytale.web.dto.SseEventRes; import com.capstone.kkumteul.domain.vocab.entity.WordEntry; import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import com.capstone.kkumteul.domain.voice.repository.TtsHistoryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -26,12 +27,14 @@ public class FairytaleCheckServiceImpl implements FairytaleCheckService { private final SseService sseService; private final WordEntryRepository wordEntryRepository; private final ParagraphRepository paragraphRepository; + private final TtsHistoryRepository ttsHistoryRepository; @Value("${vocab.fallback-threshold-seconds:300}") private long vocabFallbackThresholdSeconds; private static final String VOCAB_KEY = "vocab:%d:%d"; private static final String IMAGE_KEY = "image:%d:%d"; + private static final String TTS_KEY = "tts:%d:%d"; private static final String TOTAL_KEY = "total:%d"; private static final String SENT_KEY = "sent:%d"; private static final String DONE = "done"; @@ -51,6 +54,13 @@ public void markImageDone(Long fairytaleId, int page) { checkAndSend(fairytaleId, page); } + @Override + public void markTtsFileDone(Long fairytaleId, int page, String ttsUrl) { + redisTemplate.opsForValue().set(String.format(TTS_KEY, fairytaleId, page), DONE.concat("@" + ttsUrl)); + log.info("[TTS DONE] fairytaleId={}, page={}", fairytaleId, page); + checkAndSend(fairytaleId, page); + } + /** * image done 시점에 vocab 마커가 없고 paragraph 생성 후 임계 초과면 빈 vocab으로 강제 mark. * AI Producer가 vocab_extracted를 누락한 경우의 SSE hang을 방지한다. @@ -71,11 +81,15 @@ private void forceVocabIfStale(Long fairytaleId, int page) { redisTemplate.opsForValue().set(vocabKey, DONE); } + // FIXME isBothDone -> isAllDone @Override public boolean isBothDone(Long fairytaleId, int page) { String vocabStatus = redisTemplate.opsForValue().get(String.format(VOCAB_KEY, fairytaleId, page)); String imageStatus = redisTemplate.opsForValue().get(String.format(IMAGE_KEY, fairytaleId, page)); - return DONE.equals(vocabStatus) && DONE.equals(imageStatus); + + String ttsStatus = redisTemplate.opsForValue().get(String.format(TTS_KEY, fairytaleId, page)); + return DONE.equals(vocabStatus) && DONE.equals(imageStatus) && + ttsStatus != null && DONE.equals(ttsStatus.split("@")[0]); } //sse전송 @@ -85,6 +99,7 @@ private void checkAndSend(Long fairytaleId, int page) { if (!both) return; Optional wordEntry = wordEntryRepository.findByFairytaleIdAndPageNo(fairytaleId, page); + String ttsUrl = redisTemplate.opsForValue().get(String.format(TTS_KEY, fairytaleId, page)).split("@")[1]; List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); if (paragraphs.isEmpty()) { @@ -104,7 +119,8 @@ private void checkAndSend(Long fairytaleId, int page) { page, sentences, vocab, - paragraph.getImageUrl() + paragraph.getImageUrl(), + ttsUrl ); log.info("[PAGE_CONTENT SEND] fairytaleId={}, page={}", fairytaleId, page); @@ -112,6 +128,7 @@ private void checkAndSend(Long fairytaleId, int page) { redisTemplate.delete(String.format(VOCAB_KEY, fairytaleId, page)); redisTemplate.delete(String.format(IMAGE_KEY, fairytaleId, page)); + redisTemplate.delete(String.format(TTS_KEY, fairytaleId, page)); Long sent = redisTemplate.opsForValue().increment(String.format(SENT_KEY, fairytaleId)); log.info("[SENT COUNT] fairytaleId={}, sent={}", fairytaleId, sent); diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleService.java index b237d70..f1eff86 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleService.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleService.java @@ -12,5 +12,5 @@ public interface FairytaleService { Page getSharedFairytales(Long userId, Pageable pageable); - FairytaleDetailRes getFairytaleDetail(Long fairytaleId); + FairytaleDetailRes getFairytaleDetail(Long fairytaleId, Long userId); } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleServiceImpl.java index 1024dcb..b44f8c4 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleServiceImpl.java @@ -10,6 +10,8 @@ import com.capstone.kkumteul.domain.fairytale.web.dto.ParagraphRes; import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; +import com.capstone.kkumteul.domain.voice.repository.TtsHistoryRepository; +import com.capstone.kkumteul.domain.voice.web.dto.TtsResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -26,6 +28,7 @@ public class FairytaleServiceImpl implements FairytaleService { private final FairytaleRepository fairytaleRepository; private final ParagraphRepository paragraphRepository; private final WordEntryRepository wordEntryRepository; + private final TtsHistoryRepository ttsHistoryRepository; @Override public Page getMyFairytales(Long userId, Island island, Pageable pageable) { @@ -40,13 +43,19 @@ public Page getSharedFairytales(Long userId, Pageable pageable } @Override - public FairytaleDetailRes getFairytaleDetail(Long fairytaleId) { + public FairytaleDetailRes getFairytaleDetail(Long fairytaleId, Long userId) { Fairytale fairytale = fairytaleRepository.findByIdWithUser(fairytaleId) .orElseThrow(FairytaleNotFoundException::new); List paragraphs = paragraphRepository.findByFairytaleIdOrderByPageAsc(fairytaleId) .stream() - .map(ParagraphRes::from) + .map(p -> { + String ttsUrl = ttsHistoryRepository + .findTtsUrlByFairytaleIdAndUserIdAndPageNo(fairytaleId, userId, p.getPage()) + .map(TtsResponse::ttsUrl) + .orElse(null); + return ParagraphRes.from(p, ttsUrl); + }) .toList(); List vocab = WordEntryRes.listOf( diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java index 9805c52..236b561 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java @@ -63,9 +63,10 @@ public ResponseEntity>> getSharedFairytal @GetMapping("/{fairytaleId}") public ResponseEntity> getFairytaleDetail( + @AuthUser User user, @PathVariable Long fairytaleId ) { - FairytaleDetailRes res = fairytaleService.getFairytaleDetail(fairytaleId); + FairytaleDetailRes res = fairytaleService.getFairytaleDetail(fairytaleId, user.getId()); return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java index b5bca26..f737422 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java @@ -7,13 +7,15 @@ public record ParagraphRes( int page, List sentences, - String imageUrl + String imageUrl, + String ttsUrl ) { - public static ParagraphRes from(Paragraph paragraph) { + public static ParagraphRes from(Paragraph paragraph, String ttsUrl) { return new ParagraphRes( paragraph.getPage(), List.of(paragraph.getText().split("\n")), - paragraph.getImageUrl() + paragraph.getImageUrl(), + ttsUrl ); } } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java index 7f6e98d..e64dc45 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java @@ -7,7 +7,8 @@ public record SseEventRes( int pageNo, List text, Vocabulary vocab, - String imageUrl + String imageUrl, + String ttsUrl ) { public record Vocabulary( String word, String meaning diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index dd21193..09e17a1 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -2,30 +2,13 @@ import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; -import com.capstone.kkumteul.domain.game.entity.EdgeChoice; -import com.capstone.kkumteul.domain.game.entity.GameResult; -import com.capstone.kkumteul.domain.game.entity.GraphEdge; -import com.capstone.kkumteul.domain.game.entity.GraphNode; -import com.capstone.kkumteul.domain.game.entity.NodeCategory; -import com.capstone.kkumteul.domain.game.exception.AlreadyAnsweredException; -import com.capstone.kkumteul.domain.game.exception.EdgeNotFoundException; -import com.capstone.kkumteul.domain.game.exception.GameAlreadyCompletedException; -import com.capstone.kkumteul.domain.game.exception.GameForbiddenException; -import com.capstone.kkumteul.domain.game.exception.GameNotCompletedException; -import com.capstone.kkumteul.domain.game.exception.GraphNotFoundException; -import com.capstone.kkumteul.domain.game.exception.InvalidEdgeException; -import com.capstone.kkumteul.domain.game.exception.QuizNotFoundException; +import com.capstone.kkumteul.domain.game.entity.*; +import com.capstone.kkumteul.domain.game.exception.*; import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; import com.capstone.kkumteul.domain.game.repository.GameResultRepository; import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; -import com.capstone.kkumteul.domain.game.web.dto.ClassifyRes; -import com.capstone.kkumteul.domain.game.web.dto.EdgeDetailRes; -import com.capstone.kkumteul.domain.game.web.dto.GameStartRes; -import com.capstone.kkumteul.domain.game.web.dto.GameStatusRes; -import com.capstone.kkumteul.domain.game.web.dto.GraphDetailRes; -import com.capstone.kkumteul.domain.game.web.dto.QuizAnswerRes; -import com.capstone.kkumteul.domain.game.web.dto.QuizRes; +import com.capstone.kkumteul.domain.game.web.dto.*; import com.capstone.kkumteul.domain.user.entity.User; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java index 91ff62a..05ed2df 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java @@ -7,14 +7,7 @@ import lombok.Getter; import java.time.LocalDateTime; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class GameSession { diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java index e2903e9..f5b7b29 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java @@ -1,11 +1,14 @@ package com.capstone.kkumteul.domain.kafka.consumer; import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.extern.ParagraphPort; import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; import com.capstone.kkumteul.domain.kafka.dto.FairytaleCompletedMessage; import com.capstone.kkumteul.domain.kafka.dto.ImageMessage; +import com.capstone.kkumteul.domain.kafka.dto.TtsFileDoneMessage; import com.capstone.kkumteul.global.client.GraphExtractTrigger; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,8 +25,20 @@ public class FairytaleKafkaConsumer { private final ParagraphRepository paragraphRepository; private final FairytaleCheckService fairytaleCheckService; private final GraphExtractTrigger graphExtractTrigger; + private final ParagraphPort paragraphPort; private final ObjectMapper objectMapper; + @KafkaListener(topics = "tts_done", groupId = "kkumteul-group") + public void consumeTtsFileDone(String message) { + try { + TtsFileDoneMessage msg = objectMapper.readValue(message, TtsFileDoneMessage.class); + int pageNo = paragraphPort.getPageNoByParagraphId(msg.getParagraphId()); + fairytaleCheckService.markTtsFileDone(msg.getFairytaleId(), pageNo, msg.getTtsUrl()); + } catch (JsonProcessingException e) { + log.error("tts_done 처리 실패 message={}", message, e); + } + } + @KafkaListener(topics = "fairytale_done", groupId = "kkumteul-group") public void consumeDone(String message) { try { diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/TtsFileDoneMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/TtsFileDoneMessage.java new file mode 100644 index 0000000..6aa81c8 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/TtsFileDoneMessage.java @@ -0,0 +1,24 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TtsFileDoneMessage { + @JsonProperty("userId") + private Long userId; + + @JsonProperty("fairytaleId") + private Long fairytaleId; + + @JsonProperty("paragraphId") + private Long paragraphId; + + @JsonProperty("ttsHistoryId") + private Long ttsHistoryId; + + @JsonProperty("ttsUrl") + private String ttsUrl; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java index cebda4a..d44b12a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java @@ -4,7 +4,8 @@ import lombok.AllArgsConstructor; import lombok.Getter; -import static com.capstone.kkumteul.global.constant.StaticValue.*; +import static com.capstone.kkumteul.global.constant.StaticValue.INTERNAL_SERVER_ERROR; +import static com.capstone.kkumteul.global.constant.StaticValue.NOT_FOUND; @Getter @AllArgsConstructor diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceErrorCode.java index 5bc0071..7d9dfdf 100644 --- a/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceErrorCode.java @@ -10,7 +10,7 @@ public enum VoiceErrorCode implements BaseResponseCode { INVALID_FILE_EXCEPTION("INVALID_FILE_400", 400, "요청값으로 전달한 파일의 양식이 잘못었습니다."), FILE_NOT_CREATED("FILE_NOT_FOUND_404", 404, "TTS 음성 파일을 찾을 수 없습니다."), - FILE_UPLOAD_FAIL("FILE_UPLOAD_FAIL_500", 500, "파일을 변환하고, S3에 업로드하는 것에 실패했습니다."); + FILE_UPLOAD_FAIL("FILE_UPLOAD_FAIL_500", 500, "파일을 변환하고, S3에 업로드하는 것에 실패했습니다."),; private final String code; private final int httpStatus; diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/repository/TtsHistoryRepository.java b/src/main/java/com/capstone/kkumteul/domain/voice/repository/TtsHistoryRepository.java index 5153671..95c2476 100644 --- a/src/main/java/com/capstone/kkumteul/domain/voice/repository/TtsHistoryRepository.java +++ b/src/main/java/com/capstone/kkumteul/domain/voice/repository/TtsHistoryRepository.java @@ -1,6 +1,7 @@ package com.capstone.kkumteul.domain.voice.repository; import com.capstone.kkumteul.domain.voice.entity.TtsHistory; +import com.capstone.kkumteul.domain.voice.web.dto.TtsResponse; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; @@ -20,4 +21,21 @@ public interface TtsHistoryRepository extends CrudRepository { Optional findByParagraphIdAndUserId( @Param("paragraphId") Long paragraphId, @Param("userId") Long userId); + + + @Query(""" +select new com.capstone.kkumteul.domain.voice.web.dto.TtsResponse( + t.ttsUrl +) +from TtsHistory t join + t.paragraph p +where p.fairytale.id = :fairytaleId + and p.page = :pageNo + and t.user.id = :userId +""") + Optional findTtsUrlByFairytaleIdAndUserIdAndPageNo( + @Param("fairytaleId") Long fairytaleId, + @Param("userId") Long userId, + @Param("pageNo") int pageNo + ); } diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceService.java b/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceService.java index e827b7c..34e4680 100644 --- a/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceService.java +++ b/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceService.java @@ -2,7 +2,6 @@ import com.capstone.kkumteul.domain.user.entity.User; import com.capstone.kkumteul.domain.voice.web.dto.TtsFileResponse; -import org.springframework.web.multipart.MultipartFile; public interface VoiceService { Void saveWav(byte[] wavFile, String originalFilename, User user); diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceServiceImpl.java index 6a1144f..03a983f 100644 --- a/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceServiceImpl.java @@ -17,7 +17,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.Optional; diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsResponse.java b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsResponse.java new file mode 100644 index 0000000..9c338e3 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsResponse.java @@ -0,0 +1,6 @@ +package com.capstone.kkumteul.domain.voice.web.dto; + +public record TtsResponse ( + String ttsUrl +) { +}