feat: 책 생성 api 구현

This commit is contained in:
2025-12-19 17:13:47 +09:00
parent ca74cc42ed
commit dc1110bdb4
19 changed files with 432 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package com.audio.core.book.application.event;
public record BookGenerationRequestedEvent(Long accountId, String subject, String genre) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Book, Long> {
}

View File

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

View File

@@ -1,3 +1,5 @@
databaseChangeLog:
- include:
file: db/sql/account/251203/1_create_account.sql
- includeAll:
path: db/sql/account/251203
- includeAll:
path: db/sql/book/251215

View File

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

View File

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