-
Notifications
You must be signed in to change notification settings - Fork 0
feat(#21): Book 검색 API (LIKE) #23
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package io.github.fnzl54.library.book.presentation.get | ||
|
|
||
| import io.github.fnzl54.library.book.service.ReadBookListService | ||
| import io.github.fnzl54.library.config.swagger.ApiErrorCode | ||
| import io.github.fnzl54.library.core.domain.entity.BookItem | ||
| import io.github.fnzl54.library.core.exception.error.GlobalErrorCode | ||
| import io.github.fnzl54.library.core.presentation.SuccessResponse | ||
| import io.github.fnzl54.library.core.presentation.pagination.Pagination | ||
| import io.github.fnzl54.library.core.presentation.pagination.PaginationRequest | ||
| import io.swagger.v3.oas.annotations.Operation | ||
| 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.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.RestController | ||
|
|
||
| @RestController | ||
| @Tag(name = "Book", description = "책") | ||
| class ReadBookListController( | ||
| private val readBookListService: ReadBookListService, | ||
| ) { | ||
| @Operation( | ||
| summary = "도서 검색 API", | ||
| description = | ||
| "키워드(제목 / 저자 부분 검색)로 도서를 페이지 단위로 조회합니다.\n\n" + | ||
| "키워드를 입력하지 않으면 전체 도서를 조회합니다.\n\n" + | ||
| "응답의 items는 해당 도서의 소장본 목록(청구기호, 상태)입니다.", | ||
| operationId = "readBookList", | ||
| ) | ||
| @ApiResponse( | ||
| responseCode = "200", | ||
| content = [Content(schema = Schema(implementation = ReadBookListResponse::class))], | ||
| ) | ||
| @ApiErrorCode(errorCodes = [GlobalErrorCode::class]) | ||
| @GetMapping("/books") | ||
| fun readBookList( | ||
| @Valid @ParameterObject request: ReadBookListRequest, | ||
| ): ResponseEntity<ReadBookListResponse> { | ||
| val serviceRequest = | ||
| ReadBookListService.Request( | ||
| keyword = request.keyword?.trim(), | ||
| pageable = request.toPageRequest(), | ||
| ) | ||
|
|
||
| val serviceResponse = readBookListService.execute(serviceRequest) | ||
| return ResponseEntity.ok(toResponse(serviceResponse.result)) | ||
| } | ||
|
|
||
| private fun toResponse(pageResult: ReadBookListService.PageResult): ReadBookListResponse = | ||
| ReadBookListResponse( | ||
| statusCode = HttpStatus.OK.value(), | ||
| result = | ||
| ReadBookListResponse.Result( | ||
| pagination = pageResult.pagination, | ||
| books = | ||
| pageResult.books.map { book -> | ||
| ReadBookListResponse.BookDetail( | ||
| bookId = book.bookId, | ||
| isbn = book.isbn, | ||
| title = book.title, | ||
| author = book.author, | ||
| publisher = book.publisher, | ||
| items = | ||
| book.items.map { item -> | ||
| ReadBookListResponse.BookItemDetail( | ||
| callNumber = item.callNumber, | ||
| status = item.status, | ||
| ) | ||
| }, | ||
| ) | ||
| }, | ||
| ), | ||
| ) | ||
|
|
||
| data class ReadBookListRequest( | ||
| @Schema(description = "검색 키워드 (제목/저자 부분 검색)", example = "릴케") | ||
| val keyword: String? = null, | ||
| @Schema(description = "페이지 번호 (1부터 시작, 기본값: 1)", example = "1") | ||
| override val page: Int = 1, | ||
| @Schema(description = "페이지 크기 (기본값: 10, 최대: 100)", example = "10") | ||
| override val size: Int = 10, | ||
| ) : PaginationRequest(page, size) | ||
|
Comment on lines
+83
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요청 객체에서 부모 클래스의 @Schema(description = "페이지 번호 (1부터 시작, 기본값: 1)", example = "1")
@field:Min(1)
override val page: Int = 1,
@Schema(description = "페이지 크기 (기본값: 10, 최대: 100)", example = "10")
@field:Min(1) @field:Max(100)
override val size: Int = 10,
) : PaginationRequest(page, size)References
|
||
|
|
||
| class ReadBookListResponse( | ||
| @Schema(description = "HTTP 상태 코드", example = "200") | ||
| override val statusCode: Int, | ||
| result: Result, | ||
| ) : SuccessResponse<ReadBookListResponse.Result>(statusCode = statusCode, result = result) { | ||
| data class Result( | ||
| val pagination: Pagination, | ||
| val books: List<BookDetail>, | ||
| ) | ||
|
|
||
| data class BookDetail( | ||
| @Schema(description = "도서 ID", example = "1") | ||
| val bookId: Long, | ||
| @Schema(description = "ISBN", example = "9788937425370", nullable = true) | ||
| val isbn: String?, | ||
| @Schema(description = "도서 제목", example = "말테의 수기") | ||
| val title: String, | ||
| @Schema(description = "저자", example = "라이너 마리아 릴케") | ||
| val author: String, | ||
| @Schema(description = "출판사", example = "민음사", nullable = true) | ||
| val publisher: String?, | ||
| @Schema(description = "소장본 목록") | ||
| val items: List<BookItemDetail>, | ||
| ) | ||
|
|
||
| data class BookItemDetail( | ||
| @Schema(description = "청구기호", example = "A-001") | ||
| val callNumber: String, | ||
| @Schema(description = "대출 상태", example = "AVAILABLE") | ||
| val status: BookItem.Status, | ||
| ) | ||
|
Comment on lines
+114
to
+119
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 특정 도메인 엔티티를 나타내는 API 엔드포인트에서는 연관된 엔티티의 물리적 상태(예: References
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package io.github.fnzl54.library.book.service | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnore | ||
| import io.github.fnzl54.library.core.application.BaseRequest | ||
| import io.github.fnzl54.library.core.application.BaseResponse | ||
| import io.github.fnzl54.library.core.application.BaseService | ||
| import io.github.fnzl54.library.core.domain.entity.BookItem | ||
| import io.github.fnzl54.library.core.domain.repository.BookQueryRepository | ||
| import io.github.fnzl54.library.core.presentation.pagination.Pagination | ||
| import org.springframework.data.domain.Pageable | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| @Transactional(readOnly = true) | ||
| class ReadBookListService( | ||
| private val bookQueryRepository: BookQueryRepository, | ||
| ) : BaseService<ReadBookListService.Request, ReadBookListService.Response>() { | ||
| override fun doExecute(request: Request): Response { | ||
| val searchRequest = BookQueryRepository.BookSearchRequest(keyword = request.keyword) | ||
| val bookPage = bookQueryRepository.findBooksByKeyword(searchRequest, request.pageable) | ||
|
|
||
| val bookIds = bookPage.content.map { it.bookId } | ||
| val itemsByBookId = | ||
| bookQueryRepository | ||
| .findBookItemsByBookIds(bookIds) | ||
| .groupBy { it.bookId } | ||
|
|
||
| val books = | ||
| bookPage.content.map { summary -> | ||
| BookDetail( | ||
| bookId = summary.bookId, | ||
| isbn = summary.isbn, | ||
| title = summary.title, | ||
| author = summary.author, | ||
| publisher = summary.publisher, | ||
| items = | ||
| itemsByBookId[summary.bookId].orEmpty().map { item -> | ||
| BookItemDetail( | ||
| callNumber = item.callNumber, | ||
| status = item.status, | ||
| ) | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| return Response( | ||
| PageResult( | ||
| pagination = Pagination.from(bookPage), | ||
| books = books, | ||
| ), | ||
| ) | ||
| } | ||
|
|
||
| data class Request( | ||
| val keyword: String?, | ||
| val pageable: Pageable, | ||
| ) : BaseRequest { | ||
| @get:JsonIgnore | ||
| override val isValid: Boolean | ||
| get() = true | ||
| } | ||
|
|
||
| data class PageResult( | ||
| val pagination: Pagination, | ||
| val books: List<BookDetail>, | ||
| ) | ||
|
|
||
| data class BookDetail( | ||
| val bookId: Long, | ||
| val isbn: String?, | ||
| val title: String, | ||
| val author: String, | ||
| val publisher: String?, | ||
| val items: List<BookItemDetail>, | ||
| ) | ||
|
|
||
| data class BookItemDetail( | ||
| val callNumber: String, | ||
| val status: BookItem.Status, | ||
| ) | ||
|
|
||
| class Response(pageResult: PageResult) : BaseResponse<PageResult>(result = pageResult) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,98 @@ | ||||||||||||||||||||||
| package io.github.fnzl54.library.core.domain.repository | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import com.querydsl.core.types.Projections | ||||||||||||||||||||||
| import com.querydsl.core.types.dsl.BooleanExpression | ||||||||||||||||||||||
| import com.querydsl.jpa.impl.JPAQueryFactory | ||||||||||||||||||||||
| import io.github.fnzl54.library.core.domain.entity.BookItem | ||||||||||||||||||||||
| import io.github.fnzl54.library.core.domain.entity.QBook.book | ||||||||||||||||||||||
| import io.github.fnzl54.library.core.domain.entity.QBookItem.bookItem | ||||||||||||||||||||||
| import org.springframework.data.domain.Page | ||||||||||||||||||||||
| import org.springframework.data.domain.Pageable | ||||||||||||||||||||||
| import org.springframework.data.support.PageableExecutionUtils | ||||||||||||||||||||||
| import org.springframework.stereotype.Repository | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| @Repository | ||||||||||||||||||||||
| class BookQueryRepository( | ||||||||||||||||||||||
| private val jpaQueryFactory: JPAQueryFactory, | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| data class BookSearchRequest( | ||||||||||||||||||||||
| val keyword: String?, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| data class BookSummary( | ||||||||||||||||||||||
| val bookId: Long, | ||||||||||||||||||||||
| val isbn: String?, | ||||||||||||||||||||||
| val title: String, | ||||||||||||||||||||||
| val author: String, | ||||||||||||||||||||||
| val publisher: String?, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| data class BookItemSummary( | ||||||||||||||||||||||
| val bookId: Long, | ||||||||||||||||||||||
| val callNumber: String, | ||||||||||||||||||||||
| val status: BookItem.Status, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| fun findBooksByKeyword( | ||||||||||||||||||||||
| request: BookSearchRequest, | ||||||||||||||||||||||
| pageable: Pageable, | ||||||||||||||||||||||
| ): Page<BookSummary> { | ||||||||||||||||||||||
| val predicates = | ||||||||||||||||||||||
| arrayOf( | ||||||||||||||||||||||
| book.deleted.isFalse, | ||||||||||||||||||||||
| keywordContains(request.keyword), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
Comment on lines
+40
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 Kotlin의
Suggested change
References
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| val content = | ||||||||||||||||||||||
| jpaQueryFactory | ||||||||||||||||||||||
| .select( | ||||||||||||||||||||||
| Projections.constructor( | ||||||||||||||||||||||
| BookSummary::class.java, | ||||||||||||||||||||||
| book.id, | ||||||||||||||||||||||
| book.isbn, | ||||||||||||||||||||||
| book.title, | ||||||||||||||||||||||
| book.author, | ||||||||||||||||||||||
| book.publisher, | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| ).from(book) | ||||||||||||||||||||||
| .where(*predicates) | ||||||||||||||||||||||
| .orderBy(book.title.asc(), book.id.asc()) | ||||||||||||||||||||||
| .offset(pageable.offset) | ||||||||||||||||||||||
| .limit(pageable.pageSize.toLong()) | ||||||||||||||||||||||
| .fetch() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| val countQuery = | ||||||||||||||||||||||
| jpaQueryFactory | ||||||||||||||||||||||
| .select(book.count()) | ||||||||||||||||||||||
| .from(book) | ||||||||||||||||||||||
| .where(*predicates) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return PageableExecutionUtils.getPage(content, pageable) { countQuery.fetchOne() ?: 0L } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| fun findBookItemsByBookIds(bookIds: Collection<Long>): List<BookItemSummary> { | ||||||||||||||||||||||
| if (bookIds.isEmpty()) return emptyList() | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return jpaQueryFactory | ||||||||||||||||||||||
| .select( | ||||||||||||||||||||||
| Projections.constructor( | ||||||||||||||||||||||
| BookItemSummary::class.java, | ||||||||||||||||||||||
| bookItem.book.id, | ||||||||||||||||||||||
| bookItem.callNumber, | ||||||||||||||||||||||
| bookItem.status, | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| ).from(bookItem) | ||||||||||||||||||||||
| .where( | ||||||||||||||||||||||
| bookItem.deleted.isFalse, | ||||||||||||||||||||||
| bookItem.book.id.`in`(bookIds), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| .orderBy(bookItem.callNumber.asc()) | ||||||||||||||||||||||
| .fetch() | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private fun keywordContains(keyword: String?): BooleanExpression? = | ||||||||||||||||||||||
| keyword?.takeIf { it.isNotBlank() }?.let { kw -> | ||||||||||||||||||||||
| book.title.containsIgnoreCase(kw) | ||||||||||||||||||||||
| .or(book.author.containsIgnoreCase(kw)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
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.
검색 키워드(
keyword)에 대한 길이 제한 검증이 누락되어 있습니다. 사용자가 악의적으로 매우 긴 문자열을 입력할 경우 데이터베이스 쿼리 성능 저하나 예기치 못한 오류가 발생할 수 있으므로,@field:Size(max = 100)등을 통해 적절한 길이 제한 검증을 추가하는 것을 권장합니다.References