diff --git a/build.gradle b/build.gradle index 793a76c..c501adf 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // minio + implementation "io.minio:minio:8.5.12" } tasks.named('test') { diff --git a/src/main/java/com/audio/common/GlobalExceptionResponse.java b/src/main/java/com/audio/common/GlobalExceptionResponse.java index da68452..6075a71 100644 --- a/src/main/java/com/audio/common/GlobalExceptionResponse.java +++ b/src/main/java/com/audio/common/GlobalExceptionResponse.java @@ -11,8 +11,10 @@ import lombok.NoArgsConstructor; @Getter public class GlobalExceptionResponse { - private int code; + private String title; + private String errorCode; private String message; + private int code; private String status; private String instance; } \ No newline at end of file diff --git a/src/main/java/com/audio/common/entity/BaseEntity.java b/src/main/java/com/audio/common/entity/BaseEntity.java index 098ff7c..e5d5c7e 100644 --- a/src/main/java/com/audio/common/entity/BaseEntity.java +++ b/src/main/java/com/audio/common/entity/BaseEntity.java @@ -1,7 +1,6 @@ package com.audio.common.entity; import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import java.time.Instant; import lombok.AllArgsConstructor; @@ -9,6 +8,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; @Getter @Setter @@ -19,13 +20,15 @@ import lombok.experimental.SuperBuilder; public abstract class BaseEntity { @Column(nullable = false, updatable = false) + @CreationTimestamp protected Instant createdAt; @Column(nullable = false) + @UpdateTimestamp protected Instant updatedAt; @Column(nullable = false) - protected boolean deleted; + protected boolean deleted = false; protected Instant deletedAt; diff --git a/src/main/java/com/audio/common/exception/NotExistEntityException.java b/src/main/java/com/audio/common/exception/NotExistEntityException.java new file mode 100644 index 0000000..f7b390d --- /dev/null +++ b/src/main/java/com/audio/common/exception/NotExistEntityException.java @@ -0,0 +1,24 @@ +package com.audio.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class NotExistEntityException extends ErrorResponseException { + + public NotExistEntityException(String entityName) { + super(HttpStatus.NOT_FOUND, problem(entityName), null); + } + + private static ProblemDetail problem(String entityName) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail( + HttpStatus.NOT_FOUND, + "존재하지 않는 엔티티: " + entityName + ); + + pd.setTitle("NOT_EXIST_ENTITY"); + pd.setProperty("errorCode", "COMM-001"); + + return pd; + } +} \ No newline at end of file diff --git a/src/main/java/com/audio/config/JpaConfig.java b/src/main/java/com/audio/config/JpaConfig.java new file mode 100644 index 0000000..6d4b238 --- /dev/null +++ b/src/main/java/com/audio/config/JpaConfig.java @@ -0,0 +1,8 @@ +package com.audio.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig {} diff --git a/src/main/java/com/audio/config/web/GlobalExceptionHandler.java b/src/main/java/com/audio/config/web/GlobalExceptionHandler.java index 7ebb58a..dda12bb 100644 --- a/src/main/java/com/audio/config/web/GlobalExceptionHandler.java +++ b/src/main/java/com/audio/config/web/GlobalExceptionHandler.java @@ -5,10 +5,12 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Objects; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; @@ -32,9 +34,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler ) { HttpStatus httpStatus = HttpStatus.valueOf(ex.getStatusCode().value()); - String message = ex.getBody().getDetail() != null - ? ex.getBody().getDetail() - : ex.getMessage(); + ProblemDetail problemDetail = ex.getBody(); String uri = null; if (request instanceof ServletWebRequest servletWebRequest) { @@ -48,8 +48,10 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler } GlobalExceptionResponse body = GlobalExceptionResponse.builder() + .title(problemDetail.getTitle()) + .errorCode(Objects.requireNonNull(problemDetail.getProperties()).get("errorCode").toString()) .code(httpStatus.value()) - .message(message) + .message(problemDetail.getDetail()) .status(httpStatus.name()) .instance(uri) .build(); diff --git a/src/main/java/com/audio/core/auth/api/controller/AuthController.java b/src/main/java/com/audio/core/auth/api/controller/AuthController.java index 1cb8760..09bb00f 100644 --- a/src/main/java/com/audio/core/auth/api/controller/AuthController.java +++ b/src/main/java/com/audio/core/auth/api/controller/AuthController.java @@ -28,8 +28,11 @@ public class AuthController { public ResponseEntity register( @RequestBody SignUpRequest request ) { - RegisterUseCaseResult result = registerUseCase.execute( - RegisterUseCaseCommand.builder().loginId(request.getLoginId()).password(request.getPassword()).build()); + RegisterUseCaseCommand command = RegisterUseCaseCommand.builder() + .loginId(request.getLoginId()) + .password(request.getPassword()) + .build(); + RegisterUseCaseResult result = registerUseCase.execute(command); SignUpResponse response = SignUpResponse.builder() .accessToken(result.getAccessToken()) diff --git a/src/main/java/com/audio/core/auth/application/RegisterUseCase.java b/src/main/java/com/audio/core/auth/application/RegisterUseCase.java index eef737d..46d1c58 100644 --- a/src/main/java/com/audio/core/auth/application/RegisterUseCase.java +++ b/src/main/java/com/audio/core/auth/application/RegisterUseCase.java @@ -4,7 +4,7 @@ import com.audio.common.TokenProvider; import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import com.audio.core.auth.domain.service.AccountService; -import com.audio.core.exception.auth.ExistLoginIdException; +import com.audio.core.auth.exception.ExistLoginIdException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/audio/core/auth/domain/service/AccountService.java b/src/main/java/com/audio/core/auth/domain/service/AccountService.java index 1a8c46d..b2c7c94 100644 --- a/src/main/java/com/audio/core/auth/domain/service/AccountService.java +++ b/src/main/java/com/audio/core/auth/domain/service/AccountService.java @@ -2,10 +2,8 @@ package com.audio.core.auth.domain.service; import com.audio.core.auth.domain.entity.Account; import com.audio.core.auth.domain.repository.AccountRepository; -import com.audio.core.exception.auth.ExistLoginIdException; -import com.audio.core.exception.auth.InvalidCredentialsException; -import com.audio.core.exception.common.NotExistEntityException; -import java.util.Optional; +import com.audio.core.auth.exception.InvalidCredentialsException; +import com.audio.common.exception.NotExistEntityException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; diff --git a/src/main/java/com/audio/core/auth/exception/ExistLoginIdException.java b/src/main/java/com/audio/core/auth/exception/ExistLoginIdException.java new file mode 100644 index 0000000..f82a264 --- /dev/null +++ b/src/main/java/com/audio/core/auth/exception/ExistLoginIdException.java @@ -0,0 +1,24 @@ +package com.audio.core.auth.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class ExistLoginIdException extends ErrorResponseException { + + public ExistLoginIdException(String loginId) { + super(HttpStatus.CONFLICT, problem(loginId), null); + } + + private static ProblemDetail problem(String loginId) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, + "이미 존재하는 loginId 입니다: " + loginId + ); + + pd.setTitle("EXIST_LOGIN_ID"); + pd.setProperty("errorCode", "AUTH-001"); + + return pd; + } +} \ No newline at end of file diff --git a/src/main/java/com/audio/core/auth/exception/InvalidCredentialsException.java b/src/main/java/com/audio/core/auth/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..08f2202 --- /dev/null +++ b/src/main/java/com/audio/core/auth/exception/InvalidCredentialsException.java @@ -0,0 +1,24 @@ +package com.audio.core.auth.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +public class InvalidCredentialsException extends ErrorResponseException { + + public InvalidCredentialsException() { + super(HttpStatus.UNAUTHORIZED, problem(), null); + } + + private static ProblemDetail problem() { + ProblemDetail pd = ProblemDetail.forStatusAndDetail( + HttpStatus.UNAUTHORIZED, + "아이디 또는 비밀번호가 올바르지 않습니다." + ); + + pd.setTitle("INVALID_CREDENTIALS"); + pd.setProperty("errorCode", "AUTH-002"); + + return pd; + } +} \ No newline at end of file diff --git a/src/main/java/com/audio/core/book/api/controller/BookController.java b/src/main/java/com/audio/core/book/api/controller/BookController.java index 48b243b..a8e4437 100644 --- a/src/main/java/com/audio/core/book/api/controller/BookController.java +++ b/src/main/java/com/audio/core/book/api/controller/BookController.java @@ -7,6 +7,7 @@ import com.audio.core.auth.application.userDetail.User; import com.audio.core.book.application.usecase.dto.command.CreateBookCommand; import com.audio.core.book.application.usecase.dto.result.CreateBookResult; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/book") +@Slf4j public class BookController { private final CreateBookUseCase createBookUseCase; @@ -26,6 +28,7 @@ public class BookController { @AuthenticationPrincipal User user, @RequestBody BookCreateRequest bookCreateRequest ) { + log.info("책 생성"); CreateBookCommand command = CreateBookCommand.builder() .accountId(user.getAccountId()) .subject(bookCreateRequest.getSubject()) @@ -33,7 +36,8 @@ public class BookController { CreateBookResult result = createBookUseCase.execute(command); - BookCreateResponse response = BookCreateResponse.builder().build(); + BookCreateResponse response = BookCreateResponse.builder() + .build(); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/audio/core/book/application/dto/command/CreateTextBookCommand.java b/src/main/java/com/audio/core/book/application/dto/command/CreateTextBookCommand.java new file mode 100644 index 0000000..93e7897 --- /dev/null +++ b/src/main/java/com/audio/core/book/application/dto/command/CreateTextBookCommand.java @@ -0,0 +1,18 @@ +package com.audio.core.book.application.dto.command; + +import com.audio.core.book.domain.entity.BookVersion.LengthPreset; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class CreateTextBookCommand { + private String subject; + private String genre; + private LengthPreset lengthPreset; +} diff --git a/src/main/java/com/audio/core/book/application/dto/result/CreateTextBookResult.java b/src/main/java/com/audio/core/book/application/dto/result/CreateTextBookResult.java new file mode 100644 index 0000000..52ca269 --- /dev/null +++ b/src/main/java/com/audio/core/book/application/dto/result/CreateTextBookResult.java @@ -0,0 +1,17 @@ +package com.audio.core.book.application.dto.result; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class CreateTextBookResult { + private Boolean success; + private MultipartFile file; +} diff --git a/src/main/java/com/audio/core/book/application/event/BookGenerationRequestedEvent.java b/src/main/java/com/audio/core/book/application/event/BookGenerationRequestedEvent.java index 3759a2a..d998ba6 100644 --- a/src/main/java/com/audio/core/book/application/event/BookGenerationRequestedEvent.java +++ b/src/main/java/com/audio/core/book/application/event/BookGenerationRequestedEvent.java @@ -1,5 +1,9 @@ package com.audio.core.book.application.event; -public record BookGenerationRequestedEvent(Long accountId, String subject, String genre) { - -} +public record BookGenerationRequestedEvent( + Long accountId, + Long bookId, + Long bookVersionId, + String subject, + String genre +) {} \ No newline at end of file diff --git a/src/main/java/com/audio/core/book/application/listener/BookGenerationEventListener.java b/src/main/java/com/audio/core/book/application/listener/BookGenerationEventListener.java index 05267e8..0f9e60b 100644 --- a/src/main/java/com/audio/core/book/application/listener/BookGenerationEventListener.java +++ b/src/main/java/com/audio/core/book/application/listener/BookGenerationEventListener.java @@ -17,6 +17,11 @@ public class BookGenerationEventListener { @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void on(BookGenerationRequestedEvent event) { - bookGenerationWorkflow.run(event.accountId(), event.subject(), event.genre()); + bookGenerationWorkflow.run( + event.bookId(), + event.bookVersionId(), + event.subject(), + event.genre() + ); } } diff --git a/src/main/java/com/audio/core/book/application/usecase/ChangeBookVersionStatusUseCase.java b/src/main/java/com/audio/core/book/application/usecase/ChangeBookVersionStatusUseCase.java new file mode 100644 index 0000000..c09b420 --- /dev/null +++ b/src/main/java/com/audio/core/book/application/usecase/ChangeBookVersionStatusUseCase.java @@ -0,0 +1,21 @@ +package com.audio.core.book.application.usecase; + +import com.audio.core.book.domain.entity.BookVersion.Status; +import com.audio.core.book.domain.service.BookVersionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChangeBookVersionStatusUseCase { + + private final BookVersionService bookVersionService; + + @Transactional + public void execute(Long bookVersionId, Status status) { + bookVersionService.changeStatus(bookVersionId, status); + } +} diff --git a/src/main/java/com/audio/core/book/application/usecase/CreateBookUseCase.java b/src/main/java/com/audio/core/book/application/usecase/CreateBookUseCase.java index 765a4e7..6c0c55e 100644 --- a/src/main/java/com/audio/core/book/application/usecase/CreateBookUseCase.java +++ b/src/main/java/com/audio/core/book/application/usecase/CreateBookUseCase.java @@ -4,6 +4,7 @@ import com.audio.core.book.application.event.BookGenerationRequestedEvent; import com.audio.core.book.application.usecase.dto.command.CreateBookCommand; import com.audio.core.book.application.usecase.dto.result.CreateBookResult; import com.audio.core.book.domain.entity.Book; +import com.audio.core.book.domain.entity.BookVersion; import com.audio.core.book.domain.service.BookService; import com.audio.core.auth.domain.entity.Account; import com.audio.core.auth.domain.service.AccountService; @@ -25,14 +26,22 @@ public class CreateBookUseCase { @Transactional public CreateBookResult execute(CreateBookCommand command) { Account account = accountService.getAccountById(command.getAccountId()); - Book createdBook = bookService.createBookForAccountId(account.getId(), command.getSubject()); + Book createdBook = bookService.createBookForAccountId( + account.getId(), + command.getSubject() + ); + BookVersion activeVersion = createdBook.getActiveVersion(); applicationEventPublisher.publishEvent(new BookGenerationRequestedEvent( account.getId(), + createdBook.getId(), + activeVersion.getId(), command.getSubject(), "다른 필요한 값" )); - return CreateBookResult.builder().title(createdBook.getTitle()).build(); + return CreateBookResult.builder() + .title(createdBook.getTitle()) + .build(); } } diff --git a/src/main/java/com/audio/core/book/application/usecase/CreateTextBookUseCase.java b/src/main/java/com/audio/core/book/application/usecase/CreateTextBookUseCase.java new file mode 100644 index 0000000..9e2d20f --- /dev/null +++ b/src/main/java/com/audio/core/book/application/usecase/CreateTextBookUseCase.java @@ -0,0 +1,57 @@ +package com.audio.core.book.application.usecase; + +import com.audio.core.book.application.dto.command.CreateTextBookCommand; +import com.audio.core.book.application.dto.result.CreateTextBookResult; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CreateTextBookUseCase { + + // 테스트 파일 경로 (요청하신 그대로) + private static final Path TEST_RICH_TEXT_PATH = Path.of( + "/Users/ijeongmin/Desktop/corpi/test.rtf"); + + public CreateTextBookResult execute(CreateTextBookCommand command) { + try { + if (!Files.exists(TEST_RICH_TEXT_PATH) || !Files.isRegularFile(TEST_RICH_TEXT_PATH)) { + throw new IllegalStateException("테스트 리치텍스트 파일이 존재하지 않습니다: " + TEST_RICH_TEXT_PATH); + } + + byte[] bytes = Files.readAllBytes(TEST_RICH_TEXT_PATH); + String originalFileName = TEST_RICH_TEXT_PATH.getFileName() + .toString(); + + String contentType = Files.probeContentType(TEST_RICH_TEXT_PATH); + if (contentType == null || contentType.isBlank()) { + // 리치텍스트(일반적으로 rtf) 기본값 + contentType = "application/rtf"; + } + + MultipartFile file = new MockMultipartFile( + "file", + originalFileName, + contentType, + bytes + ); + + return CreateTextBookResult.builder() + .success(true) + .file(file) + .build(); + + } catch (IOException e) { + log.error("테스트 리치텍스트 파일 로드 실패: {}", TEST_RICH_TEXT_PATH, e); + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/java/com/audio/core/book/application/workflow/BookGenerationWorkflow.java b/src/main/java/com/audio/core/book/application/workflow/BookGenerationWorkflow.java index 993b05e..0c95fb5 100644 --- a/src/main/java/com/audio/core/book/application/workflow/BookGenerationWorkflow.java +++ b/src/main/java/com/audio/core/book/application/workflow/BookGenerationWorkflow.java @@ -1,19 +1,48 @@ package com.audio.core.book.application.workflow; +import com.audio.core.book.application.dto.command.CreateTextBookCommand; +import com.audio.core.book.application.dto.result.CreateTextBookResult; +import com.audio.core.book.application.usecase.ChangeBookVersionStatusUseCase; +import com.audio.core.book.application.usecase.CreateTextBookUseCase; +import com.audio.core.book.domain.entity.BookVersion.LengthPreset; +import com.audio.core.book.domain.entity.BookVersion.Status; +import com.audio.core.storage.application.UploadFileUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @Slf4j public class BookGenerationWorkflow { - public void run(Long accountId, String subject, String genre) { - log.info("1. llm 으로 책 생성"); - log.info("2. 성공/실패 응답으로 책 상태 업데이트"); -// log.info("2-1. 오디오북 생성"); -// log.info("2-2."); - log.info("3. S3 저장"); + private final CreateTextBookUseCase createTextBookUseCase; + + private final ChangeBookVersionStatusUseCase changeBookVersionStatusUseCase; + private final UploadFileUseCase uploadFileUseCase; + + @Transactional + public void run(Long bookId, Long bookVersionId, String subject, String genre) { + log.info("llm 으로 책 생성"); + CreateTextBookCommand command = CreateTextBookCommand.builder() + .subject(subject) + .genre(genre) + .lengthPreset(LengthPreset.SHORT) + .build(); + CreateTextBookResult result = createTextBookUseCase.execute(command); + log.info("책 생성 결과: {}", result.getSuccess()); + + if (result.getSuccess()) { + log.info("파일 업로드"); + uploadFileUseCase.execute(bookId, bookVersionId, result.getFile()); + changeBookVersionStatusUseCase.execute(bookVersionId, Status.TEXT_READY); + } else { + changeBookVersionStatusUseCase.execute(bookVersionId, Status.FAILED_TEXT); + return; + } + + changeBookVersionStatusUseCase.execute(bookVersionId, Status.AUDIO_GENERATING); + } } diff --git a/src/main/java/com/audio/core/book/domain/entity/Book.java b/src/main/java/com/audio/core/book/domain/entity/Book.java index 1ef8422..0344e34 100644 --- a/src/main/java/com/audio/core/book/domain/entity/Book.java +++ b/src/main/java/com/audio/core/book/domain/entity/Book.java @@ -1,6 +1,7 @@ package com.audio.core.book.domain.entity; import com.audio.common.entity.BaseEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -15,7 +16,6 @@ import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Builder.Default; import lombok.Getter; import lombok.NoArgsConstructor; @@ -40,7 +40,7 @@ public class Book extends BaseEntity { @Column(nullable = false, length = 200) private String title; - @OneToOne + @OneToOne(cascade = CascadeType.PERSIST) @JoinColumn(name = "active_version_id") private BookVersion activeVersion; @@ -50,6 +50,7 @@ public class Book extends BaseEntity { public void addVersion(BookVersion version) { bookVersions.add(version); + version.setBook(this); this.updatedAt = Instant.now(); } diff --git a/src/main/java/com/audio/core/book/domain/entity/BookVersion.java b/src/main/java/com/audio/core/book/domain/entity/BookVersion.java index d029cea..4924b10 100644 --- a/src/main/java/com/audio/core/book/domain/entity/BookVersion.java +++ b/src/main/java/com/audio/core/book/domain/entity/BookVersion.java @@ -15,7 +15,9 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; @Entity @SuperBuilder @@ -23,6 +25,7 @@ import lombok.experimental.SuperBuilder; @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Table(name = "book_version") +@Slf4j public class BookVersion extends BaseEntity { @Id @@ -31,11 +34,12 @@ public class BookVersion extends BaseEntity { @ManyToOne @JoinColumn(name = "book_id") + @Setter private Book book; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) - private Status status = Status.GENERATING; + private Status status = Status.TEXT_GENERATING; @Enumerated(EnumType.STRING) private LengthPreset lengthPreset; @@ -48,6 +52,16 @@ public class BookVersion extends BaseEntity { private Integer audioDurationSec; - public enum Status { GENERATING, READY, FAILED, DELETED } - public enum LengthPreset { SHORT, NORMAL, LONG } + public void changeStatus(Status status) { + log.info("bookVersion '{}' 상태 변경 {} to {}", this.id, this.status, status); + this.status = status; + } + + public enum Status { + TEXT_GENERATING, TEXT_READY, AUDIO_GENERATING, READY, FAILED_TEXT, FAILED_AUDIO, DELETED + } + + public enum LengthPreset { + SHORT, NORMAL, LONG + } } diff --git a/src/main/java/com/audio/core/book/domain/repository/BookVersionRepository.java b/src/main/java/com/audio/core/book/domain/repository/BookVersionRepository.java new file mode 100644 index 0000000..62805c3 --- /dev/null +++ b/src/main/java/com/audio/core/book/domain/repository/BookVersionRepository.java @@ -0,0 +1,8 @@ +package com.audio.core.book.domain.repository; + +import com.audio.core.book.domain.entity.BookVersion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BookVersionRepository extends JpaRepository {} diff --git a/src/main/java/com/audio/core/book/domain/service/BookService.java b/src/main/java/com/audio/core/book/domain/service/BookService.java index ee1352b..bb57a3f 100644 --- a/src/main/java/com/audio/core/book/domain/service/BookService.java +++ b/src/main/java/com/audio/core/book/domain/service/BookService.java @@ -1,9 +1,12 @@ package com.audio.core.book.domain.service; +import com.audio.common.exception.NotExistEntityException; import com.audio.core.book.domain.entity.Book; import com.audio.core.book.domain.entity.BookVersion; +import com.audio.core.book.domain.entity.BookVersion.Status; import com.audio.core.book.domain.repository.BookRepository; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -20,14 +23,22 @@ public class BookService { public Book createBookForAccountId(Long ownerId, String subject) { Book book = Book.builder() .ownerId(ownerId) + .title("") .subject(subject) .build(); - BookVersion bookVersion = BookVersion.builder().build(); + BookVersion bookVersion = BookVersion.builder() + .status(Status.READY) + .build(); book.addVersion(bookVersion); book.setActiveVersion(bookVersion); return bookRepository.save(book); } + + public Book getBookById(Long id) { + return bookRepository.findById(id) + .orElseThrow(() -> new NotExistEntityException(Book.class.getName())); + } } diff --git a/src/main/java/com/audio/core/book/domain/service/BookVersionService.java b/src/main/java/com/audio/core/book/domain/service/BookVersionService.java new file mode 100644 index 0000000..78715f9 --- /dev/null +++ b/src/main/java/com/audio/core/book/domain/service/BookVersionService.java @@ -0,0 +1,29 @@ +package com.audio.core.book.domain.service; + +import com.audio.common.exception.NotExistEntityException; +import com.audio.core.book.domain.entity.BookVersion; +import com.audio.core.book.domain.entity.BookVersion.Status; +import com.audio.core.book.domain.repository.BookVersionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookVersionService { + + private final BookVersionRepository bookVersionRepository; + + public BookVersion getBookVersionById(long versionId) { + return bookVersionRepository.findById(versionId) + .orElseThrow(() -> new NotExistEntityException(BookVersion.class.getName())); + } + + @Transactional + public void changeStatus(Long bookVersionId, Status status) { + BookVersion bookVersion = getBookVersionById(bookVersionId); + bookVersion.changeStatus(status); + } +} diff --git a/src/main/java/com/audio/core/exception/auth/ExistLoginIdException.java b/src/main/java/com/audio/core/exception/auth/ExistLoginIdException.java deleted file mode 100644 index cc4204d..0000000 --- a/src/main/java/com/audio/core/exception/auth/ExistLoginIdException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.audio.core.exception.auth; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.web.ErrorResponseException; - -public class ExistLoginIdException extends ErrorResponseException { - - public ExistLoginIdException(String loginId) { - super( - HttpStatus.CONFLICT, - ProblemDetail.forStatusAndDetail( - HttpStatus.CONFLICT, - "이미 존재하는 loginId 입니다: " + loginId - ), - null - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/audio/core/exception/auth/InvalidCredentialsException.java b/src/main/java/com/audio/core/exception/auth/InvalidCredentialsException.java deleted file mode 100644 index caef08c..0000000 --- a/src/main/java/com/audio/core/exception/auth/InvalidCredentialsException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.audio.core.exception.auth; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.web.ErrorResponseException; - -public class InvalidCredentialsException extends ErrorResponseException { - - public InvalidCredentialsException() { - super( - HttpStatus.UNAUTHORIZED, - ProblemDetail.forStatusAndDetail( - HttpStatus.UNAUTHORIZED, - "아이디 또는 비밀번호가 올바르지 않습니다." - ), - null - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/audio/core/exception/common/NotExistEntityException.java b/src/main/java/com/audio/core/exception/common/NotExistEntityException.java deleted file mode 100644 index 8b91bfe..0000000 --- a/src/main/java/com/audio/core/exception/common/NotExistEntityException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.audio.core.exception.common; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.web.ErrorResponseException; - -public class NotExistEntityException extends ErrorResponseException { - - public NotExistEntityException(String entityName) { - super( - HttpStatus.NOT_FOUND, - ProblemDetail.forStatusAndDetail( - HttpStatus.NOT_FOUND, - "존재하지 않는 엔티티: " + entityName - ), - null - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/audio/core/storage/application/UploadFileUseCase.java b/src/main/java/com/audio/core/storage/application/UploadFileUseCase.java new file mode 100644 index 0000000..2c7fde6 --- /dev/null +++ b/src/main/java/com/audio/core/storage/application/UploadFileUseCase.java @@ -0,0 +1,99 @@ +package com.audio.core.storage.application; + +import com.audio.core.storage.application.dto.UploadFileResult; +import com.audio.core.storage.domain.entity.FileStore; +import com.audio.core.storage.domain.port.FileStorage; +import com.audio.core.storage.domain.service.FileStoreService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class UploadFileUseCase { + private final FileStoreService fileStoreService; + private final FileStorage fileStorage; + + @Transactional + public void execute( + Long bookId, + Long bookVersionId, + MultipartFile multipartFile + ) { + String objectKey = buildObjectKey(bookId, bookVersionId, multipartFile); + + // TODO: 트랜잭션 롤백 생각해야함 + fileStorage.save(objectKey, multipartFile); + + FileStore fileStore = fileStoreService.save(objectKey, multipartFile); + + UploadFileResult.builder() + .fileStoreId(fileStore.getId()) + .objectKey(objectKey) + .originalFileName(fileStore.getOriginalFileName()) + .contentType(fileStore.getContentType()) + .sizeBytes(fileStore.getSizeBytes()) + .build(); + } + + private String buildObjectKey( + Long bookId, + Long bookVersionId, + MultipartFile file + ) { + String ext = extractExtension(file.getOriginalFilename()); + LocalDate now = LocalDate.now(); + + String uuid = UUID.randomUUID() + .toString() + .replace("-", ""); + String filename = ext.isBlank() ? uuid : (uuid + "." + ext); + + return String.format( + "%04d/%02d/%02d/b%d_v%d_%s", + now.getYear(), + now.getMonthValue(), + now.getDayOfMonth(), + bookId, + bookVersionId, + filename + ); + } + + private String extractExtension(String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + return ""; + } + + // 브라우저/클라이언트가 전체 경로를 넘기는 케이스 방어 + String name = originalFilename.replace("\\", "/"); + int slash = name.lastIndexOf('/'); + if (slash >= 0) { + name = name.substring(slash + 1); + } + + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return ""; + } + + String ext = name.substring(dot + 1) + .toLowerCase(); + + // 과도하게 긴 확장자 / 이상한 문자 방어(필요 없으면 제거해도 됨) + if (ext.length() > 10) { + return ""; + } + if (!ext.matches("[a-z0-9]+")) { + return ""; + } + + return ext; + } +} diff --git a/src/main/java/com/audio/core/storage/application/dto/UploadFileResult.java b/src/main/java/com/audio/core/storage/application/dto/UploadFileResult.java new file mode 100644 index 0000000..6162df5 --- /dev/null +++ b/src/main/java/com/audio/core/storage/application/dto/UploadFileResult.java @@ -0,0 +1,21 @@ +package com.audio.core.storage.application.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class UploadFileResult { + + private Long fileStoreId; + private String objectKey; + private String originalFileName; + private String contentType; + private Long sizeBytes; + +} diff --git a/src/main/java/com/audio/core/storage/domain/port/FileStorage.java b/src/main/java/com/audio/core/storage/domain/port/FileStorage.java new file mode 100644 index 0000000..d99e151 --- /dev/null +++ b/src/main/java/com/audio/core/storage/domain/port/FileStorage.java @@ -0,0 +1,19 @@ +package com.audio.core.storage.domain.port; + +import java.io.InputStream; +import org.springframework.web.multipart.MultipartFile; + +public interface FileStorage { + + void save(String objectKey, MultipartFile file); + + StoredFile get(String objectKey); + + record StoredFile( + String objectKey, + InputStream inputStream, + long sizeBytes, + String contentType, + String originalFileName + ) {} +} diff --git a/src/main/java/com/audio/core/storage/domain/service/FileStoreService.java b/src/main/java/com/audio/core/storage/domain/service/FileStoreService.java index ff415c0..8df2b7b 100644 --- a/src/main/java/com/audio/core/storage/domain/service/FileStoreService.java +++ b/src/main/java/com/audio/core/storage/domain/service/FileStoreService.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @@ -14,17 +15,17 @@ public class FileStoreService { private final FileStoreRepository fileStoreRepository; - public record SaveFileStoreRequest(String objectKey, String fileName, String contentType, Long sizeBytes) {} - @Transactional - public void save(SaveFileStoreRequest request) { + public FileStore save(String objectKey, MultipartFile file) { FileStore fileStore = FileStore.builder() - .objectKey(request.objectKey()) - .originalFileName(request.fileName) - .contentType(request.contentType) - .sizeBytes(request.sizeBytes) + .objectKey(objectKey) + .originalFileName(file.getOriginalFilename()) + .contentType(file.getContentType()) + .sizeBytes(file.getSize()) .build(); fileStoreRepository.save(fileStore); + + return fileStore; } } diff --git a/src/main/java/com/audio/core/storage/exception/FileStorageException.java b/src/main/java/com/audio/core/storage/exception/FileStorageException.java new file mode 100644 index 0000000..2cd17fb --- /dev/null +++ b/src/main/java/com/audio/core/storage/exception/FileStorageException.java @@ -0,0 +1,21 @@ +package com.audio.core.storage.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.ErrorResponseException; + +@Slf4j +public class FileStorageException extends ErrorResponseException { + + public FileStorageException(String bucket, String objectKey, Exception cause) { + super( + HttpStatus.INTERNAL_SERVER_ERROR, + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + "MinIO 실패 " + bucket + ", key=" + objectKey + ), + cause + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/audio/core/storage/infra/config/MinioConfig.java b/src/main/java/com/audio/core/storage/infra/config/MinioConfig.java new file mode 100644 index 0000000..bc297a5 --- /dev/null +++ b/src/main/java/com/audio/core/storage/infra/config/MinioConfig.java @@ -0,0 +1,22 @@ +package com.audio.core.storage.infra.config; + +import io.minio.MinioClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Bean + public MinioClient minioClient( + @Value("${storage.server-url}") String endpoint, + @Value("${storage.access-key}") String accessKey, + @Value("${storage.secret-key}") String secretKey + ) { + return MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + } +} diff --git a/src/main/java/com/audio/core/storage/infra/storage/MinioFileStorage.java b/src/main/java/com/audio/core/storage/infra/storage/MinioFileStorage.java new file mode 100644 index 0000000..094fe55 --- /dev/null +++ b/src/main/java/com/audio/core/storage/infra/storage/MinioFileStorage.java @@ -0,0 +1,108 @@ +package com.audio.core.storage.infra.storage; + +import com.audio.core.storage.domain.port.FileStorage; +import com.audio.core.storage.exception.FileStorageException; +import io.minio.BucketExistsArgs; +import io.minio.GetObjectArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.ObjectWriteResponse; +import io.minio.PutObjectArgs; +import io.minio.StatObjectArgs; +import io.minio.StatObjectResponse; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MinioFileStorage implements FileStorage { + + private static final String META_ORIGINAL_FILENAME = "original-filename"; + + @Value("${storage.bucket}") + private String bucket; + + private final MinioClient minioClient; + + @Override + public void save(String objectKey, MultipartFile file) { + try { + ensureBucketExists(bucket); + + String contentType = (file.getContentType() == null || file.getContentType() + .isBlank()) ? "application/octet-stream" : file.getContentType(); + + Map userMetadata = new HashMap<>(); + if (file.getOriginalFilename() != null && !file.getOriginalFilename() + .isBlank()) { + userMetadata.put(META_ORIGINAL_FILENAME, file.getOriginalFilename()); + } + + try (InputStream in = file.getInputStream()) { + PutObjectArgs putObjectArgs = PutObjectArgs.builder() + .bucket(bucket) + .object(objectKey) + .stream(in, file.getSize(), -1) + .contentType(contentType) + .userMetadata(userMetadata) + .build(); + + minioClient.putObject(putObjectArgs); + } + } catch (Exception e) { + log.error("MinIO 업로드 실패. bucket={}, key={}", bucket, objectKey); + throw new FileStorageException(bucket, objectKey, e); + } + } + + @Override + public StoredFile get(String objectKey) { + try { + StatObjectResponse stat = minioClient.statObject(StatObjectArgs.builder() + .bucket(bucket) + .object(objectKey) + .build()); + + InputStream stream = minioClient.getObject(GetObjectArgs.builder() + .bucket(bucket) + .object(objectKey) + .build()); + + String originalFileName = null; + Map meta = stat.userMetadata(); + if (meta != null) { + originalFileName = meta.get(META_ORIGINAL_FILENAME); + } + + return new StoredFile( + objectKey, + stream, + stat.size(), + stat.contentType(), + originalFileName + ); + + } catch (Exception e) { + log.error("MinIO 다운로드 실패. bucket={}, key={}", bucket, objectKey); + throw new FileStorageException(bucket, objectKey, e); + } + } + + private void ensureBucketExists(String bucketName) throws Exception { + boolean exists = minioClient.bucketExists(BucketExistsArgs.builder() + .bucket(bucketName) + .build()); + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder() + .bucket(bucketName) + .build()); + } + } +} \ No newline at end of file diff --git a/src/main/resources/config/application-dev.yaml b/src/main/resources/config/application-dev.yaml index b6a8646..9772660 100644 --- a/src/main/resources/config/application-dev.yaml +++ b/src/main/resources/config/application-dev.yaml @@ -19,4 +19,9 @@ token: issuer: gram lifetime: access-token: 365d - refresh-token: 365d \ No newline at end of file + refresh-token: 365d + +storage: + bucket: "audio-dev" + access-key: "corpi" + secret-key: "corpi7589" \ No newline at end of file diff --git a/src/main/resources/config/application.yaml b/src/main/resources/config/application.yaml index 0a86262..cee9bcd 100644 --- a/src/main/resources/config/application.yaml +++ b/src/main/resources/config/application.yaml @@ -15,6 +15,8 @@ spring: change-log: classpath:/db/master.yaml enabled: true +storage: + server-url: "https://api-storage.corpi.o-r.kr" #logging: # level: # org.hibernate.SQL: null