feat: 책 생성 및 파일 업로드 구현
This commit is contained in:
@@ -50,6 +50,9 @@ dependencies {
|
|||||||
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
|
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
|
||||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
|
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
|
||||||
|
|
||||||
|
// minio
|
||||||
|
implementation "io.minio:minio:8.5.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import lombok.NoArgsConstructor;
|
|||||||
@Getter
|
@Getter
|
||||||
public class GlobalExceptionResponse {
|
public class GlobalExceptionResponse {
|
||||||
|
|
||||||
private int code;
|
private String title;
|
||||||
|
private String errorCode;
|
||||||
private String message;
|
private String message;
|
||||||
|
private int code;
|
||||||
private String status;
|
private String status;
|
||||||
private String instance;
|
private String instance;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.audio.common.entity;
|
package com.audio.common.entity;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.EntityListeners;
|
|
||||||
import jakarta.persistence.MappedSuperclass;
|
import jakarta.persistence.MappedSuperclass;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
@@ -9,6 +8,8 @@ import lombok.Getter;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import lombok.experimental.SuperBuilder;
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@@ -19,13 +20,15 @@ import lombok.experimental.SuperBuilder;
|
|||||||
public abstract class BaseEntity {
|
public abstract class BaseEntity {
|
||||||
|
|
||||||
@Column(nullable = false, updatable = false)
|
@Column(nullable = false, updatable = false)
|
||||||
|
@CreationTimestamp
|
||||||
protected Instant createdAt;
|
protected Instant createdAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@UpdateTimestamp
|
||||||
protected Instant updatedAt;
|
protected Instant updatedAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
protected boolean deleted;
|
protected boolean deleted = false;
|
||||||
|
|
||||||
protected Instant deletedAt;
|
protected Instant deletedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/main/java/com/audio/config/JpaConfig.java
Normal file
8
src/main/java/com/audio/config/JpaConfig.java
Normal file
@@ -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 {}
|
||||||
@@ -5,10 +5,12 @@ import jakarta.servlet.ServletException;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Objects;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
import org.springframework.security.core.AuthenticationException;
|
import org.springframework.security.core.AuthenticationException;
|
||||||
@@ -32,9 +34,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler
|
|||||||
) {
|
) {
|
||||||
HttpStatus httpStatus = HttpStatus.valueOf(ex.getStatusCode().value());
|
HttpStatus httpStatus = HttpStatus.valueOf(ex.getStatusCode().value());
|
||||||
|
|
||||||
String message = ex.getBody().getDetail() != null
|
ProblemDetail problemDetail = ex.getBody();
|
||||||
? ex.getBody().getDetail()
|
|
||||||
: ex.getMessage();
|
|
||||||
|
|
||||||
String uri = null;
|
String uri = null;
|
||||||
if (request instanceof ServletWebRequest servletWebRequest) {
|
if (request instanceof ServletWebRequest servletWebRequest) {
|
||||||
@@ -48,8 +48,10 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
GlobalExceptionResponse body = GlobalExceptionResponse.builder()
|
GlobalExceptionResponse body = GlobalExceptionResponse.builder()
|
||||||
|
.title(problemDetail.getTitle())
|
||||||
|
.errorCode(Objects.requireNonNull(problemDetail.getProperties()).get("errorCode").toString())
|
||||||
.code(httpStatus.value())
|
.code(httpStatus.value())
|
||||||
.message(message)
|
.message(problemDetail.getDetail())
|
||||||
.status(httpStatus.name())
|
.status(httpStatus.name())
|
||||||
.instance(uri)
|
.instance(uri)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ public class AuthController {
|
|||||||
public ResponseEntity<SignUpResponse> register(
|
public ResponseEntity<SignUpResponse> register(
|
||||||
@RequestBody SignUpRequest request
|
@RequestBody SignUpRequest request
|
||||||
) {
|
) {
|
||||||
RegisterUseCaseResult result = registerUseCase.execute(
|
RegisterUseCaseCommand command = RegisterUseCaseCommand.builder()
|
||||||
RegisterUseCaseCommand.builder().loginId(request.getLoginId()).password(request.getPassword()).build());
|
.loginId(request.getLoginId())
|
||||||
|
.password(request.getPassword())
|
||||||
|
.build();
|
||||||
|
RegisterUseCaseResult result = registerUseCase.execute(command);
|
||||||
|
|
||||||
SignUpResponse response = SignUpResponse.builder()
|
SignUpResponse response = SignUpResponse.builder()
|
||||||
.accessToken(result.getAccessToken())
|
.accessToken(result.getAccessToken())
|
||||||
|
|||||||
@@ -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.command.RegisterUseCaseCommand;
|
||||||
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
|
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
|
||||||
import com.audio.core.auth.domain.service.AccountService;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|||||||
@@ -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.entity.Account;
|
||||||
import com.audio.core.auth.domain.repository.AccountRepository;
|
import com.audio.core.auth.domain.repository.AccountRepository;
|
||||||
import com.audio.core.exception.auth.ExistLoginIdException;
|
import com.audio.core.auth.exception.InvalidCredentialsException;
|
||||||
import com.audio.core.exception.auth.InvalidCredentialsException;
|
import com.audio.common.exception.NotExistEntityException;
|
||||||
import com.audio.core.exception.common.NotExistEntityException;
|
|
||||||
import java.util.Optional;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.command.CreateBookCommand;
|
||||||
import com.audio.core.book.application.usecase.dto.result.CreateBookResult;
|
import com.audio.core.book.application.usecase.dto.result.CreateBookResult;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequestMapping("/api/v1/book")
|
@RequestMapping("/api/v1/book")
|
||||||
|
@Slf4j
|
||||||
public class BookController {
|
public class BookController {
|
||||||
|
|
||||||
private final CreateBookUseCase createBookUseCase;
|
private final CreateBookUseCase createBookUseCase;
|
||||||
@@ -26,6 +28,7 @@ public class BookController {
|
|||||||
@AuthenticationPrincipal User user,
|
@AuthenticationPrincipal User user,
|
||||||
@RequestBody BookCreateRequest bookCreateRequest
|
@RequestBody BookCreateRequest bookCreateRequest
|
||||||
) {
|
) {
|
||||||
|
log.info("책 생성");
|
||||||
CreateBookCommand command = CreateBookCommand.builder()
|
CreateBookCommand command = CreateBookCommand.builder()
|
||||||
.accountId(user.getAccountId())
|
.accountId(user.getAccountId())
|
||||||
.subject(bookCreateRequest.getSubject())
|
.subject(bookCreateRequest.getSubject())
|
||||||
@@ -33,7 +36,8 @@ public class BookController {
|
|||||||
|
|
||||||
CreateBookResult result = createBookUseCase.execute(command);
|
CreateBookResult result = createBookUseCase.execute(command);
|
||||||
|
|
||||||
BookCreateResponse response = BookCreateResponse.builder().build();
|
BookCreateResponse response = BookCreateResponse.builder()
|
||||||
|
.build();
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package com.audio.core.book.application.event;
|
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
|
||||||
|
) {}
|
||||||
@@ -17,6 +17,11 @@ public class BookGenerationEventListener {
|
|||||||
@Async
|
@Async
|
||||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
public void on(BookGenerationRequestedEvent event) {
|
public void on(BookGenerationRequestedEvent event) {
|
||||||
bookGenerationWorkflow.run(event.accountId(), event.subject(), event.genre());
|
bookGenerationWorkflow.run(
|
||||||
|
event.bookId(),
|
||||||
|
event.bookVersionId(),
|
||||||
|
event.subject(),
|
||||||
|
event.genre()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.command.CreateBookCommand;
|
||||||
import com.audio.core.book.application.usecase.dto.result.CreateBookResult;
|
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.Book;
|
||||||
|
import com.audio.core.book.domain.entity.BookVersion;
|
||||||
import com.audio.core.book.domain.service.BookService;
|
import com.audio.core.book.domain.service.BookService;
|
||||||
import com.audio.core.auth.domain.entity.Account;
|
import com.audio.core.auth.domain.entity.Account;
|
||||||
import com.audio.core.auth.domain.service.AccountService;
|
import com.audio.core.auth.domain.service.AccountService;
|
||||||
@@ -25,14 +26,22 @@ public class CreateBookUseCase {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public CreateBookResult execute(CreateBookCommand command) {
|
public CreateBookResult execute(CreateBookCommand command) {
|
||||||
Account account = accountService.getAccountById(command.getAccountId());
|
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(
|
applicationEventPublisher.publishEvent(new BookGenerationRequestedEvent(
|
||||||
account.getId(),
|
account.getId(),
|
||||||
|
createdBook.getId(),
|
||||||
|
activeVersion.getId(),
|
||||||
command.getSubject(),
|
command.getSubject(),
|
||||||
"다른 필요한 값"
|
"다른 필요한 값"
|
||||||
));
|
));
|
||||||
|
|
||||||
return CreateBookResult.builder().title(createdBook.getTitle()).build();
|
return CreateBookResult.builder()
|
||||||
|
.title(createdBook.getTitle())
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,48 @@
|
|||||||
package com.audio.core.book.application.workflow;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class BookGenerationWorkflow {
|
public class BookGenerationWorkflow {
|
||||||
|
|
||||||
public void run(Long accountId, String subject, String genre) {
|
private final CreateTextBookUseCase createTextBookUseCase;
|
||||||
log.info("1. llm 으로 책 생성");
|
|
||||||
log.info("2. 성공/실패 응답으로 책 상태 업데이트");
|
private final ChangeBookVersionStatusUseCase changeBookVersionStatusUseCase;
|
||||||
// log.info("2-1. 오디오북 생성");
|
private final UploadFileUseCase uploadFileUseCase;
|
||||||
// log.info("2-2.");
|
|
||||||
log.info("3. S3 저장");
|
@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);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.audio.core.book.domain.entity;
|
package com.audio.core.book.domain.entity;
|
||||||
|
|
||||||
import com.audio.common.entity.BaseEntity;
|
import com.audio.common.entity.BaseEntity;
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.FetchType;
|
import jakarta.persistence.FetchType;
|
||||||
@@ -15,7 +16,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.AccessLevel;
|
import lombok.AccessLevel;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Builder.Default;
|
import lombok.Builder.Default;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
@@ -40,7 +40,7 @@ public class Book extends BaseEntity {
|
|||||||
@Column(nullable = false, length = 200)
|
@Column(nullable = false, length = 200)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@OneToOne
|
@OneToOne(cascade = CascadeType.PERSIST)
|
||||||
@JoinColumn(name = "active_version_id")
|
@JoinColumn(name = "active_version_id")
|
||||||
private BookVersion activeVersion;
|
private BookVersion activeVersion;
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ public class Book extends BaseEntity {
|
|||||||
|
|
||||||
public void addVersion(BookVersion version) {
|
public void addVersion(BookVersion version) {
|
||||||
bookVersions.add(version);
|
bookVersions.add(version);
|
||||||
|
version.setBook(this);
|
||||||
this.updatedAt = Instant.now();
|
this.updatedAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import lombok.AccessLevel;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
import lombok.experimental.SuperBuilder;
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@SuperBuilder
|
@SuperBuilder
|
||||||
@@ -23,6 +25,7 @@ import lombok.experimental.SuperBuilder;
|
|||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
@Getter
|
@Getter
|
||||||
@Table(name = "book_version")
|
@Table(name = "book_version")
|
||||||
|
@Slf4j
|
||||||
public class BookVersion extends BaseEntity {
|
public class BookVersion extends BaseEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@@ -31,11 +34,12 @@ public class BookVersion extends BaseEntity {
|
|||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "book_id")
|
@JoinColumn(name = "book_id")
|
||||||
|
@Setter
|
||||||
private Book book;
|
private Book book;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 20)
|
@Column(nullable = false, length = 20)
|
||||||
private Status status = Status.GENERATING;
|
private Status status = Status.TEXT_GENERATING;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private LengthPreset lengthPreset;
|
private LengthPreset lengthPreset;
|
||||||
@@ -48,6 +52,16 @@ public class BookVersion extends BaseEntity {
|
|||||||
|
|
||||||
private Integer audioDurationSec;
|
private Integer audioDurationSec;
|
||||||
|
|
||||||
public enum Status { GENERATING, READY, FAILED, DELETED }
|
public void changeStatus(Status status) {
|
||||||
public enum LengthPreset { SHORT, NORMAL, LONG }
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<BookVersion, Long> {}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.audio.core.book.domain.service;
|
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.Book;
|
||||||
import com.audio.core.book.domain.entity.BookVersion;
|
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 com.audio.core.book.domain.repository.BookRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -20,14 +23,22 @@ public class BookService {
|
|||||||
public Book createBookForAccountId(Long ownerId, String subject) {
|
public Book createBookForAccountId(Long ownerId, String subject) {
|
||||||
Book book = Book.builder()
|
Book book = Book.builder()
|
||||||
.ownerId(ownerId)
|
.ownerId(ownerId)
|
||||||
|
.title("")
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
BookVersion bookVersion = BookVersion.builder().build();
|
BookVersion bookVersion = BookVersion.builder()
|
||||||
|
.status(Status.READY)
|
||||||
|
.build();
|
||||||
|
|
||||||
book.addVersion(bookVersion);
|
book.addVersion(bookVersion);
|
||||||
book.setActiveVersion(bookVersion);
|
book.setActiveVersion(bookVersion);
|
||||||
|
|
||||||
return bookRepository.save(book);
|
return bookRepository.save(book);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Book getBookById(Long id) {
|
||||||
|
return bookRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new NotExistEntityException(Book.class.getName()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -14,17 +15,17 @@ public class FileStoreService {
|
|||||||
|
|
||||||
private final FileStoreRepository fileStoreRepository;
|
private final FileStoreRepository fileStoreRepository;
|
||||||
|
|
||||||
public record SaveFileStoreRequest(String objectKey, String fileName, String contentType, Long sizeBytes) {}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void save(SaveFileStoreRequest request) {
|
public FileStore save(String objectKey, MultipartFile file) {
|
||||||
FileStore fileStore = FileStore.builder()
|
FileStore fileStore = FileStore.builder()
|
||||||
.objectKey(request.objectKey())
|
.objectKey(objectKey)
|
||||||
.originalFileName(request.fileName)
|
.originalFileName(file.getOriginalFilename())
|
||||||
.contentType(request.contentType)
|
.contentType(file.getContentType())
|
||||||
.sizeBytes(request.sizeBytes)
|
.sizeBytes(file.getSize())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
fileStoreRepository.save(fileStore);
|
fileStoreRepository.save(fileStore);
|
||||||
|
|
||||||
|
return fileStore;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<String, String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,4 +19,9 @@ token:
|
|||||||
issuer: gram
|
issuer: gram
|
||||||
lifetime:
|
lifetime:
|
||||||
access-token: 365d
|
access-token: 365d
|
||||||
refresh-token: 365d
|
refresh-token: 365d
|
||||||
|
|
||||||
|
storage:
|
||||||
|
bucket: "audio-dev"
|
||||||
|
access-key: "corpi"
|
||||||
|
secret-key: "corpi7589"
|
||||||
@@ -15,6 +15,8 @@ spring:
|
|||||||
change-log: classpath:/db/master.yaml
|
change-log: classpath:/db/master.yaml
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
storage:
|
||||||
|
server-url: "https://api-storage.corpi.o-r.kr"
|
||||||
#logging:
|
#logging:
|
||||||
# level:
|
# level:
|
||||||
# org.hibernate.SQL: null
|
# org.hibernate.SQL: null
|
||||||
|
|||||||
Reference in New Issue
Block a user