diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt index 624e4488..a6525fe7 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthController.kt @@ -2,13 +2,14 @@ package org.yapp.apis.auth.controller import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.* import org.yapp.apis.auth.dto.request.SocialLoginRequest import org.yapp.apis.auth.dto.request.TokenRefreshRequest import org.yapp.apis.auth.dto.response.AuthResponse import org.yapp.apis.auth.dto.response.UserProfileResponse import org.yapp.apis.auth.usecase.AuthUseCase -import org.yapp.apis.util.AuthUtils +import java.util.* /** * Implementation of the authentication controller API. @@ -33,15 +34,13 @@ class AuthController( } @PostMapping("/signout") - override fun signOut(@RequestHeader("Authorization") authorization: String): ResponseEntity { - val userId = AuthUtils.extractUserIdFromAuthHeader(authorization, authUseCase::getUserIdFromAccessToken) + override fun signOut(@AuthenticationPrincipal userId: UUID): ResponseEntity { authUseCase.signOut(userId) return ResponseEntity.noContent().build() } @GetMapping("/me") - override fun getUserProfile(@RequestHeader("Authorization") authorization: String): ResponseEntity { - val userId = AuthUtils.extractUserIdFromAuthHeader(authorization, authUseCase::getUserIdFromAccessToken) + override fun getUserProfile(@AuthenticationPrincipal userId: UUID): ResponseEntity { val userProfile = authUseCase.getUserProfile(userId) return ResponseEntity.ok(userProfile) } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt index e61456ca..d10cb837 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/controller/AuthControllerApi.kt @@ -8,15 +8,18 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping import org.yapp.apis.auth.dto.request.SocialLoginRequest import org.yapp.apis.auth.dto.request.TokenRefreshRequest import org.yapp.apis.auth.dto.response.AuthResponse import org.yapp.apis.auth.dto.response.UserProfileResponse +import org.yapp.globalutils.exception.ErrorResponse +import java.util.UUID -/** - * API interface for authentication controller. - */ @Tag(name = "Authentication", description = "Authentication API") @RequestMapping("/api/v1/auth") interface AuthControllerApi { @@ -34,11 +37,13 @@ interface AuthControllerApi { ), ApiResponse( responseCode = "400", - description = "Invalid request or credentials" + description = "Invalid request or credentials", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ), ApiResponse( responseCode = "409", - description = "Email already in use with a different account" + description = "Email already in use with a different account", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] ) @@ -47,7 +52,7 @@ interface AuthControllerApi { @Operation( summary = "Refresh token", - description = "Refresh an access token using a refresh token. Returns both a new access token and a new refresh token. The client MUST use the new refresh token for subsequent refreshes, as the old refresh token is deleted from the server." + description = "Refresh an access token using a refresh token. Returns both a new access token and a new refresh token." ) @ApiResponses( value = [ @@ -58,41 +63,34 @@ interface AuthControllerApi { ), ApiResponse( responseCode = "400", - description = "Invalid refresh token" + description = "Invalid refresh token", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ), ApiResponse( responseCode = "404", - description = "Refresh token not found" + description = "Refresh token not found", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] ) @PostMapping("/refresh") fun refreshToken(@RequestBody @Valid request: TokenRefreshRequest): ResponseEntity - @Operation( - summary = "Sign out", - description = "Sign out a user by invalidating their refresh token" - ) + @Operation(summary = "Sign out", description = "Sign out a user by invalidating their refresh token") @ApiResponses( value = [ - ApiResponse( - responseCode = "204", - description = "Successful sign out" - ), + ApiResponse(responseCode = "204", description = "Successful sign out"), ApiResponse( responseCode = "400", - description = "Invalid user ID" + description = "Invalid user ID", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] ) @PostMapping("/signout") - fun signOut(@RequestHeader("Authorization") authorization: String): ResponseEntity + fun signOut(@AuthenticationPrincipal userId: UUID): ResponseEntity - - @Operation( - summary = "Get user profile", - description = "Retrieves profile information for the given user ID." - ) + @Operation(summary = "Get user profile", description = "Retrieves profile information for the given user ID.") @ApiResponses( value = [ ApiResponse( @@ -102,12 +100,11 @@ interface AuthControllerApi { ), ApiResponse( responseCode = "404", - description = "User not found" + description = "User not found", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] ) @GetMapping("/me") - fun getUserProfile( - @RequestHeader("Authorization") authorization: String - ): ResponseEntity + fun getUserProfile(@AuthenticationPrincipal userId: UUID): ResponseEntity } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/AuthCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/AuthCredentials.kt index 8c8f835f..2a98ba6e 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/AuthCredentials.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/AuthCredentials.kt @@ -1,6 +1,6 @@ package org.yapp.apis.auth.dto -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType sealed class AuthCredentials { abstract fun getProviderType(): ProviderType diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/UserCreateInfo.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/UserCreateInfo.kt index bb64e083..0bc3c70a 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/UserCreateInfo.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/UserCreateInfo.kt @@ -1,6 +1,6 @@ package org.yapp.apis.auth.dto -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType data class UserCreateInfo private constructor( val email: String?, diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt index b0048633..b47727ae 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/request/SocialLoginRequest.kt @@ -7,7 +7,7 @@ import org.yapp.apis.auth.dto.AuthCredentials import org.yapp.apis.auth.dto.KakaoAuthCredentials import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType @Schema( name = "SocialLoginRequest", diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserProfileResponse.kt b/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserProfileResponse.kt index 1d92dcdf..79c311f9 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserProfileResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/dto/response/UserProfileResponse.kt @@ -1,7 +1,7 @@ package org.yapp.apis.auth.dto.response import io.swagger.v3.oas.annotations.media.Schema -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import java.util.* @Schema( diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt index ac277202..10e5f45d 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/TokenService.kt @@ -3,7 +3,7 @@ package org.yapp.apis.auth.service import org.springframework.stereotype.Service import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.domain.service.redis.TokenDomainRedisService +import org.yapp.domain.token.TokenDomainRedisService import org.yapp.domain.token.RefreshToken import java.util.* diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt b/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt index 8fc04c91..6adea306 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/service/UserAuthService.kt @@ -5,7 +5,7 @@ import org.yapp.apis.auth.dto.UserCreateInfo import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.util.NicknameGenerator -import org.yapp.domain.service.domain.UserDomainService +import org.yapp.domain.user.UserDomainService import org.yapp.domain.user.User import org.yapp.domain.user.vo.SocialUserProfile import java.util.* @@ -56,4 +56,10 @@ class UserAuthService( return userDomainService.create(profile) } + + fun validateUserExists(userId: UUID) { + if (!userDomainService.existsById(userId)) { + throw AuthException(AuthErrorCode.USER_NOT_FOUND, "User not found: $userId") + } + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt index 4f1f98a7..3ee08acb 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AppleAuthStrategy.kt @@ -9,7 +9,7 @@ import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.auth.helper.AppleJwtHelper import org.yapp.apis.util.NicknameGenerator -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType /** * Implementation of AuthStrategy for Apple authentication. diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategy.kt index 9a45f5fb..8b0313b1 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/AuthStrategy.kt @@ -2,7 +2,7 @@ package org.yapp.apis.auth.strategy import org.yapp.apis.auth.dto.AuthCredentials import org.yapp.apis.auth.dto.UserCreateInfo -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType /** * Strategy interface for authentication. diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt index 3a292d82..1899fcce 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/KakaoAuthStrategy.kt @@ -9,7 +9,7 @@ import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.auth.helper.KakaoApiHelper import org.yapp.apis.util.NicknameGenerator -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo /** diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt index 7391f477..70bddbc0 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/usecase/AuthUseCase.kt @@ -51,9 +51,4 @@ class AuthUseCase( provider = user.providerType ) } - - @Transactional(propagation = Propagation.NOT_SUPPORTED) - fun getUserIdFromAccessToken(accessToken: String): UUID { - return authTokenHelper.getUserIdFromAccessToken(accessToken) - } } diff --git a/apis/src/main/kotlin/org/yapp/apis/book/constant/BookQueryServiceQualifier.kt b/apis/src/main/kotlin/org/yapp/apis/book/constant/BookQueryServiceQualifier.kt new file mode 100644 index 00000000..778576d9 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/constant/BookQueryServiceQualifier.kt @@ -0,0 +1,5 @@ +package org.yapp.apis.book.constant + +object BookQueryServiceQualifier { + const val ALADIN = "aladinBookQueryService" +} 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 e64d27b1..ff9f9ca4 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 @@ -2,21 +2,26 @@ package org.yapp.apis.book.controller import jakarta.validation.Valid 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.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.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.UserBookResponse import org.yapp.apis.book.usecase.BookUseCase - +import java.util.UUID @RestController @RequestMapping("/api/v1/books") class BookController( - private val bookUseCase: BookUseCase + private val bookUseCase: BookUseCase, ) : BookControllerApi { @GetMapping("/search") @@ -32,4 +37,22 @@ class BookController( val response = bookUseCase.getBookDetail(request) return ResponseEntity.ok(response) } + + @PutMapping("/upsert") + override fun upsertBookToMyLibrary( + @AuthenticationPrincipal userId: UUID, + @Valid @RequestBody request: UserBookRegisterRequest + ): ResponseEntity { + val response = bookUseCase.upsertBookToMyLibrary(userId, request) + return ResponseEntity.ok(response) + } + + @GetMapping("/my-library") + override fun getUserLibraryBooks( + @AuthenticationPrincipal userId: UUID + ): ResponseEntity> { + + val response = bookUseCase.getUserLibraryBooks(userId) + 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 501a0f05..35b27c7b 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 @@ -3,31 +3,32 @@ package org.yapp.apis.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.ExampleObject import io.swagger.v3.oas.annotations.media.Schema 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.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.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.UserBookResponse +import org.yapp.globalutils.exception.ErrorResponse +import java.util.UUID + -/** - * API interface for book controller. - */ @Tag(name = "Books", description = "도서 정보를 조회하는 API") @RequestMapping("/api/v1/books") interface BookControllerApi { - @Operation( - summary = "도서 검색", - description = "키워드를 사용하여 알라딘 도서 정보를 검색합니다." - ) + @Operation(summary = "도서 검색", description = "키워드를 사용하여 알라딘 도서 정보를 검색합니다.") @ApiResponses( value = [ ApiResponse( @@ -37,41 +38,15 @@ interface BookControllerApi { ), ApiResponse( responseCode = "400", - description = "잘못된 요청 파라미터" + description = "잘못된 요청 파라미터", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] ) @GetMapping("/search") - fun searchBooks( - @Valid - @Parameter( - description = "도서 검색 요청 객체. 다음 쿼리 파라미터를 포함합니다:
" + - "- `query` (필수): 검색어
" + - "- `queryType` (선택): 검색어 타입 (예: Title, Author). 기본값은 All
" + - "- `maxResults` (선택): 한 페이지당 결과 개수 (1-50). 기본값 10
" + - "- `start` (선택): 결과 시작 페이지. 기본값 1
" + - "- `sort` (선택): 정렬 방식 (예: PublishTime, SalesPoint). 기본값 Accuracy
" + - "- `categoryId` (선택): 카테고리 ID", - examples = [ - ExampleObject(name = "기본 검색", value = "http://localhost:8080/api/v1/books/search?query=코틀린"), - ExampleObject( - name = "상세 검색", - value = "http://localhost:8080/api/v1/books/search?query=클린코드&queryType=Title&maxResults=10&sort=PublishTime" - ), - ExampleObject( - name = "카테고리 검색", - value = "http://localhost:8080/api/v1/books/search?query=Spring&categoryId=170&start=2&maxResults=5" - ) - ] - ) - request: BookSearchRequest - ): ResponseEntity - + fun searchBooks(@Valid @Parameter(description = "도서 검색 요청 객체") request: BookSearchRequest): ResponseEntity - @Operation( - summary = "도서 상세 조회", - description = "특정 도서의 상세 정보를 조회합니다. `itemId`는 쿼리 파라미터로 전달됩니다." - ) + @Operation(summary = "도서 상세 조회", description = "특정 도서의 상세 정보를 조회합니다.") @ApiResponses( value = [ ApiResponse( @@ -81,33 +56,70 @@ interface BookControllerApi { ), ApiResponse( responseCode = "400", - description = "잘못된 요청 파라미터 (예: 유효하지 않은 itemId 또는 itemIdType)" + description = "잘못된 요청 파라미터", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ), ApiResponse( responseCode = "404", - description = "해당하는 itemId를 가진 도서를 찾을 수 없습니다." + description = "해당하는 itemId를 가진 도서를 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] ) ] ) @GetMapping("/detail") - fun getBookDetail( - @Valid - @Parameter( - description = "도서 상세 조회 요청 객체. 다음 쿼리 파라미터를 포함합니다:
" + - "- `itemId` (필수): 조회할 도서의 고유 ID (ISBN, ISBN13, 알라딘 ItemId 등)
" + - "- `itemIdType` (선택): `itemId`의 타입 (ISBN, ISBN13, ItemId). 기본값은 ISBN입니다.
" + - "- `optResult` (선택): 조회할 부가 정보 목록 (쉼표로 구분). 예시: `BookInfo,Toc,PreviewImg`", - examples = [ - ExampleObject( - name = "ISBN으로 상세 조회", - value = "http://localhost:8080/api/v1/books/detail?itemId=9791162241684&itemIdType=ISBN13" - ), - ExampleObject( - name = "ISBN 및 부가 정보 포함", - value = "http://localhost:8080/api/v1/books/detail?itemId=8994492040&itemIdType=ISBN&optResult=BookInfo,Toc" - ) - ] - ) - request: BookDetailRequest - ): ResponseEntity + fun getBookDetail(@Valid @Parameter(description = "도서 상세 조회 요청 객체") request: BookDetailRequest): ResponseEntity + + @Operation(summary = "서재에 책 등록 또는 상태 업데이트 (Upsert)", description = "사용자의 서재에 책을 등록하거나, 이미 등록된 책의 상태를 업데이트합니다.") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "책이 서재에 성공적으로 등록되었습니다.", + content = [Content(schema = Schema(implementation = UserBookResponse::class))] + ), + ApiResponse( + responseCode = "200", + description = "책 상태가 성공적으로 업데이트되었습니다.", + content = [Content(schema = Schema(implementation = UserBookResponse::class))] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 파라미터", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "존재하지 않는 책 (ISBN 오류)", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @PutMapping("/upsert") + fun upsertBookToMyLibrary( + @AuthenticationPrincipal userId: UUID, + @Valid @RequestBody request: UserBookRegisterRequest + ): ResponseEntity + + @Operation(summary = "사용자 서재 조회", description = "현재 사용자의 서재에 등록된 모든 책을 조회합니다.") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "서재 조회 성공", + content = [Content(schema = Schema(implementation = UserBookResponse::class))] + ), + ApiResponse( + responseCode = "400", + description = "Invalid request or credentials", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "사용자를 찾을 수 없습니다.", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @GetMapping("/my-library") + fun getUserLibraryBooks(@AuthenticationPrincipal userId: UUID): ResponseEntity> } diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookCreateRequest.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookCreateRequest.kt new file mode 100644 index 00000000..fb3323d3 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookCreateRequest.kt @@ -0,0 +1,57 @@ +package org.yapp.apis.book.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.yapp.apis.book.dto.response.BookDetailResponse +import org.yapp.domain.book.Book + +data class BookCreateRequest private constructor( + @field:NotBlank(message = "ISBN은 필수입니다.") + val isbn: String? = null, + + @field:NotBlank(message = "제목은 필수입니다.") + val title: String? = null, + + @field:NotBlank(message = "저자는 필수입니다.") + val author: String? = null, + + @field:NotBlank(message = "출판사는 필수입니다.") + val publisher: String? = null, + + val publicationYear: Int? = null, + + @field:Size(max = 2048, message = "표지 URL은 2048자 이내여야 합니다.") + val coverImageUrl: String, + + val description: String? = null, +) { + fun validIsbn(): String = isbn!! + fun validTitle(): String = title!! + fun validAuthor(): String = author!! + fun validPublisher(): String = publisher!! + + companion object { + + fun create(bookDetail: BookDetailResponse): BookCreateRequest { + val finalIsbn = bookDetail.isbn ?: bookDetail.isbn13 + ?: throw IllegalArgumentException("ISBN이 존재하지 않습니다.") + + return BookCreateRequest( + isbn = finalIsbn, + title = bookDetail.title, + author = bookDetail.author, + publisher = bookDetail.publisher, + publicationYear = parsePublicationYear(bookDetail.pubDate), + coverImageUrl = bookDetail.cover, + description = bookDetail.description + ) + } + + private fun parsePublicationYear(pubDate: String?): Int? { + return pubDate + ?.takeIf { it.length >= 4 && it.substring(0, 4).all { ch -> ch.isDigit() } } + ?.substring(0, 4) + ?.toIntOrNull() + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt index bbbdcb56..8efa078c 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt @@ -15,6 +15,10 @@ data class BookDetailRequest private constructor( val itemIdType: String? = "ISBN", val optResult: List? = null ) { + + fun validIsbn(): String = itemId!! + + fun toAladinRequest(): AladinBookLookupRequest { return AladinBookLookupRequest.create( itemId = this.itemId!!, @@ -22,4 +26,14 @@ data class BookDetailRequest private constructor( optResult = this.optResult ) } + + companion object { + fun of(isbn: String?, optResult: List? = null): BookDetailRequest { + return BookDetailRequest( + itemId = isbn, + itemIdType = "ISBN", + optResult = optResult + ) + } + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/request/UserBookRegisterRequest.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/UserBookRegisterRequest.kt new file mode 100644 index 00000000..be008096 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/UserBookRegisterRequest.kt @@ -0,0 +1,20 @@ +package org.yapp.apis.book.dto.request + +import jakarta.validation.constraints.NotBlank +import org.yapp.domain.userbook.BookStatus + + +data class UserBookRegisterRequest private constructor( + @field:NotBlank(message = "ISBN은 필수입니다.") + val bookIsbn: String? = null, + + val bookStatus: BookStatus +) { + fun validBookIsbn(): String = bookIsbn!! + + companion object { + fun create(bookIsbn: String, bookStatus: BookStatus): UserBookRegisterRequest { + return UserBookRegisterRequest(bookIsbn, bookStatus) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt index ef208be8..3ea5900b 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt @@ -11,7 +11,7 @@ data class BookDetailResponse private constructor( val version: String?, val title: String, val link: String?, - val author: String?, + val author: String, val pubDate: String?, val description: String?, val isbn: String?, @@ -22,15 +22,13 @@ data class BookDetailResponse private constructor( val mallType: String?, val stockStatus: String?, val mileage: Int?, - val cover: String?, + val cover: String, val categoryId: Int?, val categoryName: String?, - val publisher: String? + val publisher: String ) { companion object { - /** - * AladinBookDetailResponse와 Book 도메인 객체로부터 BookDetailResponse를 생성합니다. - */ + fun from(response: AladinBookDetailResponse): BookDetailResponse { val bookItem = response.item?.firstOrNull() ?: throw IllegalArgumentException("No book item found in detail response.") 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 new file mode 100644 index 00000000..b325bdaa --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/UserBookResponse.kt @@ -0,0 +1,39 @@ +package org.yapp.apis.book.dto.response + +import org.yapp.domain.userbook.BookStatus +import org.yapp.domain.userbook.UserBook +import java.time.format.DateTimeFormatter +import java.util.* + +data class UserBookResponse private constructor( + val userBookId: UUID, + val userId: UUID, + val bookIsbn: String, + val bookTitle: String, + val bookAuthor: String, + val status: BookStatus, + val coverImageUrl: String, + val publisher: String, + val createdAt: String, + val updatedAt: String, +) { + + companion object { + fun from( + userBook: UserBook, + ): UserBookResponse { + return UserBookResponse( + userBookId = userBook.id, + userId = userBook.userId, + bookIsbn = userBook.bookIsbn, + bookTitle = userBook.title, + bookAuthor = userBook.author, + status = userBook.status, + coverImageUrl = userBook.coverImageUrl, + publisher = 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/exception/BookErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/book/exception/BookErrorCode.kt new file mode 100644 index 00000000..b662c3b9 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/exception/BookErrorCode.kt @@ -0,0 +1,23 @@ +package org.yapp.apis.book.exception + +import org.springframework.http.HttpStatus +import org.yapp.globalutils.exception.BaseErrorCode + + +enum class BookErrorCode( + private val httpStatus: HttpStatus, + private val code: String, + private val message: String +) : BaseErrorCode { + + /* 404 NOT_FOUND */ + BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOK_001", "Book not found."), + + /* 500 INTERNAL_SERVER_ERROR */ + ALADIN_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "BOOK_002", "Error fetching book from external API."); + + + override fun getHttpStatus(): HttpStatus = httpStatus + override fun getCode(): String = code + override fun getMessage(): String = message +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/exception/BookException.kt b/apis/src/main/kotlin/org/yapp/apis/book/exception/BookException.kt new file mode 100644 index 00000000..a87d6b38 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/exception/BookException.kt @@ -0,0 +1,8 @@ +package org.yapp.apis.book.exception + +import org.yapp.globalutils.exception.CommonException + +class BookException( + errorCode: BookErrorCode, + message: String? = null +) : CommonException(errorCode, message) diff --git a/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt b/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt index c765003c..dd759b0f 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt @@ -22,7 +22,6 @@ class AladinApiHelper( } .getOrElse { exception -> log.error("Failed to call Aladin search API for request: '$request'", exception) - // TODO: 특정 비즈니스 예외로 맵핑하거나, 공통 예외 처리 계층에서 처리하도록 리팩토링 throw IllegalStateException( "Failed to retrieve search results from Aladin API: ${exception.message}", exception 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 new file mode 100644 index 00000000..5e119b14 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/BookManagementService.kt @@ -0,0 +1,27 @@ +package org.yapp.apis.book.service + +import org.springframework.stereotype.Service +import org.yapp.apis.book.dto.request.BookCreateRequest +import org.yapp.domain.book.Book +import org.yapp.domain.book.BookDomainService + +@Service +class BookManagementService( + private val bookDomainService: BookDomainService +) { + fun findOrCreateBook(request: BookCreateRequest): Book { + val isbn = request.validIsbn() + + return bookDomainService.findByIsbn(isbn) + ?: bookDomainService.save( + isbn = isbn, + title = request.validTitle(), + author = request.validAuthor(), + publisher = request.validPublisher(), + coverImageUrl = request.coverImageUrl, + publicationYear = request.publicationYear, + description = request.description + ) + } + +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/service/BookQueryService.kt b/apis/src/main/kotlin/org/yapp/apis/book/service/BookQueryService.kt new file mode 100644 index 00000000..01a8b89a --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/BookQueryService.kt @@ -0,0 +1,12 @@ +package org.yapp.apis.book.service + +import org.yapp.apis.book.dto.request.BookDetailRequest +import org.yapp.apis.book.dto.request.BookSearchRequest +import org.yapp.apis.book.dto.response.BookDetailResponse +import org.yapp.apis.book.dto.response.BookSearchResponse + + +interface BookQueryService { + fun searchBooks(request: BookSearchRequest): BookSearchResponse + fun getBookDetail(request: BookDetailRequest): BookDetailResponse +} 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 new file mode 100644 index 00000000..23972439 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt @@ -0,0 +1,18 @@ +package org.yapp.apis.book.service + +import org.springframework.stereotype.Service +import org.yapp.domain.book.Book +import org.yapp.domain.userbook.UserBookDomainService +import org.yapp.domain.userbook.BookStatus +import java.util.* + +@Service +class UserBookService( + private val userBookDomainService: UserBookDomainService +) { + fun upsertUserBook(userId: UUID, book: Book, status: BookStatus) = + userBookDomainService.upsertUserBook(userId, book, status) + + fun findAllUserBooks(userId: UUID) = + userBookDomainService.findAllUserBooks(userId) +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt b/apis/src/main/kotlin/org/yapp/apis/book/service/impl/AladinBookQueryService.kt similarity index 68% rename from apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt rename to apis/src/main/kotlin/org/yapp/apis/book/service/impl/AladinBookQueryService.kt index 76372795..4aa47c28 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/impl/AladinBookQueryService.kt @@ -1,32 +1,37 @@ -package org.yapp.apis.book.service +package org.yapp.apis.book.service.impl import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service import org.yapp.apis.book.dto.request.BookDetailRequest import org.yapp.apis.book.dto.request.BookSearchRequest import org.yapp.apis.book.dto.response.BookDetailResponse import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.apis.book.constant.BookQueryServiceQualifier +import org.yapp.apis.book.service.BookQueryService import org.yapp.infra.external.aladin.helper.AladinApiHelper import org.yapp.infra.external.aladin.response.AladinBookDetailResponse import org.yapp.infra.external.aladin.response.AladinSearchResponse @Service +@Qualifier(BookQueryServiceQualifier.ALADIN) class AladinBookQueryService( private val aladinApiHelper: AladinApiHelper, -) { +) : BookQueryService { private val log = KotlinLogging.logger {} - fun searchBooks(request: BookSearchRequest): BookSearchResponse { + override fun searchBooks(request: BookSearchRequest): BookSearchResponse { log.info("Service - Converting BookSearchRequest to AladinBookSearchRequest and calling Aladin API for book search.") val aladinSearchRequest = request.toAladinRequest() val response: AladinSearchResponse = aladinApiHelper.searchBooks(aladinSearchRequest) - return BookSearchResponse.from(response) + return BookSearchResponse.Companion.from(response) } - fun lookupBook(request: BookDetailRequest): BookDetailResponse { + + override fun getBookDetail(request: BookDetailRequest): BookDetailResponse { log.info("Service - Converting BookDetailRequest to AladinBookLookupRequest and calling Aladin API for book detail lookup.") val aladinLookupRequest = request.toAladinRequest() val aladinResponse: AladinBookDetailResponse = aladinApiHelper.lookupBook(aladinLookupRequest) - return BookDetailResponse.from(aladinResponse) + return BookDetailResponse.Companion.from(aladinResponse) } } 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 985cec8f..e0e2d713 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,23 +1,57 @@ package org.yapp.apis.book.usecase +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.transaction.annotation.Transactional -import org.yapp.apis.book.dto.request.BookDetailRequest // Import BookDetailRequest +import org.yapp.apis.auth.service.UserAuthService +import org.yapp.apis.book.dto.request.BookCreateRequest +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.service.AladinBookQueryService +import org.yapp.apis.book.dto.response.UserBookResponse +import org.yapp.apis.book.constant.BookQueryServiceQualifier +import org.yapp.apis.book.service.BookManagementService +import org.yapp.apis.book.service.BookQueryService +import org.yapp.apis.book.service.UserBookService import org.yapp.globalutils.annotation.UseCase +import java.util.UUID @UseCase @Transactional(readOnly = true) class BookUseCase( - private val aladinBookQueryService: AladinBookQueryService + private val userAuthService: UserAuthService, + private val userBookService: UserBookService, + @Qualifier(BookQueryServiceQualifier.ALADIN) + private val bookQueryService: BookQueryService, + private val bookManagementService: BookManagementService ) { fun searchBooks(request: BookSearchRequest): BookSearchResponse { - return aladinBookQueryService.searchBooks(request) + return bookQueryService.searchBooks(request) } - fun getBookDetail(request: BookDetailRequest): BookDetailResponse { - return aladinBookQueryService.lookupBook(request) + fun getBookDetail(bookDetailRequest: BookDetailRequest): BookDetailResponse { + return bookQueryService.getBookDetail(bookDetailRequest) + } + + @Transactional + fun upsertBookToMyLibrary(userId: UUID, request: UserBookRegisterRequest): UserBookResponse { + userAuthService.validateUserExists(userId) + + val detail = bookQueryService.getBookDetail(BookDetailRequest.of(request.validBookIsbn())) + + val book = bookManagementService.findOrCreateBook(BookCreateRequest.create(detail)) + + val userBook = userBookService.upsertUserBook(userId, book, request.bookStatus) + + return UserBookResponse.from(userBook) + } + + + fun getUserLibraryBooks(userId: UUID): List { + userAuthService.validateUserExists(userId) + + return userBookService.findAllUserBooks(userId) + .map(UserBookResponse::from) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/util/AuthUtils.kt b/apis/src/main/kotlin/org/yapp/apis/util/AuthUtils.kt deleted file mode 100644 index c86c17fd..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/util/AuthUtils.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.yapp.apis.util - -import java.util.* - -object AuthUtils { - fun extractUserIdFromAuthHeader( - authorizationHeader: String, - getUserId: (String) -> UUID - ): UUID { - val token = authorizationHeader.removePrefix("Bearer ").trim() - return getUserId(token) - } -} 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 827ade71..d22acef9 100644 --- a/domain/src/main/kotlin/org/yapp/domain/book/Book.kt +++ b/domain/src/main/kotlin/org/yapp/domain/book/Book.kt @@ -8,33 +8,25 @@ import java.time.LocalDateTime // Import LocalDateTime data class Book private constructor( val isbn: String, val title: String, - val author: String?, - val publisher: String?, + val author: String, + val publisher: String, val publicationYear: Int?, - val coverImageUrl: String?, + val coverImageUrl: String, val description: String?, - val createdAt: LocalDateTime, // Added createdAt - val updatedAt: LocalDateTime, // Added updatedAt - val deletedAt: LocalDateTime? = null // Added deletedAt + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val deletedAt: LocalDateTime? = null ) { - fun restore(): Book { - require(this.isDeleted()) { "Book is already active" } - return this.copy( - deletedAt = null, - updatedAt = LocalDateTime.now() - ) - } - fun isDeleted(): Boolean = deletedAt != null companion object { fun create( isbn: String, title: String, - author: String? = null, - publisher: String? = null, + author: String, + publisher: String, + coverImageUrl: String, publicationYear: Int? = null, - coverImageUrl: String? = null, description: String? = null ): Book { val now = LocalDateTime.now() @@ -55,10 +47,10 @@ data class Book private constructor( fun reconstruct( isbn: String, title: String, - author: String?, - publisher: String?, + author: String, + publisher: String, publicationYear: Int?, - coverImageUrl: String?, + coverImageUrl: String, description: String?, createdAt: LocalDateTime, updatedAt: LocalDateTime, diff --git a/domain/src/main/kotlin/org/yapp/domain/book/BookDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/book/BookDomainService.kt new file mode 100644 index 00000000..f8176b9c --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/book/BookDomainService.kt @@ -0,0 +1,38 @@ +package org.yapp.domain.book + +import org.yapp.globalutils.annotation.DomainService + +@DomainService +class BookDomainService( + private val bookRepository: BookRepository +) { + fun findByIsbn(isbn: String): Book? { + return bookRepository.findByIsbn(isbn) + } + + fun save( + isbn: String, + title: String, + author: String, + publisher: String, + coverImageUrl: String, + publicationYear: Int? = null, + description: String? = null + ): Book { + findByIsbn(isbn)?.let { + return it + } + + val book = Book.create( + isbn = isbn, + title = title, + author = author, + publisher = publisher, + coverImageUrl = coverImageUrl, + publicationYear = publicationYear, + description = description + ) + + return bookRepository.save(book) + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt b/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt index e3c0003a..0d531165 100644 --- a/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt +++ b/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt @@ -1,11 +1,6 @@ package org.yapp.domain.book -/** - * Repository interface for Book domain model. - */ interface BookRepository { - fun findByIsbn(isbn: String): Book? - fun save(book: Book): Book } diff --git a/domain/src/main/kotlin/org/yapp/domain/service/redis/TokenDomainRedisService.kt b/domain/src/main/kotlin/org/yapp/domain/token/TokenDomainRedisService.kt similarity index 89% rename from domain/src/main/kotlin/org/yapp/domain/service/redis/TokenDomainRedisService.kt rename to domain/src/main/kotlin/org/yapp/domain/token/TokenDomainRedisService.kt index a3401a73..fe816f56 100644 --- a/domain/src/main/kotlin/org/yapp/domain/service/redis/TokenDomainRedisService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/token/TokenDomainRedisService.kt @@ -1,10 +1,8 @@ -package org.yapp.domain.service.redis +package org.yapp.domain.token -import org.yapp.domain.token.RefreshToken -import org.yapp.domain.token.RefreshTokenRepository import org.yapp.globalutils.annotation.DomainService import java.time.LocalDateTime -import java.util.* +import java.util.UUID @DomainService class TokenDomainRedisService( diff --git a/domain/src/main/kotlin/org/yapp/domain/auth/ProviderType.kt b/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt similarity index 79% rename from domain/src/main/kotlin/org/yapp/domain/auth/ProviderType.kt rename to domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt index 677d39ff..4a8aad81 100644 --- a/domain/src/main/kotlin/org/yapp/domain/auth/ProviderType.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt @@ -1,4 +1,4 @@ -package org.yapp.domain.auth +package org.yapp.domain.user /** * Enum representing different authentication providers. 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 26a0019e..60aec5bd 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/User.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/User.kt @@ -1,6 +1,6 @@ package org.yapp.domain.user -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import java.time.LocalDateTime import java.util.* diff --git a/domain/src/main/kotlin/org/yapp/domain/service/domain/UserDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt similarity index 89% rename from domain/src/main/kotlin/org/yapp/domain/service/domain/UserDomainService.kt rename to domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt index 1ee87328..f66009e9 100644 --- a/domain/src/main/kotlin/org/yapp/domain/service/domain/UserDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/UserDomainService.kt @@ -1,12 +1,9 @@ -package org.yapp.domain.service.domain +package org.yapp.domain.user -import org.yapp.domain.auth.ProviderType -import org.yapp.domain.user.User -import org.yapp.domain.user.UserRepository import org.yapp.domain.user.vo.SocialUserProfile import org.yapp.globalutils.annotation.DomainService import org.yapp.globalutils.util.TimeProvider -import java.util.* +import java.util.UUID @DomainService class UserDomainService( @@ -48,4 +45,8 @@ class UserDomainService( fun save(user: User): User = userRepository.save(user) + + fun existsById(userId: UUID): Boolean { + return userRepository.existsById(userId) + } } diff --git a/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt b/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt index fbd40e04..a2f761a0 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/UserRepository.kt @@ -1,6 +1,6 @@ package org.yapp.domain.user -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import java.util.* /** @@ -17,4 +17,7 @@ interface UserRepository { fun findByProviderTypeAndProviderIdIncludingDeleted(providerType: ProviderType, providerId: String): User? fun save(user: User): User + + fun existsById(id: UUID): Boolean + } diff --git a/domain/src/main/kotlin/org/yapp/domain/user/vo/SocialUserProfile.kt b/domain/src/main/kotlin/org/yapp/domain/user/vo/SocialUserProfile.kt index 5b722764..4af4dcc3 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/vo/SocialUserProfile.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/vo/SocialUserProfile.kt @@ -1,6 +1,6 @@ package org.yapp.domain.user.vo -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import org.yapp.globalutils.util.RegexUtils data class SocialUserProfile private constructor( diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/BookStatus.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/BookStatus.kt new file mode 100644 index 00000000..6358bb3b --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/BookStatus.kt @@ -0,0 +1,7 @@ +package org.yapp.domain.userbook + +enum class BookStatus { + BEFORE_READING, // 읽기 전 + READING, // 읽는 중 + COMPLETED // 완독 +} diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt new file mode 100644 index 00000000..5d3ac971 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt @@ -0,0 +1,77 @@ +package org.yapp.domain.userbook // UserBook 도메인 모델의 올바른 패키지 + +import org.yapp.domain.book.Book +import org.yapp.globalutils.util.UuidGenerator +import java.time.LocalDateTime +import java.util.* + + +data class UserBook private constructor( + val id: UUID, + val userId: UUID, + val bookIsbn: String, + val coverImageUrl: String, + val publisher: String, + val title: String, + val author: String, + val status: BookStatus, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val deletedAt: LocalDateTime? = null, +) { + fun updateStatus(newStatus: BookStatus): UserBook { + return this.copy(status = newStatus, updatedAt = LocalDateTime.now()) + } + + + companion object { + fun create( + userId: UUID, + book: Book, + initialStatus: BookStatus = BookStatus.BEFORE_READING + ): UserBook { + val now = LocalDateTime.now() + return UserBook( + id = UuidGenerator.create(), + coverImageUrl = book.coverImageUrl, + publisher = book.publisher, + title = book.title, + author = book.author, + userId = userId, + bookIsbn = book.isbn, + status = initialStatus, + createdAt = now, + updatedAt = now, + deletedAt = null, + ) + } + + fun reconstruct( + id: UUID, + userId: UUID, + bookIsbn: String, + status: BookStatus, + coverImageUrl: String, + title: String, + author: String, + publisher: String, + createdAt: LocalDateTime, + updatedAt: LocalDateTime, + deletedAt: LocalDateTime? = null, + ): UserBook { + return UserBook( + id = id, + userId = userId, + bookIsbn = bookIsbn, + status = status, + coverImageUrl = coverImageUrl, + title = title, + author = author, + publisher = publisher, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, + ) + } + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt new file mode 100644 index 00000000..e8acaa4f --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt @@ -0,0 +1,26 @@ +package org.yapp.domain.userbook + +import org.yapp.domain.book.Book +import org.yapp.globalutils.annotation.DomainService +import java.util.UUID + +@DomainService +class UserBookDomainService( + private val userBookRepository: UserBookRepository +) { + + fun upsertUserBook(userId: UUID, book: Book, status: BookStatus): UserBook { + val existing = userBookRepository.findByUserIdAndBookIsbn(userId, book.isbn) + return if (existing != null) { + val updated = existing.updateStatus(status) + userBookRepository.save(updated) + } else { + val created = UserBook.create(userId, book, status) + userBookRepository.save(created) + } + } + + fun findAllUserBooks(userId: UUID): List { + return userBookRepository.findAllByUserId(userId) + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt new file mode 100644 index 00000000..c7f224b7 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookRepository.kt @@ -0,0 +1,13 @@ +package org.yapp.domain.userbook + +import java.util.UUID + + +interface UserBookRepository { + + fun findByUserIdAndBookIsbn(userId: UUID, isbn: String): UserBook? + + fun save(userBook: UserBook): UserBook + + fun findAllByUserId(userId: UUID): List +} 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 3839cb7f..a0b3b4b6 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 @@ -16,26 +16,41 @@ import java.sql.Types class BookEntity private constructor( @Id @JdbcTypeCode(Types.VARCHAR) - @Column(length = 13, updatable = false, nullable = false) // ISBN/ISBN13 length + @Column(length = 13, updatable = false, nullable = false) val isbn: String, + + title: String, + author: String, + publisher: String, + publicationYear: Int? = null, + coverImageUrl: String, + description: String? = null + +) : BaseTimeEntity() { + @Column(nullable = false, length = 255) - val title: String, + var title: String = title + protected set @Column(length = 255) - val author: String? = null, + var author: String = author + protected set @Column(length = 255) - val publisher: String? = null, + var publisher: String = publisher + protected set @Column(name = "publication_year") - val publicationYear: Int? = null, + var publicationYear: Int? = publicationYear + protected set @Column(name = "cover_image_url", length = 2048) - val coverImageUrl: String? = null, + var coverImageUrl: String = coverImageUrl + protected set @Column(length = 2000) - val description: String? = null -) : BaseTimeEntity() { + var description: String? = description + protected set fun toDomain(): Book = Book.reconstruct( isbn = isbn, diff --git a/infra/src/main/kotlin/org/yapp/infra/user/entity/UserBookEntity.kt b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserBookEntity.kt new file mode 100644 index 00000000..84a02046 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/user/entity/UserBookEntity.kt @@ -0,0 +1,96 @@ +package org.yapp.infra.user.entity + +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.annotations.SQLDelete +import org.yapp.domain.userbook.BookStatus +import org.yapp.domain.common.BaseTimeEntity +import org.yapp.domain.userbook.UserBook +import java.sql.Types +import java.util.* + +@Entity +@Table(name = "user_books") +@SQLDelete(sql = "UPDATE user_books SET deleted_at = NOW() WHERE id = ?") +class UserBookEntity( + @Id + @JdbcTypeCode(Types.VARCHAR) + @Column(length = 36, updatable = false, nullable = false) + val id: UUID, + + @Column(name = "user_id", nullable = false, length = 36) + @JdbcTypeCode(Types.VARCHAR) + val userId: UUID, + + @Column(name = "book_isbn", nullable = false) + val bookIsbn: String, + + coverImageUrl: String, + publisher: String, + title: String, + author: String, + status: BookStatus +) : BaseTimeEntity() { + + @Column(name = "cover_image_url", nullable = false, length = 2048) + var coverImageUrl: String = coverImageUrl + protected set + + @Column(name = "publisher", nullable = false, length = 300) + var publisher: String = publisher + protected set + + @Column(nullable = false, length = 255) + var title: String = title + protected set + + @Column(length = 255) + var author: String = author + protected set + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var status: BookStatus = status + protected set + + fun toDomain(): UserBook = UserBook.reconstruct( + id = id, + userId = userId, + bookIsbn = bookIsbn, + status = status, + coverImageUrl = coverImageUrl, + publisher = publisher, + title = title, + author = author, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UserBookEntity) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() + + companion object { + fun fromDomain(userBook: UserBook): UserBookEntity { + return UserBookEntity( + id = userBook.id, + userId = userBook.userId, + bookIsbn = userBook.bookIsbn, + status = userBook.status, + coverImageUrl = userBook.coverImageUrl, + publisher = userBook.publisher, + title = userBook.title, + author = userBook.author, + ).apply { + this.createdAt = userBook.createdAt + this.updatedAt = userBook.updatedAt + this.deletedAt = userBook.deletedAt + } + } + } +} 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 86e6261d..7cb4f728 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 @@ -3,7 +3,7 @@ package org.yapp.infra.user.entity import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import org.yapp.domain.common.BaseTimeEntity import org.yapp.domain.user.User import java.sql.Types diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserBookRepository.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserBookRepository.kt new file mode 100644 index 00000000..9214f00c --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserBookRepository.kt @@ -0,0 +1,10 @@ +package org.yapp.infra.user.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.yapp.infra.user.entity.UserBookEntity +import java.util.* + +interface JpaUserBookRepository : JpaRepository { + fun findByUserIdAndBookIsbn(userId: UUID, bookIsbn: String): UserBookEntity? + fun findAllByUserId(userId: UUID): List +} diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt index 52786ad7..4cc0906b 100644 --- a/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt +++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/JpaUserRepository.kt @@ -2,7 +2,7 @@ package org.yapp.infra.user.repository import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import org.yapp.infra.user.entity.UserEntity import java.util.* diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserBookRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserBookRepositoryImpl.kt new file mode 100644 index 00000000..6483af81 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserBookRepositoryImpl.kt @@ -0,0 +1,27 @@ +package org.yapp.infra.user.repository.impl + +import org.springframework.stereotype.Repository +import org.yapp.domain.userbook.UserBookRepository +import org.yapp.domain.userbook.UserBook +import org.yapp.infra.user.entity.UserBookEntity +import org.yapp.infra.user.repository.JpaUserBookRepository +import java.util.* + +@Repository +class UserBookRepositoryImpl( + private val jpaUserBookRepository: JpaUserBookRepository +) : UserBookRepository { + + override fun findByUserIdAndBookIsbn(userId: UUID, isbn: String): UserBook? { + return jpaUserBookRepository.findByUserIdAndBookIsbn(userId, isbn)?.toDomain() + + } + + override fun save(userBook: UserBook): UserBook { + return jpaUserBookRepository.save(UserBookEntity.fromDomain(userBook)).toDomain() + } + + override fun findAllByUserId(userId: UUID): List { + return jpaUserBookRepository.findAllByUserId(userId).map { it.toDomain() } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt index 7854594c..8866d24b 100644 --- a/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt @@ -1,7 +1,7 @@ package org.yapp.infra.user.repository.impl import org.springframework.stereotype.Repository -import org.yapp.domain.auth.ProviderType +import org.yapp.domain.user.ProviderType import org.yapp.domain.user.User import org.yapp.domain.user.UserRepository import org.yapp.infra.user.entity.UserEntity @@ -33,6 +33,10 @@ class UserRepositoryImpl( }.toDomain() } + override fun existsById(id: UUID): Boolean { + return jpaUserRepository.existsById(id) + } + override fun findByProviderTypeAndProviderIdIncludingDeleted( providerType: ProviderType, providerId: String ): User? {