feat: 책 생성 및 파일 업로드 구현

This commit is contained in:
2025-12-24 15:14:27 +09:00
parent f599a9f143
commit 6c1320efb3
37 changed files with 657 additions and 98 deletions

View File

@@ -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') {

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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;
}
}

View 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 {}

View File

@@ -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();

View File

@@ -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())

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
} }

View File

@@ -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;
}

View File

@@ -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;
}

View 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
) {}

View File

@@ -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()
);
} }
} }

View File

@@ -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);
}
}

View File

@@ -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();
} }
} }

View File

@@ -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);
}
}
}

View File

@@ -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);
} }
} }

View File

@@ -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();
} }

View File

@@ -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
}
} }

View File

@@ -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> {}

View File

@@ -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()));
}
} }

View File

@@ -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);
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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
) {}
}

View File

@@ -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;
} }
} }

View File

@@ -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
);
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}
}

View File

@@ -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"

View File

@@ -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