From dc1110bdb40bee3441d47f5e067c497bef5accdd Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 19 Dec 2025 17:13:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B1=85=20=EC=83=9D=EC=84=B1=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/audio/common/entity/BaseEntity.java | 36 +++++++++++ .../auth/api/controller/AuthController.java | 1 - .../auth/domain/service/AccountService.java | 11 ++-- .../book/api/controller/BookController.java | 40 ++++++++++++ .../book/api/dto/req/BookCreateRequest.java | 17 ++++++ .../book/api/dto/res/BookCreateResponse.java | 20 ++++++ .../event/BookGenerationRequestedEvent.java | 5 ++ .../listener/BookGenerationEventListener.java | 22 +++++++ .../usecase/CreateBookUseCase.java | 38 ++++++++++++ .../dto/command/CreateBookCommand.java | 16 +++++ .../usecase/dto/result/CreateBookResult.java | 16 +++++ .../workflow/BookGenerationWorkflow.java | 19 ++++++ .../audio/core/book/domain/entity/Book.java | 61 +++++++++++++++++++ .../core/book/domain/entity/BookVersion.java | 53 ++++++++++++++++ .../domain/repository/BookRepository.java | 9 +++ .../core/book/domain/service/BookService.java | 33 ++++++++++ .../resources/db/changelog/changelog.yaml | 6 +- .../db/sql/book/251215/1_create_book.sql | 17 ++++++ .../sql/book/251215/2_create_book_version.sql | 20 ++++++ 19 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/audio/common/entity/BaseEntity.java create mode 100644 src/main/java/com/audio/core/book/api/controller/BookController.java create mode 100644 src/main/java/com/audio/core/book/api/dto/req/BookCreateRequest.java create mode 100644 src/main/java/com/audio/core/book/api/dto/res/BookCreateResponse.java create mode 100644 src/main/java/com/audio/core/book/application/event/BookGenerationRequestedEvent.java create mode 100644 src/main/java/com/audio/core/book/application/listener/BookGenerationEventListener.java create mode 100644 src/main/java/com/audio/core/book/application/usecase/CreateBookUseCase.java create mode 100644 src/main/java/com/audio/core/book/application/usecase/dto/command/CreateBookCommand.java create mode 100644 src/main/java/com/audio/core/book/application/usecase/dto/result/CreateBookResult.java create mode 100644 src/main/java/com/audio/core/book/application/workflow/BookGenerationWorkflow.java create mode 100644 src/main/java/com/audio/core/book/domain/entity/Book.java create mode 100644 src/main/java/com/audio/core/book/domain/entity/BookVersion.java create mode 100644 src/main/java/com/audio/core/book/domain/repository/BookRepository.java create mode 100644 src/main/java/com/audio/core/book/domain/service/BookService.java create mode 100644 src/main/resources/db/sql/book/251215/1_create_book.sql create mode 100644 src/main/resources/db/sql/book/251215/2_create_book_version.sql diff --git a/src/main/java/com/audio/common/entity/BaseEntity.java b/src/main/java/com/audio/common/entity/BaseEntity.java new file mode 100644 index 0000000..098ff7c --- /dev/null +++ b/src/main/java/com/audio/common/entity/BaseEntity.java @@ -0,0 +1,36 @@ +package com.audio.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Getter +@Setter +@MappedSuperclass +@AllArgsConstructor +@NoArgsConstructor +@SuperBuilder +public abstract class BaseEntity { + + @Column(nullable = false, updatable = false) + protected Instant createdAt; + + @Column(nullable = false) + protected Instant updatedAt; + + @Column(nullable = false) + protected boolean deleted; + + protected Instant deletedAt; + + public void delete() { + this.deleted = true; + this.deletedAt = Instant.now(); + } +} \ No newline at end of file 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 f78b375..1cb8760 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 @@ -11,7 +11,6 @@ import com.audio.core.auth.application.dto.result.LoginResult; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; 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 0d9046f..1a8c46d 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 @@ -26,11 +26,7 @@ public class AccountService { String hashedPassword = passwordEncoder.encode(password); - Account account = Account.builder() - .loginId(loginId) - .name("임시 이름") - .passwordHash(hashedPassword) - .build(); + Account account = Account.builder().loginId(loginId).name("임시 이름").passwordHash(hashedPassword).build(); log.info("account: {}", account); @@ -58,4 +54,9 @@ public class AccountService { return account.getId(); } + + public Account getAccountById(Long accountId) { + return accountRepository.findById(accountId) + .orElseThrow(() -> new NotExistEntityException(Account.class.getName())); + } } 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 new file mode 100644 index 0000000..48b243b --- /dev/null +++ b/src/main/java/com/audio/core/book/api/controller/BookController.java @@ -0,0 +1,40 @@ +package com.audio.core.book.api.controller; + +import com.audio.core.book.api.dto.req.BookCreateRequest; +import com.audio.core.book.api.dto.res.BookCreateResponse; +import com.audio.core.book.application.usecase.CreateBookUseCase; +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 org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/book") +public class BookController { + + private final CreateBookUseCase createBookUseCase; + + @PostMapping("/create") + public ResponseEntity create( + @AuthenticationPrincipal User user, + @RequestBody BookCreateRequest bookCreateRequest + ) { + CreateBookCommand command = CreateBookCommand.builder() + .accountId(user.getAccountId()) + .subject(bookCreateRequest.getSubject()) + .build(); + + CreateBookResult result = createBookUseCase.execute(command); + + BookCreateResponse response = BookCreateResponse.builder().build(); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/audio/core/book/api/dto/req/BookCreateRequest.java b/src/main/java/com/audio/core/book/api/dto/req/BookCreateRequest.java new file mode 100644 index 0000000..224d937 --- /dev/null +++ b/src/main/java/com/audio/core/book/api/dto/req/BookCreateRequest.java @@ -0,0 +1,17 @@ +package com.audio.core.book.api.dto.req; + +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 BookCreateRequest { + + private String subject; + +} diff --git a/src/main/java/com/audio/core/book/api/dto/res/BookCreateResponse.java b/src/main/java/com/audio/core/book/api/dto/res/BookCreateResponse.java new file mode 100644 index 0000000..dc2eead --- /dev/null +++ b/src/main/java/com/audio/core/book/api/dto/res/BookCreateResponse.java @@ -0,0 +1,20 @@ +package com.audio.core.book.api.dto.res; + +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 BookCreateResponse { + + private String title; + + private Integer durationSec; + + private String contentPath; +} 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 new file mode 100644 index 0000000..3759a2a --- /dev/null +++ b/src/main/java/com/audio/core/book/application/event/BookGenerationRequestedEvent.java @@ -0,0 +1,5 @@ +package com.audio.core.book.application.event; + +public record BookGenerationRequestedEvent(Long accountId, String subject, String genre) { + +} 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 new file mode 100644 index 0000000..05267e8 --- /dev/null +++ b/src/main/java/com/audio/core/book/application/listener/BookGenerationEventListener.java @@ -0,0 +1,22 @@ +package com.audio.core.book.application.listener; + +import com.audio.core.book.application.event.BookGenerationRequestedEvent; +import com.audio.core.book.application.workflow.BookGenerationWorkflow; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class BookGenerationEventListener { + + private final BookGenerationWorkflow bookGenerationWorkflow; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(BookGenerationRequestedEvent event) { + bookGenerationWorkflow.run(event.accountId(), event.subject(), event.genre()); + } +} 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 new file mode 100644 index 0000000..765a4e7 --- /dev/null +++ b/src/main/java/com/audio/core/book/application/usecase/CreateBookUseCase.java @@ -0,0 +1,38 @@ +package com.audio.core.book.application.usecase; + +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.service.BookService; +import com.audio.core.auth.domain.entity.Account; +import com.audio.core.auth.domain.service.AccountService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CreateBookUseCase { + + private final AccountService accountService; + private final BookService bookService; + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public CreateBookResult execute(CreateBookCommand command) { + Account account = accountService.getAccountById(command.getAccountId()); + Book createdBook = bookService.createBookForAccountId(account.getId(), command.getSubject()); + + applicationEventPublisher.publishEvent(new BookGenerationRequestedEvent( + account.getId(), + command.getSubject(), + "다른 필요한 값" + )); + + return CreateBookResult.builder().title(createdBook.getTitle()).build(); + } +} diff --git a/src/main/java/com/audio/core/book/application/usecase/dto/command/CreateBookCommand.java b/src/main/java/com/audio/core/book/application/usecase/dto/command/CreateBookCommand.java new file mode 100644 index 0000000..e81def6 --- /dev/null +++ b/src/main/java/com/audio/core/book/application/usecase/dto/command/CreateBookCommand.java @@ -0,0 +1,16 @@ +package com.audio.core.book.application.usecase.dto.command; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class CreateBookCommand { + + private Long accountId; + private String subject; +} diff --git a/src/main/java/com/audio/core/book/application/usecase/dto/result/CreateBookResult.java b/src/main/java/com/audio/core/book/application/usecase/dto/result/CreateBookResult.java new file mode 100644 index 0000000..7744f5b --- /dev/null +++ b/src/main/java/com/audio/core/book/application/usecase/dto/result/CreateBookResult.java @@ -0,0 +1,16 @@ +package com.audio.core.book.application.usecase.dto.result; + +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 CreateBookResult { + + private String title; +} 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 new file mode 100644 index 0000000..993b05e --- /dev/null +++ b/src/main/java/com/audio/core/book/application/workflow/BookGenerationWorkflow.java @@ -0,0 +1,19 @@ +package com.audio.core.book.application.workflow; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@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 저장"); + } +} 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 new file mode 100644 index 0000000..1ef8422 --- /dev/null +++ b/src/main/java/com/audio/core/book/domain/entity/Book.java @@ -0,0 +1,61 @@ +package com.audio.core.book.domain.entity; + +import com.audio.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import java.time.Instant; +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; +import lombok.experimental.SuperBuilder; + +@Entity +@SuperBuilder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Book extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long ownerId; + + @Column(nullable = false, length = 200) + private String subject; + + @Column(nullable = false, length = 200) + private String title; + + @OneToOne + @JoinColumn(name = "active_version_id") + private BookVersion activeVersion; + + @Default + @OneToMany(fetch = FetchType.LAZY, mappedBy = "book") + private List bookVersions = new ArrayList<>(); + + public void addVersion(BookVersion version) { + bookVersions.add(version); + this.updatedAt = Instant.now(); + } + + public void setActiveVersion(BookVersion version) { + this.activeVersion = version; + 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 new file mode 100644 index 0000000..2ce4aad --- /dev/null +++ b/src/main/java/com/audio/core/book/domain/entity/BookVersion.java @@ -0,0 +1,53 @@ +package com.audio.core.book.domain.entity; + +import com.audio.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@SuperBuilder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "book_version") +public class BookVersion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "book_id") + private Book book; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status = Status.GENERATING; + + @Enumerated(EnumType.STRING) + private LengthPreset lengthPreset; + + private String bookS3Key; + + private String audioS3Key; + + private String language = "en-Us"; + + private Integer audioDurationSec; + + public enum Status { GENERATING, READY, FAILED, DELETED } + public enum LengthPreset { SHORT, NORMAL, LONG } +} diff --git a/src/main/java/com/audio/core/book/domain/repository/BookRepository.java b/src/main/java/com/audio/core/book/domain/repository/BookRepository.java new file mode 100644 index 0000000..ade9d0c --- /dev/null +++ b/src/main/java/com/audio/core/book/domain/repository/BookRepository.java @@ -0,0 +1,9 @@ +package com.audio.core.book.domain.repository; + +import com.audio.core.book.domain.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BookRepository 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 new file mode 100644 index 0000000..ee1352b --- /dev/null +++ b/src/main/java/com/audio/core/book/domain/service/BookService.java @@ -0,0 +1,33 @@ +package com.audio.core.book.domain.service; + +import com.audio.core.book.domain.entity.Book; +import com.audio.core.book.domain.entity.BookVersion; +import com.audio.core.book.domain.repository.BookRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookService { + + private final BookRepository bookRepository; + + @Transactional + public Book createBookForAccountId(Long ownerId, String subject) { + Book book = Book.builder() + .ownerId(ownerId) + .subject(subject) + .build(); + + BookVersion bookVersion = BookVersion.builder().build(); + + book.addVersion(bookVersion); + book.setActiveVersion(bookVersion); + + return bookRepository.save(book); + } +} diff --git a/src/main/resources/db/changelog/changelog.yaml b/src/main/resources/db/changelog/changelog.yaml index 3d5d285..010683d 100644 --- a/src/main/resources/db/changelog/changelog.yaml +++ b/src/main/resources/db/changelog/changelog.yaml @@ -1,3 +1,5 @@ databaseChangeLog: - - include: - file: db/sql/account/251203/1_create_account.sql \ No newline at end of file + - includeAll: + path: db/sql/account/251203 + - includeAll: + path: db/sql/book/251215 \ No newline at end of file diff --git a/src/main/resources/db/sql/book/251215/1_create_book.sql b/src/main/resources/db/sql/book/251215/1_create_book.sql new file mode 100644 index 0000000..571498e --- /dev/null +++ b/src/main/resources/db/sql/book/251215/1_create_book.sql @@ -0,0 +1,17 @@ +-- liquibase formatted sql +-- changeset ijeongmin:1_create_book.sql +create table book ( + id bigint not null auto_increment primary key, + owner_id bigint null, + subject varchar(200) not null, + title varchar(200) not null, + active_version_id bigint null, + + created_at timestamp null, + updated_at timestamp null, + deleted boolean not null default false, + deleted_at timestamp null, + + key index_active_version_id (active_version_id) +) engine = InnoDB + default charset = utf8mb4; diff --git a/src/main/resources/db/sql/book/251215/2_create_book_version.sql b/src/main/resources/db/sql/book/251215/2_create_book_version.sql new file mode 100644 index 0000000..f2304ae --- /dev/null +++ b/src/main/resources/db/sql/book/251215/2_create_book_version.sql @@ -0,0 +1,20 @@ +-- liquibase formatted sql +-- changeset ijeongmin:2_create_book_version.sql +create table book_version ( + id bigint not null auto_increment primary key, + book_id bigint null, + status varchar(20) not null, + length_preset varchar(10) null, + book_s3_key varchar(500) null, + audio_s3_key varchar(500) null, + language varchar(10) null, + audio_duration_sec int null, + + created_at timestamp null, + updated_at timestamp null, + deleted boolean not null default false, + deleted_at timestamp null, + + key idx_book_version_book_id (book_id), + key idx_book_version_status (status) +);