diff --git a/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt b/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt index a2d59910..fb5c2683 100644 --- a/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt +++ b/apis/src/main/kotlin/org/yapp/apis/ApisApplication.kt @@ -14,7 +14,7 @@ import org.springframework.boot.runApplication "org.yapp.domain", "org.yapp.gateway", "org.yapp.globalutils" - ] , + ], exclude = [JpaRepositoriesAutoConfiguration::class] ) class ApisApplication diff --git a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt index f29c7f1b..f678e9c5 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt @@ -1,6 +1,10 @@ package org.yapp.apis.book.controller import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping @@ -8,14 +12,17 @@ import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.PutMapping 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 import org.yapp.apis.book.dto.request.BookDetailRequest import org.yapp.apis.book.dto.request.BookSearchRequest import org.yapp.apis.book.dto.request.UserBookRegisterRequest import org.yapp.apis.book.dto.response.BookDetailResponse import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.apis.book.dto.response.UserBookPageResponse import org.yapp.apis.book.dto.response.UserBookResponse import org.yapp.apis.book.usecase.BookUseCase +import org.yapp.domain.userbook.BookStatus import java.util.UUID @RestController @@ -53,10 +60,14 @@ class BookController( @GetMapping("/my-library") override fun getUserLibraryBooks( - @AuthenticationPrincipal userId: UUID - ): ResponseEntity> { - - val response = bookUseCase.getUserLibraryBooks(userId) + @AuthenticationPrincipal userId: UUID, + @RequestParam(required = false) status: BookStatus?, + @RequestParam(required = false) sort: String?, + @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) + pageable: Pageable + ): ResponseEntity { + val response = bookUseCase.getUserLibraryBooks(userId, status, sort, pageable) return ResponseEntity.ok(response) } + } diff --git a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt index 84ed0c5c..0ab92eb3 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt @@ -8,18 +8,25 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.web.PageableDefault import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.yapp.apis.book.dto.request.BookDetailRequest import org.yapp.apis.book.dto.request.BookSearchRequest import org.yapp.apis.book.dto.request.UserBookRegisterRequest import org.yapp.apis.book.dto.response.BookDetailResponse import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.apis.book.dto.response.UserBookPageResponse import org.yapp.apis.book.dto.response.UserBookResponse +import org.yapp.domain.userbook.BookStatus import org.yapp.globalutils.exception.ErrorResponse import java.util.UUID @@ -130,5 +137,12 @@ interface BookControllerApi { ] ) @GetMapping("/my-library") - fun getUserLibraryBooks(@AuthenticationPrincipal userId: UUID): ResponseEntity> + fun getUserLibraryBooks( + @AuthenticationPrincipal userId: UUID, + @RequestParam(required = false) status: BookStatus?, + @RequestParam(required = false) sort: String?, + @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) + pageable: Pageable + ): ResponseEntity + } diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookPageResponse.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookPageResponse.kt new file mode 100644 index 00000000..ed964dc2 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookPageResponse.kt @@ -0,0 +1,36 @@ +package org.yapp.apis.book.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.Page + +@Schema(description = "사용자의 책 페이지 응답") +data class UserBookPageResponse private constructor( + + @Schema(description = "책 목록 (페이지네이션)", implementation = UserBookResponse::class) + val books: Page, + + @Schema(description = "읽기 전 상태의 책 개수") + val beforeReadingCount: Long, + + @Schema(description = "읽고 있는 책 개수") + val readingCount: Long, + + @Schema(description = "완독한 책 개수") + val completedCount: Long +) { + companion object { + fun of( + books: Page, + beforeReadingCount: Long, + readingCount: Long, + completedCount: Long + ): UserBookPageResponse { + return UserBookPageResponse( + books = books, + beforeReadingCount = beforeReadingCount, + readingCount = readingCount, + completedCount = completedCount + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookResponse.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookResponse.kt index 2819998f..f28a6402 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookResponse.kt @@ -2,6 +2,7 @@ package org.yapp.apis.book.dto.response import org.yapp.domain.userbook.BookStatus import org.yapp.domain.userbook.vo.UserBookInfoVO +import org.yapp.globalutils.validator.BookDataValidator import java.time.format.DateTimeFormatter import java.util.UUID @@ -26,10 +27,10 @@ data class UserBookResponse private constructor( userId = userBook.userId.value, bookIsbn = userBook.bookIsbn.value, bookTitle = userBook.title, - bookAuthor = userBook.author, + bookAuthor = BookDataValidator.removeParenthesesFromAuthor(userBook.author), status = userBook.status, coverImageUrl = userBook.coverImageUrl, - publisher = userBook.publisher, + publisher = BookDataValidator.removeParenthesesFromPublisher(userBook.publisher), createdAt = userBook.createdAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), updatedAt = userBook.updatedAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), ) diff --git a/apis/src/main/kotlin/org/yapp/apis/book/service/BookManagementService.kt b/apis/src/main/kotlin/org/yapp/apis/book/service/BookManagementService.kt index 0f2067d1..b7c6df90 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/service/BookManagementService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/BookManagementService.kt @@ -14,7 +14,7 @@ class BookManagementService( request.validIsbn(), request.validTitle(), request.validAuthor(), - request.validAuthor(), + request.validPublisher(), request.coverImageUrl, request.publicationYear, request.description diff --git a/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt b/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt index 9d51096c..cacf880a 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt @@ -1,11 +1,16 @@ package org.yapp.apis.book.service +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.yapp.apis.auth.dto.request.UserBooksByIsbnsRequest +import org.yapp.apis.book.dto.response.UserBookPageResponse import org.yapp.apis.book.dto.response.UserBookResponse import org.yapp.apis.book.dto.request.UpsertUserBookRequest +import org.yapp.domain.userbook.BookStatus import org.yapp.domain.userbook.UserBookDomainService import org.yapp.domain.userbook.vo.UserBookInfoVO +import org.yapp.domain.userbook.vo.UserBookStatusCountsVO import java.util.UUID @@ -18,9 +23,9 @@ class UserBookService( userBookDomainService.upsertUserBook( upsertUserBookRequest.userId, upsertUserBookRequest.bookIsbn, - upsertUserBookRequest.bookPublisher, - upsertUserBookRequest.bookAuthor, upsertUserBookRequest.bookTitle, + upsertUserBookRequest.bookAuthor, + upsertUserBookRequest.bookPublisher, upsertUserBookRequest.bookCoverImageUrl, upsertUserBookRequest.status ) @@ -42,5 +47,30 @@ class UserBookService( .map { UserBookResponse.from(it) } } -} + private fun findUserBooksByDynamicCondition( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): Page { + return userBookDomainService.findUserBooksByDynamicCondition(userId, status, sort, pageable) + .map { UserBookResponse.from(it) } + } + + fun findUserBooksByDynamicConditionWithStatusCounts( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): UserBookPageResponse { + val userBookResponsePage = findUserBooksByDynamicCondition(userId, status, sort, pageable) + val userBookStatusCountsVO = userBookDomainService.getUserBookStatusCounts(userId) + return UserBookPageResponse.of( + books = userBookResponsePage, + beforeReadingCount = userBookStatusCountsVO.beforeReadingCount, + readingCount = userBookStatusCountsVO.readingCount, + completedCount = userBookStatusCountsVO.completedCount + ) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt index 9dd53009..4cd095fd 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt @@ -1,6 +1,8 @@ package org.yapp.apis.book.usecase import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.transaction.annotation.Transactional import org.yapp.apis.auth.dto.request.UserBooksByIsbnsRequest import org.yapp.apis.auth.service.UserAuthService @@ -10,12 +12,14 @@ import org.yapp.apis.book.dto.request.BookSearchRequest import org.yapp.apis.book.dto.request.UserBookRegisterRequest import org.yapp.apis.book.dto.response.BookDetailResponse import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.apis.book.dto.response.UserBookPageResponse import org.yapp.apis.book.dto.response.UserBookResponse import org.yapp.apis.book.constant.BookQueryServiceQualifier import org.yapp.apis.book.dto.request.UpsertUserBookRequest import org.yapp.apis.book.service.BookManagementService import org.yapp.apis.book.service.BookQueryService import org.yapp.apis.book.service.UserBookService +import org.yapp.domain.userbook.BookStatus import org.yapp.globalutils.annotation.UseCase import java.util.UUID @@ -66,9 +70,14 @@ class BookUseCase( return userBookResponse } - fun getUserLibraryBooks(userId: UUID): List { + fun getUserLibraryBooks( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): UserBookPageResponse { userAuthService.validateUserExists(userId) - return userBookService.findAllUserBooks(userId) + return userBookService.findUserBooksByDynamicConditionWithStatusCounts(userId, status, sort, pageable) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt index d6d5242e..0c03e16a 100644 --- a/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt +++ b/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt @@ -10,7 +10,9 @@ import org.yapp.infra.InfraBaseConfigGroup InfraBaseConfigGroup.JPA, InfraBaseConfigGroup.ASYNC, InfraBaseConfigGroup.REDIS, - InfraBaseConfigGroup.REST_CLIENT + InfraBaseConfigGroup.REST_CLIENT, + InfraBaseConfigGroup.QUERY_DSL, + InfraBaseConfigGroup.PAGE, ] ) class InfraConfig diff --git a/build.gradle.kts b/build.gradle.kts index e6fe519d..8084f136 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,26 @@ subprojects { } } +// QueryDSL 설정 +val querydslDir = "${layout.buildDirectory.get()}/generated/querydsl" + + +sourceSets { + main { + kotlin { + srcDir(querydslDir) + } + } +} + + +tasks.withType().configureEach { + doFirst { + delete(querydslDir) + } +} + + // 루트 프로젝트에서 모든 JaCoCo 설정 관리 configure(subprojects) { jacoco { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 52445198..ad06fb0e 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -8,7 +8,8 @@ object Dependencies { const val BOOT_STARTER_ACTUATOR = "org.springframework.boot:spring-boot-starter-actuator" const val BOOT_STARTER_TEST = "org.springframework.boot:spring-boot-starter-test" const val BOOT_STARTER_DATA_REDIS = "org.springframework.boot:spring-boot-starter-data-redis" - const val BOOT_STARTER_OAUTH2_RESOURCE_SERVER = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" + const val BOOT_STARTER_OAUTH2_RESOURCE_SERVER = + "org.springframework.boot:spring-boot-starter-oauth2-resource-server" const val KOTLIN_REFLECT = "org.jetbrains.kotlin:kotlin-reflect" } @@ -51,4 +52,9 @@ object Dependencies { object Flyway { const val MYSQL = "org.flywaydb:flyway-mysql" } + + object QueryDsl { + const val JPA = "com.querydsl:querydsl-jpa:5.0.0:jakarta" + const val APT = "com.querydsl:querydsl-apt:5.0.0:jakarta" + } } diff --git a/domain/src/main/kotlin/org/yapp/domain/book/Book.kt b/domain/src/main/kotlin/org/yapp/domain/book/Book.kt index 4a4251f3..35c90551 100644 --- a/domain/src/main/kotlin/org/yapp/domain/book/Book.kt +++ b/domain/src/main/kotlin/org/yapp/domain/book/Book.kt @@ -14,8 +14,8 @@ data class Book private constructor( val publicationYear: Int?, val coverImageUrl: String, val description: String?, - val createdAt: LocalDateTime, - val updatedAt: LocalDateTime, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, val deletedAt: LocalDateTime? = null ) { companion object { @@ -28,7 +28,6 @@ data class Book private constructor( publicationYear: Int? = null, description: String? = null ): Book { - val now = LocalDateTime.now() return Book( isbn = Isbn.newInstance(isbn), title = title, @@ -37,9 +36,6 @@ data class Book private constructor( publicationYear = publicationYear, coverImageUrl = coverImageUrl, description = description, - createdAt = now, - updatedAt = now, - deletedAt = null ) } @@ -51,8 +47,8 @@ data class Book private constructor( publicationYear: Int?, coverImageUrl: String, description: String?, - createdAt: LocalDateTime, - updatedAt: LocalDateTime, + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null, deletedAt: LocalDateTime? = null ): Book { return Book( diff --git a/domain/src/main/kotlin/org/yapp/domain/user/User.kt b/domain/src/main/kotlin/org/yapp/domain/user/User.kt index b4989621..86fd6c67 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/User.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/User.kt @@ -28,16 +28,15 @@ data class User private constructor( val providerType: ProviderType, val providerId: ProviderId, val role: Role, - val createdAt: LocalDateTime, - val updatedAt: LocalDateTime, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, val deletedAt: LocalDateTime? = null ) { fun restore(): User { require(this.isDeleted()) { "User is already active" } return this.copy( - deletedAt = null, - updatedAt = LocalDateTime.now() + deletedAt = null ) } @@ -47,10 +46,7 @@ data class User private constructor( nickname: String, profileImageUrl: String?, providerType: ProviderType, - providerId: String, - createdAt: LocalDateTime, - updatedAt: LocalDateTime, - deletedAt: LocalDateTime? = null + providerId: String ): User { return User( id = Id.newInstance(UuidGenerator.create()), @@ -59,10 +55,7 @@ data class User private constructor( profileImageUrl = profileImageUrl, providerType = providerType, providerId = ProviderId.newInstance(providerId), - role = Role.USER, - createdAt = createdAt, - updatedAt = updatedAt, - deletedAt = deletedAt + role = Role.USER ) } @@ -73,10 +66,7 @@ data class User private constructor( profileImageUrl: String?, providerType: ProviderType, providerId: String, - role: Role, - createdAt: LocalDateTime, - updatedAt: LocalDateTime, - deletedAt: LocalDateTime? = null + role: Role ): User { return User( id = Id.newInstance(UuidGenerator.create()), @@ -85,10 +75,7 @@ data class User private constructor( profileImageUrl = profileImageUrl, providerType = providerType, providerId = ProviderId.newInstance(providerId), - role = role, - createdAt = createdAt, - updatedAt = updatedAt, - deletedAt = deletedAt + role = role ) } @@ -100,8 +87,8 @@ data class User private constructor( providerType: ProviderType, providerId: ProviderId, role: Role, - createdAt: LocalDateTime, - updatedAt: LocalDateTime, + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null, deletedAt: LocalDateTime? = null ): User { return User( diff --git a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt index b4cc7728..036486ce 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt @@ -28,7 +28,10 @@ class UserDomainService( ?.let { UserIdentityVO.newInstance(it) } } - fun findUserByProviderTypeAndProviderIdIncludingDeleted(providerType: ProviderType, providerId: String): UserIdentityVO? { + fun findUserByProviderTypeAndProviderIdIncludingDeleted( + providerType: ProviderType, + providerId: String + ): UserIdentityVO? { return userRepository.findByProviderTypeAndProviderIdIncludingDeleted(providerType, providerId) ?.let { UserIdentityVO.newInstance(it) } } @@ -48,15 +51,12 @@ class UserDomainService( providerType: ProviderType, providerId: String ): UserIdentityVO { - val now = timeProvider.now() val user = User.create( email = email, nickname = nickname, profileImageUrl = profileImageUrl, providerType = providerType, - providerId = providerId, - createdAt = now, - updatedAt = now + providerId = providerId ) val savedUser = userRepository.save(user) return UserIdentityVO.newInstance(savedUser) diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt index 3a170206..18508944 100644 --- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt @@ -14,12 +14,12 @@ data class UserBook private constructor( val title: String, val author: String, val status: BookStatus, - val createdAt: LocalDateTime, - val updatedAt: LocalDateTime, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, val deletedAt: LocalDateTime? = null, ) { fun updateStatus(newStatus: BookStatus): UserBook { - return this.copy(status = newStatus, updatedAt = LocalDateTime.now()) + return this.copy(status = newStatus) } companion object { @@ -30,9 +30,8 @@ data class UserBook private constructor( publisher: String, title: String, author: String, - initialStatus: BookStatus = BookStatus.BEFORE_READING + status: BookStatus ): UserBook { - val now = LocalDateTime.now() return UserBook( id = Id.newInstance(UuidGenerator.create()), userId = UserId.newInstance(userId), @@ -41,9 +40,7 @@ data class UserBook private constructor( publisher = publisher, title = title, author = author, - status = initialStatus, - createdAt = now, - updatedAt = now + status = status, ) } @@ -56,9 +53,9 @@ data class UserBook private constructor( title: String, author: String, status: BookStatus, - createdAt: LocalDateTime, - updatedAt: LocalDateTime, - deletedAt: LocalDateTime? + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null, + deletedAt: LocalDateTime? = null ): UserBook { return UserBook( id = id, diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt index 8063ca88..aa155081 100644 --- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt @@ -1,6 +1,9 @@ package org.yapp.domain.userbook +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.yapp.domain.userbook.vo.UserBookInfoVO +import org.yapp.domain.userbook.vo.UserBookStatusCountsVO import org.yapp.globalutils.annotation.DomainService import java.util.UUID @@ -17,8 +20,7 @@ class UserBookDomainService( bookCoverImageUrl: String, status: BookStatus ): UserBookInfoVO { - val userBook = userBookRepository.findByUserIdAndBookIsbn(userId, bookIsbn) - ?.apply { updateStatus(status) } + val userBook = userBookRepository.findByUserIdAndBookIsbn(userId, bookIsbn)?.updateStatus(status) ?: UserBook.create( userId = userId, bookIsbn = bookIsbn, @@ -26,6 +28,7 @@ class UserBookDomainService( author = bookAuthor, publisher = bookPublisher, coverImageUrl = bookCoverImageUrl, + status = status ) val savedUserBook = userBookRepository.save(userBook) @@ -37,6 +40,16 @@ class UserBookDomainService( .map(UserBookInfoVO::newInstance) } + fun findUserBooksByDynamicCondition( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): Page { + return userBookRepository.findUserBooksByDynamicCondition(userId, status, sort, pageable) + .map(UserBookInfoVO::newInstance) + } + fun findAllByUserIdAndBookIsbnIn(userId: UUID, isbns: List): List { if (isbns.isEmpty()) { return emptyList() @@ -44,4 +57,15 @@ class UserBookDomainService( return userBookRepository.findAllByUserIdAndBookIsbnIn(userId, isbns) .map { UserBookInfoVO.newInstance(it) } } + + fun getUserBookStatusCounts(userId: UUID): UserBookStatusCountsVO { + val statusCounts = BookStatus.entries.associateWith { status -> + countUserBooksByStatus(userId, status) + } + return UserBookStatusCountsVO.newInstance(statusCounts) + } + + private fun countUserBooksByStatus(userId: UUID, status: BookStatus): Long { + return userBookRepository.countUserBooksByStatus(userId, status) + } } diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt index 54933ec3..e6708655 100644 --- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt @@ -1,5 +1,7 @@ package org.yapp.domain.userbook +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import java.util.UUID @@ -13,4 +15,13 @@ interface UserBookRepository { fun findAllByUserIdAndBookIsbnIn(userId: UUID, bookIsbns: List): List + fun findUserBooksByDynamicCondition( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): Page + + fun countUserBooksByStatus(userId: UUID, status: BookStatus): Long + } diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt index 0c464781..27cfbb03 100644 --- a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookInfoVO.kt @@ -39,8 +39,8 @@ data class UserBookInfoVO private constructor( title = userBook.title, author = userBook.author, status = userBook.status, - createdAt = userBook.createdAt, - updatedAt = userBook.updatedAt + createdAt = userBook.createdAt ?: throw IllegalStateException("createdAt은 null일 수 없습니다."), + updatedAt = userBook.updatedAt ?: throw IllegalStateException("updatedAt은 null일 수 없습니다.") ) } } diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookStatusCountsVO.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookStatusCountsVO.kt new file mode 100644 index 00000000..db50b30a --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/vo/UserBookStatusCountsVO.kt @@ -0,0 +1,38 @@ +package org.yapp.domain.userbook.vo + +import org.yapp.domain.userbook.BookStatus + + +data class UserBookStatusCountsVO private constructor( + val beforeReadingCount: Long, + val readingCount: Long, + val completedCount: Long +) { + init { + require(beforeReadingCount >= 0) { "Before reading count cannot be negative" } + require(readingCount >= 0) { "Reading count cannot be negative" } + require(completedCount >= 0) { "Completed count cannot be negative" } + } + + companion object { + fun newInstance( + beforeReadingCount: Long, + readingCount: Long, + completedCount: Long + ): UserBookStatusCountsVO { + return UserBookStatusCountsVO( + beforeReadingCount = beforeReadingCount, + readingCount = readingCount, + completedCount = completedCount + ) + } + + fun newInstance(statusCounts: Map): UserBookStatusCountsVO { + return UserBookStatusCountsVO( + beforeReadingCount = statusCounts[BookStatus.BEFORE_READING] ?: 0L, + readingCount = statusCounts[BookStatus.READING] ?: 0L, + completedCount = statusCounts[BookStatus.COMPLETED] ?: 0L + ) + } + } +} diff --git a/global-utils/src/main/kotlin/org/yapp/globalutils/validator/BookDataValidator.kt b/global-utils/src/main/kotlin/org/yapp/globalutils/validator/BookDataValidator.kt new file mode 100644 index 00000000..221c5bb6 --- /dev/null +++ b/global-utils/src/main/kotlin/org/yapp/globalutils/validator/BookDataValidator.kt @@ -0,0 +1,14 @@ +package org.yapp.globalutils.validator + +object BookDataValidator { + + private val PARENTHESIS_REGEX = "\\s*\\([^)]*\\)\\s*".toRegex() + + fun removeParenthesesFromAuthor(author: String): String { + return author.replace(PARENTHESIS_REGEX, "") + } + + fun removeParenthesesFromPublisher(publisher: String): String { + return publisher.replace(PARENTHESIS_REGEX, "") + } +} diff --git a/infra/build.gradle.kts b/infra/build.gradle.kts index 868fa00a..56f6a6f4 100644 --- a/infra/build.gradle.kts +++ b/infra/build.gradle.kts @@ -1,5 +1,9 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar +plugins { + kotlin(Plugins.Kotlin.Short.KAPT) version Versions.KOTLIN +} + dependencies { implementation(project(Dependencies.Projects.GLOBAL_UTILS)) implementation(project(Dependencies.Projects.DOMAIN)) @@ -17,6 +21,10 @@ dependencies { implementation(Dependencies.Flyway.MYSQL) + implementation(Dependencies.QueryDsl.JPA) + kapt(Dependencies.QueryDsl.APT) + + testImplementation(Dependencies.TestContainers.MYSQL) testImplementation(Dependencies.TestContainers.JUNIT_JUPITER) testImplementation(Dependencies.TestContainers.REDIS) diff --git a/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt b/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt index 209551ac..c1b1612b 100644 --- a/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt +++ b/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt @@ -4,12 +4,16 @@ import org.yapp.infra.config.external.api.RestClientConfig import org.yapp.infra.config.external.redis.RedisConfig import org.yapp.infra.config.internal.async.AsyncConfig import org.yapp.infra.config.internal.jpa.JpaConfig +import org.yapp.infra.config.internal.page.PageConfig +import org.yapp.infra.config.internal.querydsl.QuerydslConfig enum class InfraBaseConfigGroup( val configClass: Class ) { ASYNC(AsyncConfig::class.java), JPA(JpaConfig::class.java), + PAGE(PageConfig::class.java), REDIS(RedisConfig::class.java), - REST_CLIENT(RestClientConfig::class.java) + REST_CLIENT(RestClientConfig::class.java), + QUERY_DSL(QuerydslConfig::class.java) } diff --git a/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt b/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt index a7a6ef88..0282ca8c 100644 --- a/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt @@ -7,7 +7,7 @@ import jakarta.persistence.Table import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete import org.yapp.domain.book.Book -import org.yapp.domain.common.BaseTimeEntity +import org.yapp.infra.common.BaseTimeEntity import java.sql.Types @Entity @@ -74,11 +74,7 @@ class BookEntity private constructor( publicationYear = book.publicationYear, coverImageUrl = book.coverImageUrl, description = book.description - ).apply { - this.createdAt = book.createdAt - this.updatedAt = book.updatedAt - this.deletedAt = book.deletedAt - } + ) } override fun equals(other: Any?): Boolean { diff --git a/domain/src/main/kotlin/org/yapp/domain/common/BaseTimeEntity.kt b/infra/src/main/kotlin/org/yapp/infra/common/BaseTimeEntity.kt similarity index 96% rename from domain/src/main/kotlin/org/yapp/domain/common/BaseTimeEntity.kt rename to infra/src/main/kotlin/org/yapp/infra/common/BaseTimeEntity.kt index 91538097..00e10807 100644 --- a/domain/src/main/kotlin/org/yapp/domain/common/BaseTimeEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/common/BaseTimeEntity.kt @@ -1,4 +1,4 @@ -package org.yapp.domain.common +package org.yapp.infra.common import jakarta.persistence.Column import jakarta.persistence.EntityListeners diff --git a/infra/src/main/kotlin/org/yapp/infra/config/internal/jpa/JpaConfig.kt b/infra/src/main/kotlin/org/yapp/infra/config/internal/jpa/JpaConfig.kt index a0f37562..6d5592db 100644 --- a/infra/src/main/kotlin/org/yapp/infra/config/internal/jpa/JpaConfig.kt +++ b/infra/src/main/kotlin/org/yapp/infra/config/internal/jpa/JpaConfig.kt @@ -1,11 +1,16 @@ package org.yapp.infra.config.internal.jpa import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.data.jpa.repository.config.EnableJpaRepositories import org.springframework.transaction.annotation.EnableTransactionManagement import org.yapp.infra.InfraBaseConfig +@Configuration @EnableTransactionManagement +@EnableJpaAuditing @EntityScan(basePackages = ["org.yapp.infra"]) @EnableJpaRepositories(basePackages = ["org.yapp.infra"]) -class JpaConfig : InfraBaseConfig +class JpaConfig : InfraBaseConfig { +} diff --git a/infra/src/main/kotlin/org/yapp/infra/config/internal/page/PageConfig.kt b/infra/src/main/kotlin/org/yapp/infra/config/internal/page/PageConfig.kt new file mode 100644 index 00000000..1370d1b4 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/config/internal/page/PageConfig.kt @@ -0,0 +1,10 @@ +package org.yapp.infra.config.internal.page + +import org.springframework.context.annotation.Configuration +import org.springframework.data.web.config.EnableSpringDataWebSupport +import org.yapp.infra.InfraBaseConfig + +@Configuration +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) +class PageConfig : InfraBaseConfig { +} diff --git a/infra/src/main/kotlin/org/yapp/infra/config/internal/querydsl/QuerydslConfig.kt b/infra/src/main/kotlin/org/yapp/infra/config/internal/querydsl/QuerydslConfig.kt new file mode 100644 index 00000000..90930b55 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/config/internal/querydsl/QuerydslConfig.kt @@ -0,0 +1,17 @@ +package org.yapp.infra.config.internal.querydsl + +import com.querydsl.jpa.impl.JPAQueryFactory +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.context.annotation.Bean +import org.yapp.infra.InfraBaseConfig + +class QuerydslConfig : InfraBaseConfig { + @PersistenceContext + private lateinit var entityManager: EntityManager + + @Bean + fun jpaQueryFactory(): JPAQueryFactory { + return JPAQueryFactory(entityManager) + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt index d8e06180..f1888fe0 100644 --- a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserEntity.kt @@ -4,7 +4,7 @@ import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete import org.hibernate.annotations.SQLRestriction -import org.yapp.domain.common.BaseTimeEntity +import org.yapp.infra.common.BaseTimeEntity import org.yapp.domain.user.ProviderType import org.yapp.domain.user.User import org.yapp.globalutils.auth.Role @@ -73,11 +73,7 @@ class UserEntity private constructor( providerType = user.providerType, providerId = user.providerId.value, role = user.role - ).apply { - this.createdAt = user.createdAt - this.updatedAt = user.updatedAt - this.deletedAt = user.deletedAt - } + ) } override fun equals(other: Any?): Boolean { diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt index 0a144c27..8f688aa4 100644 --- a/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/userbook/entity/UserBookEntity.kt @@ -3,7 +3,7 @@ package org.yapp.infra.userbook.entity import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete -import org.yapp.domain.common.BaseTimeEntity +import org.yapp.infra.common.BaseTimeEntity import org.yapp.domain.userbook.BookStatus import org.yapp.domain.userbook.UserBook import java.sql.Types @@ -78,11 +78,7 @@ class UserBookEntity( title = userBook.title, author = userBook.author, status = userBook.status, - ).apply { - this.createdAt = userBook.createdAt - this.updatedAt = userBook.updatedAt - this.deletedAt = userBook.deletedAt - } + ) } } diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookQuerydslRepository.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookQuerydslRepository.kt new file mode 100644 index 00000000..5149427b --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookQuerydslRepository.kt @@ -0,0 +1,21 @@ +package org.yapp.infra.userbook.repository + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.yapp.domain.userbook.BookStatus +import org.yapp.infra.userbook.entity.UserBookEntity +import java.util.UUID + +interface JpaUserBookQuerydslRepository { + fun findUserBooksByDynamicCondition( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): Page + + fun countUserBooksByStatus( + userId: UUID, + status: BookStatus + ): Long +} diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt index 53efb57b..e4a66dd5 100644 --- a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt +++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/JpaUserBookRepository.kt @@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.yapp.infra.userbook.entity.UserBookEntity import java.util.* -interface JpaUserBookRepository : JpaRepository { +interface JpaUserBookRepository : JpaRepository, JpaUserBookQuerydslRepository { fun findByUserIdAndBookIsbn(userId: UUID, bookIsbn: String): UserBookEntity? fun findAllByUserId(userId: UUID): List fun findAllByUserIdAndBookIsbnIn(userId: UUID, bookIsbnList: List): List diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt new file mode 100644 index 00000000..da6f5bc8 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/JpaUserBookQuerydslRepositoryImpl.kt @@ -0,0 +1,80 @@ +package org.yapp.infra.userbook.repository.impl + +import com.querydsl.core.types.Order +import com.querydsl.core.types.OrderSpecifier +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.yapp.domain.userbook.BookStatus +import org.yapp.infra.userbook.entity.QUserBookEntity +import org.yapp.infra.userbook.entity.UserBookEntity +import org.yapp.infra.userbook.repository.JpaUserBookQuerydslRepository +import java.util.UUID + +class JpaUserBookQuerydslRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : JpaUserBookQuerydslRepository { + + private val userBook = QUserBookEntity.userBookEntity + + override fun findUserBooksByDynamicCondition( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): Page { + val baseQuery = queryFactory + .selectFrom(userBook) + .where( + userBook.userId.eq(userId), + statusEq(status) + ) + + val results = baseQuery + .orderBy(createOrderSpecifier(sort)) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .fetch() + + val total = queryFactory + .select(userBook.count()) + .from(userBook) + .where( + userBook.userId.eq(userId), + statusEq(status) + ) + .fetchOne() ?: 0L + + return PageImpl(results, pageable, total) + } + + override fun countUserBooksByStatus( + userId: UUID, + status: BookStatus + ): Long { + return queryFactory + .select(userBook.count()) + .from(userBook) + .where( + userBook.userId.eq(userId), + userBook.status.eq(status) + ) + .fetchOne() ?: 0L + } + + private fun statusEq(status: BookStatus?): BooleanExpression? { + return status?.let { userBook.status.eq(it) } + } + + private fun createOrderSpecifier(sort: String?): OrderSpecifier<*> { + return when (sort) { + "title_asc" -> userBook.title.asc() + "title_desc" -> userBook.title.desc() + "date_asc" -> userBook.createdAt.asc() + "date_desc" -> userBook.createdAt.desc() + else -> userBook.createdAt.desc() + } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt index 79bc98ac..3acc6d42 100644 --- a/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/userbook/repository/impl/UserBookRepositoryImpl.kt @@ -1,6 +1,9 @@ package org.yapp.infra.userbook.repository.impl +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Repository +import org.yapp.domain.userbook.BookStatus import org.yapp.domain.userbook.UserBookRepository import org.yapp.domain.userbook.UserBook import org.yapp.infra.userbook.entity.UserBookEntity @@ -32,4 +35,18 @@ class UserBookRepositoryImpl( return jpaUserBookRepository.findAllByUserIdAndBookIsbnIn(userId, bookIsbns) .map { it.toDomain() } } + + override fun findUserBooksByDynamicCondition( + userId: UUID, + status: BookStatus?, + sort: String?, + pageable: Pageable + ): Page { + return jpaUserBookRepository.findUserBooksByDynamicCondition(userId, status, sort, pageable) + .map { it.toDomain() } + } + + override fun countUserBooksByStatus(userId: UUID, status: BookStatus): Long { + return jpaUserBookRepository.countUserBooksByStatus(userId, status) + } }