diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..d29cb2b --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,72 @@ +name: FairyTale Dev CI/CD + +on: + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + workflow_dispatch: + + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + # JDK 설정 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # gradle build 시간 향상 + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # application.yml 파일 생성 + - name: make application.yml + run: | + mkdir -p src/main/resources + echo "$APPLICATION" > src/main/resources/application.yml + env: + APPLICATION: ${{ secrets.APPLICATION }} + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Docker build + run: | + docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build -t app . + docker tag app ${{ secrets.DOCKER_USERNAME }}/fairytale:latest + docker push ${{ secrets.DOCKER_USERNAME }}/fairytale:latest + + - name: Deploy to dev + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ubuntu + key: ${{ secrets.PRIVATE_KEY }} + script: | + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/fairytale:latest + sudo docker stop $(docker ps -a -q) + sudo docker run -d --log-driver=syslog -p 8080:8080 -e SPRING_PROFILES_ACTIVE= ${{ secrets.DOCKER_USERNAME }}/fairytale:latest + sudo docker rm $(docker ps --filter 'status=exited' -a -q) + sudo docker image prune -a -f + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4986f7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM openjdk:17 +COPY ./build/libs/TBD-0.0.1-SNAPSHOT.jar fairytale.jar +ENTRYPOINT ["java", "-jar", "fairytale.jar"] diff --git a/build.gradle b/build.gradle index 190ca83..69b430b 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,13 @@ java { sourceCompatibility = '17' } +// build 이름 변경 +jar{ + archiveBaseName = 'fairytale' + version = '0.0.1-SNAPSHOT' + enabled = false +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -25,10 +32,52 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + + //logging + implementation 'org.springframework.boot:spring-boot-starter-log4j2' + modules { + module("org.springframework.boot:spring-boot-starter-logging") { + replacedBy("org.springframework.boot:spring-boot-starter-log4j2") + } + } + + // ElevenLabs API + implementation ('net.andrewcpu:elevenlabs-api:2.7.8') + + // AWS + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Face Swap API + implementation 'com.squareup.okhttp3:okhttp:3.10.0' + implementation 'org.json:json:20200518' + + // Face Swap API + implementation 'com.squareup.okhttp3:okhttp:3.10.0' + implementation 'org.json:json:20200518' + + // Face Swap API + implementation 'com.squareup.okhttp3:okhttp:3.10.0' + implementation 'org.json:json:20200518' + + // webhook + implementation 'javax.xml.bind:jaxb-api:2.3.1' + + // Spring Security OAUTH 2.1 + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-oauth2-client' + + // JWT token + implementation 'io.jsonwebtoken:jjwt:0.12.3' + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-test' } tasks.named('test') { diff --git a/src/main/java/fairytale/tbd/FairytaleApplication.java b/src/main/java/fairytale/tbd/FairytaleApplication.java index aa0f9eb..8c4abd6 100644 --- a/src/main/java/fairytale/tbd/FairytaleApplication.java +++ b/src/main/java/fairytale/tbd/FairytaleApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class FairytaleApplication { public static void main(String[] args) { diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/converter/FaceSwapConverter.java b/src/main/java/fairytale/tbd/domain/faceSwap/converter/FaceSwapConverter.java new file mode 100644 index 0000000..ab46e7c --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/converter/FaceSwapConverter.java @@ -0,0 +1,18 @@ +package fairytale.tbd.domain.faceSwap.converter; + +import java.time.LocalDateTime; + +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; +import fairytale.tbd.domain.faceSwap.web.dto.FaceResponseDTO; + +public class FaceSwapConverter { + + public static FaceResponseDTO.OriginalCharacterSaveResponseDTO toOriginalCharacterSaveResponseDTO( + OriginalCharacter originalCharacter) { + return FaceResponseDTO.OriginalCharacterSaveResponseDTO.builder() + .created_at(LocalDateTime.now()) + .image_url(originalCharacter.getOriginalURL()) + .original_character_id(originalCharacter.getOriginId()) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/entity/CustomCharacter.java b/src/main/java/fairytale/tbd/domain/faceSwap/entity/CustomCharacter.java new file mode 100644 index 0000000..041bbfc --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/entity/CustomCharacter.java @@ -0,0 +1,53 @@ +package fairytale.tbd.domain.faceSwap.entity; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "custom_character") +public class CustomCharacter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "custom_character_id") + private Long customId; + + @Column(name = "custom_character_image_url", nullable = false) + private String customURL; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "page_num", nullable = false) + private Long pageNum; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fairytale_id") + private Fairytale fairytale; + + public void setUser(User user) { + this.user = user; + } + + public void setFairytale(Fairytale fairytale) { + this.fairytale = fairytale; + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/entity/ImageSaveQueue.java b/src/main/java/fairytale/tbd/domain/faceSwap/entity/ImageSaveQueue.java new file mode 100644 index 0000000..8931810 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/entity/ImageSaveQueue.java @@ -0,0 +1,41 @@ +package fairytale.tbd.domain.faceSwap.entity; + +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "image_save_queue") +public class ImageSaveQueue extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_save_id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "image_url", nullable = false) + private String imageURL; + + @Column(name = "page_num", nullable = false) + private Long pageNum; + + @Column(name = "fairytale_id", nullable = false) + private Long fairytaleId; + + @Column(name = "image_swap_request_id", nullable = false) + private String requestId; +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/entity/OriginalCharacter.java b/src/main/java/fairytale/tbd/domain/faceSwap/entity/OriginalCharacter.java new file mode 100644 index 0000000..08b1a39 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/entity/OriginalCharacter.java @@ -0,0 +1,50 @@ +package fairytale.tbd.domain.faceSwap.entity; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "original_character") +public class OriginalCharacter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "original_character_id") + private Long originId; + + @Column(name = "originl_character_image_url", nullable = false) + private String originalURL; + + @Column(name = "page_num", nullable = false) + private Long pageNum; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fairytale_id") + private Fairytale fairytale; + + @Column(name = "original_character_image_opts", nullable = false) + private String opts; + + // 연관 관계 편의 메서드 + + public void setFairytale(Fairytale fairytale) { + this.fairytale = fairytale; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/entity/Uuid.java b/src/main/java/fairytale/tbd/domain/faceSwap/entity/Uuid.java new file mode 100644 index 0000000..849e973 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/entity/Uuid.java @@ -0,0 +1,18 @@ +package fairytale.tbd.domain.faceSwap.entity; + +import jakarta.persistence.*; +import lombok.*; +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) + +public class Uuid { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long uuidId; + + @Column(unique = true) + private String uuid; +} \ No newline at end of file diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/exception/FaceNotDetectException.java b/src/main/java/fairytale/tbd/domain/faceSwap/exception/FaceNotDetectException.java new file mode 100644 index 0000000..a0d4f63 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/exception/FaceNotDetectException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.faceSwap.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class FaceNotDetectException extends GeneralException { + public FaceNotDetectException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/exception/FaceSwapFailureException.java b/src/main/java/fairytale/tbd/domain/faceSwap/exception/FaceSwapFailureException.java new file mode 100644 index 0000000..b8b80f0 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/exception/FaceSwapFailureException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.faceSwap.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class FaceSwapFailureException extends GeneralException { + public FaceSwapFailureException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/exception/UserFaceNotExistException.java b/src/main/java/fairytale/tbd/domain/faceSwap/exception/UserFaceNotExistException.java new file mode 100644 index 0000000..1d0724d --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/exception/UserFaceNotExistException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.faceSwap.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class UserFaceNotExistException extends GeneralException { + public UserFaceNotExistException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/repository/CustomCharacterRepository.java b/src/main/java/fairytale/tbd/domain/faceSwap/repository/CustomCharacterRepository.java new file mode 100644 index 0000000..8b5a0e4 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/repository/CustomCharacterRepository.java @@ -0,0 +1,13 @@ +package fairytale.tbd.domain.faceSwap.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.faceSwap.entity.CustomCharacter; + +@Repository +public interface CustomCharacterRepository extends JpaRepository { + List findByUserIdAndFairytaleId(Long userId, Long fairytaleId); +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/repository/ImageSaveQueueRepository.java b/src/main/java/fairytale/tbd/domain/faceSwap/repository/ImageSaveQueueRepository.java new file mode 100644 index 0000000..37896e5 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/repository/ImageSaveQueueRepository.java @@ -0,0 +1,14 @@ +package fairytale.tbd.domain.faceSwap.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.faceSwap.entity.ImageSaveQueue; + +@Repository +public interface ImageSaveQueueRepository extends JpaRepository { + Optional findFirstByOrderByCreatedAtAsc(); + +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/repository/OriginalCharacterRepository.java b/src/main/java/fairytale/tbd/domain/faceSwap/repository/OriginalCharacterRepository.java new file mode 100644 index 0000000..27fa53a --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/repository/OriginalCharacterRepository.java @@ -0,0 +1,13 @@ +package fairytale.tbd.domain.faceSwap.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; + +@Repository +public interface OriginalCharacterRepository extends JpaRepository { + List findByFairytaleId(Long fairytaleId); +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/repository/UuidRepository.java b/src/main/java/fairytale/tbd/domain/faceSwap/repository/UuidRepository.java new file mode 100644 index 0000000..3fe33c4 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/repository/UuidRepository.java @@ -0,0 +1,7 @@ +package fairytale.tbd.domain.faceSwap.repository; + +import fairytale.tbd.domain.faceSwap.entity.Uuid; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UuidRepository extends JpaRepository { +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceDetectApiService.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceDetectApiService.java new file mode 100644 index 0000000..eed33c3 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceDetectApiService.java @@ -0,0 +1,8 @@ +package fairytale.tbd.domain.faceSwap.service; + +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectRequestDto; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectResponseDto; + +public interface FaceDetectApiService { + FaceDetectResponseDto getOptFromFaceDetectApi(FaceDetectRequestDto faceDetectRequestDto); +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceDetectApiServiceImpl.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceDetectApiServiceImpl.java new file mode 100644 index 0000000..a35eed9 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceDetectApiServiceImpl.java @@ -0,0 +1,87 @@ +package fairytale.tbd.domain.faceSwap.service; + +import fairytale.tbd.domain.faceSwap.exception.FaceNotDetectException; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectRequestDto; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectResponseDto; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import okhttp3.*; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FaceDetectApiServiceImpl implements FaceDetectApiService{ + + private static final Logger LOGGER = LogManager.getLogger(FaceDetectApiServiceImpl.class); + + @Value("${face.akool.apikey}") + private static String apiKey; + + + @Override + public FaceDetectResponseDto getOptFromFaceDetectApi(FaceDetectRequestDto faceDetectRequestDto) { + + + String landmarkStr = ""; + + OkHttpClient client = new OkHttpClient(); + + MediaType mediaType = MediaType.parse("application/json"); + + String requestBodyJson = "{\n" + + " \"single_face\": true, \n" + + " \"image_url\": \"" + faceDetectRequestDto.getImgURL() + "\"\n" + + "}"; + + RequestBody body = RequestBody.create(mediaType, requestBodyJson); + + Request request = new Request.Builder() + .url("https://sg3.akool.com/detect") + .method("POST", body) + .addHeader("Authorization", "Bearer " + apiKey) + .addHeader("Content-Type", "application/json") + .build(); + + try (Response response = client.newCall(request).execute()){ + + if(!response.isSuccessful()){ + LOGGER.error("외부 API 요청에 실패했습니다."); + throw new GeneralException(ErrorStatus._REQUEST_FAIL_ERROR); + } + + String responseData = response.body().string(); + + JSONObject jsonObject = new JSONObject(responseData); + + int errCode = jsonObject.getInt("error_code"); + String errorMsg = jsonObject.getString("error_msg"); + + if(errCode!=0 || !errorMsg.equals("SUCCESS")){ + throw new FaceNotDetectException(ErrorStatus._FACE_NOT_DETECT_ERROR); + } + + landmarkStr = jsonObject.getString("landmarks_str"); + } catch (IOException e) { + LOGGER.error("외부 API 요청에 실패했습니다."); + e.printStackTrace(); + throw new GeneralException(ErrorStatus._REQUEST_FAIL_ERROR); + + } + + return FaceDetectResponseDto.builder() + .photoUrl(faceDetectRequestDto.getImgURL()) + .opt(landmarkStr) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapApiService.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapApiService.java new file mode 100644 index 0000000..b2d3bf5 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapApiService.java @@ -0,0 +1,20 @@ +package fairytale.tbd.domain.faceSwap.service; + +import java.io.IOException; + +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; +import fairytale.tbd.domain.faceSwap.util.SwapResult; +import fairytale.tbd.domain.faceSwap.web.dto.FaceRequestDTO; +import fairytale.tbd.domain.faceSwap.web.dto.FaceSwapRequestDto; +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.user.entity.User; + +public interface FaceSwapApiService { + SwapResult getFaceSwapImg(FaceSwapRequestDto.FaceSwapRequest faceSwapRequestDto) throws IOException; + + void addSaveQueue(FaceSwapRequestDto.FaceSwapRequest request, User user, Fairytale fairytale, Long pageNum); + + void swapAllImage(User user); + + OriginalCharacter saveOriginalCharacter(FaceRequestDTO.OriginalCharacterSaveRequestDTO request); +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapApiServiceImpl.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapApiServiceImpl.java new file mode 100644 index 0000000..73d63ab --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapApiServiceImpl.java @@ -0,0 +1,228 @@ +package fairytale.tbd.domain.faceSwap.service; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import fairytale.tbd.domain.faceSwap.entity.ImageSaveQueue; +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; +import fairytale.tbd.domain.faceSwap.exception.FaceSwapFailureException; +import fairytale.tbd.domain.faceSwap.exception.UserFaceNotExistException; +import fairytale.tbd.domain.faceSwap.repository.ImageSaveQueueRepository; +import fairytale.tbd.domain.faceSwap.repository.OriginalCharacterRepository; +import fairytale.tbd.domain.faceSwap.util.SwapResult; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectRequestDto; +import fairytale.tbd.domain.faceSwap.web.dto.FaceRequestDTO; +import fairytale.tbd.domain.faceSwap.web.dto.FaceSwapRequestDto; +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.exception.FairytaleNotFoundException; +import fairytale.tbd.domain.fairytale.repository.FairytaleRepository; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.global.aws.s3.AmazonS3Manager; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FaceSwapApiServiceImpl implements FaceSwapApiService { + + @Value("${face.akool.apikey}") + private String apikey; + + @Value("${face.akool.clientId}") + private String clientId; + + private static final Logger LOGGER = LogManager.getLogger(FaceSwapApiServiceImpl.class); + + private final ObjectMapper objectMapper; + + private final ImageSaveQueueRepository imageSaveQueueRepository; + + private final FairytaleRepository fairytaleRepository; + + private final String WEBHOOK_URL = "http://13.125.16.41:8080/faceSwap/webhook"; + + private final OriginalCharacterRepository originalCharacterRepository; + + private final AmazonS3Manager amazonS3Manager; + private final FaceDetectApiService faceDetectApiService; + + @Transactional + @Override + public OriginalCharacter saveOriginalCharacter(FaceRequestDTO.OriginalCharacterSaveRequestDTO request) { + + Fairytale fairytale = fairytaleRepository.findById(request.getFairytale_id()) + .orElseThrow(() -> new FairytaleNotFoundException(ErrorStatus._FAIRYTALE_NOT_FOUND)); + + String saveURL = amazonS3Manager.uploadFile( + amazonS3Manager.generateS3SavePath(amazonS3Manager.FACE_SWAP_SAMPLE_PATH), + request.getFile()); + + FaceDetectRequestDto faceDetectRequestDto = new FaceDetectRequestDto(saveURL); + String opts = faceDetectApiService.getOptFromFaceDetectApi(faceDetectRequestDto).getOpt(); + + OriginalCharacter originalCharacter = OriginalCharacter.builder() + .originalURL(saveURL) + .opts(opts) + .pageNum(request.getPage_num()) + .build(); + + fairytale.addOriginalCharacter(originalCharacter); + + return originalCharacterRepository.save(originalCharacter); + } + + @Transactional + @Override + public void swapAllImage(User user) { + if (user.getFaceImageUrl() == null || user.getOpts() == null) + throw new UserFaceNotExistException(ErrorStatus._USER_FACE_NOT_EXIST); + for (Fairytale fairytale : fairytaleRepository.findAll()) { + for (OriginalCharacter originalCharacter : fairytale.getOriginalCharacterList()) { + FaceSwapRequestDto.FaceSwapRequest request = FaceSwapRequestDto.FaceSwapRequest.builder() + .sourceImage(FaceSwapRequestDto.SourceImage.builder() + .sourceOpts(user.getOpts()) + .sourcePath(user.getFaceImageUrl()) + .build()) + .targetImage(FaceSwapRequestDto.TargetImage.builder() + .targetOpts(originalCharacter.getOpts()) + .targetPath(originalCharacter.getOriginalURL()) + .build()) + .modifyImage(originalCharacter.getOriginalURL()) + .webhookUrl(WEBHOOK_URL) + .build(); + + addSaveQueue(request, user, fairytale, originalCharacter.getPageNum()); + } + } + } + + @Override + @Transactional + public void addSaveQueue(FaceSwapRequestDto.FaceSwapRequest request, User user, Fairytale fairytale, Long pageNum) { + SwapResult result = getFaceSwapImg(request); + + ImageSaveQueue imageSaveQueue = ImageSaveQueue.builder() + .imageURL(result.getUrl()) + .userId(user.getId()) + .fairytaleId(fairytale.getId()) + .pageNum(pageNum) + .requestId(result.getId()) + .build(); + + imageSaveQueueRepository.save(imageSaveQueue); + } + + @Override + public SwapResult getFaceSwapImg(FaceSwapRequestDto.FaceSwapRequest faceSwapRequest) { + + OkHttpClient client = new OkHttpClient().newBuilder() + .build(); + + MediaType mediaType = MediaType.parse("application/json"); + + String requestString = "{\n" + + " \"sourceImage\": [\n" + + " {\n" + + " \"path\": \"" + faceSwapRequest.getSourceImage().getSourcePath() + "\",\n" + + " \"opts\": \"" + faceSwapRequest.getSourceImage().getSourceOpts() + "\"\n" + + " }\n" + + " ],\n" + + " \"targetImage\": [\n" + + " {\n" + + " \"path\": \"" + faceSwapRequest.getTargetImage().getTargetPath() + "\",\n" + + " \"opts\": \"" + faceSwapRequest.getTargetImage().getTargetOpts() + "\"\n" + + " }\n" + + " ],\n" + + " \"modifyImage\": \"" + faceSwapRequest.getModifyImage() + "\",\n" + + " \"webhookUrl\": \"" + faceSwapRequest.getWebhookUrl() + "\"\n" + + "}"; + + LOGGER.info("requestString = {}", requestString); + + RequestBody body = RequestBody.create(mediaType, requestString); + + Request request = new Request.Builder() + .url("https://openapi.akool.com/api/open/v3/faceswap/highquality/specifyimage") + .method("POST", body) + .addHeader("Authorization", "Bearer " + getToken()) + .addHeader("Content-Type", "application/json") + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Unexpected code " + response); + } + + String responseData = response.body().string(); + + LOGGER.info("responseData = {}", responseData); + + JSONObject jsonObject = new JSONObject(responseData); + + int errCode = jsonObject.getInt("code"); + String errorMsg = jsonObject.getString("msg"); + + if (errCode != 1000) { + throw new IOException("Error! \n" + + "error code : " + + errCode + "\n" + + "error massage : " + + errorMsg + "\n"); + } + + String url = jsonObject.getJSONObject("data").getString("url"); + String id = jsonObject.getJSONObject("data").getString("_id"); + + return new SwapResult(id, url); + } catch (IOException e) { + LOGGER.error("Face Swap에 실패했습니다."); + e.printStackTrace(); + throw new FaceSwapFailureException(ErrorStatus._FACE_SWAP_FAILURE_ERROR); + } + } + + public String getToken() { + + OkHttpClient client = new OkHttpClient().newBuilder() + .build(); + MediaType mediaType = MediaType.parse("application/json"); + RequestBody body = RequestBody.create(mediaType, + "{\r\n \"clientId\": \"" + clientId + "\" ,\r\n \"clientSecret\": \"" + apikey + "\"\r\n}"); + Request request = new Request.Builder() + .url("https://openapi.akool.com/api/open/v3/getToken") + .method("POST", body) + .addHeader("Content-Type", "application/json") + .build(); + try { + Response response = client.newCall(request).execute(); + String responseBody = response.body().string(); + JsonNode jsonNode = objectMapper.readTree(responseBody); + String token = jsonNode.get("token").asText(); + return token; + } catch (IOException e) { + e.printStackTrace(); + LOGGER.error("getToken() 요청 중 에러 발생"); + throw new GeneralException(ErrorStatus._REQUEST_FAIL_ERROR); + } + } + +} + diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapQueryService.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapQueryService.java new file mode 100644 index 0000000..b4c681d --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapQueryService.java @@ -0,0 +1,9 @@ +package fairytale.tbd.domain.faceSwap.service; + +import java.util.Map; + +import fairytale.tbd.domain.user.entity.User; + +public interface FaceSwapQueryService { + Map getFaceImages(User user, Long fairytaleId, boolean changeFace); +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapQueryServiceImpl.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapQueryServiceImpl.java new file mode 100644 index 0000000..d70c0de --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/FaceSwapQueryServiceImpl.java @@ -0,0 +1,48 @@ +package fairytale.tbd.domain.faceSwap.service; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.faceSwap.entity.CustomCharacter; +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; +import fairytale.tbd.domain.faceSwap.repository.CustomCharacterRepository; +import fairytale.tbd.domain.faceSwap.repository.OriginalCharacterRepository; +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.exception.FairytaleNotFoundException; +import fairytale.tbd.domain.fairytale.repository.FairytaleRepository; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FaceSwapQueryServiceImpl implements FaceSwapQueryService { + private final FairytaleRepository fairytaleRepository; + private final OriginalCharacterRepository originalCharacterRepository; + private final CustomCharacterRepository customCharacterRepository; + + @Override + public Map getFaceImages(User user, Long fairytaleId, boolean changeFace) { + Map result = new HashMap<>(); + Fairytale fairytale = fairytaleRepository.findById(fairytaleId) + .orElseThrow(() -> new FairytaleNotFoundException( + ErrorStatus._FAIRYTALE_NOT_FOUND)); + + if (changeFace) { + for (CustomCharacter customCharacter : customCharacterRepository.findByUserIdAndFairytaleId(user.getId(), + fairytale.getId())) { + result.put(customCharacter.getPageNum(), customCharacter.getCustomURL()); + } + } else { + for (OriginalCharacter originalCharacter : originalCharacterRepository.findByFairytaleId( + fairytale.getId())) { + result.put(originalCharacter.getPageNum(), originalCharacter.getOriginalURL()); + } + } + return result; + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/PhotoUploadService.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/PhotoUploadService.java new file mode 100644 index 0000000..7cd0d8f --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/PhotoUploadService.java @@ -0,0 +1,19 @@ +package fairytale.tbd.domain.faceSwap.service; + +import java.util.Optional; + +import org.springframework.web.multipart.MultipartFile; + +import fairytale.tbd.domain.faceSwap.entity.ImageSaveQueue; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectRequestDto; +import fairytale.tbd.domain.user.entity.User; + +public interface PhotoUploadService { + FaceDetectRequestDto savePhotos(User userId, MultipartFile photoUploadRequestDto); + + String migrateToS3(String customImageUrl); + + Optional getLastSaveQueueAndDelete(); + + void saveCustomCharacter(); +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/service/PhotoUploadServiceImpl.java b/src/main/java/fairytale/tbd/domain/faceSwap/service/PhotoUploadServiceImpl.java new file mode 100644 index 0000000..8c6bfbe --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/service/PhotoUploadServiceImpl.java @@ -0,0 +1,92 @@ +package fairytale.tbd.domain.faceSwap.service; + +import java.util.Optional; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import fairytale.tbd.domain.faceSwap.entity.CustomCharacter; +import fairytale.tbd.domain.faceSwap.entity.ImageSaveQueue; +import fairytale.tbd.domain.faceSwap.repository.CustomCharacterRepository; +import fairytale.tbd.domain.faceSwap.repository.ImageSaveQueueRepository; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectRequestDto; +import fairytale.tbd.domain.fairytale.service.FairytaleQueryService; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.service.UserQueryService; +import fairytale.tbd.global.aws.s3.AmazonS3Manager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoUploadServiceImpl implements PhotoUploadService { + + private static final Logger LOGGER = LogManager.getLogger(PhotoUploadServiceImpl.class); + + private final AmazonS3Manager amazonS3Manager; + + private final ImageSaveQueueRepository imageSaveQueueRepository; + + private final UserQueryService userQueryService; + + private final FairytaleQueryService fairytaleQueryService; + + private final CustomCharacterRepository customCharacterRepository; + + @Override + @Transactional + public FaceDetectRequestDto savePhotos(User userId, MultipartFile file) { + String imgURL = ""; + imgURL = amazonS3Manager.uploadFile(amazonS3Manager.generateS3SavePath(amazonS3Manager.FACE_SWAP_USER_PATH), + file); + + FaceDetectRequestDto faceDetectRequestDto = new FaceDetectRequestDto(imgURL); + + return faceDetectRequestDto; + } + + @Override + public String migrateToS3(String customImageUrl) { + return amazonS3Manager.uploadJpegImageFromUrl( + amazonS3Manager.generateS3SavePath(amazonS3Manager.FACE_SWAP_USER_PATH), customImageUrl); + } + + @Override + @Transactional + public Optional getLastSaveQueueAndDelete() { + Optional imageSaveQueue = imageSaveQueueRepository.findFirstByOrderByCreatedAtAsc(); + imageSaveQueue.ifPresent(saveQueue -> imageSaveQueueRepository.delete(saveQueue)); + return imageSaveQueue; + } + + @Transactional + @Override + public void saveCustomCharacter() { + Optional lastSaveQueue = getLastSaveQueueAndDelete(); + lastSaveQueue.ifPresent(imageSaveQueue -> { + userQueryService.getUserWithUserId(imageSaveQueue.getUserId()).ifPresent(user -> { + fairytaleQueryService.getFairytaleById(imageSaveQueue.getFairytaleId()).ifPresent(fairytale -> { + String savedURL = amazonS3Manager.uploadJpegImageFromUrl( + amazonS3Manager.generateS3SavePath(amazonS3Manager.FACE_SWAP_USER_PATH), + imageSaveQueue.getImageURL()); + + CustomCharacter customCharacter = CustomCharacter.builder() + .customURL(savedURL) + .pageNum(imageSaveQueue.getPageNum()) + .build(); + + user.addCustomCharacter(customCharacter); + fairytale.addCustomCharacter(customCharacter); + + customCharacterRepository.save(customCharacter); + LOGGER.info("Custom Character 저장 성공"); + }); + }); + }); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/util/CryptoUtils.java b/src/main/java/fairytale/tbd/domain/faceSwap/util/CryptoUtils.java new file mode 100644 index 0000000..6b4e0e7 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/util/CryptoUtils.java @@ -0,0 +1,78 @@ +package fairytale.tbd.domain.faceSwap.util; + +import java.security.MessageDigest; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.util.Arrays; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import javax.xml.bind.DatatypeConverter; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import fairytale.tbd.domain.faceSwap.web.dto.WebhookRequestDTO; + +@Component +public class CryptoUtils { + + @Value("${face.akool.apikey}") + private String clientSecret; + + @Value("${face.akool.clientId}") + private String clientId; + + + // Generate signature + public static String generateMsgSignature(String clientId, String timestamp, String nonce, String msgEncrypt) { + String[] arr = {clientId, timestamp, nonce, msgEncrypt}; + Arrays.sort(arr); + String sortedStr = String.join("", arr); + return sha1(sortedStr); + } + + // SHA-1 hash function + private static String sha1(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); + return DatatypeConverter.printHexBinary(hashBytes).toLowerCase(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + // Decryption algorithm + public static String generateAesDecrypt(String dataEncrypt, String clientId, String clientSecret) { + try { + byte[] keyBytes = clientSecret.getBytes(StandardCharsets.UTF_8); + byte[] ivBytes = clientId.getBytes(StandardCharsets.UTF_8); + + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); + IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + + byte[] encryptedBytes = DatatypeConverter.parseHexBinary(dataEncrypt); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + // Example usage + public String getURL(WebhookRequestDTO.RequestDTO webhookRequestDTO){ + String newSignature = generateMsgSignature(clientId, webhookRequestDTO.getTimestamp(), webhookRequestDTO.getNonce(), webhookRequestDTO.getDataEncrypt()); + if (webhookRequestDTO.getSignature().equals(newSignature)) { + String result = generateAesDecrypt(webhookRequestDTO.getDataEncrypt(), clientId, clientSecret); + return result; + } + return null; + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/util/SwapResult.java b/src/main/java/fairytale/tbd/domain/faceSwap/util/SwapResult.java new file mode 100644 index 0000000..9a39879 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/util/SwapResult.java @@ -0,0 +1,18 @@ +package fairytale.tbd.domain.faceSwap.util; + +public class SwapResult { + String id, url; + + public SwapResult(String id, String url) { + this.id = id; + this.url = url; + } + + public String getId() { + return id; + } + + public String getUrl() { + return url; + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/controller/FaceSwapRestController.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/controller/FaceSwapRestController.java new file mode 100644 index 0000000..c1dfb5b --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/controller/FaceSwapRestController.java @@ -0,0 +1,101 @@ +package fairytale.tbd.domain.faceSwap.web.controller; + +import java.io.IOException; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import fairytale.tbd.domain.faceSwap.converter.FaceSwapConverter; +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; +import fairytale.tbd.domain.faceSwap.service.FaceDetectApiServiceImpl; +import fairytale.tbd.domain.faceSwap.service.FaceSwapApiService; +import fairytale.tbd.domain.faceSwap.service.PhotoUploadServiceImpl; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectRequestDto; +import fairytale.tbd.domain.faceSwap.web.dto.FaceDetectResponseDto; +import fairytale.tbd.domain.faceSwap.web.dto.FaceRequestDTO; +import fairytale.tbd.domain.faceSwap.web.dto.FaceResponseDTO; +import fairytale.tbd.domain.faceSwap.web.dto.FaceSwapRequestDto; +import fairytale.tbd.domain.faceSwap.web.dto.WebhookRequestDTO; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.service.UserCommandService; +import fairytale.tbd.global.annotation.LoginUser; +import fairytale.tbd.global.response.ApiResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/faceSwap") +@RequiredArgsConstructor +public class FaceSwapRestController { + + private final Logger LOGGER = LogManager.getLogger(FaceSwapRestController.class); + private final PhotoUploadServiceImpl photoUploadService; + private final FaceDetectApiServiceImpl faceDetectApiService; + private final FaceSwapApiService faceSwapApiService; + private final UserCommandService userCommandService; + + @PostMapping("/uploadImg") + public ApiResponse uploadImg(@LoginUser User user, @ModelAttribute MultipartFile file) throws + IOException { + FaceDetectRequestDto faceDetectRequestDto = photoUploadService.savePhotos(user, file); + String imgURL = faceDetectRequestDto.getImgURL(); + + FaceDetectRequestDto requestDto = new FaceDetectRequestDto(imgURL); + + FaceDetectResponseDto optFromFaceDetectApi = faceDetectApiService.getOptFromFaceDetectApi(requestDto); + + userCommandService.setUserImageOpts(optFromFaceDetectApi.getOpt(), user); + userCommandService.setUserImageUrl(optFromFaceDetectApi.getPhotoUrl(), user); + + faceSwapApiService.swapAllImage(user); + return ApiResponse.onSuccess("이미지 업로드 요청을 성공했습니다."); + } + + public ApiResponse test(@RequestBody String faceDetectRequestDto) { + + LOGGER.info("Face detect request : {}", faceDetectRequestDto); + + FaceDetectRequestDto requestDto = new FaceDetectRequestDto(faceDetectRequestDto); + + FaceDetectResponseDto optFromFaceDetectApi = null; + + try { + optFromFaceDetectApi = faceDetectApiService.getOptFromFaceDetectApi(requestDto); + } catch (Exception e) { + e.printStackTrace(); + } + + return ApiResponse.onSuccess(optFromFaceDetectApi); + } + + @PostMapping("/originalCharacter") + public ApiResponse originalCharacter(@ModelAttribute + FaceRequestDTO.OriginalCharacterSaveRequestDTO request) { + OriginalCharacter originalCharacter = faceSwapApiService.saveOriginalCharacter(request); + FaceResponseDTO.OriginalCharacterSaveResponseDTO responseDTO = FaceSwapConverter.toOriginalCharacterSaveResponseDTO( + originalCharacter); + return ApiResponse.onSuccess(responseDTO); + } + + // @PostMapping("/swapUserFace") + public ApiResponse> swapUserFace( + @RequestBody FaceSwapRequestDto.FaceSwapRequest faceSwapRequestDto, @LoginUser User user) { + // String test = faceSwapApiService.getFaceSwapImg(faceSwapRequestDto); + + return null; + } + + @PostMapping("/webhook") + public ResponseEntity webhook(@RequestBody WebhookRequestDTO.RequestDTO request) { + LOGGER.info("request = {}", request); + photoUploadService.saveCustomCharacter(); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceDetectRequestDto.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceDetectRequestDto.java new file mode 100644 index 0000000..ec07e31 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceDetectRequestDto.java @@ -0,0 +1,16 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class FaceDetectRequestDto { + + private String imgURL; + +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceDetectResponseDto.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceDetectResponseDto.java new file mode 100644 index 0000000..d944b0c --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceDetectResponseDto.java @@ -0,0 +1,11 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FaceDetectResponseDto { + private String photoUrl; + private String opt; +} \ No newline at end of file diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceRequestDTO.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceRequestDTO.java new file mode 100644 index 0000000..c706385 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceRequestDTO.java @@ -0,0 +1,17 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.Getter; +import lombok.Setter; + +public class FaceRequestDTO { + + @Getter + @Setter + public static class OriginalCharacterSaveRequestDTO { + private MultipartFile file; + private Long page_num; + private Long fairytale_id; + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceResponseDTO.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceResponseDTO.java new file mode 100644 index 0000000..582a912 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceResponseDTO.java @@ -0,0 +1,21 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class FaceResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class OriginalCharacterSaveResponseDTO { + private Long original_character_id; + private String image_url; + private LocalDateTime created_at; + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceSwapRequestDto.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceSwapRequestDto.java new file mode 100644 index 0000000..3b8537b --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceSwapRequestDto.java @@ -0,0 +1,56 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@ToString +public class FaceSwapRequestDto { + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FaceSwapRequest { + + private SourceImage sourceImage; + + private TargetImage targetImage; + + private String modifyImage; + + private String webhookUrl; + } + + // Original Image + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SourceImage { + + private String sourcePath; + + private String sourceOpts; + + } + + // User Image + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TargetImage { + + private String targetPath; + + private String targetOpts; + + } +} diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceSwapResponseDto.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceSwapResponseDto.java new file mode 100644 index 0000000..398f841 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/FaceSwapResponseDto.java @@ -0,0 +1,11 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FaceSwapResponseDto { + private Long fairytaleID; + +} \ No newline at end of file diff --git a/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/WebhookRequestDTO.java b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/WebhookRequestDTO.java new file mode 100644 index 0000000..57a3764 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/faceSwap/web/dto/WebhookRequestDTO.java @@ -0,0 +1,18 @@ +package fairytale.tbd.domain.faceSwap.web.dto; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +public class WebhookRequestDTO { + @Getter + @Setter + @ToString + public static class RequestDTO{ + private String signature; + private String dataEncrypt; + private String timestamp; + private String nonce; + + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/converter/Fairytaleconverter.java b/src/main/java/fairytale/tbd/domain/fairytale/converter/Fairytaleconverter.java new file mode 100644 index 0000000..8dcb252 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/converter/Fairytaleconverter.java @@ -0,0 +1,23 @@ +package fairytale.tbd.domain.fairytale.converter; + +import java.util.ArrayList; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.web.dto.FairytaleRequestDTO; +import fairytale.tbd.domain.fairytale.web.dto.FairytaleResponseDTO; + +public class Fairytaleconverter { + public static Fairytale toFairytale(FairytaleRequestDTO.AddFairytaleRequestDTO request) { + return Fairytale.builder() + .name(request.getName()) + .segmentList(new ArrayList<>()) + .build(); + } + + public static FairytaleResponseDTO.AddFairytaleResultDTO toAddFairytaleResultDTO(Fairytale fairytale) { + return FairytaleResponseDTO.AddFairytaleResultDTO.builder() + .id(fairytale.getId()) + .createdAt(fairytale.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/entity/Fairytale.java b/src/main/java/fairytale/tbd/domain/fairytale/entity/Fairytale.java new file mode 100644 index 0000000..67f0389 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/entity/Fairytale.java @@ -0,0 +1,63 @@ +package fairytale.tbd.domain.fairytale.entity; + +import java.util.ArrayList; +import java.util.List; + +import fairytale.tbd.domain.faceSwap.entity.CustomCharacter; +import fairytale.tbd.domain.faceSwap.entity.OriginalCharacter; +import fairytale.tbd.domain.voice.entity.Segment; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Fairytale extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fairytale_id") + private Long id; + + @Column(name = "fairytale_name", nullable = false) + private String name; + + @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) + private List segmentList = new ArrayList<>(); + + @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) + private List customCharacterList = new ArrayList<>(); + + @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) + private List originalCharacterList = new ArrayList<>(); + + + + // 연관 관계 편의 메서드 + public void addSegment(Segment segment) { + segmentList.add(segment); + segment.setFairytale(this); + } + + public void addCustomCharacter(CustomCharacter customCharacter){ + customCharacterList.add(customCharacter); + customCharacter.setFairytale(this); + } + + public void addOriginalCharacter(OriginalCharacter originalCharacter){ + originalCharacterList.add(originalCharacter); + originalCharacter.setFairytale(this); + } + +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/exception/FairytaleExistExcption.java b/src/main/java/fairytale/tbd/domain/fairytale/exception/FairytaleExistExcption.java new file mode 100644 index 0000000..a539c1f --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/exception/FairytaleExistExcption.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.fairytale.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class FairytaleExistExcption extends GeneralException { + public FairytaleExistExcption(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/exception/FairytaleNotFoundException.java b/src/main/java/fairytale/tbd/domain/fairytale/exception/FairytaleNotFoundException.java new file mode 100644 index 0000000..62117fe --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/exception/FairytaleNotFoundException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.fairytale.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class FairytaleNotFoundException extends GeneralException { + public FairytaleNotFoundException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/repository/FairytaleRepository.java b/src/main/java/fairytale/tbd/domain/fairytale/repository/FairytaleRepository.java new file mode 100644 index 0000000..4175fa2 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/repository/FairytaleRepository.java @@ -0,0 +1,15 @@ +package fairytale.tbd.domain.fairytale.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; + +@Repository +public interface FairytaleRepository extends JpaRepository { + boolean existsByName(String name); + + Optional findByName(String name); +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleCommandService.java b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleCommandService.java new file mode 100644 index 0000000..7998897 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleCommandService.java @@ -0,0 +1,8 @@ +package fairytale.tbd.domain.fairytale.service; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.web.dto.FairytaleRequestDTO; + +public interface FairytaleCommandService { + Fairytale saveFairytale(FairytaleRequestDTO.AddFairytaleRequestDTO request); +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleCommandServiceImpl.java b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleCommandServiceImpl.java new file mode 100644 index 0000000..f00f524 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleCommandServiceImpl.java @@ -0,0 +1,34 @@ +package fairytale.tbd.domain.fairytale.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.fairytale.converter.Fairytaleconverter; +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.repository.FairytaleRepository; +import fairytale.tbd.domain.fairytale.web.dto.FairytaleRequestDTO; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FairytaleCommandServiceImpl implements FairytaleCommandService { + private final FairytaleRepository fairytaleRepository; + + /** + * 동화 추가 서비스 로직 + */ + @Override + @Transactional + public Fairytale saveFairytale(FairytaleRequestDTO.AddFairytaleRequestDTO request) { + // TODO BEAN VALIDATION + if (fairytaleRepository.existsByName(request.getName())) { + throw new GeneralException(ErrorStatus._FAIRYTALE_EXIST_ERROR); + } + Fairytale fairytale = Fairytaleconverter.toFairytale(request); + return fairytaleRepository.save(fairytale); + } + +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleQueryService.java b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleQueryService.java new file mode 100644 index 0000000..5d5555b --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleQueryService.java @@ -0,0 +1,9 @@ +package fairytale.tbd.domain.fairytale.service; + +import java.util.Optional; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; + +public interface FairytaleQueryService { + Optional getFairytaleById(Long fairytaleId); +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleQueryServiceImpl.java b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleQueryServiceImpl.java new file mode 100644 index 0000000..7397d35 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/service/FairytaleQueryServiceImpl.java @@ -0,0 +1,22 @@ +package fairytale.tbd.domain.fairytale.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.repository.FairytaleRepository; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FairytaleQueryServiceImpl implements FairytaleQueryService { + private final FairytaleRepository fairytaleRepository; + + @Override + public Optional getFairytaleById(Long fairytaleId) { + return fairytaleRepository.findById(fairytaleId); + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/web/controller/FairytaleRestController.java b/src/main/java/fairytale/tbd/domain/fairytale/web/controller/FairytaleRestController.java new file mode 100644 index 0000000..186ad53 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/web/controller/FairytaleRestController.java @@ -0,0 +1,86 @@ +package fairytale.tbd.domain.fairytale.web.controller; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import fairytale.tbd.domain.faceSwap.service.FaceSwapQueryService; +import fairytale.tbd.domain.fairytale.converter.Fairytaleconverter; +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.service.FairytaleCommandService; +import fairytale.tbd.domain.fairytale.web.dto.FairytaleRequestDTO; +import fairytale.tbd.domain.fairytale.web.dto.FairytaleResponseDTO; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.service.VoiceQueryService; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; +import fairytale.tbd.global.annotation.LoginUser; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.exception.GeneralException; +import fairytale.tbd.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/fairytale") +public class FairytaleRestController { + private static final Logger LOGGER = LogManager.getLogger(FairytaleRestController.class); + + private final FairytaleCommandService fairytaleCommandService; + private final VoiceQueryService voiceQueryService; + private final FaceSwapQueryService faceSwapQueryService; + + /** + * 동화 추가 메서드 + */ + @PostMapping("") + public ApiResponse addFairytale(@Valid @RequestBody + FairytaleRequestDTO.AddFairytaleRequestDTO request) { + Fairytale fairytale = fairytaleCommandService.saveFairytale(request); + return ApiResponse.onSuccess(Fairytaleconverter.toAddFairytaleResultDTO(fairytale)); + } + + /** + * 동화 데이터 조회 메서드 + */ + @GetMapping("/{fairytaleId}") + public ApiResponse> getFairytaleWithId( + @LoginUser User user, + @PathVariable(name = "fairytaleId") Long fairytaleId, + @RequestParam(name = "change_voice") boolean changeVoice, + @RequestParam(name = "change_face") boolean changeFace) { + + Map> userTTSSegmentList = voiceQueryService.getUserTTSSegmentList( + user, fairytaleId, changeVoice); + + Map faceImages = faceSwapQueryService.getFaceImages(user, fairytaleId, changeFace); + + if (!userTTSSegmentList.keySet().equals(faceImages.keySet())) { + LOGGER.error("동화 데이터 호출 중 에러가 발생했습니다."); + throw new GeneralException(ErrorStatus._FAIRYTALE_DATA_NOT_EXIST); + } + + Map result = new HashMap<>(); + for (Long pageNum : userTTSSegmentList.keySet()) { + + FairytaleResponseDTO.GetFairytaleDetailDTO detailDTO = FairytaleResponseDTO.GetFairytaleDetailDTO.builder() + .voice_list(userTTSSegmentList.get(pageNum)) + .image_url(faceImages.get(pageNum)) + .build(); + + result.put(pageNum, detailDTO); + } + + return ApiResponse.onSuccess(result); + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/web/dto/FairytaleRequestDTO.java b/src/main/java/fairytale/tbd/domain/fairytale/web/dto/FairytaleRequestDTO.java new file mode 100644 index 0000000..698e9e9 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/web/dto/FairytaleRequestDTO.java @@ -0,0 +1,14 @@ +package fairytale.tbd.domain.fairytale.web.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +public class FairytaleRequestDTO { + @Getter + @Setter + public static class AddFairytaleRequestDTO { + @NotBlank + private String name; + } +} diff --git a/src/main/java/fairytale/tbd/domain/fairytale/web/dto/FairytaleResponseDTO.java b/src/main/java/fairytale/tbd/domain/fairytale/web/dto/FairytaleResponseDTO.java new file mode 100644 index 0000000..8cfdc56 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/fairytale/web/dto/FairytaleResponseDTO.java @@ -0,0 +1,30 @@ +package fairytale.tbd.domain.fairytale.web.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class FairytaleResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddFairytaleResultDTO { + private Long id; + private LocalDateTime createdAt; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class GetFairytaleDetailDTO { + private List voice_list; + private String image_url; + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java b/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java new file mode 100644 index 0000000..fa58d05 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java @@ -0,0 +1,26 @@ +package fairytale.tbd.domain.user.converter; + +import java.util.ArrayList; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.web.dto.UserRequestDTO; +import fairytale.tbd.domain.user.web.dto.UserResponseDTO; + +public class UserConverter { + public static User toUser(UserRequestDTO.AddUserDTO request, String encodedPassword) { + return User.builder() + .loginId(request.getLoginId()) + .password(encodedPassword) + .username(request.getUsername()) + .gender(request.getGender()) + .authorityList(new ArrayList<>()) + .build(); + } + + public static UserResponseDTO.AddUserResultDTO toAddUserResultDTO(User user) { + return UserResponseDTO.AddUserResultDTO.builder() + .userId(user.getId()) + .createdAt(user.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/entity/Authority.java b/src/main/java/fairytale/tbd/domain/user/entity/Authority.java new file mode 100644 index 0000000..3655b7f --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/entity/Authority.java @@ -0,0 +1,39 @@ +package fairytale.tbd.domain.user.entity; + +import fairytale.tbd.domain.user.enums.Role; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Authority { + @Id + @GeneratedValue(strategy= GenerationType.IDENTITY) + @Column(name = "authority_id") + private Long id; + + @Column(name = "authority_role") + private Role role; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + // 연관 관계 편의 메서드 + public void setUser(User user){ + this.user = user; + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/entity/User.java b/src/main/java/fairytale/tbd/domain/user/entity/User.java new file mode 100644 index 0000000..acc8646 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/entity/User.java @@ -0,0 +1,100 @@ +package fairytale.tbd.domain.user.entity; + +import java.util.ArrayList; +import java.util.List; + +import fairytale.tbd.domain.faceSwap.entity.CustomCharacter; +import fairytale.tbd.domain.user.enums.Gender; +import fairytale.tbd.domain.voice.entity.Voice; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(name = "login_id", nullable = false) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(name = "gender", nullable = false) + private Gender gender; + + @Column(name = "username", nullable = false) + private String username; + + @Column(name = "refresh_token", nullable = true) + private String refreshToken; + + @Column(name = "user_face_image_url", nullable = true) + private String faceImageUrl; + + @Column(name = "user_face_image_opts", nullable = true) + private String opts; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Voice voice; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List authorityList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List customCharacterList = new ArrayList<>(); + + // 연관관계 편의 메서드 + public void setVoice(Voice voice) { + this.voice = voice; + voice.setUser(this); + } + + public void addAuthority(Authority authority) { + authorityList.add(authority); + authority.setUser(this); + } + + public void addCustomCharacter(CustomCharacter customCharacter) { + customCharacterList.add(customCharacter); + customCharacter.setUser(this); + } + + // + + public void setFaceImageUrl(String faceImageUrl) { + this.faceImageUrl = faceImageUrl; + } + + public void setOpts(String opts) { + this.opts = opts; + } + + // RefreshToken update + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/user/enums/Gender.java b/src/main/java/fairytale/tbd/domain/user/enums/Gender.java new file mode 100644 index 0000000..939e9c2 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/enums/Gender.java @@ -0,0 +1,8 @@ +package fairytale.tbd.domain.user.enums; + +/** + * 사용자 성별 + */ +public enum Gender { + MALE, FEMALE; +} diff --git a/src/main/java/fairytale/tbd/domain/user/enums/Role.java b/src/main/java/fairytale/tbd/domain/user/enums/Role.java new file mode 100644 index 0000000..7f531d0 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/enums/Role.java @@ -0,0 +1,8 @@ +package fairytale.tbd.domain.user.enums; + +/** + * 권한 역할 + */ +public enum Role { + ROLE_USER, ROLE_ADMIN, ROLE_GUEST +} diff --git a/src/main/java/fairytale/tbd/domain/user/exception/ExistUsernameException.java b/src/main/java/fairytale/tbd/domain/user/exception/ExistUsernameException.java new file mode 100644 index 0000000..6fa3a61 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/exception/ExistUsernameException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.user.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class ExistUsernameException extends GeneralException { + public ExistUsernameException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/exception/UserNotExistException.java b/src/main/java/fairytale/tbd/domain/user/exception/UserNotExistException.java new file mode 100644 index 0000000..6614ee1 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/exception/UserNotExistException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.user.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class UserNotExistException extends GeneralException { + public UserNotExistException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java b/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..94ff231 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java @@ -0,0 +1,19 @@ +package fairytale.tbd.domain.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.user.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + boolean existsByUsername(String username); + Optional findByLoginId(String loginId); + + Optional findById(Long userId); + + Optional findByRefreshToken(String refreshToken); + +} diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserCommandService.java b/src/main/java/fairytale/tbd/domain/user/service/UserCommandService.java new file mode 100644 index 0000000..8a28514 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/service/UserCommandService.java @@ -0,0 +1,12 @@ +package fairytale.tbd.domain.user.service; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.web.dto.UserRequestDTO; + +public interface UserCommandService { + User addUser(UserRequestDTO.AddUserDTO request); + + void setUserImageUrl(String imageUrl, User user); + + void setUserImageOpts(String imageOpts, User user); +} diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java b/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java new file mode 100644 index 0000000..467e195 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java @@ -0,0 +1,54 @@ +package fairytale.tbd.domain.user.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.user.converter.UserConverter; +import fairytale.tbd.domain.user.entity.Authority; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.enums.Role; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.web.dto.UserRequestDTO; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserCommandServiceImpl implements UserCommandService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + /** + * 사용자가 회원가입에서 입력한 PLANE TEXT를 + * 암호화 하고, 유저의 권한을 추가 한 후, 데이터베이스에 저장 + */ + @Transactional + @Override + public User addUser(UserRequestDTO.AddUserDTO request) { + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(request.getPassword()); + User user = UserConverter.toUser(request, encodedPassword); + // 유저 권한 추가 + Authority authority = Authority.builder() + .role(Role.ROLE_USER) + .build(); + user.addAuthority(authority); + return userRepository.save(user); + } + + @Transactional + @Override + public void setUserImageUrl(String imageUrl, User user) { + user.setFaceImageUrl(imageUrl); + userRepository.saveAndFlush(user); + } + + @Transactional + @Override + public void setUserImageOpts(String imageOpts, User user) { + user.setOpts(imageOpts); + userRepository.saveAndFlush(user); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserQueryService.java b/src/main/java/fairytale/tbd/domain/user/service/UserQueryService.java new file mode 100644 index 0000000..862b12a --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/service/UserQueryService.java @@ -0,0 +1,14 @@ +package fairytale.tbd.domain.user.service; + +import java.util.Optional; + +import fairytale.tbd.domain.user.entity.User; + +public interface UserQueryService { + + Optional getUserWithAuthorities(String loginId); + + void updateRefreshToken(User user, String reIssuedRefreshToken); + + Optional getUserWithUserId(Long userId); +} diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserQueryServiceImpl.java b/src/main/java/fairytale/tbd/domain/user/service/UserQueryServiceImpl.java new file mode 100644 index 0000000..2882fc2 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/service/UserQueryServiceImpl.java @@ -0,0 +1,44 @@ +package fairytale.tbd.domain.user.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserQueryServiceImpl implements UserQueryService { + + private final UserRepository userRepository; + + /** + * 사용자를 권한과 함께 반환 + */ + @Override + public Optional getUserWithAuthorities(String loginId) { + User user = userRepository.findByLoginId(loginId).orElse(null); + user.getAuthorityList().size(); + return Optional.ofNullable(user); + } + + /** + * 사용자의 RefreshToken을 데이터베이스에 업데이트 + */ + @Transactional + @Override + public void updateRefreshToken(User user, String reIssuedRefreshToken) { + user.updateRefreshToken(reIssuedRefreshToken); + userRepository.saveAndFlush(user); + } + + @Override + public Optional getUserWithUserId(Long userId) { + return userRepository.findById(userId); + } + +} diff --git a/src/main/java/fairytale/tbd/domain/user/validation/annotation/ExistUsername.java b/src/main/java/fairytale/tbd/domain/user/validation/annotation/ExistUsername.java new file mode 100644 index 0000000..4361ebe --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/validation/annotation/ExistUsername.java @@ -0,0 +1,23 @@ +package fairytale.tbd.domain.user.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import fairytale.tbd.domain.user.validation.validator.ExistUsernameValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ExistUsernameValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExistUsername { + String message() default("이미 존재하는 닉네임입니다."); + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/fairytale/tbd/domain/user/validation/validator/ExistUsernameValidator.java b/src/main/java/fairytale/tbd/domain/user/validation/validator/ExistUsernameValidator.java new file mode 100644 index 0000000..d95287b --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/validation/validator/ExistUsernameValidator.java @@ -0,0 +1,33 @@ +package fairytale.tbd.domain.user.validation.validator; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.validation.annotation.ExistUsername; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ExistUsernameValidator implements ConstraintValidator { + + private static final Logger LOGGER = LogManager.getLogger(ExistUsernameValidator.class); + private final UserRepository userRepository; + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + boolean isValid = !userRepository.existsByUsername(value); + if(!isValid){ + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(ErrorStatus._EXIST_USERNAME.getMessage().toString()).addConstraintViolation(); + } + return isValid; + } + + @Override + public void initialize(ExistUsername constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java b/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java new file mode 100644 index 0000000..9a185cf --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java @@ -0,0 +1,36 @@ +package fairytale.tbd.domain.user.web.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import fairytale.tbd.domain.user.converter.UserConverter; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.service.UserCommandService; +import fairytale.tbd.domain.user.web.dto.UserRequestDTO; +import fairytale.tbd.domain.user.web.dto.UserResponseDTO; +import fairytale.tbd.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/user") +public class UserRestController { + + private final UserCommandService userCommandService; + private static final Logger LOGGER = LogManager.getLogger(UserRestController.class); + + /** + * 회원 가입 + */ + @PostMapping("/signup") + public ApiResponse join(@Valid @RequestBody UserRequestDTO.AddUserDTO request) { + LOGGER.info("request = {}", request); + User user = userCommandService.addUser(request); + return ApiResponse.onSuccess(UserConverter.toAddUserResultDTO(user)); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/web/dto/UserRequestDTO.java b/src/main/java/fairytale/tbd/domain/user/web/dto/UserRequestDTO.java new file mode 100644 index 0000000..837e6fe --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/web/dto/UserRequestDTO.java @@ -0,0 +1,43 @@ +package fairytale.tbd.domain.user.web.dto; + +import fairytale.tbd.domain.user.enums.Gender; +import fairytale.tbd.domain.user.validation.annotation.ExistUsername; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +public class UserRequestDTO { + @Getter + @Setter + public static class AddUserDTO{ + + @Size(min = 4, message = "아이디는 최소 4자 이상이어야 합니다.") + @NotBlank(message = "LoginId값은 필수입니다.") + private String loginId; + + @NotBlank(message = "password값은 필수입니다.") + @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") + @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[!@#$%^&*()_+={};:,<.>]).{8,}$", message = "비밀번호는 영어, 숫자, 특수문자를 포함해야 합니다.") + private String password; + + @NotNull(message = "gender값은 필수입니다.") + private Gender gender; + + @ExistUsername + @NotBlank(message = "username값은 필수입니다.") + private String username; + + @Override + public String toString() { + return "AddUserDTO{" + + "loginId='" + loginId + '\'' + + ", password='" + password + '\'' + + ", gender=" + gender + + ", username='" + username + '\'' + + '}'; + } + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/web/dto/UserResponseDTO.java b/src/main/java/fairytale/tbd/domain/user/web/dto/UserResponseDTO.java new file mode 100644 index 0000000..e3e374d --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/web/dto/UserResponseDTO.java @@ -0,0 +1,19 @@ +package fairytale.tbd.domain.user.web.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class UserResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddUserResultDTO{ + private Long userId; + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/converter/VoiceConverter.java b/src/main/java/fairytale/tbd/domain/voice/converter/VoiceConverter.java new file mode 100644 index 0000000..a75ed5d --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/converter/VoiceConverter.java @@ -0,0 +1,46 @@ +package fairytale.tbd.domain.voice.converter; + +import java.util.ArrayList; + +import fairytale.tbd.domain.voice.entity.Segment; +import fairytale.tbd.domain.voice.entity.TTSSegment; +import fairytale.tbd.domain.voice.entity.Voice; +import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; + +public class VoiceConverter { + + public static VoiceResponseDTO.AddVoiceResultDTO toAddVoiceResult(Voice voice) { + return VoiceResponseDTO.AddVoiceResultDTO.builder() + .voiceId(voice.getId()) + .createdAt(voice.getCreatedAt()) + .build(); + } + + public static Voice toVoice(String keyId) { + return Voice.builder() + .voiceSampleList(new ArrayList<>()) + .keyId(keyId) + .build(); + } + + public static Segment toSegment(VoiceRequestDTO.AddSegmentDTO request) { + return Segment.builder() + .context(request.getContext()) + .isMainCharacter(request.isMainCharacter()) + .voiceType(request.getVoiceType()) + .num(request.getSegmentNum()) + .userTTSSegmentList(new ArrayList<>()) + .pageNum(request.getPageNum()) + .build(); + } + + public static VoiceResponseDTO.AddTTSSegmentResultDTO toAddSegmentResultDTO(TTSSegment ttsSegment, Long segmentId) { + return VoiceResponseDTO.AddTTSSegmentResultDTO.builder() + .segmentId(segmentId) + .createdAt(ttsSegment.getCreatedAt()) + .ttsSegmentId(ttsSegment.getId()) + .url(ttsSegment.getUrl()) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/Segment.java b/src/main/java/fairytale/tbd/domain/voice/entity/Segment.java new file mode 100644 index 0000000..e8076e4 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/Segment.java @@ -0,0 +1,89 @@ +package fairytale.tbd.domain.voice.entity; + +import java.util.List; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.voice.enums.VoiceType; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "fairytale_segment") +public class Segment extends BaseEntity implements Comparable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fairytale_segment_id") + private Long id; + + @Column(name = "segment_context", nullable = false) + private String context; + + @Column(name = "is_main_character", nullable = false) + private boolean isMainCharacter; + + @Column(name = "voice_type", nullable = false) + private VoiceType voiceType; + + @Column(name = "segment_num", nullable = false) + private Double num; + + @Column(name = "page_num", nullable = false) + private Long pageNum; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fairytale_id", nullable = false) + private Fairytale fairytale; + + @OneToOne(mappedBy = "segment", cascade = CascadeType.ALL, orphanRemoval = true) + private TTSSegment ttsSegment; + + @OneToMany(mappedBy = "segment", cascade = CascadeType.ALL, orphanRemoval = true) + private List userTTSSegmentList; + + @Override + public int compareTo(Segment segment) { + if (this.pageNum == segment.pageNum) { + if (this.num < segment.num) { + return -1; + } else if (this.num == segment.num) { + return 0; + } else + return 1; + } + return 1; + } + + // 연관 관계 편의 메서드 + + public void setFairytale(Fairytale fairytale) { + this.fairytale = fairytale; + } + + public void setTtsSegment(TTSSegment ttsSegment) { + this.ttsSegment = ttsSegment; + } + + public void addUserTTSSegment(UserTTSSegment userTTSSegment) { + userTTSSegmentList.add(userTTSSegment); + userTTSSegment.setSegment(this); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/TTSSegment.java b/src/main/java/fairytale/tbd/domain/voice/entity/TTSSegment.java new file mode 100644 index 0000000..9fbc121 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/TTSSegment.java @@ -0,0 +1,43 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "text_to_speech_segment") +public class TTSSegment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "text_to_speech_segment_id") + private Long id; + + @Column(name = "text_to_speech_segment_url") + private String url; + + @OneToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "fairytale_segment_id") + private Segment segment; + + // 연관 관계 편의 메서드 + + public void setSegment(Segment segment) { + this.segment = segment; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/UserTTSSegment.java b/src/main/java/fairytale/tbd/domain/voice/entity/UserTTSSegment.java new file mode 100644 index 0000000..1f4bc44 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/UserTTSSegment.java @@ -0,0 +1,50 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "user_text_to_speech_segment") +public class UserTTSSegment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "uset_text_to_speech_segment_id") + private Long id; + + @Column(name = "user_text_to_speech_segment_url") + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fairytale_segment_id") + private Segment segment; + + // 연관 관계 편의 메서드 + public void setUser(User user) { + this.user = user; + } + + public void setSegment(Segment segment) { + this.segment = segment; + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java b/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java new file mode 100644 index 0000000..59f3506 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java @@ -0,0 +1,54 @@ +package fairytale.tbd.domain.voice.entity; + +import java.util.ArrayList; +import java.util.List; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Voice extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "voice_id") + private Long id; + + @Column(name = "voice_key_id", nullable = false) + private String keyId; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "voice", cascade = CascadeType.ALL, orphanRemoval = true) + private List voiceSampleList = new ArrayList<>(); + + // 연관 관계 편의 메소드 + public void setUser(User user) { + this.user = user; + } + + public void addVoiceSample(VoiceSample voiceSample) { + voiceSampleList.add(voiceSample); + voiceSample.setVoice(this); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/VoiceSample.java b/src/main/java/fairytale/tbd/domain/voice/entity/VoiceSample.java new file mode 100644 index 0000000..d22bf6a --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/VoiceSample.java @@ -0,0 +1,40 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class VoiceSample extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "voice_sample_id") + private Long id; + + @Column(name = "voice_sample_url") + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voice_id") + private Voice voice; + + // 연관 관계 편의 메서드 + public void setVoice(Voice voice) { + this.voice = voice; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/enums/VoiceType.java b/src/main/java/fairytale/tbd/domain/voice/enums/VoiceType.java new file mode 100644 index 0000000..dc12871 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/enums/VoiceType.java @@ -0,0 +1,6 @@ +package fairytale.tbd.domain.voice.enums; + +public enum VoiceType { + OLD_WOMAN, OLD_MAN, YOUNG_WOMAN, YOUNG_MAN, NARRATION, MIDDLEAGE_MAN, MIDDLEAGE_WOMAN; +} + diff --git a/src/main/java/fairytale/tbd/domain/voice/exception/ExistVoiceException.java b/src/main/java/fairytale/tbd/domain/voice/exception/ExistVoiceException.java new file mode 100644 index 0000000..0df512f --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/exception/ExistVoiceException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class ExistVoiceException extends GeneralException { + public ExistVoiceException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/exception/InvalidVoiceTypeException.java b/src/main/java/fairytale/tbd/domain/voice/exception/InvalidVoiceTypeException.java new file mode 100644 index 0000000..a6fa928 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/exception/InvalidVoiceTypeException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class InvalidVoiceTypeException extends GeneralException { + public InvalidVoiceTypeException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/exception/VoiceNotFoundException.java b/src/main/java/fairytale/tbd/domain/voice/exception/VoiceNotFoundException.java new file mode 100644 index 0000000..3844c71 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/exception/VoiceNotFoundException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class VoiceNotFoundException extends GeneralException { + public VoiceNotFoundException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/exception/VoiceSaveErrorException.java b/src/main/java/fairytale/tbd/domain/voice/exception/VoiceSaveErrorException.java new file mode 100644 index 0000000..be3f8cb --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/exception/VoiceSaveErrorException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class VoiceSaveErrorException extends GeneralException { + public VoiceSaveErrorException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/repository/SegmentRepository.java b/src/main/java/fairytale/tbd/domain/voice/repository/SegmentRepository.java new file mode 100644 index 0000000..fee9b6b --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/repository/SegmentRepository.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.voice.entity.Segment; + +@Repository +public interface SegmentRepository extends JpaRepository { +} diff --git a/src/main/java/fairytale/tbd/domain/voice/repository/TTSSegmentRepository.java b/src/main/java/fairytale/tbd/domain/voice/repository/TTSSegmentRepository.java new file mode 100644 index 0000000..1d4510b --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/repository/TTSSegmentRepository.java @@ -0,0 +1,13 @@ +package fairytale.tbd.domain.voice.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.voice.entity.TTSSegment; + +@Repository +public interface TTSSegmentRepository extends JpaRepository { + Optional findBySegmentId(Long segmentId); +} diff --git a/src/main/java/fairytale/tbd/domain/voice/repository/UserTTSSegmentRepository.java b/src/main/java/fairytale/tbd/domain/voice/repository/UserTTSSegmentRepository.java new file mode 100644 index 0000000..2992c95 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/repository/UserTTSSegmentRepository.java @@ -0,0 +1,15 @@ +package fairytale.tbd.domain.voice.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.entity.Segment; +import fairytale.tbd.domain.voice.entity.UserTTSSegment; + +@Repository +public interface UserTTSSegmentRepository extends JpaRepository { + Optional findByUserAndSegment(User user, Segment segment); +} diff --git a/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java b/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java new file mode 100644 index 0000000..20d9398 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java @@ -0,0 +1,11 @@ +package fairytale.tbd.domain.voice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import fairytale.tbd.domain.voice.entity.Voice; + +@Repository +public interface VoiceRepository extends JpaRepository { + boolean existsVoiceByUserId(Long userId); +} diff --git a/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java new file mode 100644 index 0000000..e140bef --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java @@ -0,0 +1,19 @@ +package fairytale.tbd.domain.voice.service; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.entity.Segment; +import fairytale.tbd.domain.voice.entity.TTSSegment; +import fairytale.tbd.domain.voice.entity.UserTTSSegment; +import fairytale.tbd.domain.voice.entity.Voice; +import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; + +public interface VoiceCommandService { + Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request, User user); + + VoiceResponseDTO.AddTTSSegmentResultDTO addTTSSegment(VoiceRequestDTO.AddSegmentDTO request); + + TTSSegment saveTTSSegment(Segment segment); + + UserTTSSegment saveUserTTSSegment(User user, Segment segment); +} diff --git a/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java new file mode 100644 index 0000000..5e0f838 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java @@ -0,0 +1,164 @@ +package fairytale.tbd.domain.voice.service; + +import java.io.File; +import java.util.UUID; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.exception.FairytaleNotFoundException; +import fairytale.tbd.domain.fairytale.repository.FairytaleRepository; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.converter.VoiceConverter; +import fairytale.tbd.domain.voice.entity.Segment; +import fairytale.tbd.domain.voice.entity.TTSSegment; +import fairytale.tbd.domain.voice.entity.UserTTSSegment; +import fairytale.tbd.domain.voice.entity.Voice; +import fairytale.tbd.domain.voice.exception.ExistVoiceException; +import fairytale.tbd.domain.voice.exception.VoiceNotFoundException; +import fairytale.tbd.domain.voice.exception.VoiceSaveErrorException; +import fairytale.tbd.domain.voice.repository.SegmentRepository; +import fairytale.tbd.domain.voice.repository.TTSSegmentRepository; +import fairytale.tbd.domain.voice.repository.UserTTSSegmentRepository; +import fairytale.tbd.domain.voice.repository.VoiceRepository; +import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; +import fairytale.tbd.global.aws.s3.AmazonS3Manager; +import fairytale.tbd.global.elevenlabs.ElevenlabsManager; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.util.FileConverter; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoiceCommandServiceImpl implements VoiceCommandService { + + private static final Logger LOGGER = LogManager.getLogger(VoiceCommandServiceImpl.class); + + private final ElevenlabsManager elevenlabsManager; + private final VoiceRepository voiceRepository; + private final AmazonS3Manager amazonS3Manager; + private final FairytaleRepository fairytaleRepository; + private final SegmentRepository segmentRepository; + private final TTSSegmentRepository ttsSegmentRepository; + private final UserTTSSegmentRepository userTTSSegmentRepository; + + /** + * ElevenLabs Voice 추가 + * + * @param request MultiPartFile sample 사용자 녹음 파일 + */ + + @Transactional + @Override + public Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request, User user) { + // 사용자의 목소리가 이미 저장되어 있으면 오류 + if (voiceRepository.existsVoiceByUserId(user.getId())) { + LOGGER.error("=== 이미 존재하는 목소리 === userId = {}", user.getId()); + throw new ExistVoiceException(ErrorStatus._EXIST_VOICE); + } + + String keyId = elevenlabsManager.addVoice(user.getUsername(), request.getSample()); + if (keyId == null) { + LOGGER.error("Eleven Labs 음성 저장에 실패했습니다."); + throw new VoiceSaveErrorException(ErrorStatus._VOICE_SAVE_ERROR); + } + + // String savePath = amazonS3Manager.uploadFile( + // amazonS3Manager.generateS3SavePath(amazonS3Manager.VOICE_SAMPLE_PATH), + // request.getSample()); + // + // VoiceSample voiceSample = VoiceSample.builder() + // .url(savePath) + // .build(); + + Voice voice = VoiceConverter.toVoice(keyId); + // voice.addVoiceSample(voiceSample); + + user.setVoice(voice); + voiceRepository.save(voice); + + return voice; + } + + /** + * 동화 내부 텍스트 문장을 추가하는 메서드 + * 기본 음성으로 변환한 데이터도 저장 + */ + @Transactional + @Override + public VoiceResponseDTO.AddTTSSegmentResultDTO addTTSSegment(VoiceRequestDTO.AddSegmentDTO request) { + // TODO Bean Validation + Fairytale fairytale = fairytaleRepository.findById(request.getFairytaleId()) + .orElseThrow(() -> new FairytaleNotFoundException(ErrorStatus._FAIRYTALE_NOT_FOUND)); // 동화가 없는 경우 + + Segment segment = VoiceConverter.toSegment(request); + fairytale.addSegment(segment); + segmentRepository.save(segment); + + TTSSegment save = saveTTSSegment(segment); + return VoiceConverter.toAddSegmentResultDTO(save, segment.getId()); + } + + /** + * 기본 제공 음성으로 변환한 후, 저장하는 메서드 + */ + @Override + public TTSSegment saveTTSSegment(Segment segment) { + File file = elevenlabsManager.elevenLabsTTS(segment.getContext(), segment.getVoiceType()); + + String savePath = saveSegmentFile(file); + + TTSSegment ttsSegment = TTSSegment.builder() + .url(savePath) + .segment(segment) + .build(); + + return ttsSegmentRepository.save(ttsSegment); + } + + /** + * 동화 문장을 사용자의 음성으로 변환한 후, 저장하는 메서드 + */ + @Override + public UserTTSSegment saveUserTTSSegment(User user, Segment segment) { + LOGGER.info("saveUserTTSSegment Start"); + + // 사용자의 음성이 저장되지 않은 경우 + Voice userVoice = user.getVoice(); + if (userVoice == null) { + throw new VoiceNotFoundException(ErrorStatus._VOICE_NOT_FOUND); + } + + File file = elevenlabsManager.elevenLabsTTS(segment.getContext(), userVoice.getKeyId()); + + String savedUrl = saveSegmentFile(file); + + UserTTSSegment userTTSSegment = UserTTSSegment.builder() + .user(user) + .url(savedUrl) + .build(); + + segment.addUserTTSSegment(userTTSSegment); + return userTTSSegmentRepository.save(userTTSSegment); + } + + /** + * ElevenLabs의 TTS 호출로 반환 된 File객체를 저장하는 메서드 + */ + private String saveSegmentFile(File file) { + String fileName = UUID.randomUUID().toString(); + + // MultipartFile 형태로 변환 -> S3에서는 File형태의 저장 지원 X + MultipartFile multipartFile = FileConverter.toMultipartFile(file, fileName); + String savedUrl = amazonS3Manager.uploadFile( + amazonS3Manager.generateS3SavePath(amazonS3Manager.TTS_USER_VOICE_PATH, fileName), multipartFile); + return savedUrl; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/service/VoiceQueryService.java b/src/main/java/fairytale/tbd/domain/voice/service/VoiceQueryService.java new file mode 100644 index 0000000..2e0239c --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/service/VoiceQueryService.java @@ -0,0 +1,12 @@ +package fairytale.tbd.domain.voice.service; + +import java.util.List; +import java.util.Map; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; + +public interface VoiceQueryService { + Map> getUserTTSSegmentList(User user, + Long fairytaleId, boolean changeVoice); +} diff --git a/src/main/java/fairytale/tbd/domain/voice/service/VoiceQueryServiceImpl.java b/src/main/java/fairytale/tbd/domain/voice/service/VoiceQueryServiceImpl.java new file mode 100644 index 0000000..9bbea93 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/service/VoiceQueryServiceImpl.java @@ -0,0 +1,94 @@ +package fairytale.tbd.domain.voice.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.fairytale.entity.Fairytale; +import fairytale.tbd.domain.fairytale.exception.FairytaleNotFoundException; +import fairytale.tbd.domain.fairytale.repository.FairytaleRepository; +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.entity.Segment; +import fairytale.tbd.domain.voice.entity.TTSSegment; +import fairytale.tbd.domain.voice.entity.UserTTSSegment; +import fairytale.tbd.domain.voice.repository.TTSSegmentRepository; +import fairytale.tbd.domain.voice.repository.UserTTSSegmentRepository; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoiceQueryServiceImpl implements VoiceQueryService { + + private static final Logger LOGGER = LogManager.getLogger(VoiceQueryServiceImpl.class); + + private final VoiceCommandService voiceCommandService; + private final FairytaleRepository fairytaleRepository; + private final UserTTSSegmentRepository userTTSSegmentRepository; + private final TTSSegmentRepository ttsSegmentRepository; + + /** + * 특정 사용자의 목소리로 변경한 음성과 기본 모든 음성을 가져옴 + * 동화의 문장 목록들을 탐색하며 + * 주인공이라면 사용자의 음성을 (만약 존재하지 않는다면 생성 후 저장) + * 주인공이 아니라면 기본 음성을 (마찬가지로 존재하지 않는다면 생성 후 저장) + * 받은 후 문장의 페이지 번호를 키값으로 Map 형태로 반환 (문장 순서대로 정렬) + */ + @Transactional + @Override + public Map> getUserTTSSegmentList(User user, + Long fairytaleId, boolean changeVoice) { + // 동화 이름이 유효한지 검증 + // TODO Bean Validation으로 넘기기 + Fairytale fairytale = fairytaleRepository.findById(fairytaleId) + .orElseThrow(() -> new FairytaleNotFoundException(ErrorStatus._FAIRYTALE_NOT_FOUND)); + + // 동화의 문장 목록을 불러온 후, 페이지 번호, 문장 번호에 대해 오름차순 정렬 + List segmentList = fairytale.getSegmentList(); + Collections.sort(segmentList); + + Map> result = new HashMap<>(); + + for (Segment segment : segmentList) { + VoiceResponseDTO.GetUserTTSSegmentResultDetailDTO resultDTO = null; + // TODO 상속관계로 변경, ASYNC + if (segment.isMainCharacter() && changeVoice) { + UserTTSSegment userTTSSegment = userTTSSegmentRepository.findByUserAndSegment(user, segment) + .orElseGet(() -> voiceCommandService.saveUserTTSSegment(user, segment)); + + resultDTO = VoiceResponseDTO.GetUserTTSSegmentResultDetailDTO.builder() + .segmentId(segment.getId()) + .audioUrl(userTTSSegment.getUrl()) + .build(); + } else { + + TTSSegment ttsSegment = ttsSegmentRepository.findBySegmentId(segment.getId()) + .orElseGet(() -> voiceCommandService.saveTTSSegment(segment)); + + resultDTO = VoiceResponseDTO.GetUserTTSSegmentResultDetailDTO.builder() + .segmentId(segment.getId()) + .audioUrl(ttsSegment.getUrl()) + .build(); + } + if (resultDTO == null) { + LOGGER.error("::: 음성 목록을 가져오는 중 에러 발생 :::"); + throw new RuntimeException("음성 목록을 가져오는 중 에러가 발생했습니다."); + } + if (!result.containsKey(segment.getPageNum())) { + result.put(segment.getPageNum(), new ArrayList()); + } + result.get(segment.getPageNum()).add(resultDTO); + } + return result; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java b/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java new file mode 100644 index 0000000..2e8f1cc --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java @@ -0,0 +1,71 @@ +package fairytale.tbd.domain.voice.web.controller; + +import java.util.List; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.voice.converter.VoiceConverter; +import fairytale.tbd.domain.voice.entity.Voice; +import fairytale.tbd.domain.voice.service.VoiceCommandService; +import fairytale.tbd.domain.voice.service.VoiceQueryService; +import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; +import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; +import fairytale.tbd.global.annotation.LoginUser; +import fairytale.tbd.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/voice") +public class VoiceRestController { + + private static final Logger LOGGER = LogManager.getLogger(VoiceRestController.class); + private final VoiceCommandService voiceCommandService; + private final VoiceQueryService voiceQueryService; + + /** + * 사용자의 음성 추가 + */ + @PostMapping("") + public ApiResponse addVoice( + @Valid @ModelAttribute VoiceRequestDTO.AddVoiceDTO request, @LoginUser User user) { + LOGGER.info("request = {}", request); + Voice voice = voiceCommandService.uploadVoice(request, user); + return ApiResponse.onSuccess(VoiceConverter.toAddVoiceResult(voice)); + } + + /** + * 동화의 문장 추가 + */ + @PostMapping("/segment") + public ApiResponse addSegment( + @Valid @RequestBody VoiceRequestDTO.AddSegmentDTO request) { + LOGGER.info("::: Segment 추가 요청 :::"); + LOGGER.info("request = {}", request); + VoiceResponseDTO.AddTTSSegmentResultDTO result = voiceCommandService.addTTSSegment(request); + LOGGER.info("::: Segment 추가 성공 SegmentId = {}:::", result.getSegmentId()); + return ApiResponse.onSuccess(result); + } + + @GetMapping("/segment/test") + public ApiResponse>> getUserSegment( + @LoginUser User user, + @RequestParam(name = "fairytaleId") Long fairytaleId) { + LOGGER.info("getUserSegment START"); + Map> userTTSSegmentList = voiceQueryService.getUserTTSSegmentList( + user, fairytaleId, true); + return ApiResponse.onSuccess(userTTSSegmentList); + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/web/dto/VoiceRequestDTO.java b/src/main/java/fairytale/tbd/domain/voice/web/dto/VoiceRequestDTO.java new file mode 100644 index 0000000..d111089 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/web/dto/VoiceRequestDTO.java @@ -0,0 +1,35 @@ +package fairytale.tbd.domain.voice.web.dto; + +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import fairytale.tbd.domain.voice.enums.VoiceType; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +public class VoiceRequestDTO { + @Getter + @Setter + public static class AddVoiceDTO { + //파일 형식 검증 + @NotNull + MultipartFile sample; + } + + @Getter + @Setter + @ToString + public static class AddSegmentDTO { + private String context; + @JsonProperty("isMainCharacter") + private boolean isMainCharacter; + private VoiceType voiceType; + private Long fairytaleId; + private Double segmentNum; + private Long pageNum; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/web/dto/VoiceResponseDTO.java b/src/main/java/fairytale/tbd/domain/voice/web/dto/VoiceResponseDTO.java new file mode 100644 index 0000000..09fc8c3 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/web/dto/VoiceResponseDTO.java @@ -0,0 +1,57 @@ +package fairytale.tbd.domain.voice.web.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class VoiceResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddVoiceResultDTO { + private Long voiceId; + private LocalDateTime createdAt; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddTTSSegmentResultDTO { + private Long segmentId; + private Long ttsSegmentId; + private String url; + private LocalDateTime createdAt; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class GetUserTTSSegmentResultWithDateDTO { + Map> ttsSegmentResultList; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class GetUserTTSSegmentResultDTO { + List ttsSegmentList; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class GetUserTTSSegmentResultDetailDTO { + private String audioUrl; + private Long segmentId; + } +} diff --git a/src/main/java/fairytale/tbd/global/annotation/LoginUser.java b/src/main/java/fairytale/tbd/global/annotation/LoginUser.java new file mode 100644 index 0000000..1b30fd8 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/annotation/LoginUser.java @@ -0,0 +1,14 @@ +package fairytale.tbd.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginUser { + +} diff --git a/src/main/java/fairytale/tbd/global/aws/s3/AmazonS3Manager.java b/src/main/java/fairytale/tbd/global/aws/s3/AmazonS3Manager.java new file mode 100644 index 0000000..fff1d43 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/aws/s3/AmazonS3Manager.java @@ -0,0 +1,88 @@ +package fairytale.tbd.global.aws.s3; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.PutObjectResult; + +import fairytale.tbd.global.config.AmazonConfig; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AmazonS3Manager { + + private final AmazonS3 amazonS3; + private final AmazonConfig amazonConfig; + + @Value("${cloud.aws.s3.path.voice-sample}") + public String VOICE_SAMPLE_PATH; + + @Value("${cloud.aws.s3.path.common-voice}") + public String TTS_COMMON_VOICE_PATH; + + @Value("${cloud.aws.s3.path.user-voice}") + public String TTS_USER_VOICE_PATH; + + @Value("${cloud.aws.s3.path.face-sample}") + public String FACE_SWAP_SAMPLE_PATH; + + @Value("${cloud.aws.s3.path.user-face}") + public String FACE_SWAP_USER_PATH; + + public String uploadFile(String keyName, MultipartFile file) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType(file.getContentType()); + metadata.setContentLength(file.getSize()); + try { + PutObjectResult putObjectResult = amazonS3.putObject( + new PutObjectRequest(amazonConfig.getBucket(), keyName, file.getInputStream(), metadata)); + log.info("result={}", putObjectResult.getContentMd5()); + } catch (IOException e) { + log.error("error at AmazonS3Manager uploadFile : {}", (Object)e.getStackTrace()); + throw new GeneralException(ErrorStatus._S3_FILE_UPLOAD_ERROR); + } + + return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); + } + + public String uploadJpegImageFromUrl(String keyName, String imageUrl) { + + try { + URL url = new URL(imageUrl); + InputStream inputStream = url.openStream(); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentType("image/jpeg"); // Change content type based on your image type + + PutObjectResult putObjectResult = amazonS3.putObject( + new PutObjectRequest(amazonConfig.getBucket(), keyName, inputStream, metadata)); + log.info("result={}", putObjectResult.getContentMd5()); + } catch (IOException e) { + log.error("error at AmazonS3Manager uploadFile : {}", (Object)e.getStackTrace()); + throw new GeneralException(ErrorStatus._S3_FILE_UPLOAD_ERROR); + } + return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); + } + + public String generateS3SavePath(String path) { + return path + '/' + UUID.randomUUID().toString(); + } + + public String generateS3SavePath(String path, String name) { + return path + '/' + name; + } + +} diff --git a/src/main/java/fairytale/tbd/global/config/AmazonConfig.java b/src/main/java/fairytale/tbd/global/config/AmazonConfig.java new file mode 100644 index 0000000..acb5591 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/config/AmazonConfig.java @@ -0,0 +1,56 @@ +package fairytale.tbd.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; + +@Configuration +@Getter +public class AmazonConfig { + + private AWSCredentials awsCredentials; + + @Value("${cloud.aws.s3.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.s3.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.s3.region.static}") + private String region; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + + + @PostConstruct + public void init(){ + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3(){ + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider(){ + return new AWSStaticCredentialsProvider(awsCredentials); + } + +} diff --git a/src/main/java/fairytale/tbd/global/config/SecurityConfig.java b/src/main/java/fairytale/tbd/global/config/SecurityConfig.java new file mode 100644 index 0000000..66169e0 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/config/SecurityConfig.java @@ -0,0 +1,108 @@ +package fairytale.tbd.global.config; + +import java.util.Arrays; +import java.util.Collections; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.service.UserQueryService; +import fairytale.tbd.global.security.LoginService; +import fairytale.tbd.global.security.jwt.JwtService; +import fairytale.tbd.global.security.jwt.filter.CustomUsernamePwdAuthenticationFilter; +import fairytale.tbd.global.security.jwt.filter.JwtAuthenticationFilter; +import fairytale.tbd.global.security.jwt.handler.JwtLoginFailureHandler; +import fairytale.tbd.global.security.jwt.handler.JwtLoginSuccessHandler; +import lombok.RequiredArgsConstructor; + +/** + * 인증은 CustomJsonUsernamePasswordAuthenticationFilter에서 authenticate()로 인증된 사용자로 처리 + * JwtAuthenticationProcessingFilter는 AccessToken, RefreshToken 재발급 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final LoginService loginService; + private final JwtService jwtService; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + private final JwtLoginSuccessHandler jwtLoginSuccessHandler; + private final JwtLoginFailureHandler jwtLoginFailureHandler; + private final UserQueryService userQueryService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) // csrf 보안 사용 X => Rest API + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) // Token 기반 인증 => session 사용 X + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/login", "/api/user/signup", "/faceSwap/webhook").permitAll() // 허용된 주소 + .anyRequest().authenticated() + ) + // CORS + .cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowCredentials(true); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setExposedHeaders(Arrays.asList("Authorization", "Authorization-refresh")); + config.setMaxAge(3600L); + return config; + })); + http.addFilterAfter(customUsernamePwdAuthenticationFilter(), LogoutFilter.class); + http.addFilterBefore(jwtAuthenticationFilter(), CustomUsernamePwdAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setPasswordEncoder(passwordEncoder()); + provider.setUserDetailsService(loginService); + return new ProviderManager(provider); + } + + @Bean + public CustomUsernamePwdAuthenticationFilter customUsernamePwdAuthenticationFilter() { + CustomUsernamePwdAuthenticationFilter customJsonUsernamePasswordLoginFilter + = new CustomUsernamePwdAuthenticationFilter(objectMapper); + customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager()); + customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(jwtLoginSuccessHandler); + customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(jwtLoginFailureHandler); + return customJsonUsernamePasswordLoginFilter; + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtService, + userRepository, userQueryService); + return jwtAuthenticationFilter; + } +} diff --git a/src/main/java/fairytale/tbd/global/config/WebConfig.java b/src/main/java/fairytale/tbd/global/config/WebConfig.java new file mode 100644 index 0000000..3e3ff43 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/config/WebConfig.java @@ -0,0 +1,22 @@ +package fairytale.tbd.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.global.resolver.LoginUserArgumentResolver; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final UserRepository userRepository; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginUserArgumentResolver(userRepository)); + } +} diff --git a/src/main/java/fairytale/tbd/global/elevenlabs/ElevenlabsManager.java b/src/main/java/fairytale/tbd/global/elevenlabs/ElevenlabsManager.java new file mode 100644 index 0000000..fe11080 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/elevenlabs/ElevenlabsManager.java @@ -0,0 +1,187 @@ +package fairytale.tbd.global.elevenlabs; + +import java.io.File; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import net.andrewcpu.elevenlabs.ElevenLabs; +import net.andrewcpu.elevenlabs.builders.SpeechGenerationBuilder; +import net.andrewcpu.elevenlabs.enums.GeneratedAudioOutputFormat; +import net.andrewcpu.elevenlabs.enums.StreamLatencyOptimization; +import net.andrewcpu.elevenlabs.model.voice.Voice; +import net.andrewcpu.elevenlabs.model.voice.VoiceBuilder; +import net.andrewcpu.elevenlabs.model.voice.VoiceSettings; + +import fairytale.tbd.domain.voice.enums.VoiceType; +import fairytale.tbd.domain.voice.exception.InvalidVoiceTypeException; +import fairytale.tbd.global.elevenlabs.exception.FileConvertException; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * https://github.com/Andrewcpu/elevenlabs-api + */ + +@Slf4j +@Component +@RequiredArgsConstructor +public class ElevenlabsManager { + + private static final Logger LOGGER = LogManager.getLogger(ElevenlabsManager.class); + + @Value("${voice.elevenlabs.apikey}") + private String apiKey; + + @Value("${voice.elevenlabs.voice_setting.similarity_boost}") + private double similarity; + @Value("${voice.elevenlabs.voice_setting.stability}") + private double stability; + @Value("${voice.elevenlabs.voice_setting.style}") + private double style; + + @Value("${voice.elevenlabs.narration-voice-setting.similarity_boost}") + private double Nsimilarity; + @Value("${voice.elevenlabs.narration-voice-setting.stability}") + private double Nstability; + @Value("${voice.elevenlabs.narration-voice-setting.style}") + private double Nstyle; + @Value("${voice.elevenlabs.narration-voice-setting.use-speaker-boost}") + private boolean NuseSpeakerBoost; + + @Value("${voice.elevenlabs.voice_setting.use_speaker_boost}") + private boolean useSpeakerBoost; + + @Value("${voice.elevenlabs.common-voice-id.old-man}") + private String oldManKey; + + @Value("${voice.elevenlabs.common-voice-id.old-woman}") + private String oldWomanKey; + + @Value("${voice.elevenlabs.common-voice-id.middle-age-man}") + + private String middleAgeManKey; + + @Value("${voice.elevenlabs.common-voice-id.middle-age-woman}") + private String middleAgeWomanKey; + + @Value("${voice.elevenlabs.common-voice-id.young-man}") + private String youngManKey; + + @Value("${voice.elevenlabs.common-voice-id.young_woman}") + private String youngWomanKey; + + @Value("${voice.elevenlabs.common-voice-id.narration-woman}") + private String narrationWomanKey; + + @PostConstruct + public void init() { + ElevenLabs.setApiKey(apiKey); + ElevenLabs.setDefaultModel("eleven_multilingual_v2"); + } + + /** + * EleventLabs TTS 변환 + * @param text 음성 TTS 변환 할 내용 + * @param voiceId voice 고유 값 + * @return 생성된 .mpga 파일 + */ + public File elevenLabsTTS(String text, String voiceId) { + return SpeechGenerationBuilder.textToSpeech() + .file() + .setText(text) + .setGeneratedAudioOutputFormat(GeneratedAudioOutputFormat.MP3_44100_128) + .setVoiceId(voiceId) + .setVoiceSettings(new VoiceSettings(stability, similarity, style, useSpeakerBoost)) + .setLatencyOptimization(StreamLatencyOptimization.NONE) + .build(); + } + + public File elevenLabsTTS(String text, VoiceType voiceType) { + if (voiceType == VoiceType.NARRATION) { + return elevenLabsTTSNarration(text); + } + return elevenLabsTTS(text, getCommonVoiceId(voiceType)); + } + + private String getCommonVoiceId(VoiceType voiceType) { + switch (voiceType) { + case OLD_MAN: + return oldManKey; + case OLD_WOMAN: + return oldWomanKey; + case YOUNG_MAN: + return youngManKey; + case YOUNG_WOMAN: + return youngWomanKey; + case MIDDLEAGE_MAN: + return middleAgeManKey; + case MIDDLEAGE_WOMAN: + return middleAgeWomanKey; + default: + throw new InvalidVoiceTypeException(ErrorStatus._INVALID_VOICE_TYPE); + } + + } + + /** + * EleventLabs TTS 나레이션 변환 + * @param text 음성 TTS 변환 할 내용 + * @return 생성된 .mpga 파일 + */ + public File elevenLabsTTSNarration(String text) { + return SpeechGenerationBuilder.textToSpeech() + .file() + .setText(text) + .setGeneratedAudioOutputFormat(GeneratedAudioOutputFormat.MP3_44100_128) + .setVoiceId(narrationWomanKey) + .setVoiceSettings(new VoiceSettings(Nstability, Nsimilarity, Nstyle, NuseSpeakerBoost)) + .setLatencyOptimization(StreamLatencyOptimization.NONE) + .build(); + } + + /** + * Voice 생성 + * @param userName 유저 이름 (voice에 저장될 이름) + * @param sample 실제 음성 녹음 파일 + * @return voice 고유번호 + */ + public String addVoice(String userName, MultipartFile sample) { + File file = convertMultipartFileToFile(sample); + VoiceBuilder builder = new VoiceBuilder(); + builder.withName(userName); + builder.withFile(file); + builder.withDescription("the emotional voice of the main character of a children's book"); + builder.withLabel("language", "ko"); + Voice voice = builder.create(); + if (file != null && file.exists()) { + file.delete(); + } + return voice.getVoiceId(); + } + + /** + * MultiPartFile -> File 변환 + * @param multipartFile + * @return + */ + public static File convertMultipartFileToFile(MultipartFile multipartFile) { + // 임시 파일 생성 + File file = new File(System.getProperty("java.io.tmpdir") + "/" + multipartFile.getOriginalFilename()); + + // MultipartFile의 내용을 파일에 쓰기 + try { + multipartFile.transferTo(file); + } catch (Exception e) { + e.printStackTrace(); + throw new FileConvertException(ErrorStatus._FILE_CONVERT_ERROR); + } + + return file; + } +} diff --git a/src/main/java/fairytale/tbd/global/elevenlabs/exception/FileConvertException.java b/src/main/java/fairytale/tbd/global/elevenlabs/exception/FileConvertException.java new file mode 100644 index 0000000..311bc0b --- /dev/null +++ b/src/main/java/fairytale/tbd/global/elevenlabs/exception/FileConvertException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.global.elevenlabs.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class FileConvertException extends GeneralException { + public FileConvertException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/global/entity/BaseEntity.java b/src/main/java/fairytale/tbd/global/entity/BaseEntity.java new file mode 100644 index 0000000..6c2a7a4 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/entity/BaseEntity.java @@ -0,0 +1,22 @@ +package fairytale.tbd.global.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity { + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/fairytale/tbd/global/enums/statuscode/BaseCode.java b/src/main/java/fairytale/tbd/global/enums/statuscode/BaseCode.java new file mode 100644 index 0000000..f4b4c43 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/enums/statuscode/BaseCode.java @@ -0,0 +1,13 @@ +package fairytale.tbd.global.enums.statuscode; + +import org.springframework.http.HttpStatus; + +public interface BaseCode { + String getCode(); + + String getMessage(); + + HttpStatus getHttpStatus(); + + Integer getStatusValue(); +} diff --git a/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java b/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java new file mode 100644 index 0000000..6a1ba5b --- /dev/null +++ b/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java @@ -0,0 +1,74 @@ +package fairytale.tbd.global.enums.statuscode; + +import org.springframework.http.HttpStatus; + +public enum ErrorStatus implements BaseCode { + // common + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + + _REQUEST_FAIL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON5001", "외부 API 요청에 실패했습니다."), + + _S3_FILE_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON5002", "S3 파일 업로드 중 오류가 발생했습니다."), + + // Fairytale + + _FAIRYTALE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FAIRYTALE4001", "존재하지 않는 동화입니다."), + _FAIRYTALE_EXIST_ERROR(HttpStatus.BAD_REQUEST, "FAIRYTALE4002", "이미 존재하는 이름의 동화입니다."), + _FAIRYTALE_DATA_NOT_EXIST(HttpStatus.INTERNAL_SERVER_ERROR, "FAIRYTALE5001", "동화 변환에 실패했습니다. 동화 데이터에 오류가 있습니다."), + + // ElevenLabs + _FILE_CONVERT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE5001", "파일 변환에 실패했습니다."), + + // Face Swap + + _USER_FACE_NOT_EXIST(HttpStatus.BAD_REQUEST, "FACESWAP4002", "사용자가 업로드한 얼굴 이미지가 없습니다."), + _FACE_NOT_DETECT_ERROR(HttpStatus.BAD_REQUEST, "FACESWAP4001", "이미지에서 인식된 얼굴이 없습니다."), + _FACE_SWAP_FAILURE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FACESWAP5001", "얼굴 변환에 실패했습니다."), + _EMPTY_SAVE_QUEUE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FACESWAP5002", "저장 큐가 비어있습니다."), + + // Voice + + _EXIST_VOICE(HttpStatus.BAD_REQUEST, "VOICE4001", "이미 존재하는 목소리입니다."), + _VOICE_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "VOICE5002", "목소리 저장에 실패했습니다."), + _INVALID_VOICE_TYPE(HttpStatus.BAD_REQUEST, "VOICE4002", "올바르지 않은 목소리 종류입니다."), + _VOICE_NOT_FOUND(HttpStatus.BAD_REQUEST, "VOICE4003", "저장되어 있는 사용자 음성이 없습니다."), + _USER_TTS_DUPLCATION(HttpStatus.INTERNAL_SERVER_ERROR, "VOICE5003", "변환된 음성에서 중복되는 데이터가 있습니다."), + + // User + _EXIST_USERNAME(HttpStatus.BAD_REQUEST, "USER4001", "이미 존재하는 닉네임입니다."), + _USER_NOT_EXIST(HttpStatus.BAD_REQUEST, "USER4002", "존재하지 않는 사용자입니다."), + _AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST, "USER4003", "인증에 실패하였습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + ErrorStatus(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public Integer getStatusValue() { + return httpStatus.value(); + } +} diff --git a/src/main/java/fairytale/tbd/global/enums/statuscode/SuccessStatus.java b/src/main/java/fairytale/tbd/global/enums/statuscode/SuccessStatus.java new file mode 100644 index 0000000..48fd35a --- /dev/null +++ b/src/main/java/fairytale/tbd/global/enums/statuscode/SuccessStatus.java @@ -0,0 +1,38 @@ +package fairytale.tbd.global.enums.statuscode; + +import org.springframework.http.HttpStatus; + +public enum SuccessStatus implements BaseCode{ + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _ACCEPTED(HttpStatus.ACCEPTED, "COMMON204", "별도의 응답 데이터가 없으며, 정상 처리되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + SuccessStatus(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public Integer getStatusValue() { + return httpStatus.value(); + } +} diff --git a/src/main/java/fairytale/tbd/global/exception/ExceptionAdvice.java b/src/main/java/fairytale/tbd/global/exception/ExceptionAdvice.java new file mode 100644 index 0000000..ff7f749 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/exception/ExceptionAdvice.java @@ -0,0 +1,84 @@ +package fairytale.tbd.global.exception; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.response.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class ExceptionAdvice extends ResponseEntityExceptionHandler { + @ExceptionHandler(value = GeneralException.class) + public ResponseEntity handleGeneralException(GeneralException exception, HttpServletRequest request) { + return handleExceptionInternal(exception, HttpHeaders.EMPTY, request); + } + + @Override + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException exception, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + + log.info("handleGeneralException 발생 ={}", exception); + Map errors = new LinkedHashMap<>(); + exception.getBindingResult().getFieldErrors().stream() + .forEach(fieldError -> { + + String fieldName = fieldError.getField(); + String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + errors.merge(fieldName, errorMessage, + (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); + }); + + exception.getBindingResult().getGlobalErrors().stream() + .forEach(globalError -> { + log.info("globalError = {}", globalError); + String objectName = globalError.getObjectName(); + String errorMessage = Optional.ofNullable(globalError.getDefaultMessage()).orElse(""); + errors.merge(objectName, errorMessage, + (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); + }); + + return handleExceptionInternalArgs(exception, HttpHeaders.EMPTY, ErrorStatus.valueOf("_BAD_REQUEST"), request, + errors); + } + + @ExceptionHandler + public ResponseEntity handlingException(Exception exception, WebRequest request) { + return handleExceptionInternal(exception, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, + ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, exception.getMessage()); + } + + private ResponseEntity handleExceptionInternal(GeneralException exception, HttpHeaders headers, + HttpServletRequest request) { + ApiResponse body = ApiResponse.onFailure(exception.getErrorCode(), exception.getErrorReason(), null); + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal(exception, body, headers, exception.getHttpStatus(), webRequest); + } + + private ResponseEntity handleExceptionInternal(Exception exception, ErrorStatus errorStatus, + HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + ApiResponse body = ApiResponse.onFailure(errorStatus.getCode(), errorStatus.getMessage(), errorPoint); + return super.handleExceptionInternal(exception, body, headers, status, request); + } + + private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, + ErrorStatus errorCommonStatus, WebRequest request, Map errorArgs) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), + errorArgs); + return super.handleExceptionInternal(e, body, headers, errorCommonStatus.getHttpStatus(), request); + } +} diff --git a/src/main/java/fairytale/tbd/global/exception/GeneralException.java b/src/main/java/fairytale/tbd/global/exception/GeneralException.java new file mode 100644 index 0000000..3dc4440 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/exception/GeneralException.java @@ -0,0 +1,23 @@ +package fairytale.tbd.global.exception; + +import org.springframework.http.HttpStatus; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class GeneralException extends RuntimeException{ + private final BaseCode errorStatus; + + public String getErrorCode() { + return errorStatus.getCode(); + } + + public String getErrorReason() { + return errorStatus.getMessage(); + } + + public HttpStatus getHttpStatus() { + return errorStatus.getHttpStatus(); + } +} diff --git a/src/main/java/fairytale/tbd/global/resolver/LoginUserArgumentResolver.java b/src/main/java/fairytale/tbd/global/resolver/LoginUserArgumentResolver.java new file mode 100644 index 0000000..ff36d5c --- /dev/null +++ b/src/main/java/fairytale/tbd/global/resolver/LoginUserArgumentResolver.java @@ -0,0 +1,54 @@ +package fairytale.tbd.global.resolver; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.exception.UserNotExistException; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.global.annotation.LoginUser; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + private static final Logger LOGGER = LogManager.getLogger(LoginUserArgumentResolver.class); + private final UserRepository userRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasParameterAnnotations = parameter.hasParameterAnnotation(LoginUser.class); + boolean hasUserType = User.class.isAssignableFrom(parameter.getParameterType()); + return hasParameterAnnotations && hasUserType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new IllegalStateException("올바르지 않은 요청 타입입니다. webRequest : " + webRequest); + } + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + User user = null; + if (authentication.getPrincipal() instanceof UserDetails) { + LOGGER.info("UserDetails (JWT) Login"); + UserDetails userDetails = (UserDetails)authentication.getPrincipal(); + return userRepository.findByLoginId(userDetails.getUsername()) + .orElseThrow(() -> new UserNotExistException(ErrorStatus._USER_NOT_EXIST)); + } else { + LOGGER.info("알 수 없는 인증 타입"); + throw new IllegalStateException("지원하지 않는 인증 타입입니다."); + } + } +} diff --git a/src/main/java/fairytale/tbd/global/response/ApiResponse.java b/src/main/java/fairytale/tbd/global/response/ApiResponse.java new file mode 100644 index 0000000..5658e63 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/response/ApiResponse.java @@ -0,0 +1,35 @@ +package fairytale.tbd.global.response; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.enums.statuscode.SuccessStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + private final Boolean isSuccess; + private final String code; + + private final String message; + private T result; + + // 성공한 경우 응답 생성 + + public static ApiResponse onSuccess(T result) { + return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result); + } + + public static ApiResponse of(boolean isSuccess, BaseCode code, T result) { + return new ApiResponse<>(isSuccess, code.getCode(), code.getMessage(), result); + } + + // 실패한 경우 응답 생성 + public static ApiResponse onFailure(String code, String message, T data) { + return new ApiResponse<>(false, code, message, data); + } +} diff --git a/src/main/java/fairytale/tbd/global/security/LoginService.java b/src/main/java/fairytale/tbd/global/security/LoginService.java new file mode 100644 index 0000000..9178362 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/LoginService.java @@ -0,0 +1,44 @@ +package fairytale.tbd.global.security; + +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LoginService implements UserDetailsService { + + private final UserRepository userRepository; + private static final Logger LOGGER = LogManager.getLogger(UserDetailsService.class); + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("해당 아이디가 존재하지 않습니다.")); + + LOGGER.info("loadUserByUsername = {}", user); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getLoginId()) + .authorities(user.getAuthorityList() + .stream() + .map(authority -> new SimpleGrantedAuthority(authority.getRole().toString())) + .collect( + Collectors.toSet())) + .password(user.getPassword()) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/JwtService.java b/src/main/java/fairytale/tbd/global/security/jwt/JwtService.java new file mode 100644 index 0000000..23f85e4 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/JwtService.java @@ -0,0 +1,177 @@ +package fairytale.tbd.global.security.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Optional; + +import javax.crypto.SecretKey; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.service.UserQueryService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Getter +/** + * JWT 관련 서비스 + */ +public class JwtService { + @Value("${spring.security.jwt.secretKey}") + private String secretKey; + + @Value("${spring.security.jwt.access.expiration}") + private Long accessTokenExpirationPeriod; + + @Value("${spring.security.jwt.refresh.expiration}") + private Long refreshTokenExpirationPeriod; + + @Value("${spring.security.jwt.access.header}") + private String accessHeader; + + @Value("${spring.security.jwt.refresh.header}") + private String refreshHeader; + + private static final Logger LOGGER = LogManager.getLogger(JwtService.class); + private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; + private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + private static final String LOGINID_CLAIM = "loginId"; + private static final String BEARER = "Bearer "; + private final UserQueryService userQueryService; + + private final UserRepository userRepository; + + /** + * AccessToken 생성 + */ + public String createAccessToken(String loginId) { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(ACCESS_TOKEN_SUBJECT) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + accessTokenExpirationPeriod)) + .claim(LOGINID_CLAIM, loginId) + .signWith(key).compact(); + } + + /** + * RefreshToken 생성 + */ + public String createRefreshToken() { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(REFRESH_TOKEN_SUBJECT) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + refreshTokenExpirationPeriod)) + .signWith(key).compact(); + } + + /** + * AccessToken 헤더에 실어서 보내기 + */ + + public void sendAccessToken(HttpServletResponse response, String accessToken) { + response.setStatus(HttpServletResponse.SC_OK); + + response.setHeader(accessHeader, accessToken); + LOGGER.info("재발급된 Access Token : {}", accessToken); + } + + /** + * AccessToken + RefreshToken 헤더에 실어서 보내기 + */ + public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) { + response.setStatus(HttpServletResponse.SC_OK); + + setAccessTokenHeader(response, accessToken); + setRefreshTokenHeader(response, refreshToken); + LOGGER.info("Access Token, Refresh Token 헤더 설정 완료"); + } + + /** + * Token 내용 추출 + */ + public Optional extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(refreshHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * 헤더에서 AccessToken 추출 + */ + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(accessHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * AccessToken에서 LoginId 추출 + */ + public Optional extractLoginId(String accessToken) { + + SecretKey key = Keys.hmacShaKeyFor( + secretKey.getBytes(StandardCharsets.UTF_8)); + + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + + return Optional.ofNullable(String.valueOf(claims.get("loginId"))); + } + + /** + * AccessToken 헤더 설정 + */ + public void setAccessTokenHeader(HttpServletResponse response, String accessToken) { + response.setHeader(accessHeader, accessToken); + } + + /** + * RefreshToken 헤더 설정 + */ + public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) { + response.setHeader(refreshHeader, refreshToken); + } + + /** + * RefreshToken DB 저장(업데이트) + */ + public void updateRefreshToken(String loginId, String refreshToken) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원이 없습니다.")); + userQueryService.updateRefreshToken(user, refreshToken); + } + + /** + * 유효한 서명의 토큰인지 검증 + */ + public boolean isTokenValid(String token) { + SecretKey key = Keys.hmacShaKeyFor( + secretKey.getBytes(StandardCharsets.UTF_8)); + try { + Jwts.parser().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (Exception e) { + LOGGER.error("유효하지 않은 토큰입니다. {}", e.getMessage()); + return false; + } + } + +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/filter/CustomUsernamePwdAuthenticationFilter.java b/src/main/java/fairytale/tbd/global/security/jwt/filter/CustomUsernamePwdAuthenticationFilter.java new file mode 100644 index 0000000..62eff31 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/filter/CustomUsernamePwdAuthenticationFilter.java @@ -0,0 +1,73 @@ +package fairytale.tbd.global.security.jwt.filter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.StreamUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * JWT 로그인 POST 요청 왔을 때 인증 필터 + */ +public class CustomUsernamePwdAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + public static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; + private static final String HTTP_METHOD = "POST"; + private static final String CONTENT_TYPE = "application/json"; + private static final String LOGINID_KEY = "loginId"; + private static final String PASSWORD_KEY = "password"; + + private static final Logger LOGGER = LogManager.getLogger(CustomUsernamePwdAuthenticationFilter.class); + private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); + + private final ObjectMapper objectMapper; + + public CustomUsernamePwdAuthenticationFilter(ObjectMapper objectMapper) { + super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 기존 formlogin 형태를 변경 + this.objectMapper = objectMapper; + } + + /** + * JWT 로컬 로그인 인증 과정 + */ + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws + AuthenticationException, + IOException, + ServletException { + // 지원하는 ContentType이 아닌 경우 + if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) { + throw new AuthenticationServiceException( + "Authentication Content-Type not supported: " + request.getContentType()); + } + + // request body에서 로그인 ID와 비밀번호 추출 + String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); + + Map usernamePasswordMap = objectMapper.readValue(messageBody, Map.class); + String loginId = usernamePasswordMap.get(LOGINID_KEY); + String password = usernamePasswordMap.get(PASSWORD_KEY); + + LOGGER.info("JWT Local Login ::: loginId = {}, password = {}", loginId, password); + + //principal 과 credentials 전달 + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(loginId, password); + return this.getAuthenticationManager().authenticate(authRequest); + } + +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/fairytale/tbd/global/security/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..aadbcb5 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,129 @@ +package fairytale.tbd.global.security.jwt.filter; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.service.UserQueryService; +import fairytale.tbd.global.security.jwt.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +/** + * JWT Authentication 필터 + */ +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final UserRepository userRepository; + private final UserQueryService userQueryService; + + /** + * 로그인 요청 시 JWT 검증 X + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + return request.getServletPath().equals(CustomUsernamePwdAuthenticationFilter.DEFAULT_LOGIN_REQUEST_URL); + } + + /** + * JWT 검증 후 + * 요청에 Refresh Token 존재 -> Refresh Token 검증 후 Access Token, Refresh Token 생성 + * 요청에 Refresh Token 존재 X -> Access Token 검증 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String refreshToken = jwtService.extractRefreshToken(request) + .filter(jwtService::isTokenValid) + .orElse(null); + + if (refreshToken != null) { + checkRefreshTokenAndReIssueAccessToken(response, refreshToken); + return; + } + + if (refreshToken == null) { + checkAccessTokenAndAuthentication(request, response, filterChain); + } + } + + /** + * Refresh Token이 유효한 지 검증 후 Access Token 재발급 + */ + public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) { + userRepository.findByRefreshToken(refreshToken) + .ifPresent(user -> { + String reIssuedRefreshToken = reIssueRefreshToken(user); + // AccessToken, RefreshToken response에 전달 + jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getLoginId()), + reIssuedRefreshToken); + }); + } + + /** + * Refresh Token 재발급 + */ + private String reIssueRefreshToken(User user) { + String reIssuedRefreshToken = jwtService.createRefreshToken(); + userQueryService.updateRefreshToken(user, reIssuedRefreshToken); + // 새로운 Refresh Token으로 업데이트 + return reIssuedRefreshToken; + } + + /** + * Access Token 검증 후 인증 + */ + public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .ifPresent(accessToken -> jwtService.extractLoginId(accessToken) + .ifPresent(loginId -> userQueryService.getUserWithAuthorities(loginId) + .ifPresent(this::saveAuthentication))); + + filterChain.doFilter(request, response); + } + + /** + * 검증된 토큰이면 인증 + */ + public void saveAuthentication(User myUser) { + String password = myUser.getPassword(); + if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정 + password = UUID.randomUUID().toString(); + } + + Set authorities = myUser.getAuthorityList() + .stream() + .map(authority -> new SimpleGrantedAuthority(authority.getRole().toString())) + .collect( + Collectors.toSet()); + + UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder() + .username(myUser.getLoginId()) + .password(password) + .authorities(authorities) + .build(); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(userDetailsUser, null, + authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginFailureHandler.java b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginFailureHandler.java new file mode 100644 index 0000000..b2b6c0f --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginFailureHandler.java @@ -0,0 +1,38 @@ +package fairytale.tbd.global.security.jwt.handler; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.response.ApiResponse; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +/** + * JWT 로그인 필터 Failure Handler + */ +public class JwtLoginFailureHandler implements AuthenticationFailureHandler { + private static final Logger LOGGER = LogManager.getLogger(JwtLoginSuccessHandler.class); + private final ObjectMapper objectMapper; + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws + IOException, ServletException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onFailure(ErrorStatus._BAD_REQUEST.getCode(),ErrorStatus._BAD_REQUEST.getMessage(), "로그인에 실패했습니다."))); + LOGGER.info("Jwt Login fail :: error = {}", exception.getMessage()); + } +} + diff --git a/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginSuccessHandler.java b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginSuccessHandler.java new file mode 100644 index 0000000..69b957d --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginSuccessHandler.java @@ -0,0 +1,56 @@ +package fairytale.tbd.global.security.jwt.handler; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import fairytale.tbd.global.enums.statuscode.SuccessStatus; +import fairytale.tbd.global.response.ApiResponse; +import fairytale.tbd.global.security.jwt.JwtService; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +/** + * JWT 로그인 필터 Success Handler + */ +public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler { + private final JwtService jwtService; + private static final Logger LOGGER = LogManager.getLogger(JwtLoginSuccessHandler.class); + private final ObjectMapper objectMapper; + + /** + * 인증에 성공하면 Access Token과 Refresh Token을 생성한 후 반환 + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + UserDetails principal = (UserDetails)authentication.getPrincipal(); + String loginId = principal.getUsername(); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.onSuccess("로그인에 성공했습니다."))); + LOGGER.info("Jwt Login Success :: Login ID = {}", loginId); + loginSuccess(response, loginId); + } + + private void loginSuccess(HttpServletResponse response, String loginId) throws IOException { + String accessToken = jwtService.createAccessToken(loginId); + String refreshToken = jwtService.createRefreshToken(); + response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken); + response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken); + + jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + jwtService.updateRefreshToken(loginId, refreshToken); + } +} diff --git a/src/main/java/fairytale/tbd/global/util/FileConvertException.java b/src/main/java/fairytale/tbd/global/util/FileConvertException.java new file mode 100644 index 0000000..ae1f603 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/util/FileConvertException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.global.util; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class FileConvertException extends GeneralException { + public FileConvertException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/global/util/FileConverter.java b/src/main/java/fairytale/tbd/global/util/FileConverter.java new file mode 100644 index 0000000..b063487 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/util/FileConverter.java @@ -0,0 +1,30 @@ +package fairytale.tbd.global.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import fairytale.tbd.global.exception.GeneralException; + +public class FileConverter { + public static MultipartFile toMultipartFile(File file, String fileName) { + + MultipartFile multipartFile = null; + try { + FileInputStream input = new FileInputStream(file); + multipartFile = new MockMultipartFile("file", + file.getName(), + "audio/mpeg", + input); + }catch (Exception e){ + e.printStackTrace(); + throw new FileConvertException(ErrorStatus._FILE_CONVERT_ERROR); + } + return multipartFile; + } +} diff --git a/src/test/java/fairytale/tbd/global/elevenlabs/ElevenLabsManagerTest.java b/src/test/java/fairytale/tbd/global/elevenlabs/ElevenLabsManagerTest.java new file mode 100644 index 0000000..6319ffe --- /dev/null +++ b/src/test/java/fairytale/tbd/global/elevenlabs/ElevenLabsManagerTest.java @@ -0,0 +1,23 @@ +package fairytale.tbd.global.elevenlabs; + +import java.io.File; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class ElevenLabsManagerTest { + + @Autowired + ElevenlabsManager elevenlabsManager; + + + @Test + void ttsTest(){ + String voiceId = "sCGuNWRaRQXJYQ4xoCLG"; + File file = elevenlabsManager.elevenLabsTTS("안녕하세요 toTTS 테스트입니다.", voiceId); + Assertions.assertThat(file).exists(); + } +}