-
Notifications
You must be signed in to change notification settings - Fork 0
feat(#15): 대출 등록·반납·검색 API 구현 #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 8 additions & 0 deletions
8
src/main/kotlin/org/library/bookitem/domain/BookItemRepository.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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? | ||
| } | ||
111 changes: 111 additions & 0 deletions
111
src/main/kotlin/org/library/loan/application/CreateLoanService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
75
src/main/kotlin/org/library/loan/application/ReturnLoanService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
80
src/main/kotlin/org/library/loan/application/SearchLoansService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
79
src/main/kotlin/org/library/loan/controller/LoanController.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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조건이 없습니다.BookItem은BaseEntity를 상속해deletedAt/softDelete()를 이미 갖고 있으므로, 소프트 삭제된 소장본도 이 메서드로 조회·락·대출 처리될 수 있습니다.Evidence: 같은 PR의
CreateLoanService.kt:36은Book조회 시findByIdAndDeletedAtIsNull을 사용해 소프트 삭제 규약을 지키는데, 새로 추가된 이BookItem조회 메서드만 그 규약을 따르지 않습니다. 현재는BookItem삭제 기능이 없어 당장 악용되지는 않지만, 추후 소장본 삭제 기능이 추가되는 즉시 삭제된 소장본이 대출 가능해지는 잠재 버그가 됩니다.Fix direction: 쿼리에
deletedAt is null조건을 추가하세요.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BookItem 삭제 기능이 실제로 추가되는 시점(현재 기획에 존재 X)에 관련 기능 리펙토링 (별도 이슈로 관리 예정)