Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/main/kotlin/org/library/bookitem/domain/BookItem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ class BookItem(
this.status = status
}

fun markOnLoan() {
require(status == BookItemStatus.AVAILABLE) { "대출 가능한 상태가 아닙니다." }
status = BookItemStatus.ON_LOAN
}

fun markAvailable() {
require(status == BookItemStatus.ON_LOAN) { "대출 중인 상태가 아닙니다." }
status = BookItemStatus.AVAILABLE
}

companion object {

val MANAGEMENT_NUMBER_REGEX = Regex("^[^-\\s]+-\\d+$")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package org.library.bookitem.domain

import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param

interface BookItemRepository : JpaRepository<BookItem, Long> {

fun findByManagementNumber(managementNumber: String): BookItem?

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select bi from BookItem bi where bi.managementNumber = :managementNumber")
fun findByManagementNumberForUpdate(@Param("managementNumber") managementNumber: String): BookItem?
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] 신규 조회 메서드에 소프트 삭제 조건(deletedAt is null)이 누락됐습니다

Problem: 신규 추가된 findByManagementNumberForUpdate 쿼리는 deletedAt is null 조건이 없습니다. BookItemBaseEntity를 상속해 deletedAt/softDelete()를 이미 갖고 있으므로, 소프트 삭제된 소장본도 이 메서드로 조회·락·대출 처리될 수 있습니다.

Evidence: 같은 PR의 CreateLoanService.kt:36Book 조회 시 findByIdAndDeletedAtIsNull을 사용해 소프트 삭제 규약을 지키는데, 새로 추가된 이 BookItem 조회 메서드만 그 규약을 따르지 않습니다. 현재는 BookItem 삭제 기능이 없어 당장 악용되지는 않지만, 추후 소장본 삭제 기능이 추가되는 즉시 삭제된 소장본이 대출 가능해지는 잠재 버그가 됩니다.

Fix direction: 쿼리에 deletedAt is null 조건을 추가하세요.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select bi from BookItem bi where bi.managementNumber = :managementNumber and bi.deletedAt is null")
fun findByManagementNumberForUpdate(@Param("managementNumber") managementNumber: String): BookItem?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BookItem 삭제 기능이 실제로 추가되는 시점(현재 기획에 존재 X)에 관련 기능 리펙토링 (별도 이슈로 관리 예정)

}
111 changes: 111 additions & 0 deletions src/main/kotlin/org/library/loan/application/CreateLoanService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package org.library.loan.application

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import org.library.book.domain.BookRepository
import org.library.bookitem.domain.BookItemRepository
import org.library.bookitem.domain.BookItemStatus
import org.library.core.application.Result
import org.library.core.application.err
import org.library.core.application.ok
import org.library.loan.domain.Loan
import org.library.loan.domain.LoanRepository
import org.library.loan.domain.LoanStatus
import org.library.loan.domain.error.LoanError
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate

@Service
class CreateLoanService(
private val bookItemRepository: BookItemRepository,
private val bookRepository: BookRepository,
private val loanRepository: LoanRepository,
) {

@Transactional
fun execute(request: Request): Result<Response, LoanError> {
val bookItem = bookItemRepository.findByManagementNumberForUpdate(request.managementNumber)
?: return LoanError.BOOK_ITEM_NOT_FOUND.err()

if (bookItem.status != BookItemStatus.AVAILABLE) {
return LoanError.BOOK_ITEM_NOT_AVAILABLE.err()
}

val book = bookRepository.findByIdAndDeletedAtIsNull(bookItem.bookId)
?: error("BookItem(${bookItem.id})이 가리키는 도서를 찾을 수 없습니다.")

bookItem.markOnLoan()

val loan = loanRepository.save(
Loan(
bookItemId = bookItem.id,
managementNumber = bookItem.managementNumber,
bookTitle = book.title,
borrowerName = request.borrowerName,
department = request.department,
borrowerEmail = request.borrowerEmail,
loanDate = request.loanDate,
),
)
return Response.from(loan).ok()
}

@Schema(name = "CreateLoanRequest")
data class Request(
@field:NotBlank
@field:Schema(description = "대출할 소장본 관리번호", example = "기술-0001")
val managementNumber: String,
@field:NotBlank
@field:Schema(description = "대출자 이름", example = "홍길동")
val borrowerName: String,
@field:NotBlank
@field:Schema(description = "부서명", example = "총무과")
val department: String,
@field:NotNull
@field:Schema(description = "대여일", example = "2026-07-31")
val loanDate: LocalDate,
@field:Schema(description = "대출자 이메일", example = "hong@example.com", nullable = true)
val borrowerEmail: String? = null,
)

@Schema(name = "CreateLoanResponse")
data class Response(
@field:Schema(description = "대출 ID", example = "100")
val loanId: Long,
@field:Schema(description = "소장본 ID", example = "10")
val bookItemId: Long,
@field:Schema(description = "관리번호", example = "기술-0001")
val managementNumber: String,
@field:Schema(description = "도서명", example = "클린 코드")
val bookTitle: String,
@field:Schema(description = "대출자 이름", example = "홍길동")
val borrowerName: String,
@field:Schema(description = "부서명", example = "총무과")
val department: String,
@field:Schema(description = "대출자 이메일", example = "hong@example.com", nullable = true)
val borrowerEmail: String?,
@field:Schema(description = "대여일", example = "2026-07-31")
val loanDate: LocalDate,
@field:Schema(description = "반납 예정일", example = "2026-08-14")
val dueDate: LocalDate,
@field:Schema(description = "대출 상태", example = "ON_LOAN")
val status: LoanStatus,
) {
companion object {
fun from(loan: Loan): Response = Response(
loanId = loan.id,
bookItemId = loan.bookItemId,
managementNumber = loan.managementNumber,
bookTitle = loan.bookTitle,
borrowerName = loan.borrowerName,
department = loan.department,
borrowerEmail = loan.borrowerEmail,
loanDate = loan.loanDate,
dueDate = loan.dueDate,
status = loan.status,
)
}
}
}
75 changes: 75 additions & 0 deletions src/main/kotlin/org/library/loan/application/ReturnLoanService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.library.loan.application

import io.swagger.v3.oas.annotations.media.Schema
import org.library.bookitem.domain.BookItemRepository
import org.library.core.application.Result
import org.library.core.application.err
import org.library.core.application.ok
import org.library.loan.domain.Loan
import org.library.loan.domain.LoanRepository
import org.library.loan.domain.LoanStatus
import org.library.loan.domain.error.LoanError
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate

@Service
class ReturnLoanService(
private val loanRepository: LoanRepository,
private val bookItemRepository: BookItemRepository,
) {

@Transactional
fun execute(loanId: Long, request: Request): Result<Response, LoanError> {
val loan = loanRepository.findByIdForUpdate(loanId)
?: return LoanError.LOAN_NOT_FOUND.err()

if (loan.status != LoanStatus.ON_LOAN) {
return LoanError.LOAN_NOT_ON_LOAN.err()
}

val bookItem = bookItemRepository.findById(loan.bookItemId).orElse(null)
?: error("Loan(${loan.id})이 가리키는 소장본을 찾을 수 없습니다.")

loan.markReturned(request.returnedAt ?: LocalDate.now())
bookItem.markAvailable()

return Response.from(loan).ok()
}

@Schema(name = "ReturnLoanRequest")
data class Request(
@field:Schema(description = "실제 반납일 (미지정 시 오늘 날짜)", example = "2026-08-20", nullable = true)
val returnedAt: LocalDate? = null,
)

@Schema(name = "ReturnLoanResponse")
data class Response(
@field:Schema(description = "대출 ID", example = "100")
val loanId: Long,
@field:Schema(description = "관리번호", example = "기술-0001")
val managementNumber: String,
@field:Schema(description = "대여일", example = "2026-07-31")
val loanDate: LocalDate,
@field:Schema(description = "반납 예정일", example = "2026-08-14")
val dueDate: LocalDate,
@field:Schema(description = "실제 반납일", example = "2026-08-20")
val returnedAt: LocalDate?,
@field:Schema(description = "대출 상태", example = "RETURNED")
val status: LoanStatus,
@field:Schema(description = "반납 지연 일수 (지연 없으면 null)", example = "6", nullable = true)
val overdueDays: Long?,
) {
companion object {
fun from(loan: Loan): Response = Response(
loanId = loan.id,
managementNumber = loan.managementNumber,
loanDate = loan.loanDate,
dueDate = loan.dueDate,
returnedAt = loan.returnedAt,
status = loan.status,
overdueDays = loan.overdueDays(),
)
}
}
}
80 changes: 80 additions & 0 deletions src/main/kotlin/org/library/loan/application/SearchLoansService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package org.library.loan.application

import io.swagger.v3.oas.annotations.media.Schema
import org.library.core.presentation.PageRequestParams
import org.library.core.presentation.Pagination
import org.library.loan.domain.Loan
import org.library.loan.domain.LoanRepository
import org.library.loan.domain.LoanStatus
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDate

@Service
@Transactional(readOnly = true)
class SearchLoansService(
private val loanRepository: LoanRepository,
) {

fun execute(
bookTitle: String?,
borrowerName: String?,
department: String?,
status: LoanStatus?,
params: PageRequestParams,
): Response {
val pageRequest = params.toPageRequest(Sort.by(Sort.Direction.DESC, "loanDate"))
val page = loanRepository.search(bookTitle, borrowerName, department, status, pageRequest)
val today = LocalDate.now()
return Response(
loans = page.content.map { LoanSummary.from(it, today) },
pagination = Pagination.from(page),
)
}

@Schema(name = "LoanSummary")
data class LoanSummary(
@field:Schema(description = "대출 ID", example = "100")
val loanId: Long,
@field:Schema(description = "관리번호", example = "기술-0001")
val managementNumber: String,
@field:Schema(description = "도서명", example = "클린 코드")
val bookTitle: String,
@field:Schema(description = "대출자 이름", example = "홍길동")
val borrowerName: String,
@field:Schema(description = "부서명", example = "총무과")
val department: String,
@field:Schema(description = "대여일", example = "2026-07-31")
val loanDate: LocalDate,
@field:Schema(description = "반납 예정일", example = "2026-08-14")
val dueDate: LocalDate,
@field:Schema(description = "실제 반납일", example = "2026-08-20", nullable = true)
val returnedAt: LocalDate?,
@field:Schema(description = "대출 상태", example = "ON_LOAN")
val status: LoanStatus,
@field:Schema(description = "연체 여부 (대출 중이면서 반납 예정일이 지남)", example = "true")
val overdue: Boolean,
) {
companion object {
fun from(loan: Loan, today: LocalDate): LoanSummary = LoanSummary(
loanId = loan.id,
managementNumber = loan.managementNumber,
bookTitle = loan.bookTitle,
borrowerName = loan.borrowerName,
department = loan.department,
loanDate = loan.loanDate,
dueDate = loan.dueDate,
returnedAt = loan.returnedAt,
status = loan.status,
overdue = loan.isOverdue(today),
)
}
}

@Schema(name = "LoanSearchResponse", description = "대출 내역 검색·목록 응답")
data class Response(
val loans: List<LoanSummary>,
val pagination: Pagination,
)
}
79 changes: 79 additions & 0 deletions src/main/kotlin/org/library/loan/controller/LoanController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.library.loan.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.core.application.getOrThrow
import org.library.core.presentation.PageRequestParams
import org.library.core.swagger.ApiErrorCode
import org.library.loan.application.CreateLoanService
import org.library.loan.application.ReturnLoanService
import org.library.loan.application.SearchLoansService
import org.library.loan.domain.LoanStatus
import org.library.loan.domain.error.LoanError
import org.springdoc.core.annotations.ParameterObject
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
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.RequestParam
import org.springframework.web.bind.annotation.RestController

@Tag(name = "Loan", description = "대출 관리 API")
@RestController
@RequestMapping("/loans")
class LoanController(
private val createLoanService: CreateLoanService,
private val returnLoanService: ReturnLoanService,
private val searchLoansService: SearchLoansService,
) {

@Operation(
summary = "대출 등록",
description = "관리번호로 소장본을 조회해 대출을 등록한다. 반납 예정일은 대여일 + 14일로 자동 계산되며, 등록 성공 시 소장본 상태가 ON_LOAN으로 바뀐다.",
)
@ApiResponse(
responseCode = "201",
description = "대출 등록 성공",
content = [Content(schema = Schema(implementation = CreateLoanService.Response::class))],
)
@ApiErrorCode(errorCodes = [LoanError::class], only = ["BOOK_ITEM_NOT_FOUND", "BOOK_ITEM_NOT_AVAILABLE"])
@PostMapping
fun create(@Valid @RequestBody request: CreateLoanService.Request): ResponseEntity<CreateLoanService.Response> {
val loan = createLoanService.execute(request).getOrThrow()
return ResponseEntity.status(HttpStatus.CREATED).body(loan)
}

@Operation(
summary = "반납 처리",
description = "대출 건을 반납 처리한다. 실제 반납일을 지정하지 않으면 오늘 날짜로 기록되며, 소장본 상태가 AVAILABLE로 돌아간다.",
)
@ApiErrorCode(errorCodes = [LoanError::class], only = ["LOAN_NOT_FOUND", "LOAN_NOT_ON_LOAN"])
@PostMapping("/{loanId}/return")
fun returnLoan(
@Parameter(description = "대출 ID") @PathVariable loanId: Long,
@RequestBody(required = false) request: ReturnLoanService.Request?,
): ReturnLoanService.Response =
returnLoanService.execute(loanId, request ?: ReturnLoanService.Request()).getOrThrow()

@Operation(
summary = "대출 내역 검색·목록",
description = "도서명·대출자 이름·부서·상태로 대출 내역을 검색한다. 기본 정렬은 대여일 내림차순.",
)
@GetMapping
fun search(
@Parameter(description = "도서명 부분 일치") @RequestParam(required = false) bookTitle: String?,
@Parameter(description = "대출자 이름 부분 일치") @RequestParam(required = false) borrowerName: String?,
@Parameter(description = "부서명 부분 일치") @RequestParam(required = false) department: String?,
@Parameter(description = "대출 상태") @RequestParam(required = false) status: LoanStatus?,
@ParameterObject params: PageRequestParams,
): SearchLoansService.Response =
searchLoansService.execute(bookTitle, borrowerName, department, status, params)
}
Loading