diff --git a/build.gradle b/build.gradle index 8a2b888..0557c30 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { // .env 파일 로딩 implementation 'me.paulschwarz:spring-dotenv:4.0.0' + + // S3 (latest dependency) + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.2' } tasks.named('test') { diff --git a/src/main/java/com/capstone/kkumteul/domain/auth/web/controller/AuthController.java b/src/main/java/com/capstone/kkumteul/domain/auth/web/controller/AuthController.java index de7ff76..2f9be52 100644 --- a/src/main/java/com/capstone/kkumteul/domain/auth/web/controller/AuthController.java +++ b/src/main/java/com/capstone/kkumteul/domain/auth/web/controller/AuthController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/auth") +@RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java index 4c3abb1..c27b127 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java @@ -10,7 +10,8 @@ @AllArgsConstructor public enum FairytaleErrorCode implements BaseResponseCode { - FAIRYTALE_NOT_FOUND("FAIRYTALE_404_1", NOT_FOUND, "동화를 찾을 수 없습니다."); + FAIRYTALE_NOT_FOUND("FAIRYTALE_404_1", NOT_FOUND, "동화를 찾을 수 없습니다."), + PARAGRAPH_NOT_FOUND("FAIRYTALE_404_2", NOT_FOUND, "요청한 ID에 해당하는 Paragraph를 찾을 수 없습니다."); private final String code; private final int httpStatus; diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/ParagraphNotFoundException.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/ParagraphNotFoundException.java new file mode 100644 index 0000000..8b457a1 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/ParagraphNotFoundException.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.domain.fairytale.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class ParagraphNotFoundException extends BaseException { + public ParagraphNotFoundException() { + super(FairytaleErrorCode.PARAGRAPH_NOT_FOUND); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java index aeb4f04..83f0167 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java @@ -13,4 +13,8 @@ public interface ParagraphRepository extends JpaRepository { /** 특정 페이지의 문장들 조회 — 단어장 추출 시 페이지 단위 본문 로드 */ List findByFairytaleIdAndPage(Long fairytaleId, int page); + + boolean existsById(Long paragraphId); + + boolean existsByFairytaleId(Long fairytaleId); } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/validator/ParagraphValidator.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/validator/ParagraphValidator.java new file mode 100644 index 0000000..66792f3 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/validator/ParagraphValidator.java @@ -0,0 +1,20 @@ +package com.capstone.kkumteul.domain.fairytale.validator; + +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ParagraphValidator { + + private final ParagraphRepository paragraphRepository; + + public boolean paragraphIdIsValid(Long paragraphId) { + return paragraphRepository.existsById(paragraphId); + } + + public boolean fairytaleIdIsValid(Long fairytaleId) { + return paragraphRepository.existsByFairytaleId(fairytaleId); + } +} 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 0373c1b..87195a3 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 @@ -2,6 +2,7 @@ import com.capstone.kkumteul.domain.fairytale.entity.Island; import com.capstone.kkumteul.domain.fairytale.service.FairytaleService; +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleDetailRes; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleListRes; @@ -9,8 +10,6 @@ import com.capstone.kkumteul.domain.user.entity.User; import com.capstone.kkumteul.global.response.SuccessResponse; import com.capstone.kkumteul.global.security.AuthUser; - -import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -24,7 +23,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/fairytales") +@RequestMapping("/api/fairytales") public class FairytaleController { private final FairytaleService fairytaleService; diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java index fd733ce..6415d5f 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java @@ -1,6 +1,4 @@ package com.capstone.kkumteul.domain.kafka.dto; public interface MessageInterface { - - Long getUserId(); } diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index 81938b1..236841f 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -6,6 +6,8 @@ import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; import com.capstone.kkumteul.domain.user.entity.User; +import com.capstone.kkumteul.domain.voice.web.dto.TtsFileRequest; +import com.capstone.kkumteul.domain.voice.web.dto.TtsModelingRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -28,6 +30,9 @@ public class EventService { @Value("${FAIRYTALE_GENERATION}") private String FAIRYTALE_GENERATION; + @Value(("${TTS_MODELING}")) + private String TTS_MODELING; + @Transactional public Long createFairytaleMessageSend(User user, FairytaleGenerateReq request) { @@ -55,10 +60,30 @@ public Long createFairytaleMessageSend(User user, FairytaleGenerateReq request) kafkaTemplate.send(FAIRYTALE_GENERATION, message) .whenComplete((result, e) -> { if (e != null) { - log.error("fairytale_generate failed", e); + log.error("fairytale_generate failed. userId={}, message={}", user.getId(), message, e); } }); return saved.getId(); } + + public void sendTtsModelingRequest(TtsModelingRequest message) { + + kafkaTemplate.send(TTS_MODELING, message) + .whenComplete((result, e) -> { + if(e != null) { + log.error("tts_modeling_request failed. userId={}, message={}", message.getUserId(), message.getUploadedUrl(), e); + } + }); + } + + public void sendTtsFileRequest(TtsFileRequest message) { + + kafkaTemplate.send(TTS_MODELING, message) + .whenComplete((result, e) -> { + if( e != null) { + log.error("tts_file_request occurred error. userId={}, paragraphId={}", message.getUserId(), message.getFairytaleId(), e); + } + }); + } } diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/entity/TtsHistory.java b/src/main/java/com/capstone/kkumteul/domain/voice/entity/TtsHistory.java new file mode 100644 index 0000000..d0c1b3e --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/entity/TtsHistory.java @@ -0,0 +1,27 @@ +package com.capstone.kkumteul.domain.voice.entity; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class TtsHistory { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "paragraph_id") + private Paragraph paragraph; + + private String ttsUrl; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/entity/VoiceModel.java b/src/main/java/com/capstone/kkumteul/domain/voice/entity/VoiceModel.java new file mode 100644 index 0000000..7ca2201 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/entity/VoiceModel.java @@ -0,0 +1,28 @@ +package com.capstone.kkumteul.domain.voice.entity; + +import com.capstone.kkumteul.domain.user.entity.User; +import com.capstone.kkumteul.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class VoiceModel extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + // extracted tts model name + @Column(unique = true, nullable = true) + private String modelName; + + @Column(nullable = false, unique = true) + private String wavFileUrl; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/exception/FileUploadFailException.java b/src/main/java/com/capstone/kkumteul/domain/voice/exception/FileUploadFailException.java new file mode 100644 index 0000000..dcb2599 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/exception/FileUploadFailException.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.domain.voice.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class FileUploadFailException extends BaseException { + public FileUploadFailException() { + super(VoiceErrorCode.FILE_UPLOAD_FAIL); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/exception/InvalidFileException.java b/src/main/java/com/capstone/kkumteul/domain/voice/exception/InvalidFileException.java new file mode 100644 index 0000000..0e80b00 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/exception/InvalidFileException.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.domain.voice.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class InvalidFileException extends BaseException { + public InvalidFileException() { + super(VoiceErrorCode.INVALID_FILE_EXCEPTION); + } +} 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 new file mode 100644 index 0000000..5bc0071 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceErrorCode.java @@ -0,0 +1,18 @@ +package com.capstone.kkumteul.domain.voice.exception; + +import com.capstone.kkumteul.global.response.code.BaseResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +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에 업로드하는 것에 실패했습니다."); + + private final String code; + private final int httpStatus; + private final String message; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceFileNotFoundException.java b/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceFileNotFoundException.java new file mode 100644 index 0000000..bc1419c --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/exception/VoiceFileNotFoundException.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.domain.voice.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class VoiceFileNotFoundException extends BaseException { + public VoiceFileNotFoundException() { + super(VoiceErrorCode.FILE_NOT_CREATED); + } +} 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 new file mode 100644 index 0000000..5153671 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/repository/TtsHistoryRepository.java @@ -0,0 +1,23 @@ +package com.capstone.kkumteul.domain.voice.repository; + +import com.capstone.kkumteul.domain.voice.entity.TtsHistory; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface TtsHistoryRepository extends CrudRepository { + + @Query(""" +select th +from TtsHistory th join fetch + th.paragraph p +where p.id = :paragraphId + and th.user.id = :userId +""" + ) + Optional findByParagraphIdAndUserId( + @Param("paragraphId") Long paragraphId, + @Param("userId") Long userId); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/repository/VoiceModelRepository.java b/src/main/java/com/capstone/kkumteul/domain/voice/repository/VoiceModelRepository.java new file mode 100644 index 0000000..67d95c0 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/repository/VoiceModelRepository.java @@ -0,0 +1,7 @@ +package com.capstone.kkumteul.domain.voice.repository; + +import com.capstone.kkumteul.domain.voice.entity.VoiceModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VoiceModelRepository extends JpaRepository { +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/service/S3Uploader.java b/src/main/java/com/capstone/kkumteul/domain/voice/service/S3Uploader.java new file mode 100644 index 0000000..4547d7a --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/service/S3Uploader.java @@ -0,0 +1,65 @@ +package com.capstone.kkumteul.domain.voice.service; + +import com.capstone.kkumteul.domain.user.entity.User; +import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Operations; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +@Slf4j +@Component +public class S3Uploader { + + private final S3Operations s3Operations; + private final String AWS_S3_BUCKET_NAME; + + // for dependency injection with environment variable + public S3Uploader( + S3Operations s3Operations, + @Value("${AWS_S3_BUCKET_NAME}") + String AWS_S3_BUCKET_NAME + ) { + this.s3Operations = s3Operations; + this.AWS_S3_BUCKET_NAME = AWS_S3_BUCKET_NAME; + } + + // upload S3 bucket + public String upload(MultipartFile wavFile, User user) throws IOException { + return putS3( + convert(wavFile), + createFilename( + wavFile.getOriginalFilename(), + user.getId() + ), + wavFile.getContentType() + ); + } + + private InputStream convert(MultipartFile wavFile) throws IOException { + return wavFile.getInputStream(); + } + + // with UUID + private String createFilename(String originalFilename, Long userId) { + String uuid = UUID.randomUUID().toString(); + String uniqueFilename = uuid + "-" + originalFilename.replaceAll("\\s", "-"); + return "tts/" + userId.toString() + "/train/" + uniqueFilename; + } + + private String putS3(InputStream inputStream,String filename, String contentType) throws IOException { + return s3Operations.upload( + AWS_S3_BUCKET_NAME, + filename, + inputStream, + ObjectMetadata.builder() + .contentType(contentType) + .build() + ).getURL().toString(); + } +} 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 new file mode 100644 index 0000000..e126ec9 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceService.java @@ -0,0 +1,12 @@ +package com.capstone.kkumteul.domain.voice.service; + +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(MultipartFile wavFile, User user); + Boolean hasTtsHistory(Long userId, Long paragraphId); + TtsFileResponse getTtsFile(Long userId, Long paragraphId); + Void createTtsFile(Long userId, Long paragraphId); +} 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 new file mode 100644 index 0000000..05428b5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/service/VoiceServiceImpl.java @@ -0,0 +1,115 @@ +package com.capstone.kkumteul.domain.voice.service; + +import com.capstone.kkumteul.domain.fairytale.exception.ParagraphNotFoundException; +import com.capstone.kkumteul.domain.fairytale.validator.ParagraphValidator; +import com.capstone.kkumteul.domain.kafka.service.EventService; +import com.capstone.kkumteul.domain.user.entity.User; +import com.capstone.kkumteul.domain.voice.entity.TtsHistory; +import com.capstone.kkumteul.domain.voice.entity.VoiceModel; +import com.capstone.kkumteul.domain.voice.exception.FileUploadFailException; +import com.capstone.kkumteul.domain.voice.exception.VoiceFileNotFoundException; +import com.capstone.kkumteul.domain.voice.repository.TtsHistoryRepository; +import com.capstone.kkumteul.domain.voice.repository.VoiceModelRepository; +import com.capstone.kkumteul.domain.voice.web.dto.TtsFileRequest; +import com.capstone.kkumteul.domain.voice.web.dto.TtsFileResponse; +import com.capstone.kkumteul.domain.voice.web.dto.TtsModelingRequest; +import lombok.RequiredArgsConstructor; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VoiceServiceImpl implements VoiceService { + + private final S3Uploader s3Uploader; + private final VoiceModelRepository voiceModelRepository; + private final TtsHistoryRepository ttsRepository; + private final EventService eventService; + private final ParagraphValidator paragraphValidator; + + @Override + @Transactional + public Void saveWav(MultipartFile wavFile, User user) { + + String uploadedUrl; + + try { + uploadedUrl = s3Uploader.upload(wavFile, user); + } catch (IOException e) { + log.error("VoiceService error occurred. userId = {}, filename = {}", user.getId(), wavFile.getOriginalFilename()); + throw new FileUploadFailException(); + } + + VoiceModel saved = VoiceModel.builder() + .user(user) + .wavFileUrl(uploadedUrl) + .build(); + + voiceModelRepository.save(saved); + sendTtsModelingRequest(user.getId(), uploadedUrl); + + return null; + } + + @Override + public Boolean hasTtsHistory(Long userId, Long paragraphId) { + return ttsRepository.findByParagraphIdAndUserId(paragraphId, userId).isPresent(); + } + + @Override + public Void createTtsFile(Long userId, Long fairytaleId) { + + if(!paragraphValidator.fairytaleIdIsValid(fairytaleId)) { + throw new ParagraphNotFoundException(); + } + + sendTtsFileRequest(userId, fairytaleId); + return null; + } + + @Override + public TtsFileResponse getTtsFile(Long userId, Long paragraphId) { + + // validation paragraph id by using fairytale package's validator + if (!paragraphValidator.paragraphIdIsValid(paragraphId)) + throw new ParagraphNotFoundException(); + + // User가 해당하는 paragraphId에 대한 tts 음성 파일을 만든 이력이 있는지 체크 + Optional found = ttsRepository.findByParagraphIdAndUserId(paragraphId, userId); + boolean isFound = found.isPresent(); + + TtsFileResponse response; + + if (!isFound) { + // 만든 적 없다면 해당 요청에 들어오면 안됨. 예외처리 + log.error("Voice file not created. userId={}, paragraphId={}", userId, paragraphId); + throw new VoiceFileNotFoundException(); + } else { + // 만든 적 있다면 DB에서 그 음성 파일을 조회 + TtsHistory ttsHistory = found.get(); + response = new TtsFileResponse( + ttsHistory.getParagraph().getId(), + ttsHistory.getTtsUrl() + ); + } + + return response; + } + + private void sendTtsFileRequest(Long userId, Long paragraphId) { + TtsFileRequest request = new TtsFileRequest(userId, paragraphId); + + eventService.sendTtsFileRequest(request); + } + + private void sendTtsModelingRequest(Long userId, String uploadedUrl) { + TtsModelingRequest requestBody = new TtsModelingRequest(userId, uploadedUrl); + eventService.sendTtsModelingRequest(requestBody); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/web/controller/VoiceController.java b/src/main/java/com/capstone/kkumteul/domain/voice/web/controller/VoiceController.java new file mode 100644 index 0000000..ccabd15 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/web/controller/VoiceController.java @@ -0,0 +1,81 @@ +package com.capstone.kkumteul.domain.voice.web.controller; + +import com.capstone.kkumteul.domain.user.entity.User; +import com.capstone.kkumteul.domain.voice.exception.InvalidFileException; +import com.capstone.kkumteul.domain.voice.service.VoiceService; +import com.capstone.kkumteul.domain.voice.web.dto.TtsFileResponse; +import com.capstone.kkumteul.global.response.SuccessResponse; +import com.capstone.kkumteul.global.security.AuthUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RestController +// not hard-fix configuration convention +@RequestMapping("/api/users/voice") +@RequiredArgsConstructor +public class VoiceController { + + private final VoiceService voiceService; + + @PostMapping + public ResponseEntity> sendTtsRequestMessage( + @AuthUser User user, + @RequestPart MultipartFile wavFile + ) { + + // File validation + String originName = wavFile.getOriginalFilename(); + if(originName == null) { + throw new InvalidFileException(); + } + + if(wavFile.isEmpty() + || originName.isBlank() + || !originName.toLowerCase().endsWith(".wav")) + throw new InvalidFileException(); + + voiceService.saveWav(wavFile, user); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(SuccessResponse.created(user.getId())); + } + + @GetMapping("/{paragraphId}") + public ResponseEntity> getTtsFile( + @AuthUser User user, + @PathVariable Long paragraphId + ) { + TtsFileResponse response = voiceService.getTtsFile(user.getId(), paragraphId); + + return ResponseEntity.status(HttpStatus.OK) + .body(SuccessResponse.ok(response)); + } + + @PostMapping("/{fairytaleId}") + public ResponseEntity> postTtsFile( + @AuthUser User user, + @PathVariable Long fairytaleId + ) { + voiceService.createTtsFile(user.getId(), fairytaleId); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(SuccessResponse.accepted()); + } + + @GetMapping("/{paragraphId}/check") + public ResponseEntity> hasTtsHistory( + @AuthUser User user, + @PathVariable Long paragraphId + ) { + Boolean response = voiceService.hasTtsHistory(user.getId(), paragraphId); + + return ResponseEntity.status(response ? HttpStatus.OK : HttpStatus.NO_CONTENT) + .body(SuccessResponse.ok(response)); + } + +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsFileRequest.java b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsFileRequest.java new file mode 100644 index 0000000..feb07cc --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsFileRequest.java @@ -0,0 +1,13 @@ +package com.capstone.kkumteul.domain.voice.web.dto; + +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TtsFileRequest implements MessageInterface { + + private Long userId; + private Long fairytaleId; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsFileResponse.java b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsFileResponse.java new file mode 100644 index 0000000..4c5460e --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsFileResponse.java @@ -0,0 +1,13 @@ +package com.capstone.kkumteul.domain.voice.web.dto; + +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TtsFileResponse implements MessageInterface { + + private final Long paragraphId; + private final String ttsUrl; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsModelingRequest.java b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsModelingRequest.java new file mode 100644 index 0000000..7a8eff1 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/voice/web/dto/TtsModelingRequest.java @@ -0,0 +1,12 @@ +package com.capstone.kkumteul.domain.voice.web.dto; + +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TtsModelingRequest implements MessageInterface { + private final Long userId; + private final String uploadedUrl; +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java index 3b7e6a5..f04bf37 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java @@ -6,7 +6,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.kafka.core.DefaultKafkaProducerFactory; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.ProducerFactory; diff --git a/src/main/java/com/capstone/kkumteul/global/config/SecurityConfig.java b/src/main/java/com/capstone/kkumteul/global/config/SecurityConfig.java index c4e3bfc..115f4f4 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/SecurityConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/SecurityConfig.java @@ -71,7 +71,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // API path 마다 역할 기반 인가 정책 설정 .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/auth/**").permitAll() + .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() // 필터링되지 않은 모든 URL에 대해서 인증 강제 ) diff --git a/src/main/java/com/capstone/kkumteul/global/response/SuccessResponse.java b/src/main/java/com/capstone/kkumteul/global/response/SuccessResponse.java index 9eefe54..964a6c2 100644 --- a/src/main/java/com/capstone/kkumteul/global/response/SuccessResponse.java +++ b/src/main/java/com/capstone/kkumteul/global/response/SuccessResponse.java @@ -40,6 +40,10 @@ public static SuccessResponse empty() { return new SuccessResponse<>(null, SuccessResponseCode.SUCCESS_OK); } + public static SuccessResponse accepted() { + return new SuccessResponse<>(null, SuccessResponseCode.SUCCESS_ACCEPTED); + } + public static SuccessResponse of(T data, BaseResponseCode baseResponseCode) { return new SuccessResponse<>(data, baseResponseCode); } diff --git a/src/main/java/com/capstone/kkumteul/global/response/code/SuccessResponseCode.java b/src/main/java/com/capstone/kkumteul/global/response/code/SuccessResponseCode.java index a6348cd..bfcf470 100644 --- a/src/main/java/com/capstone/kkumteul/global/response/code/SuccessResponseCode.java +++ b/src/main/java/com/capstone/kkumteul/global/response/code/SuccessResponseCode.java @@ -11,7 +11,8 @@ public enum SuccessResponseCode implements BaseResponseCode { SUCCESS_OK("SUCCESS_200", OK, "호출에 성공했습니다."), - SUCCESS_CREATED("SUCCESS_201", CREATED, "호출에 성공했습니다."); + SUCCESS_CREATED("SUCCESS_201", CREATED, "호출에 성공했습니다."), + SUCCESS_ACCEPTED("SUCCESS_202", 202, "요청 진행이 받아들여졌습니다."); private final String code; private final int httpStatus;