diff --git a/build.gradle b/build.gradle index cd1c981..37f01aa 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.jetbrains.kotlin:kotlin-reflect' + implementation 'tools.jackson.module:jackson-module-kotlin' implementation 'io.github.oshai:kotlin-logging-jvm:7.0.0' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/mysql/index.sql b/mysql/index.sql new file mode 100644 index 0000000..dc9fda2 --- /dev/null +++ b/mysql/index.sql @@ -0,0 +1,4 @@ +# 사용처 : CreateBookService.execute, UpdateBookService.execute +# 활성 도서끼리만 ISBN 중복을 금지한다. +CREATE UNIQUE INDEX ux_books_active_isbn + ON books ((IF(deleted_at IS NULL, isbn, NULL))); diff --git a/src/main/kotlin/org/library/book/application/CreateBookService.kt b/src/main/kotlin/org/library/book/application/CreateBookService.kt new file mode 100644 index 0000000..f3a2bb1 --- /dev/null +++ b/src/main/kotlin/org/library/book/application/CreateBookService.kt @@ -0,0 +1,42 @@ +package org.library.book.application + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import org.library.book.domain.Book +import org.library.book.domain.BookRepository +import org.library.book.domain.error.BookError +import org.library.book.dto.BookResponse +import org.library.core.application.Result +import org.library.core.application.err +import org.library.core.application.ok +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CreateBookService( + private val bookRepository: BookRepository, +) { + + @Transactional + fun execute(request: Request): Result { + val isbn = Book.normalizeIsbn(request.isbn) + if (isbn != null && bookRepository.findByIsbnAndDeletedAtIsNull(isbn) != null) { + return BookError.DUPLICATE_ISBN.err() + } + val book = bookRepository.save( + Book(title = request.title, author = request.author, isbn = isbn), + ) + return BookResponse.from(book).ok() + } + + data class Request( + @field:NotBlank + @field:Schema(description = "도서 제목", example = "클린 아키텍처") + val title: String, + @field:NotBlank + @field:Schema(description = "저자", example = "로버트 마틴") + val author: String, + @field:Schema(description = "ISBN (미입력 가능)", example = "9788966262472", nullable = true) + val isbn: String? = null, + ) +} diff --git a/src/main/kotlin/org/library/book/application/DeleteBookService.kt b/src/main/kotlin/org/library/book/application/DeleteBookService.kt new file mode 100644 index 0000000..4b3f616 --- /dev/null +++ b/src/main/kotlin/org/library/book/application/DeleteBookService.kt @@ -0,0 +1,23 @@ +package org.library.book.application + +import org.library.book.domain.BookRepository +import org.library.book.domain.error.BookError +import org.library.core.application.Result +import org.library.core.application.err +import org.library.core.application.ok +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class DeleteBookService( + private val bookRepository: BookRepository, +) { + + @Transactional + fun execute(id: Long): Result { + val book = bookRepository.findByIdAndDeletedAtIsNull(id) + ?: return BookError.NOT_FOUND.err() + book.softDelete() + return Unit.ok() + } +} diff --git a/src/main/kotlin/org/library/book/application/GetBookService.kt b/src/main/kotlin/org/library/book/application/GetBookService.kt new file mode 100644 index 0000000..ba4962a --- /dev/null +++ b/src/main/kotlin/org/library/book/application/GetBookService.kt @@ -0,0 +1,23 @@ +package org.library.book.application + +import org.library.book.domain.BookRepository +import org.library.book.domain.error.BookError +import org.library.book.dto.BookResponse +import org.library.core.application.Result +import org.library.core.application.err +import org.library.core.application.ok +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetBookService( + private val bookRepository: BookRepository, +) { + + fun execute(id: Long): Result { + val book = bookRepository.findByIdAndDeletedAtIsNull(id) + ?: return BookError.NOT_FOUND.err() + return BookResponse.from(book).ok() + } +} diff --git a/src/main/kotlin/org/library/book/application/UpdateBookService.kt b/src/main/kotlin/org/library/book/application/UpdateBookService.kt new file mode 100644 index 0000000..6977822 --- /dev/null +++ b/src/main/kotlin/org/library/book/application/UpdateBookService.kt @@ -0,0 +1,47 @@ +package org.library.book.application + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import org.library.book.domain.Book +import org.library.book.domain.BookRepository +import org.library.book.domain.error.BookError +import org.library.book.dto.BookResponse +import org.library.core.application.Result +import org.library.core.application.err +import org.library.core.application.ok +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UpdateBookService( + private val bookRepository: BookRepository, +) { + + @Transactional + fun execute(id: Long, request: Request): Result { + val book = bookRepository.findByIdAndDeletedAtIsNull(id) + ?: return BookError.NOT_FOUND.err() + + val isbn = Book.normalizeIsbn(request.isbn) + if (isbn != null) { + val owner = bookRepository.findByIsbnAndDeletedAtIsNull(isbn) + if (owner != null && owner.id != id) { + return BookError.DUPLICATE_ISBN.err() + } + } + + book.update(title = request.title, author = request.author, isbn = isbn) + return BookResponse.from(book).ok() + } + + data class Request( + @field:NotBlank + @field:Schema(description = "도서 제목", example = "클린 아키텍처") + val title: String, + @field:NotBlank + @field:Schema(description = "저자", example = "로버트 마틴") + val author: String, + @field:Schema(description = "ISBN (미입력 시 기존 값이 지워진다)", example = "9788966262472", nullable = true) + val isbn: String? = null, + ) +} diff --git a/src/main/kotlin/org/library/book/controller/BookController.kt b/src/main/kotlin/org/library/book/controller/BookController.kt new file mode 100644 index 0000000..d8196b1 --- /dev/null +++ b/src/main/kotlin/org/library/book/controller/BookController.kt @@ -0,0 +1,81 @@ +package org.library.book.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.library.book.application.CreateBookService +import org.library.book.application.DeleteBookService +import org.library.book.application.GetBookService +import org.library.book.application.UpdateBookService +import org.library.book.domain.error.BookError +import org.library.book.dto.BookResponse +import org.library.core.application.getOrThrow +import org.library.core.swagger.ApiErrorCode +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +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 + +@Tag(name = "Book", description = "도서 관리 API") +@RestController +@RequestMapping("/books") +class BookController( + private val createBookService: CreateBookService, + private val getBookService: GetBookService, + private val updateBookService: UpdateBookService, + private val deleteBookService: DeleteBookService, +) { + + @Operation( + summary = "도서 등록", + description = "새 도서를 등록한다. ISBN은 미입력할 수 있고, 값이 있으면 중복될 수 없다.", + ) + @ApiResponse( + responseCode = "201", + description = "도서 등록 성공", + content = [Content(schema = Schema(implementation = BookResponse::class))], + ) + @ApiErrorCode(errorCodes = [BookError::class], only = ["DUPLICATE_ISBN"]) + @PostMapping + fun create(@Valid @RequestBody request: CreateBookService.Request): ResponseEntity { + val book = createBookService.execute(request).getOrThrow() + return ResponseEntity.status(HttpStatus.CREATED).body(book) + } + + @Operation(summary = "도서 단건 조회", description = "ID로 도서 한 건을 조회한다.") + @ApiErrorCode(errorCodes = [BookError::class], only = ["NOT_FOUND"]) + @GetMapping("/{id}") + fun get(@Parameter(description = "도서 ID") @PathVariable id: Long): BookResponse = + getBookService.execute(id).getOrThrow() + + @Operation( + summary = "도서 수정", + description = "도서의 제목·저자·ISBN을 수정한다. 요청 본문이 곧 최종 상태이며, ISBN을 생략하면 기존 값이 지워진다.", + ) + @ApiErrorCode(errorCodes = [BookError::class], only = ["NOT_FOUND", "DUPLICATE_ISBN"]) + @PatchMapping("/{id}") + fun update( + @Parameter(description = "도서 ID") @PathVariable id: Long, + @Valid @RequestBody request: UpdateBookService.Request, + ): BookResponse = + updateBookService.execute(id, request).getOrThrow() + + @Operation(summary = "도서 삭제", description = "도서를 소프트 삭제한다.") + @ApiResponse(responseCode = "204", description = "도서 삭제 성공") + @ApiErrorCode(errorCodes = [BookError::class], only = ["NOT_FOUND"]) + @DeleteMapping("/{id}") + fun delete(@Parameter(description = "도서 ID") @PathVariable id: Long): ResponseEntity { + deleteBookService.execute(id).getOrThrow() + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/org/library/book/domain/Book.kt b/src/main/kotlin/org/library/book/domain/Book.kt index a399557..21aced9 100644 --- a/src/main/kotlin/org/library/book/domain/Book.kt +++ b/src/main/kotlin/org/library/book/domain/Book.kt @@ -30,6 +30,14 @@ class Book( require(author.isNotBlank()) { "저자는 비어 있을 수 없습니다." } } + fun update(title: String, author: String, isbn: String?) { + require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다." } + require(author.isNotBlank()) { "저자는 비어 있을 수 없습니다." } + this.title = title + this.author = author + this.isbn = normalizeIsbn(isbn) + } + companion object { fun normalizeIsbn(isbn: String?): String? = isbn?.trim()?.takeIf { it.isNotBlank() } diff --git a/src/main/kotlin/org/library/book/domain/BookRepository.kt b/src/main/kotlin/org/library/book/domain/BookRepository.kt index 3cde0d7..2f9e37d 100644 --- a/src/main/kotlin/org/library/book/domain/BookRepository.kt +++ b/src/main/kotlin/org/library/book/domain/BookRepository.kt @@ -3,4 +3,8 @@ package org.library.book.domain import org.springframework.data.jpa.repository.JpaRepository interface BookRepository : JpaRepository { + + fun findByIsbnAndDeletedAtIsNull(isbn: String): Book? + + fun findByIdAndDeletedAtIsNull(id: Long): Book? } diff --git a/src/main/kotlin/org/library/book/domain/error/BookError.kt b/src/main/kotlin/org/library/book/domain/error/BookError.kt new file mode 100644 index 0000000..2667bcf --- /dev/null +++ b/src/main/kotlin/org/library/book/domain/error/BookError.kt @@ -0,0 +1,15 @@ +package org.library.book.domain.error + +import org.library.core.exception.ErrorCode +import org.springframework.http.HttpStatus + +enum class BookError( + override val status: HttpStatus, + override val message: String, +) : ErrorCode { + NOT_FOUND(HttpStatus.NOT_FOUND, "도서를 찾을 수 없습니다."), + DUPLICATE_ISBN(HttpStatus.CONFLICT, "이미 등록된 ISBN입니다."), + ; + + override val code: String get() = name +} diff --git a/src/main/kotlin/org/library/book/dto/BookResponse.kt b/src/main/kotlin/org/library/book/dto/BookResponse.kt new file mode 100644 index 0000000..abfdb3c --- /dev/null +++ b/src/main/kotlin/org/library/book/dto/BookResponse.kt @@ -0,0 +1,29 @@ +package org.library.book.dto + +import io.swagger.v3.oas.annotations.media.Schema +import org.library.book.domain.Book +import java.time.LocalDateTime + +@Schema(description = "도서 응답") +data class BookResponse( + @field:Schema(description = "도서 ID", example = "1") + val id: Long, + @field:Schema(description = "도서 제목", example = "클린 아키텍처") + val title: String, + @field:Schema(description = "저자", example = "로버트 마틴") + val author: String, + @field:Schema(description = "ISBN (미입력 가능)", example = "9788966262472", nullable = true) + val isbn: String?, + @field:Schema(description = "등록 일시") + val createdAt: LocalDateTime, +) { + companion object { + fun from(book: Book): BookResponse = BookResponse( + id = book.id, + title = book.title, + author = book.author, + isbn = book.isbn, + createdAt = book.createdAt, + ) + } +} diff --git a/src/main/kotlin/org/library/core/exception/CommonErrorCode.kt b/src/main/kotlin/org/library/core/exception/CommonErrorCode.kt index b5c1595..571e13d 100644 --- a/src/main/kotlin/org/library/core/exception/CommonErrorCode.kt +++ b/src/main/kotlin/org/library/core/exception/CommonErrorCode.kt @@ -8,6 +8,7 @@ enum class CommonErrorCode( ) : ErrorCode { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "요청 값이 올바르지 않습니다."), + DATA_CONFLICT(HttpStatus.CONFLICT, "이미 존재하는 데이터입니다."), ; override val code: String get() = name diff --git a/src/main/kotlin/org/library/core/exception/SystemExceptionHandler.kt b/src/main/kotlin/org/library/core/exception/SystemExceptionHandler.kt index 92cde98..e8a2255 100644 --- a/src/main/kotlin/org/library/core/exception/SystemExceptionHandler.kt +++ b/src/main/kotlin/org/library/core/exception/SystemExceptionHandler.kt @@ -1,8 +1,10 @@ package org.library.core.exception import io.github.oshai.kotlinlogging.KotlinLogging +import org.hibernate.exception.ConstraintViolationException import org.library.core.logging.TraceIdFilter import org.slf4j.MDC +import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode @@ -23,6 +25,19 @@ class SystemExceptionHandler : ResponseEntityExceptionHandler() { fun handleDomain(e: DomainException): ProblemDetail = problem(e.errorCode.status, e.errorCode.code, e.errorCode.message) + @ExceptionHandler(DataIntegrityViolationException::class) + fun handleDataIntegrity(e: DataIntegrityViolationException): ProblemDetail { + val kind = (e.cause as? ConstraintViolationException)?.kind + if (kind != ConstraintViolationException.ConstraintKind.UNIQUE) return handleUnexpected(e) + + log.warn(e) { "Unique constraint violation" } + return problem( + CommonErrorCode.DATA_CONFLICT.status, + CommonErrorCode.DATA_CONFLICT.code, + CommonErrorCode.DATA_CONFLICT.message, + ) + } + @ExceptionHandler(Exception::class) fun handleUnexpected(e: Exception): ProblemDetail { log.error(e) { "Unhandled exception" }