From 37c5a8d15ca530ec8f1f6433a42f3e8556107a2a Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Sat, 13 Dec 2025 22:06:32 +0900 Subject: [PATCH 1/8] feat: apis,infra google social login (#141) (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BOOK-469] feat: apis,infra google social login (#141) * [BOOK-469] fix(ci): ci 수정 - 테스트 프로필에 oauth 설정 추가 - 로컬에서 sonar task 스킵되도록 수정 * [BOOK-469] fix: apis,infra - 구글 소셜로그인 리팩토링(#142) --- .../auth/dto/request/SocialLoginRequest.kt | 3 + .../apis/auth/manager/GoogleApiManager.kt | 40 ++++++ .../strategy/signin/GoogleSignInStrategy.kt | 53 ++++++++ .../auth/strategy/signin/SignInCredentials.kt | 13 +- .../yapp/apis/config/GoogleOauthProperties.kt | 12 ++ .../org/yapp/apis/config/PropertiesConfig.kt | 8 ++ apis/src/main/resources/application.yml | 10 ++ .../main/resources/static/kakao-login.html | 115 +++++++++++++----- build.gradle.kts | 1 + .../org/yapp/domain/user/ProviderType.kt | 2 +- infra/build.gradle.kts | 3 + .../infra/external/oauth/google/GoogleApi.kt | 22 ++++ .../external/oauth/google/GoogleRestClient.kt | 24 ++++ .../oauth/google/response/GoogleUserInfo.kt | 12 ++ 14 files changed, 285 insertions(+), 33 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt 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 83e2c3ff..9b17c05c 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 @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException import org.yapp.apis.auth.strategy.signin.AppleAuthCredentials +import org.yapp.apis.auth.strategy.signin.GoogleAuthCredentials import org.yapp.apis.auth.strategy.signin.KakaoAuthCredentials import org.yapp.apis.auth.strategy.signin.SignInCredentials import org.yapp.domain.user.ProviderType @@ -61,6 +62,8 @@ data class SocialLoginRequest private constructor( ) AppleAuthCredentials(request.validOauthToken(), authCode) } + + ProviderType.GOOGLE -> GoogleAuthCredentials(request.validOauthToken()) } } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt b/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt new file mode 100644 index 00000000..f6904908 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/manager/GoogleApiManager.kt @@ -0,0 +1,40 @@ +package org.yapp.apis.auth.manager + +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.springframework.web.client.HttpClientErrorException +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.config.GoogleOauthProperties +import org.yapp.infra.external.oauth.google.GoogleApi +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleApiManager( + private val googleApi: GoogleApi, + private val googleOauthProperties: GoogleOauthProperties, +) { + private val log = KotlinLogging.logger {} + + fun getUserInfo(accessToken: String): GoogleUserInfo { + return googleApi.fetchUserInfo(accessToken, googleOauthProperties.url.userInfo) + .onSuccess { userInfo -> + log.info { "Successfully fetched Google user info for userId: ${userInfo.id}" } + } + .getOrElse { exception -> + log.error(exception) { "Failed to fetch Google user info" } + + when (exception) { + is HttpClientErrorException -> throw AuthException( + AuthErrorCode.INVALID_OAUTH_TOKEN, + "Invalid Google Access Token.", + ) + + else -> throw AuthException( + AuthErrorCode.OAUTH_SERVER_ERROR, + "Failed to communicate with Google server.", + ) + } + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt new file mode 100644 index 00000000..f91f956b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt @@ -0,0 +1,53 @@ +package org.yapp.apis.auth.strategy.signin + +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.yapp.apis.auth.dto.response.UserCreateInfoResponse +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.auth.manager.GoogleApiManager +import org.yapp.apis.auth.util.NicknameGenerator +import org.yapp.domain.user.ProviderType +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleSignInStrategy( + private val googleApiManager: GoogleApiManager +) : SignInStrategy { + + private val log = KotlinLogging.logger {} + + override fun getProviderType(): ProviderType = ProviderType.GOOGLE + + override fun authenticate(credentials: SignInCredentials): UserCreateInfoResponse { + return try { + val googleCredentials = validateCredentials(credentials) + val googleUser = googleApiManager.getUserInfo(googleCredentials.accessToken) + createUserInfo(googleUser) + } catch (exception: Exception) { + log.error("Google authentication failed", exception) + when (exception) { + is AuthException -> throw exception + else -> throw AuthException(AuthErrorCode.FAILED_TO_GET_USER_INFO, exception.message) + } + } + } + + private fun validateCredentials(credentials: SignInCredentials): GoogleAuthCredentials { + return credentials as? GoogleAuthCredentials + ?: throw AuthException( + AuthErrorCode.INVALID_CREDENTIALS, + "Credentials must be GoogleAuthCredentials" + ) + } + + private fun createUserInfo(googleUser: GoogleUserInfo): UserCreateInfoResponse { + return UserCreateInfoResponse.of( + email = googleUser.email ?: ("google_${googleUser.id}@google.com"), + nickname = NicknameGenerator.generate(), + profileImageUrl = googleUser.picture, + providerType = ProviderType.GOOGLE, + providerId = googleUser.id + ) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt index a274481c..ba921e8f 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt @@ -7,14 +7,23 @@ sealed class SignInCredentials { } data class KakaoAuthCredentials( - val accessToken: String + val accessToken: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType = ProviderType.KAKAO } data class AppleAuthCredentials( val idToken: String, - val authorizationCode: String + val authorizationCode: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType = ProviderType.APPLE } + +data class GoogleAuthCredentials( + val accessToken: String, +) : SignInCredentials() { + override fun getProviderType(): ProviderType { + return ProviderType.GOOGLE + } +} + diff --git a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt new file mode 100644 index 00000000..4e59c83b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt @@ -0,0 +1,12 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.google") +data class GoogleOauthProperties( + val url: Url +) + +data class Url( + val userInfo: String +) diff --git a/apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt new file mode 100644 index 00000000..13bbe364 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/PropertiesConfig.kt @@ -0,0 +1,8 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(GoogleOauthProperties::class) +class PropertiesConfig diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index dcd73de6..aaa824b0 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -61,6 +61,11 @@ swagger: description: YAPP API Documentation for Development version: v1.0.0-dev +oauth: + google: + url: + user-info: https://www.googleapis.com/oauth2/v2/userinfo + --- spring: config: @@ -85,3 +90,8 @@ springdoc: enabled: false api-docs: enabled: false + +oauth: + google: + url: + user-info: https://www.googleapis.com/oauth2/v2/userinfo diff --git a/apis/src/main/resources/static/kakao-login.html b/apis/src/main/resources/static/kakao-login.html index 1c63c4d9..c5994710 100644 --- a/apis/src/main/resources/static/kakao-login.html +++ b/apis/src/main/resources/static/kakao-login.html @@ -53,38 +53,50 @@ color: #000; } - .apple-btn { - background-color: #000; - color: #fff; - } - - - - -

소셜 로그인 테스트

- -
-
카카오 로그인
-
애플 로그인
-
- -
-

카카오 계정으로 로그인하려면 아래 버튼을 클릭하세요.

-
- -
-
- -
-

애플 계정으로 로그인하려면 아래 버튼을 클릭하세요.

-
- -
-
- + .apple-btn { + background-color: #000; + color: #fff; + } + .google-btn { + background-color: #4285F4; + color: #fff; + } + + + + +

소셜 로그인 테스트

+ +
+
카카오 로그인
+
애플 로그인
+
구글 로그인
+
+ +
+

카카오 계정으로 로그인하려면 아래 버튼을 클릭하세요.

+
+ +
+
+ +
+

애플 계정으로 로그인하려면 아래 버튼을 클릭하세요.

+
+ +
+
+ +
+

구글 계정으로 로그인하려면 아래 버튼을 클릭하세요.

+
+ +
+

 
+
 
 
 
@@ -93,6 +105,26 @@ 

소셜 로그인 테스트

src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"> + // Google Sign-In Initialization + var GoogleAuth; // GoogleAuth object + + function initGoogleAuth() { + gapi.client.init({ + clientId: document.querySelector('meta[name="google-signin-client_id"]').content, + scope: 'profile email' + }).then(function () { + GoogleAuth = gapi.auth2.getAuthInstance(); + // Attach the click listener to the Google login button + document.getElementById('google-login-btn').addEventListener('click', () => { + GoogleAuth.signIn().then(onSignIn, (error) => { + console.error('Google Sign-In failed:', error); + document.getElementById('result').textContent = 'Google 로그인 실패: ' + JSON.stringify(error); + }); + }); + }); + } + + function handleGoogleClientLoad() { + gapi.load('client:auth2', initGoogleAuth); + } + + diff --git a/build.gradle.kts b/build.gradle.kts index d6f4ce65..6ca01291 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -192,6 +192,7 @@ sonar { // SonarQube 태스크가 통합 JaCoCo 리포트에 의존하도록 설정 tasks.named("sonar") { dependsOn("jacocoRootReport") + onlyIf { System.getenv("SONAR_TOKEN") != null } } /** diff --git a/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt b/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt index 4a8aad81..ba085c91 100644 --- a/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt +++ b/domain/src/main/kotlin/org/yapp/domain/user/ProviderType.kt @@ -4,5 +4,5 @@ package org.yapp.domain.user * Enum representing different authentication providers. */ enum class ProviderType { - KAKAO, APPLE + KAKAO, APPLE, GOOGLE } diff --git a/infra/build.gradle.kts b/infra/build.gradle.kts index 68634557..bdc24c85 100644 --- a/infra/build.gradle.kts +++ b/infra/build.gradle.kts @@ -8,6 +8,9 @@ dependencies { implementation(Dependencies.Spring.BOOT_STARTER_DATA_REDIS) implementation(Dependencies.Spring.KOTLIN_REFLECT) + + implementation(Dependencies.Spring.BOOT_STARTER_OAUTH2_CLIENT) + implementation(Dependencies.RestClient.HTTP_CLIENT5) implementation(Dependencies.RestClient.HTTP_CORE5) diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt new file mode 100644 index 00000000..c1dc5726 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleApi.kt @@ -0,0 +1,22 @@ +package org.yapp.infra.external.oauth.google + +import org.springframework.stereotype.Component +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleApi( + private val googleRestClient: GoogleRestClient +) { + companion object { + private const val BEARER_PREFIX = "Bearer " + } + + fun fetchUserInfo( + accessToken: String, + userInfoUrl: String, + ): Result { + return runCatching { + googleRestClient.getUserInfo(BEARER_PREFIX + accessToken, userInfoUrl) + } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt new file mode 100644 index 00000000..773b8e23 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/GoogleRestClient.kt @@ -0,0 +1,24 @@ +package org.yapp.infra.external.oauth.google + +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.yapp.infra.external.oauth.google.response.GoogleUserInfo + +@Component +class GoogleRestClient( + builder: RestClient.Builder +) { + private val client = builder.build() + + fun getUserInfo( + bearerToken: String, + url: String, + ): GoogleUserInfo { + return client.get() + .uri(url) + .header("Authorization", bearerToken) + .retrieve() + .body(GoogleUserInfo::class.java) + ?: throw IllegalStateException("Google API 응답이 null 입니다.") + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt new file mode 100644 index 00000000..7f938491 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/google/response/GoogleUserInfo.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.external.oauth.google.response + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GoogleUserInfo( + @JsonProperty("id") + val id: String, + @JsonProperty("email") + val email: String?, + @JsonProperty("picture") + val picture: String?, +) From 20e8e65d9b88ce18a517bf273cd1e95a6a5867ff Mon Sep 17 00:00:00 2001 From: DONGHOON LEE <125895298+move-hoon@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:22:58 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EB=8F=85=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20V2=20API=20=EB=B0=8F=20=EB=8C=80=EB=B6=84=EB=A5=98/?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EA=B0=90=EC=A0=95=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=AC=ED=98=84=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BOOK-480] refactor: apis - emotionTags는 빈 리스트도 허용이 되기 때문에 valid 패턴 제거 * [BOOK-480] feat: infra - 감정 시스템 DB 스키마 마이그레이션 추가 * [BOOK-480] feat: domain - PrimaryEmotion 대분류 감정 enum 추가 * [BOOK-480] feat: domain - DetailTag 세부감정 도메인 및 Repository 인터페이스 추가 * [BOOK-480] feat: domain - ReadingRecordDetailTag 연결 도메인 및 Repository 인터페이스 추가 * [BOOK-480] feat: infra - DetailTag 엔티티 및 Repository 구현체 추가 * [BOOK-480] feat: infra - ReadingRecordDetailTag 엔티티 및 Repository 구현체 추가 * [BOOK-480] feat: domain, infra - ReadingRecord에 primaryEmotion 필드 추가 * [BOOK-480] feat: domain - ReadingRecordInfoVO에 V2용 detailEmotions 필드 추가 * [BOOK-480] feat: domain - DetailTagDomainService 추가 * [BOOK-480] feat: domain - ReadingRecordDetailTagDomainService 추가 * [BOOK-480] refactor: domain - ReadingRecordDomainService V2 메서드 추가 및 의존성 분리 * [BOOK-480] feat: apis - V2 Request/Response DTO 추가 * [BOOK-480] feat: apis - ReadingRecordServiceV2 ApplicationService 추가 * [BOOK-480] feat: apis - ReadingRecordUseCaseV2 유스케이스 추가 * [BOOK-480] feat: apis - ReadingRecordControllerV2 컨트롤러 추가 * [BOOK-480] feat: apis - EmotionController 감정 목록 조회 API 추가 * [BOOK-480] fix: apis - V1 ReadingRecordResponse pageNumber nullable 호환 수정 * [BOOK-480] refactor: apis - private constructor 및 정적 팩토리 메서드로 패턴 변경 * [BOOK-480] fix: apis - 독서 기록 목록 조회 (V2) 명세 오류 수정 * [BOOK-480] fix: apis - 누락된 NotNull 어노테이션 추가 * [BOOK-480] chore: apis - pageNumber가 선택 사항임을 명시 * [BOOK-480] fix: apis - primaryEmotion 변경 시 detailEmotion 데이터 일관성 보장 변경 사항: - primaryEmotion 변경 + 새 detailTags 제공 → 새 태그로 교체 - primaryEmotion 변경 + detailTags 미제공 → 기존 태그 삭제 (불일치 방지) - primaryEmotion 유지 + 새 detailTags 제공 → 새 태그로 교체 - primaryEmotion 유지 + detailTags 미제공 → 기존 태그 유지 리팩토링: - updateDetailEmotionTags() private 메서드로 분리 - isPrimaryEmotionChanged() 헬퍼 메서드 추가 * [BOOK-480] refactor: apis - 독시 기록에 대한 소유권 검증 로직 추가 * [BOOK-480] refactor: apis - readingRecords를 불변 리스트로 변경 * [BOOK-480] chore: apis - 403 에러 명세 추가 * [BOOK-480] refactor: apis - 기존 단언 패턴으로 valid 메서드 구현방식 변경 * [BOOK-480] chore: apis - Void 타입 대신 Kotlin의 Unit 타입으로 변경 * [BOOK-480] refactor: apis - 코드레빗 리뷰 반영 --- .../apis/book/exception/UserBookErrorCode.kt | 3 +- .../yapp/apis/book/service/UserBookService.kt | 4 +- .../emotion/controller/EmotionController.kt | 21 ++ .../controller/EmotionControllerApi.kt | 33 +++ .../dto/response/EmotionListResponse.kt | 72 ++++++ .../apis/emotion/service/EmotionService.kt | 16 ++ .../ReadingRecordControllerApiV2.kt | 175 ++++++++++++++ .../controller/ReadingRecordControllerV2.kt | 90 +++++++ .../dto/request/CreateReadingRecordRequest.kt | 2 - .../request/CreateReadingRecordRequestV2.kt | 50 ++++ .../request/UpdateReadingRecordRequestV2.kt | 34 +++ .../dto/response/ReadingRecordResponse.kt | 7 +- .../dto/response/ReadingRecordResponseV2.kt | 108 +++++++++ .../service/ReadingRecordService.kt | 15 +- .../service/ReadingRecordServiceV2.kt | 221 ++++++++++++++++++ .../service/ReadingRecordTagService.kt | 2 + .../usecase/ReadingRecordUseCase.kt | 13 +- .../usecase/ReadingRecordUseCaseV2.kt | 91 ++++++++ .../org/yapp/domain/detailtag/DetailTag.kt | 58 +++++ .../detailtag/DetailTagDomainService.kt | 27 +++ .../domain/detailtag/DetailTagRepository.kt | 13 ++ .../domain/readingrecord/PrimaryEmotion.kt | 17 ++ .../domain/readingrecord/ReadingRecord.kt | 15 +- .../ReadingRecordDomainService.kt | 105 ++++++++- .../readingrecord/vo/ReadingRecordInfoVO.kt | 17 +- .../ReadingRecordDetailTag.kt | 55 +++++ .../ReadingRecordDetailTagDomainService.kt | 34 +++ .../ReadingRecordDetailTagRepository.kt | 11 + .../infra/detailtag/entity/DetailTagEntity.kt | 73 ++++++ .../repository/DetailTagRepositoryImpl.kt | 45 ++++ .../repository/JpaDetailTagRepository.kt | 12 + .../entity/ReadingRecordEntity.kt | 23 +- .../entity/ReadingRecordDetailTagEntity.kt | 73 ++++++ .../JpaReadingRecordDetailTagRepository.kt | 12 + .../ReadingRecordDetailTagRepositoryImpl.kt | 39 ++++ ...1__add_primary_emotion_and_detail_tags.sql | 85 +++++++ 36 files changed, 1639 insertions(+), 32 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt create mode 100644 infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql diff --git a/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt index 4530c3ef..568780ce 100644 --- a/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/book/exception/UserBookErrorCode.kt @@ -8,8 +8,7 @@ enum class UserBookErrorCode( private val code: String, private val message: String ) : BaseErrorCode { - - USER_BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_BOOK_404_01", "사용자의 책을 찾을 수 없습니다."); + USER_BOOK_ACCESS_DENIED(HttpStatus.FORBIDDEN, "USER_BOOK_403_01", "해당 책에 대한 접근 권한이 없습니다."); override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code 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 742ad5cc..870bec4c 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 @@ -43,8 +43,8 @@ class UserBookService( fun validateUserBookExists(userBookId: UUID, userId: UUID) { if (!userBookDomainService.existsByUserBookIdAndUserId(userBookId, userId)) { throw UserBookException( - UserBookErrorCode.USER_BOOK_NOT_FOUND, - "UserBook not found or access denied: $userBookId" + UserBookErrorCode.USER_BOOK_ACCESS_DENIED, + "UserBook access denied: $userBookId" ) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt new file mode 100644 index 00000000..42cb80e3 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt @@ -0,0 +1,21 @@ +package org.yapp.apis.emotion.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.yapp.apis.emotion.dto.response.EmotionListResponse +import org.yapp.apis.emotion.service.EmotionService + +@RestController +@RequestMapping("/api/v2/emotions") +class EmotionController( + private val emotionService: EmotionService +) : EmotionControllerApi { + + @GetMapping + override fun getEmotions(): ResponseEntity { + val response = emotionService.getEmotionList() + return ResponseEntity.ok(response) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt new file mode 100644 index 00000000..4651d31d --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt @@ -0,0 +1,33 @@ +package org.yapp.apis.emotion.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.yapp.apis.emotion.dto.response.EmotionListResponse + +@Tag(name = "Emotions", description = "감정 관련 API") +@RequestMapping("/api/v2/emotions") +interface EmotionControllerApi { + + @Operation( + summary = "감정 목록 조회", + description = "대분류 감정과 세부 감정 목록을 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "감정 목록 조회 성공", + content = [Content(schema = Schema(implementation = EmotionListResponse::class))] + ) + ] + ) + @GetMapping + fun getEmotions(): ResponseEntity +} diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt new file mode 100644 index 00000000..353cc947 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt @@ -0,0 +1,72 @@ +package org.yapp.apis.emotion.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.UUID + +@Schema(name = "EmotionListResponse", description = "감정 목록 응답") +data class EmotionListResponse private constructor( + @field:Schema(description = "감정 그룹 목록") + val emotions: List +) { + companion object { + fun from(detailTags: List): EmotionListResponse { + val grouped = detailTags.groupBy { it.primaryEmotion } + + val emotions = PrimaryEmotion.entries.map { primary -> + EmotionGroupDto.of( + code = primary.name, + displayName = primary.displayName, + detailEmotions = grouped[primary] + ?.sortedBy { it.displayOrder } + ?.map { EmotionDetailDto.of(id = it.id.value, name = it.name) } + ?: emptyList() + ) + } + + return EmotionListResponse(emotions = emotions) + } + } + + @Schema(name = "EmotionGroupDto", description = "감정 그룹 (대분류 + 세부감정)") + data class EmotionGroupDto private constructor( + @field:Schema(description = "대분류 코드", example = "JOY") + val code: String, + + @field:Schema(description = "대분류 표시 이름", example = "즐거움") + val displayName: String, + + @field:Schema(description = "세부 감정 목록") + val detailEmotions: List + ) { + companion object { + fun of( + code: String, + displayName: String, + detailEmotions: List + ): EmotionGroupDto { + return EmotionGroupDto( + code = code, + displayName = displayName, + detailEmotions = detailEmotions + ) + } + } + } + + @Schema(name = "EmotionDetailDto", description = "세부 감정") + data class EmotionDetailDto private constructor( + @field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "세부 감정 이름", example = "설레는") + val name: String + ) { + companion object { + fun of(id: UUID, name: String): EmotionDetailDto { + return EmotionDetailDto(id = id, name = name) + } + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt new file mode 100644 index 00000000..41c485d2 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt @@ -0,0 +1,16 @@ +package org.yapp.apis.emotion.service + +import org.yapp.apis.emotion.dto.response.EmotionListResponse +import org.yapp.domain.detailtag.DetailTagDomainService +import org.yapp.globalutils.annotation.ApplicationService + +@ApplicationService +class EmotionService( + private val detailTagDomainService: DetailTagDomainService +) { + fun getEmotionList(): EmotionListResponse { + val detailTags = detailTagDomainService.findAll() + return EmotionListResponse.from(detailTags) + } +} + diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt new file mode 100644 index 00000000..e4497382 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt @@ -0,0 +1,175 @@ +package org.yapp.apis.readingrecord.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.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.* +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.globalutils.exception.ErrorResponse +import java.util.* + +@Tag(name = "Reading Records V2", description = "독서 기록 관련 API (V2)") +@RequestMapping("/api/v2/reading-records") +interface ReadingRecordControllerApiV2 { + + @Operation( + summary = "독서 기록 생성 (V2)", + description = "사용자의 책에 대한 독서 기록을 생성합니다. 대분류 감정은 필수, 세부 감정은 선택입니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "독서 기록 생성 성공", + content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "403", + description = "해당 책에 대한 접근 권한이 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 책을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @PostMapping("/{userBookId}") + fun createReadingRecord( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "독서 기록을 생성할 사용자 책 ID") userBookId: UUID, + @Valid @RequestBody @Parameter(description = "독서 기록 생성 요청 객체") request: CreateReadingRecordRequestV2 + ): ResponseEntity + + @Operation( + summary = "독서 기록 상세 조회 (V2)", + description = "독서 기록 ID로 독서 기록 상세 정보를 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "독서 기록 상세 조회 성공", + content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))] + ), + ApiResponse( + responseCode = "403", + description = "해당 독서 기록에 대한 접근 권한이 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 독서 기록을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @GetMapping("/detail/{readingRecordId}") + fun getReadingRecordDetail( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "조회할 독서 기록 ID") readingRecordId: UUID + ): ResponseEntity + + @Operation( + summary = "독서 기록 목록 조회 (V2)", + description = "사용자의 책에 대한 독서 기록을 페이징하여 조회합니다. sort 파라미터가 지정된 경우 해당 정렬이 우선 적용되며, 지정하지 않으면 기본 정렬(updatedAt DESC)이 적용됩니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "독서 기록 목록 조회 성공" + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 책을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @GetMapping("/{userBookId}") + fun getReadingRecords( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "독서 기록을 조회할 사용자 책 ID") userBookId: UUID, + @RequestParam(required = false) @Parameter( + description = "정렬 타입 (PAGE_NUMBER_ASC, PAGE_NUMBER_DESC, CREATED_DATE_ASC, CREATED_DATE_DESC, UPDATED_DATE_ASC, UPDATED_DATE_DESC). 지정 시 Pageable의 sort보다 우선 적용됨" + ) sort: ReadingRecordSortType?, + @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC) + @Parameter(description = "페이지네이션 정보 (기본값: 10개). 정렬은 sort 파라미터로 제어되며, Pageable의 sort는 무시됩니다.") pageable: Pageable + ): ResponseEntity> + + @Operation( + summary = "독서 기록 수정 (V2)", + description = "독서 기록을 수정합니다. 대분류 감정과 세부 감정을 변경할 수 있습니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "독서 기록 수정 성공", + content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))] + ), + ApiResponse( + responseCode = "403", + description = "해당 독서 기록에 대한 접근 권한이 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "독서 기록을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @PutMapping("/{readingRecordId}") + fun updateReadingRecord( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "수정할 독서 기록 ID") readingRecordId: UUID, + @Valid @RequestBody @Parameter(description = "독서 기록 수정 요청 객체") request: UpdateReadingRecordRequestV2 + ): ResponseEntity + + @Operation( + summary = "독서 기록 삭제", + description = "독서 기록을 삭제합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "독서 기록 삭제 성공"), + ApiResponse( + responseCode = "403", + description = "해당 독서 기록에 대한 접근 권한이 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "독서 기록을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @DeleteMapping("/{readingRecordId}") + fun deleteReadingRecord( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "삭제할 독서 기록 ID") readingRecordId: UUID + ): ResponseEntity +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt new file mode 100644 index 00000000..5e5b507f --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt @@ -0,0 +1,90 @@ +package org.yapp.apis.readingrecord.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.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.usecase.ReadingRecordUseCaseV2 +import org.yapp.domain.readingrecord.ReadingRecordSortType +import java.util.UUID + +@RestController +@RequestMapping("/api/v2/reading-records") +class ReadingRecordControllerV2( + private val readingRecordUseCaseV2: ReadingRecordUseCaseV2 +) : ReadingRecordControllerApiV2 { + + @PostMapping("/{userBookId}") + override fun createReadingRecord( + @AuthenticationPrincipal userId: UUID, + @PathVariable userBookId: UUID, + @Valid @RequestBody request: CreateReadingRecordRequestV2 + ): ResponseEntity { + val response = readingRecordUseCaseV2.createReadingRecord( + userId = userId, + userBookId = userBookId, + request = request + ) + return ResponseEntity.status(HttpStatus.CREATED).body(response) + } + + @GetMapping("/detail/{readingRecordId}") + override fun getReadingRecordDetail( + @AuthenticationPrincipal userId: UUID, + @PathVariable readingRecordId: UUID + ): ResponseEntity { + val response = readingRecordUseCaseV2.getReadingRecordDetail( + userId = userId, + readingRecordId = readingRecordId + ) + return ResponseEntity.ok(response) + } + + @GetMapping("/{userBookId}") + override fun getReadingRecords( + @AuthenticationPrincipal userId: UUID, + @PathVariable userBookId: UUID, + @RequestParam(required = false) sort: ReadingRecordSortType?, + @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC) + pageable: Pageable + ): ResponseEntity> { + val response = readingRecordUseCaseV2.getReadingRecordsByUserBookId( + userId = userId, + userBookId = userBookId, + sort = sort, + pageable = pageable + ) + return ResponseEntity.ok(response) + } + + @PutMapping("/{readingRecordId}") + override fun updateReadingRecord( + @AuthenticationPrincipal userId: UUID, + @PathVariable readingRecordId: UUID, + @Valid @RequestBody request: UpdateReadingRecordRequestV2 + ): ResponseEntity { + val response = readingRecordUseCaseV2.updateReadingRecord( + userId = userId, + readingRecordId = readingRecordId, + request = request + ) + return ResponseEntity.ok(response) + } + + @DeleteMapping("/{readingRecordId}") + override fun deleteReadingRecord( + @AuthenticationPrincipal userId: UUID, + @PathVariable readingRecordId: UUID + ): ResponseEntity { + readingRecordUseCaseV2.deleteReadingRecord(userId, readingRecordId) + return ResponseEntity.noContent().build() + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt index 9f58eaf6..1f4a7ddf 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt @@ -43,6 +43,4 @@ data class CreateReadingRecordRequest private constructor( fun validQuote(): String = requireNotNull(quote) { "quote는 null일 수 없습니다." } - - fun validEmotionTags(): List = emotionTags } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt new file mode 100644 index 00000000..2b11cd40 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt @@ -0,0 +1,50 @@ +package org.yapp.apis.readingrecord.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.UUID + +@Schema( + name = "CreateReadingRecordRequestV2", + description = "독서 기록 생성 요청 (V2)", + example = """ + { + "pageNumber": 42, + "quote": "이것은 기억에 남는 문장입니다.", + "review": "이 책은 매우 인상적이었습니다.", + "primaryEmotion": "JOY", + "detailEmotionTagIds": ["uuid-1", "uuid-2"] + } + """ +) +data class CreateReadingRecordRequestV2 private constructor( + + @field:Min(1, message = "페이지 번호는 1 이상이어야 합니다.") + @field:Max(9999, message = "페이지 번호는 9999 이하여야 합니다.") + @field:Schema(description = "현재 읽은 페이지 번호", example = "42", required = false) + val pageNumber: Int? = null, + + @field:NotBlank(message = "기억에 남는 문장은 필수입니다.") + @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.", required = true) + val quote: String? = null, + + @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "감상평", example = "이 책은 매우 인상적이었습니다.", required = false) + val review: String? = null, + + @field:NotNull(message = "대분류 감정은 필수입니다.") + @field:Schema(description = "대분류 감정", example = "JOY", required = true) + val primaryEmotion: PrimaryEmotion? = null, + + @field:Schema(description = "세부 감정 태그 ID 목록 (선택, 다중 선택 가능)", example = "[\"uuid-1\", \"uuid-2\"]") + val detailEmotionTagIds: List = emptyList() +) { + fun validQuote(): String = quote!! + fun validPrimaryEmotion(): PrimaryEmotion = primaryEmotion!! +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt new file mode 100644 index 00000000..6547ad37 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt @@ -0,0 +1,34 @@ +package org.yapp.apis.readingrecord.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Size +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.UUID + +@Schema( + name = "UpdateReadingRecordRequestV2", + description = "독서 기록 수정 요청 (V2)" +) +data class UpdateReadingRecordRequestV2 private constructor( + + @field:Min(1, message = "페이지 번호는 1 이상이어야 합니다.") + @field:Max(9999, message = "페이지 번호는 9999 이하여야 합니다.") + @field:Schema(description = "현재 읽은 페이지 번호", example = "42") + val pageNumber: Int? = null, + + @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.") + val quote: String? = null, + + @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "감상평", example = "이 책은 매우 인상적이었습니다.") + val review: String? = null, + + @field:Schema(description = "대분류 감정", example = "JOY") + val primaryEmotion: PrimaryEmotion? = null, + + @field:Schema(description = "세부 감정 태그 ID 목록 (null이면 변경하지 않음)") + val detailEmotionTagIds: List? = null +) diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt index 6107182a..27cc2a79 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt @@ -17,8 +17,8 @@ data class ReadingRecordResponse private constructor( @field:Schema(description = "사용자 책 ID", example = "123e4567-e89b-12d3-a456-426614174000") val userBookId: UUID, - @field:Schema(description = "현재 읽은 페이지 번호", example = "42") - val pageNumber: Int, + @field:Schema(description = "현재 읽은 페이지 번호 (선택)", example = "42") + val pageNumber: Int?, @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.") val quote: String, @@ -54,7 +54,7 @@ data class ReadingRecordResponse private constructor( return ReadingRecordResponse( id = readingRecordInfoVO.id.value, userBookId = readingRecordInfoVO.userBookId.value, - pageNumber = readingRecordInfoVO.pageNumber.value, + pageNumber = readingRecordInfoVO.pageNumber?.value, quote = readingRecordInfoVO.quote.value, review = readingRecordInfoVO.review?.value, emotionTags = readingRecordInfoVO.emotionTags, @@ -68,3 +68,4 @@ data class ReadingRecordResponse private constructor( } } } + diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt new file mode 100644 index 00000000..a379a002 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt @@ -0,0 +1,108 @@ +package org.yapp.apis.readingrecord.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.yapp.domain.readingrecord.vo.ReadingRecordInfoVO +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Schema( + name = "ReadingRecordResponseV2", + description = "독서 기록 응답 (V2)" +) +data class ReadingRecordResponseV2 private constructor( + @field:Schema(description = "독서 기록 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "사용자 책 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val userBookId: UUID, + + @field:Schema(description = "현재 읽은 페이지 번호 (선택)", example = "42") + val pageNumber: Int?, + + @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.") + val quote: String, + + @field:Schema(description = "감상평", example = "이 책은 매우 인상적이었습니다.") + val review: String?, + + @field:Schema(description = "대분류 감정") + val primaryEmotion: PrimaryEmotionDto, + + @field:Schema(description = "세부 감정 목록") + val detailEmotions: List, + + @field:Schema(description = "생성 일시", example = "2023-01-01T12:00:00") + val createdAt: String, + + @field:Schema(description = "수정 일시", example = "2023-01-01T12:00:00") + val updatedAt: String, + + @field:Schema(description = "도서 제목", example = "클린 코드") + val bookTitle: String?, + + @field:Schema(description = "출판사", example = "인사이트") + val bookPublisher: String?, + + @field:Schema(description = "도서 썸네일 URL", example = "https://example.com/book-cover.jpg") + val bookCoverImageUrl: String?, + + @field:Schema(description = "저자", example = "로버트 C. 마틴") + val author: String? +) { + companion object { + private val dateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + + fun from(readingRecordInfoVO: ReadingRecordInfoVO): ReadingRecordResponseV2 { + return ReadingRecordResponseV2( + id = readingRecordInfoVO.id.value, + userBookId = readingRecordInfoVO.userBookId.value, + pageNumber = readingRecordInfoVO.pageNumber?.value, + quote = readingRecordInfoVO.quote.value, + review = readingRecordInfoVO.review?.value, + primaryEmotion = PrimaryEmotionDto.of( + code = readingRecordInfoVO.primaryEmotion.name, + displayName = readingRecordInfoVO.primaryEmotion.displayName + ), + detailEmotions = readingRecordInfoVO.detailEmotions.map { + DetailEmotionDto.of(id = it.id, name = it.name) + }, + createdAt = readingRecordInfoVO.createdAt.format(dateTimeFormatter), + updatedAt = readingRecordInfoVO.updatedAt.format(dateTimeFormatter), + bookTitle = readingRecordInfoVO.bookTitle, + bookPublisher = readingRecordInfoVO.bookPublisher, + bookCoverImageUrl = readingRecordInfoVO.bookCoverImageUrl, + author = readingRecordInfoVO.author + ) + } + } + + @Schema(name = "PrimaryEmotionDto", description = "대분류 감정") + data class PrimaryEmotionDto private constructor( + @field:Schema(description = "감정 코드", example = "JOY") + val code: String, + + @field:Schema(description = "감정 표시 이름", example = "즐거움") + val displayName: String + ) { + companion object { + fun of(code: String, displayName: String): PrimaryEmotionDto { + return PrimaryEmotionDto(code = code, displayName = displayName) + } + } + } + + @Schema(name = "DetailEmotionDto", description = "세부 감정") + data class DetailEmotionDto private constructor( + @field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "세부 감정 이름", example = "설레는") + val name: String + ) { + companion object { + fun of(id: UUID, name: String): DetailEmotionDto { + return DetailEmotionDto(id = id, name = name) + } + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt index 06c37a84..201deeff 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt @@ -2,6 +2,7 @@ package org.yapp.apis.readingrecord.service import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable +import org.springframework.transaction.annotation.Transactional import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequest import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponse @@ -16,6 +17,7 @@ class ReadingRecordService( private val readingRecordDomainService: ReadingRecordDomainService, private val userDomainService: UserDomainService ) { + @Transactional fun createReadingRecord( userId: UUID, userBookId: UUID, @@ -26,7 +28,7 @@ class ReadingRecordService( pageNumber = request.validPageNumber(), quote = request.validQuote(), review = request.review, - emotionTags = request.validEmotionTags() + emotionTags = request.emotionTags ) // Update user's lastActivity when a reading record is created @@ -35,6 +37,7 @@ class ReadingRecordService( return ReadingRecordResponse.from(readingRecordInfoVO) } + @Transactional(readOnly = true) fun getReadingRecordDetail( userId: UUID, readingRecordId: UUID @@ -43,6 +46,7 @@ class ReadingRecordService( return ReadingRecordResponse.from(readingRecordInfoVO) } + @Transactional(readOnly = true) fun getReadingRecordsByDynamicCondition( userBookId: UUID, sort: ReadingRecordSortType?, @@ -52,9 +56,12 @@ class ReadingRecordService( return page.map { ReadingRecordResponse.from(it) } } + @Transactional fun deleteAllByUserBookId(userBookId: UUID) { readingRecordDomainService.deleteAllByUserBookId(userBookId) } + + @Transactional fun updateReadingRecord( userId: UUID, readingRecordId: UUID, @@ -74,9 +81,15 @@ class ReadingRecordService( return ReadingRecordResponse.from(readingRecordInfoVO) } + @Transactional fun deleteReadingRecord( readingRecordId: UUID ) { readingRecordDomainService.deleteReadingRecord(readingRecordId) } + + @Transactional(readOnly = true) + fun getUserBookIdByReadingRecordId(readingRecordId: UUID): UUID { + return readingRecordDomainService.findById(readingRecordId).userBookId.value + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt new file mode 100644 index 00000000..2a5ae20e --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -0,0 +1,221 @@ +package org.yapp.apis.readingrecord.service + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.transaction.annotation.Transactional +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.domain.detailtag.DetailTagDomainService +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.domain.readingrecord.ReadingRecord +import org.yapp.domain.readingrecord.ReadingRecordDomainService +import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.domain.readingrecord.vo.ReadingRecordInfoVO +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTagDomainService +import org.yapp.domain.user.UserDomainService +import org.yapp.globalutils.annotation.ApplicationService +import java.util.* + +@ApplicationService +class ReadingRecordServiceV2( + private val readingRecordDomainService: ReadingRecordDomainService, + private val detailTagDomainService: DetailTagDomainService, + private val readingRecordDetailTagDomainService: ReadingRecordDetailTagDomainService, + private val userDomainService: UserDomainService +) { + @Transactional + fun createReadingRecord( + userId: UUID, + userBookId: UUID, + request: CreateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + val primaryEmotion = request.validPrimaryEmotion() + val detailEmotionTagIds = request.detailEmotionTagIds + + // Validate detail emotion tags belong to the selected primary emotion + validateDetailEmotionTags(detailEmotionTagIds, primaryEmotion) + + // Create reading record + val savedReadingRecord = readingRecordDomainService.createReadingRecordV2( + userBookId = userBookId, + pageNumber = request.pageNumber, + quote = request.validQuote(), + review = request.review, + primaryEmotion = primaryEmotion + ) + + // Save detail emotion tags + readingRecordDetailTagDomainService.createAndSaveAll( + readingRecordId = savedReadingRecord.id.value, + detailTagIds = detailEmotionTagIds + ) + + // Update user's lastActivity + userDomainService.updateLastActivity(userId) + + return buildResponse(savedReadingRecord, detailEmotionTagIds) + } + + @Transactional(readOnly = true) + fun getReadingRecordDetail( + readingRecordId: UUID + ): ReadingRecordResponseV2 { + val readingRecord = readingRecordDomainService.findById(readingRecordId) + val detailTagIds = readingRecordDetailTagDomainService.findByReadingRecordId(readingRecordId) + .map { it.detailTagId.value } + + return buildResponse(readingRecord, detailTagIds) + } + + @Transactional(readOnly = true) + fun getReadingRecordsByDynamicCondition( + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page { + val readingRecordPage = readingRecordDomainService.findByDynamicCondition(userBookId, sort, pageable) + if (readingRecordPage.isEmpty) { + return Page.empty(pageable) + } + + val readingRecords = readingRecordPage.content.toList() + val readingRecordIds = readingRecords.map { it.id.value } + + // Fetch detail tags + val readingRecordDetailTags = readingRecordDetailTagDomainService.findByReadingRecordIdIn(readingRecordIds) + val detailTagIds = readingRecordDetailTags.map { it.detailTagId.value }.distinct() + val detailTagsById = detailTagDomainService.findAllById(detailTagIds).associateBy { it.id.value } + + val detailTagsByReadingRecordId = readingRecordDetailTags + .groupBy { it.readingRecordId.value } + .mapValues { (_, tags) -> + tags.mapNotNull { detailTagsById[it.detailTagId.value] } + .map { ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) } + } + + return readingRecordPage.map { readingRecord -> + val detailEmotions = detailTagsByReadingRecordId[readingRecord.id.value] ?: emptyList() + ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = readingRecord, + detailEmotions = detailEmotions + ) + ) + } + } + + @Transactional + fun updateReadingRecord( + userId: UUID, + readingRecordId: UUID, + request: UpdateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + val existingRecord = readingRecordDomainService.findById(readingRecordId) + val newPrimaryEmotion = request.primaryEmotion ?: existingRecord.primaryEmotion + val primaryEmotionChanged = isPrimaryEmotionChanged(request.primaryEmotion, existingRecord.primaryEmotion) + + // Validate detail emotion tags if provided + if (!request.detailEmotionTagIds.isNullOrEmpty()) { + validateDetailEmotionTags(request.detailEmotionTagIds, newPrimaryEmotion) + } + + // Update reading record + val savedReadingRecord = readingRecordDomainService.modifyReadingRecordV2( + readingRecordId = readingRecordId, + pageNumber = request.pageNumber, + quote = request.quote, + review = request.review, + primaryEmotion = request.primaryEmotion + ) + + // Handle detail emotion tags + val finalDetailTagIds = updateDetailEmotionTags( + readingRecordId = readingRecordId, + newDetailTagIds = request.detailEmotionTagIds, + primaryEmotionChanged = primaryEmotionChanged + ) + + // Update user's lastActivity + userDomainService.updateLastActivity(userId) + + return buildResponse(savedReadingRecord, finalDetailTagIds) + } + + @Transactional + fun deleteReadingRecord(readingRecordId: UUID) { + readingRecordDetailTagDomainService.deleteAllByReadingRecordId(readingRecordId) + readingRecordDomainService.deleteReadingRecordV2(readingRecordId) + } + + private fun validateDetailEmotionTags(detailEmotionTagIds: List, primaryEmotion: PrimaryEmotion) { + if (detailEmotionTagIds.isEmpty()) return + + val detailTags = detailTagDomainService.findAllById(detailEmotionTagIds) + require(detailTags.size == detailEmotionTagIds.size) { + "Some detail emotion tag IDs are invalid" + } + require(detailTags.all { it.primaryEmotion == primaryEmotion }) { + "All detail emotions must belong to the selected primary emotion" + } + } + + private fun isPrimaryEmotionChanged( + requestPrimaryEmotion: PrimaryEmotion?, + existingPrimaryEmotion: PrimaryEmotion + ): Boolean { + return requestPrimaryEmotion != null && requestPrimaryEmotion != existingPrimaryEmotion + } + + private fun updateDetailEmotionTags( + readingRecordId: UUID, + newDetailTagIds: List?, + primaryEmotionChanged: Boolean + ): List { + return when { + newDetailTagIds != null -> { + readingRecordDetailTagDomainService.deleteAllByReadingRecordId(readingRecordId) + readingRecordDetailTagDomainService.createAndSaveAll( + readingRecordId = readingRecordId, + detailTagIds = newDetailTagIds + ) + newDetailTagIds + } + + primaryEmotionChanged -> { + readingRecordDetailTagDomainService.deleteAllByReadingRecordId(readingRecordId) + emptyList() + } + + else -> { + readingRecordDetailTagDomainService.findByReadingRecordId(readingRecordId) + .map { it.detailTagId.value } + } + } + } + + private fun buildResponse( + readingRecord: ReadingRecord, + detailTagIds: List + ): ReadingRecordResponseV2 { + val detailEmotions = if (detailTagIds.isNotEmpty()) { + detailTagDomainService.findAllById(detailTagIds).map { + ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) + } + } else { + emptyList() + } + + return ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = readingRecord, + detailEmotions = detailEmotions + ) + ) + } + + @Transactional(readOnly = true) + fun getUserBookIdByReadingRecordId(readingRecordId: UUID): UUID { + return readingRecordDomainService.findById(readingRecordId).userBookId.value + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordTagService.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordTagService.kt index 83bb8101..170b2024 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordTagService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordTagService.kt @@ -1,5 +1,6 @@ package org.yapp.apis.readingrecord.service +import org.springframework.transaction.annotation.Transactional import org.yapp.apis.readingrecord.dto.response.SeedStatsResponse import org.yapp.domain.readingrecordtag.ReadingRecordTagDomainService import org.yapp.globalutils.annotation.ApplicationService @@ -10,6 +11,7 @@ import java.util.* class ReadingRecordTagService( private val readingRecordTagDomainService: ReadingRecordTagDomainService ) { + @Transactional(readOnly = true) fun getSeedStatsByUserIdAndUserBookId(userId: UUID, userBookId: UUID): SeedStatsResponse { val tagStatsVO = readingRecordTagDomainService.countTagsByUserIdAndUserBookIdAndCategories( userId = userId, diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt index 7ab0bce5..10236cd0 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCase.kt @@ -1,7 +1,7 @@ package org.yapp.apis.readingrecord.usecase import org.springframework.data.domain.Pageable -import org.springframework.transaction.annotation.Transactional + import org.yapp.apis.book.service.UserBookService import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequest import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequest @@ -16,14 +16,12 @@ import org.yapp.globalutils.annotation.UseCase import java.util.* @UseCase -@Transactional(readOnly = true) class ReadingRecordUseCase( private val readingRecordService: ReadingRecordService, private val readingRecordTagService: ReadingRecordTagService, private val userService: UserService, private val userBookService: UserBookService, ) { - @Transactional fun createReadingRecord( userId: UUID, userBookId: UUID, @@ -44,6 +42,8 @@ class ReadingRecordUseCase( readingRecordId: UUID ): ReadingRecordResponse { userService.validateUserExists(userId) + val userBookId = readingRecordService.getUserBookIdByReadingRecordId(readingRecordId) + userBookService.validateUserBookExists(userBookId, userId) return readingRecordService.getReadingRecordDetail( userId = userId, @@ -77,13 +77,15 @@ class ReadingRecordUseCase( return readingRecordTagService.getSeedStatsByUserIdAndUserBookId(userId, userBookId) } - @Transactional + fun updateReadingRecord( userId: UUID, readingRecordId: UUID, request: UpdateReadingRecordRequest ): ReadingRecordResponse { userService.validateUserExists(userId) + val userBookId = readingRecordService.getUserBookIdByReadingRecordId(readingRecordId) + userBookService.validateUserBookExists(userBookId, userId) return readingRecordService.updateReadingRecord( userId = userId, @@ -92,12 +94,13 @@ class ReadingRecordUseCase( ) } - @Transactional fun deleteReadingRecord( userId: UUID, readingRecordId: UUID ) { userService.validateUserExists(userId) + val userBookId = readingRecordService.getUserBookIdByReadingRecordId(readingRecordId) + userBookService.validateUserBookExists(userBookId, userId) readingRecordService.deleteReadingRecord(readingRecordId) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt new file mode 100644 index 00000000..da348b95 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt @@ -0,0 +1,91 @@ +package org.yapp.apis.readingrecord.usecase + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +import org.yapp.apis.book.service.UserBookService +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.service.ReadingRecordServiceV2 +import org.yapp.apis.user.service.UserService +import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.globalutils.annotation.UseCase +import java.util.* + +@UseCase +class ReadingRecordUseCaseV2( + private val readingRecordServiceV2: ReadingRecordServiceV2, + private val userService: UserService, + private val userBookService: UserBookService, +) { + fun createReadingRecord( + userId: UUID, + userBookId: UUID, + request: CreateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + userService.validateUserExists(userId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.createReadingRecord( + userId = userId, + userBookId = userBookId, + request = request + ) + } + + fun getReadingRecordDetail( + userId: UUID, + readingRecordId: UUID + ): ReadingRecordResponseV2 { + userService.validateUserExists(userId) + val userBookId = readingRecordServiceV2.getUserBookIdByReadingRecordId(readingRecordId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.getReadingRecordDetail( + readingRecordId = readingRecordId + ) + } + + fun getReadingRecordsByUserBookId( + userId: UUID, + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page { + userService.validateUserExists(userId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.getReadingRecordsByDynamicCondition( + userBookId = userBookId, + sort = sort, + pageable = pageable + ) + } + + fun updateReadingRecord( + userId: UUID, + readingRecordId: UUID, + request: UpdateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + userService.validateUserExists(userId) + val userBookId = readingRecordServiceV2.getUserBookIdByReadingRecordId(readingRecordId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.updateReadingRecord( + userId = userId, + readingRecordId = readingRecordId, + request = request + ) + } + + fun deleteReadingRecord( + userId: UUID, + readingRecordId: UUID + ) { + userService.validateUserExists(userId) + val userBookId = readingRecordServiceV2.getUserBookIdByReadingRecordId(readingRecordId) + userBookService.validateUserBookExists(userBookId, userId) + readingRecordServiceV2.deleteReadingRecord(readingRecordId) + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt new file mode 100644 index 00000000..ead3b81d --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt @@ -0,0 +1,58 @@ +package org.yapp.domain.detailtag + +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.globalutils.util.UuidGenerator +import java.time.LocalDateTime +import java.util.* + +data class DetailTag private constructor( + val id: Id, + val primaryEmotion: PrimaryEmotion, + val name: String, + val displayOrder: Int, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null +) { + companion object { + fun create( + primaryEmotion: PrimaryEmotion, + name: String, + displayOrder: Int + ): DetailTag { + require(name.isNotBlank()) { "Detail tag name cannot be blank" } + require(displayOrder >= 0) { "Display order must be non-negative" } + + return DetailTag( + id = Id.newInstance(UuidGenerator.create()), + primaryEmotion = primaryEmotion, + name = name, + displayOrder = displayOrder + ) + } + + fun reconstruct( + id: Id, + primaryEmotion: PrimaryEmotion, + name: String, + displayOrder: Int, + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null + ): DetailTag { + return DetailTag( + id = id, + primaryEmotion = primaryEmotion, + name = name, + displayOrder = displayOrder, + createdAt = createdAt, + updatedAt = updatedAt + ) + } + } + + @JvmInline + value class Id(val value: UUID) { + companion object { + fun newInstance(value: UUID) = Id(value) + } + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt new file mode 100644 index 00000000..d06f8e1a --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt @@ -0,0 +1,27 @@ +package org.yapp.domain.detailtag + +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.globalutils.annotation.DomainService +import java.util.* + +@DomainService +class DetailTagDomainService( + private val detailTagRepository: DetailTagRepository +) { + fun findById(id: UUID): DetailTag? { + return detailTagRepository.findById(id) + } + + fun findAllById(ids: List): List { + if (ids.isEmpty()) return emptyList() + return detailTagRepository.findAllById(ids) + } + + fun findByPrimaryEmotion(primaryEmotion: PrimaryEmotion): List { + return detailTagRepository.findByPrimaryEmotion(primaryEmotion) + } + + fun findAll(): List { + return detailTagRepository.findAll() + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt new file mode 100644 index 00000000..c65346d2 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt @@ -0,0 +1,13 @@ +package org.yapp.domain.detailtag + +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.* + +interface DetailTagRepository { + fun findById(id: UUID): DetailTag? + fun findAllById(ids: List): List + fun findByPrimaryEmotion(primaryEmotion: PrimaryEmotion): List + fun findAll(): List + fun save(detailTag: DetailTag): DetailTag + fun saveAll(detailTags: List): List +} diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt new file mode 100644 index 00000000..75205668 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt @@ -0,0 +1,17 @@ +package org.yapp.domain.readingrecord + +enum class PrimaryEmotion(val displayName: String) { + WARMTH("따뜻함"), + JOY("즐거움"), + SADNESS("슬픔"), + INSIGHT("깨달음"), + OTHER("기타"); + + companion object { + fun fromDisplayName(name: String): PrimaryEmotion = + entries.find { it.displayName == name } ?: OTHER + + fun fromCode(code: String): PrimaryEmotion = + entries.find { it.name == code } ?: OTHER + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt index f7cf1cab..bac0c904 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt @@ -8,9 +8,10 @@ import java.util.* data class ReadingRecord private constructor( val id: Id, val userBookId: UserBook.Id, - val pageNumber: PageNumber, + val pageNumber: PageNumber?, val quote: Quote, val review: Review?, + val primaryEmotion: PrimaryEmotion, val emotionTags: List = emptyList(), val createdAt: LocalDateTime? = null, val updatedAt: LocalDateTime? = null, @@ -19,17 +20,19 @@ data class ReadingRecord private constructor( companion object { fun create( userBookId: UUID, - pageNumber: Int, + pageNumber: Int?, quote: String, review: String?, + primaryEmotion: PrimaryEmotion, emotionTags: List = emptyList() ): ReadingRecord { return ReadingRecord( id = Id.newInstance(UuidGenerator.create()), userBookId = UserBook.Id.newInstance(userBookId), - pageNumber = PageNumber.newInstance(pageNumber), + pageNumber = pageNumber?.let { PageNumber.newInstance(it) }, quote = Quote.newInstance(quote), review = Review.newInstance(review), + primaryEmotion = primaryEmotion, emotionTags = emotionTags.map { EmotionTag.newInstance(it) } ) } @@ -37,9 +40,10 @@ data class ReadingRecord private constructor( fun reconstruct( id: Id, userBookId: UserBook.Id, - pageNumber: PageNumber, + pageNumber: PageNumber?, quote: Quote, review: Review?, + primaryEmotion: PrimaryEmotion, emotionTags: List = emptyList(), createdAt: LocalDateTime? = null, updatedAt: LocalDateTime? = null, @@ -51,6 +55,7 @@ data class ReadingRecord private constructor( pageNumber = pageNumber, quote = quote, review = review, + primaryEmotion = primaryEmotion, emotionTags = emotionTags, createdAt = createdAt, updatedAt = updatedAt, @@ -63,12 +68,14 @@ data class ReadingRecord private constructor( pageNumber: Int?, quote: String?, review: String?, + primaryEmotion: PrimaryEmotion?, emotionTags: List? ): ReadingRecord { return this.copy( pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber, quote = quote?.let { Quote.newInstance(it) } ?: this.quote, review = if (review != null) Review.newInstance(review) else this.review, + primaryEmotion = primaryEmotion ?: this.primaryEmotion, emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags, updatedAt = LocalDateTime.now() ) diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt index ae1e2a80..3075f213 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt @@ -24,6 +24,94 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 private val userBookRepository: UserBookRepository ) { + // ===================== V2 API (Simple CRUD) ===================== + + fun createReadingRecordV2( + userBookId: UUID, + pageNumber: Int?, + quote: String, + review: String?, + primaryEmotion: PrimaryEmotion + ): ReadingRecord { + val userBook = userBookRepository.findById(userBookId) + ?: throw UserBookNotFoundException( + UserBookErrorCode.USER_BOOK_NOT_FOUND, + "User book not found with id: $userBookId" + ) + + val readingRecord = ReadingRecord.create( + userBookId = userBookId, + pageNumber = pageNumber, + quote = quote, + review = review, + primaryEmotion = primaryEmotion + ) + + val savedReadingRecord = readingRecordRepository.save(readingRecord) + userBookRepository.save(userBook.increaseReadingRecordCount()) + + return savedReadingRecord + } + + fun modifyReadingRecordV2( + readingRecordId: UUID, + pageNumber: Int?, + quote: String?, + review: String?, + primaryEmotion: PrimaryEmotion? + ): ReadingRecord { + val readingRecord = readingRecordRepository.findById(readingRecordId) + ?: throw ReadingRecordNotFoundException( + ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, + "Reading record not found with id: $readingRecordId" + ) + + val updatedReadingRecord = readingRecord.update( + pageNumber = pageNumber, + quote = quote, + review = review, + primaryEmotion = primaryEmotion, + emotionTags = null + ) + + return readingRecordRepository.save(updatedReadingRecord) + } + + fun findById(readingRecordId: UUID): ReadingRecord { + return readingRecordRepository.findById(readingRecordId) + ?: throw ReadingRecordNotFoundException( + ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, + "Reading record not found with id: $readingRecordId" + ) + } + + fun findByDynamicCondition( + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page { + return readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable) + } + + fun deleteReadingRecordV2(readingRecordId: UUID) { + val readingRecord = readingRecordRepository.findById(readingRecordId) + ?: throw ReadingRecordNotFoundException( + ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, + "Reading record not found with id: $readingRecordId" + ) + + val userBook = userBookRepository.findById(readingRecord.userBookId.value) + ?: throw UserBookNotFoundException( + UserBookErrorCode.USER_BOOK_NOT_FOUND, + "User book not found with id: ${readingRecord.userBookId.value}" + ) + + readingRecordRepository.deleteById(readingRecordId) + userBookRepository.save(userBook.decreaseReadingRecordCount()) + } + + // ===================== V1 API (Legacy) ===================== + fun createReadingRecord( userBookId: UUID, pageNumber: Int, @@ -37,15 +125,22 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 "User book not found with id: $userBookId" ) + // Convert emotion tag to primary emotion + val primaryEmotion = emotionTags.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) + } ?: PrimaryEmotion.OTHER + val readingRecord = ReadingRecord.create( userBookId = userBookId, pageNumber = pageNumber, quote = quote, - review = review + review = review, + primaryEmotion = primaryEmotion ) val savedReadingRecord = readingRecordRepository.save(readingRecord) + // Also save to legacy reading_record_tags for backward compatibility val tags = emotionTags.map { tagName -> tagRepository.findByName(tagName) ?: tagRepository.save(Tag.create(tagName)) } @@ -70,7 +165,6 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 ) } - fun findReadingRecordById(readingRecordId: UUID): ReadingRecordInfoVO { val readingRecord = readingRecordRepository.findById(readingRecordId) ?: throw ReadingRecordNotFoundException( @@ -147,10 +241,16 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 "Reading record not found with id: $readingRecordId" ) + // Convert emotion tag to primary emotion if provided + val primaryEmotion = emotionTags?.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) + } + val updatedReadingRecord = readingRecord.update( pageNumber = pageNumber, quote = quote, review = review, + primaryEmotion = primaryEmotion, emotionTags = emotionTags ) @@ -177,6 +277,7 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 fun deleteAllByUserBookId(userBookId: UUID) { readingRecordRepository.deleteAllByUserBookId(userBookId) } + fun deleteReadingRecord(readingRecordId: UUID) { val readingRecord = readingRecordRepository.findById(readingRecordId) ?: throw ReadingRecordNotFoundException( diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt index 95126d28..291ec17c 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt @@ -1,16 +1,20 @@ package org.yapp.domain.readingrecord.vo +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord import org.yapp.domain.userbook.UserBook import java.time.LocalDateTime +import java.util.UUID data class ReadingRecordInfoVO private constructor( val id: ReadingRecord.Id, val userBookId: UserBook.Id, - val pageNumber: ReadingRecord.PageNumber, + val pageNumber: ReadingRecord.PageNumber?, val quote: ReadingRecord.Quote, val review: ReadingRecord.Review?, + val primaryEmotion: PrimaryEmotion, val emotionTags: List, + val detailEmotions: List, val createdAt: LocalDateTime, val updatedAt: LocalDateTime, val bookTitle: String? = null, @@ -19,7 +23,6 @@ data class ReadingRecordInfoVO private constructor( val author: String? = null ) { init { - require(emotionTags.size <= 3) { "Maximum 3 emotion tags are allowed" } require(!createdAt.isAfter(updatedAt)) { "생성일(createdAt)은 수정일(updatedAt)보다 이후일 수 없습니다." } @@ -28,7 +31,8 @@ data class ReadingRecordInfoVO private constructor( companion object { fun newInstance( readingRecord: ReadingRecord, - emotionTags: List, + emotionTags: List = emptyList(), + detailEmotions: List = emptyList(), bookTitle: String? = null, bookPublisher: String? = null, bookCoverImageUrl: String? = null, @@ -40,7 +44,9 @@ data class ReadingRecordInfoVO private constructor( pageNumber = readingRecord.pageNumber, quote = readingRecord.quote, review = readingRecord.review, + primaryEmotion = readingRecord.primaryEmotion, emotionTags = emotionTags, + detailEmotions = detailEmotions, createdAt = readingRecord.createdAt ?: throw IllegalStateException("createdAt은 null일 수 없습니다."), updatedAt = readingRecord.updatedAt ?: throw IllegalStateException("updatedAt은 null일 수 없습니다."), bookTitle = bookTitle, @@ -50,4 +56,9 @@ data class ReadingRecordInfoVO private constructor( ) } } + + data class DetailEmotionInfo( + val id: UUID, + val name: String + ) } diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt new file mode 100644 index 00000000..f594c576 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt @@ -0,0 +1,55 @@ +package org.yapp.domain.readingrecorddetailtag + +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.ReadingRecord +import org.yapp.globalutils.util.UuidGenerator +import java.time.LocalDateTime +import java.util.* + +data class ReadingRecordDetailTag private constructor( + val id: Id, + val readingRecordId: ReadingRecord.Id, + val detailTagId: DetailTag.Id, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, + val deletedAt: LocalDateTime? = null +) { + companion object { + fun create( + readingRecordId: UUID, + detailTagId: UUID + ): ReadingRecordDetailTag { + return ReadingRecordDetailTag( + id = Id.newInstance(UuidGenerator.create()), + readingRecordId = ReadingRecord.Id.newInstance(readingRecordId), + detailTagId = DetailTag.Id.newInstance(detailTagId) + ) + } + + fun reconstruct( + id: Id, + readingRecordId: ReadingRecord.Id, + detailTagId: DetailTag.Id, + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null, + deletedAt: LocalDateTime? = null + ): ReadingRecordDetailTag { + return ReadingRecordDetailTag( + id = id, + readingRecordId = readingRecordId, + detailTagId = detailTagId, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + } + } + + @JvmInline + value class Id(val value: UUID) { + companion object { + fun newInstance(value: UUID) = Id(value) + } + } +} + diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt new file mode 100644 index 00000000..9ae6b9d6 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt @@ -0,0 +1,34 @@ +package org.yapp.domain.readingrecorddetailtag + +import org.yapp.globalutils.annotation.DomainService +import java.util.* + +@DomainService +class ReadingRecordDetailTagDomainService( + private val readingRecordDetailTagRepository: ReadingRecordDetailTagRepository +) { + fun findByReadingRecordId(readingRecordId: UUID): List { + return readingRecordDetailTagRepository.findByReadingRecordId(readingRecordId) + } + + fun findByReadingRecordIdIn(readingRecordIds: List): List { + if (readingRecordIds.isEmpty()) return emptyList() + return readingRecordDetailTagRepository.findByReadingRecordIdIn(readingRecordIds) + } + + fun deleteAllByReadingRecordId(readingRecordId: UUID) { + readingRecordDetailTagRepository.deleteAllByReadingRecordId(readingRecordId) + } + + fun createAndSaveAll(readingRecordId: UUID, detailTagIds: List): List { + if (detailTagIds.isEmpty()) return emptyList() + val readingRecordDetailTags = detailTagIds.map { detailTagId -> + ReadingRecordDetailTag.create( + readingRecordId = readingRecordId, + detailTagId = detailTagId + ) + } + return readingRecordDetailTagRepository.saveAll(readingRecordDetailTags) + } +} + diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt new file mode 100644 index 00000000..1c2c83df --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt @@ -0,0 +1,11 @@ +package org.yapp.domain.readingrecorddetailtag + +import java.util.* + +interface ReadingRecordDetailTagRepository { + fun findByReadingRecordId(readingRecordId: UUID): List + fun findByReadingRecordIdIn(readingRecordIds: List): List + fun save(readingRecordDetailTag: ReadingRecordDetailTag): ReadingRecordDetailTag + fun saveAll(readingRecordDetailTags: List): List + fun deleteAllByReadingRecordId(readingRecordId: UUID) +} diff --git a/infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt b/infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt new file mode 100644 index 00000000..6198e330 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt @@ -0,0 +1,73 @@ +package org.yapp.infra.detailtag.entity + +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.infra.common.BaseTimeEntity +import java.sql.Types +import java.util.* + +@Entity +@Table( + name = "detail_tags", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_detail_tags_emotion_name", + columnNames = ["primary_emotion", "name"] + ) + ] +) +class DetailTagEntity( + @Id + @JdbcTypeCode(Types.VARCHAR) + @Column(length = 36, updatable = false, nullable = false) + val id: UUID, + + @Enumerated(EnumType.STRING) + @Column(name = "primary_emotion", nullable = false, length = 20) + val primaryEmotion: PrimaryEmotion, + + name: String, + displayOrder: Int = 0 +) : BaseTimeEntity() { + + @Column(nullable = false, length = 20) + var name: String = name + protected set + + @Column(name = "display_order", nullable = false) + var displayOrder: Int = displayOrder + protected set + + fun toDomain(): DetailTag { + return DetailTag.reconstruct( + id = DetailTag.Id.newInstance(this.id), + primaryEmotion = this.primaryEmotion, + name = this.name, + displayOrder = this.displayOrder, + createdAt = this.createdAt, + updatedAt = this.updatedAt + ) + } + + companion object { + fun fromDomain(detailTag: DetailTag): DetailTagEntity { + return DetailTagEntity( + id = detailTag.id.value, + primaryEmotion = detailTag.primaryEmotion, + name = detailTag.name, + displayOrder = detailTag.displayOrder + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DetailTagEntity) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() +} + diff --git a/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt new file mode 100644 index 00000000..b1d13493 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt @@ -0,0 +1,45 @@ +package org.yapp.infra.detailtag.repository + +import org.springframework.stereotype.Repository +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.detailtag.DetailTagRepository +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.infra.detailtag.entity.DetailTagEntity +import java.util.* + +@Repository +class DetailTagRepositoryImpl( + private val jpaDetailTagRepository: JpaDetailTagRepository +) : DetailTagRepository { + + override fun findById(id: UUID): DetailTag? { + return jpaDetailTagRepository.findById(id) + .map { it.toDomain() } + .orElse(null) + } + + override fun findAllById(ids: List): List { + return jpaDetailTagRepository.findAllById(ids) + .map { it.toDomain() } + } + + override fun findByPrimaryEmotion(primaryEmotion: PrimaryEmotion): List { + return jpaDetailTagRepository.findByPrimaryEmotionOrderByDisplayOrderAsc(primaryEmotion) + .map { it.toDomain() } + } + + override fun findAll(): List { + return jpaDetailTagRepository.findAllByOrderByPrimaryEmotionAscDisplayOrderAsc() + .map { it.toDomain() } + } + + override fun save(detailTag: DetailTag): DetailTag { + val entity = DetailTagEntity.fromDomain(detailTag) + return jpaDetailTagRepository.save(entity).toDomain() + } + + override fun saveAll(detailTags: List): List { + val entities = detailTags.map { DetailTagEntity.fromDomain(it) } + return jpaDetailTagRepository.saveAll(entities).map { it.toDomain() } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt new file mode 100644 index 00000000..c1c65540 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.detailtag.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.infra.detailtag.entity.DetailTagEntity +import java.util.* + +interface JpaDetailTagRepository : JpaRepository { + fun findByPrimaryEmotionOrderByDisplayOrderAsc(primaryEmotion: PrimaryEmotion): List + fun findAllByOrderByPrimaryEmotionAscDisplayOrderAsc(): List +} + diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt index 1b754912..4cf0c3b4 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt @@ -4,6 +4,7 @@ import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete import org.hibernate.annotations.SQLRestriction +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord import org.yapp.domain.userbook.UserBook import org.yapp.infra.common.BaseTimeEntity @@ -24,15 +25,14 @@ class ReadingRecordEntity( @JdbcTypeCode(Types.VARCHAR) val userBookId: UUID, - pageNumber: Int, + pageNumber: Int?, quote: String, review: String?, - - + primaryEmotion: PrimaryEmotion ) : BaseTimeEntity() { - @Column(name = "page_number", nullable = false) - var pageNumber: Int = pageNumber + @Column(name = "page_number", nullable = true) + var pageNumber: Int? = pageNumber protected set @Column(name = "quote", nullable = false, length = 1000) @@ -43,13 +43,19 @@ class ReadingRecordEntity( var review: String? = review protected set + @Enumerated(EnumType.STRING) + @Column(name = "primary_emotion", nullable = false, length = 20) + var primaryEmotion: PrimaryEmotion = primaryEmotion + protected set + fun toDomain(): ReadingRecord { return ReadingRecord.reconstruct( id = ReadingRecord.Id.newInstance(this.id), userBookId = UserBook.Id.newInstance(this.userBookId), - pageNumber = ReadingRecord.PageNumber.newInstance(this.pageNumber), + pageNumber = this.pageNumber?.let { ReadingRecord.PageNumber.newInstance(it) }, quote = ReadingRecord.Quote.newInstance(this.quote), review = ReadingRecord.Review.newInstance(this.review), + primaryEmotion = this.primaryEmotion, emotionTags = emptyList(), createdAt = this.createdAt, updatedAt = this.updatedAt, @@ -62,9 +68,10 @@ class ReadingRecordEntity( return ReadingRecordEntity( id = readingRecord.id.value, userBookId = readingRecord.userBookId.value, - pageNumber = readingRecord.pageNumber.value, + pageNumber = readingRecord.pageNumber?.value, quote = readingRecord.quote.value, - review = readingRecord.review?.value + review = readingRecord.review?.value, + primaryEmotion = readingRecord.primaryEmotion ) } } diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt new file mode 100644 index 00000000..4f5ff199 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt @@ -0,0 +1,73 @@ +package org.yapp.infra.readingrecorddetailtag.entity + +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.annotations.SQLDelete +import org.hibernate.annotations.SQLRestriction +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.ReadingRecord +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTag +import org.yapp.infra.common.BaseTimeEntity +import java.sql.Types +import java.util.* + +@Entity +@Table( + name = "reading_record_detail_tags", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_record_detail_tag", + columnNames = ["reading_record_id", "detail_tag_id"] + ) + ], + indexes = [ + Index(name = "idx_rrdt_reading_record_id", columnList = "reading_record_id"), + Index(name = "idx_rrdt_detail_tag_id", columnList = "detail_tag_id") + ] +) +@SQLDelete(sql = "UPDATE reading_record_detail_tags SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +class ReadingRecordDetailTagEntity( + @Id + @JdbcTypeCode(Types.VARCHAR) + @Column(length = 36, updatable = false, nullable = false) + val id: UUID, + + @Column(name = "reading_record_id", nullable = false, length = 36) + @JdbcTypeCode(Types.VARCHAR) + val readingRecordId: UUID, + + @Column(name = "detail_tag_id", nullable = false, length = 36) + @JdbcTypeCode(Types.VARCHAR) + val detailTagId: UUID +) : BaseTimeEntity() { + + fun toDomain(): ReadingRecordDetailTag { + return ReadingRecordDetailTag.reconstruct( + id = ReadingRecordDetailTag.Id.newInstance(this.id), + readingRecordId = ReadingRecord.Id.newInstance(this.readingRecordId), + detailTagId = DetailTag.Id.newInstance(this.detailTagId), + createdAt = this.createdAt, + updatedAt = this.updatedAt, + deletedAt = this.deletedAt + ) + } + + companion object { + fun fromDomain(readingRecordDetailTag: ReadingRecordDetailTag): ReadingRecordDetailTagEntity { + return ReadingRecordDetailTagEntity( + id = readingRecordDetailTag.id.value, + readingRecordId = readingRecordDetailTag.readingRecordId.value, + detailTagId = readingRecordDetailTag.detailTagId.value + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ReadingRecordDetailTagEntity) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() +} diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt new file mode 100644 index 00000000..0826f7e1 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.readingrecorddetailtag.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.yapp.infra.readingrecorddetailtag.entity.ReadingRecordDetailTagEntity +import java.util.* + +interface JpaReadingRecordDetailTagRepository : JpaRepository { + fun findByReadingRecordId(readingRecordId: UUID): List + fun findByReadingRecordIdIn(readingRecordIds: List): List + fun deleteAllByReadingRecordId(readingRecordId: UUID) +} + diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt new file mode 100644 index 00000000..00929543 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt @@ -0,0 +1,39 @@ +package org.yapp.infra.readingrecorddetailtag.repository + +import org.springframework.stereotype.Repository +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTag +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTagRepository +import org.yapp.infra.readingrecorddetailtag.entity.ReadingRecordDetailTagEntity +import java.util.* + +@Repository +class ReadingRecordDetailTagRepositoryImpl( + private val jpaReadingRecordDetailTagRepository: JpaReadingRecordDetailTagRepository +) : ReadingRecordDetailTagRepository { + + override fun findByReadingRecordId(readingRecordId: UUID): List { + return jpaReadingRecordDetailTagRepository.findByReadingRecordId(readingRecordId) + .map { it.toDomain() } + } + + override fun findByReadingRecordIdIn(readingRecordIds: List): List { + if (readingRecordIds.isEmpty()) return emptyList() + return jpaReadingRecordDetailTagRepository.findByReadingRecordIdIn(readingRecordIds) + .map { it.toDomain() } + } + + override fun save(readingRecordDetailTag: ReadingRecordDetailTag): ReadingRecordDetailTag { + val entity = ReadingRecordDetailTagEntity.fromDomain(readingRecordDetailTag) + return jpaReadingRecordDetailTagRepository.save(entity).toDomain() + } + + override fun saveAll(readingRecordDetailTags: List): List { + if (readingRecordDetailTags.isEmpty()) return emptyList() + val entities = readingRecordDetailTags.map { ReadingRecordDetailTagEntity.fromDomain(it) } + return jpaReadingRecordDetailTagRepository.saveAll(entities).map { it.toDomain() } + } + + override fun deleteAllByReadingRecordId(readingRecordId: UUID) { + jpaReadingRecordDetailTagRepository.deleteAllByReadingRecordId(readingRecordId) + } +} diff --git a/infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql b/infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql new file mode 100644 index 00000000..cd0caccf --- /dev/null +++ b/infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql @@ -0,0 +1,85 @@ +-- 1. reading_records 테이블 수정 +-- page_number를 nullable로 변경 +ALTER TABLE reading_records MODIFY COLUMN page_number INT NULL; + +-- primary_emotion 컬럼 추가 +ALTER TABLE reading_records ADD COLUMN primary_emotion VARCHAR(20) NULL; + +-- 기존 데이터 마이그레이션 (tags 기반으로 primary_emotion 설정) +UPDATE reading_records rr +SET primary_emotion = COALESCE( + (SELECT CASE t.name + WHEN '따뜻함' THEN 'WARMTH' + WHEN '즐거움' THEN 'JOY' + WHEN '슬픔' THEN 'SADNESS' + WHEN '깨달음' THEN 'INSIGHT' + ELSE 'OTHER' + END + FROM reading_record_tags rrt + JOIN tags t ON rrt.tag_id = t.id + WHERE rrt.reading_record_id = rr.id AND rrt.deleted_at IS NULL + LIMIT 1), + 'OTHER' +); + +-- NOT NULL 제약 추가 +ALTER TABLE reading_records MODIFY COLUMN primary_emotion VARCHAR(20) NOT NULL; + +-- 2. detail_tags 테이블 생성 +CREATE TABLE detail_tags ( + id VARCHAR(36) NOT NULL, + created_at datetime(6) NOT NULL, + updated_at datetime(6) NOT NULL, + primary_emotion VARCHAR(20) NOT NULL, + name VARCHAR(20) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + CONSTRAINT pk_detail_tags PRIMARY KEY (id), + CONSTRAINT uq_detail_tags_emotion_name UNIQUE (primary_emotion, name) +); + +-- 3. reading_record_detail_tags 테이블 생성 +CREATE TABLE reading_record_detail_tags ( + id VARCHAR(36) NOT NULL, + created_at datetime(6) NOT NULL, + updated_at datetime(6) NOT NULL, + deleted_at datetime(6) NULL, + reading_record_id VARCHAR(36) NOT NULL, + detail_tag_id VARCHAR(36) NOT NULL, + CONSTRAINT pk_reading_record_detail_tags PRIMARY KEY (id), + CONSTRAINT uq_record_detail_tag UNIQUE (reading_record_id, detail_tag_id), + CONSTRAINT fk_rrdt_reading_record FOREIGN KEY (reading_record_id) REFERENCES reading_records(id) ON DELETE CASCADE, + CONSTRAINT fk_rrdt_detail_tag FOREIGN KEY (detail_tag_id) REFERENCES detail_tags(id) +); + +CREATE INDEX idx_rrdt_reading_record_id ON reading_record_detail_tags(reading_record_id); +CREATE INDEX idx_rrdt_detail_tag_id ON reading_record_detail_tags(detail_tag_id); + +-- 4. 세부감정 초기 데이터 삽입 +INSERT INTO detail_tags (id, created_at, updated_at, primary_emotion, name, display_order) VALUES +-- 즐거움 +(UUID(), NOW(), NOW(), 'JOY', '설레는', 1), +(UUID(), NOW(), NOW(), 'JOY', '뿌듯한', 2), +(UUID(), NOW(), NOW(), 'JOY', '유쾌한', 3), +(UUID(), NOW(), NOW(), 'JOY', '기쁜', 4), +(UUID(), NOW(), NOW(), 'JOY', '흥미진진한', 5), +-- 따뜻함 +(UUID(), NOW(), NOW(), 'WARMTH', '위로받은', 1), +(UUID(), NOW(), NOW(), 'WARMTH', '포근한', 2), +(UUID(), NOW(), NOW(), 'WARMTH', '다정한', 3), +(UUID(), NOW(), NOW(), 'WARMTH', '고마운', 4), +(UUID(), NOW(), NOW(), 'WARMTH', '마음이 놓이는', 5), +(UUID(), NOW(), NOW(), 'WARMTH', '편안한', 6), +-- 슬픔 +(UUID(), NOW(), NOW(), 'SADNESS', '허무한', 1), +(UUID(), NOW(), NOW(), 'SADNESS', '외로운', 2), +(UUID(), NOW(), NOW(), 'SADNESS', '아쉬운', 3), +(UUID(), NOW(), NOW(), 'SADNESS', '먹먹한', 4), +(UUID(), NOW(), NOW(), 'SADNESS', '애틋한', 5), +(UUID(), NOW(), NOW(), 'SADNESS', '안타까운', 6), +(UUID(), NOW(), NOW(), 'SADNESS', '그리운', 7), +-- 깨달음 +(UUID(), NOW(), NOW(), 'INSIGHT', '감탄한', 1), +(UUID(), NOW(), NOW(), 'INSIGHT', '통찰력을 얻은', 2), +(UUID(), NOW(), NOW(), 'INSIGHT', '영감을 받은', 3), +(UUID(), NOW(), NOW(), 'INSIGHT', '생각이 깊어진', 4), +(UUID(), NOW(), NOW(), 'INSIGHT', '새롭게 이해한', 5); From 9e10c90a7b8ba8649790b58c84ab3593075f40fb Mon Sep 17 00:00:00 2001 From: DONGHOON LEE <125895298+move-hoon@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:11:06 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=EB=8F=85=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=AA=A9=EB=A1=9D=20API=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=91=9C=20=EA=B0=90=EC=A0=95=20=EC=9D=91=EB=8B=B5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B2=84=EA=B7=B8=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BOOK-486] feat: infra - 대표 감정 조회 Querydsl 집계 쿼리 추가 * [BOOK-486] refactor: domain - 대표 감정 조회 DB 집계 쿼리 사용으로 변경 * [BOOK-486] feat: apis - 독서 기록 목록 API에 대표 감정 응답 추가 * [BOOK-486] refactor: domain - DomainService 중복 코드 제거 및 헬퍼 메서드 추출 * [BOOK-486] chore: unused import 제거 및 헬퍼 메서드 하단으로 이동 * [BOOK-486] refactor: infra,apis - CodeRabbit 리뷰 반영 (QueryDSL 개선 & DTO 중복 제거) * [BOOK-486] fix: domain,apis - 독서 기록 생성/상세 조회 시 도서 정보 null 버그 수정 * [BOOK-486] chore: domain - 사용하지 않는 import문 제거 * [BOOK-486] fix: infra - 세부 감정 수정 시 Duplicate Key 에러 해결 (Soft Delete → Hard Delete) * [BOOK-486] fix: domain - 독서 기록 수정 시 pageNumber, review 삭제 가능하도록 로직 변경 * [BOOK-486] refactor: infra - ReadingRecordDetailTagEntity Soft Delete 제거 --- .../emotion/dto/response/EmotionDetailDto.kt | 19 +++ .../dto/response/EmotionListResponse.kt | 14 -- .../ReadingRecordControllerApiV2.kt | 6 +- .../controller/ReadingRecordControllerV2.kt | 4 +- .../dto/response/PrimaryEmotionDto.kt | 18 +++ ...eadingRecordsWithPrimaryEmotionResponse.kt | 28 ++++ .../service/ReadingRecordServiceV2.kt | 74 ++++++--- .../usecase/ReadingRecordUseCaseV2.kt | 5 +- .../domain/readingrecord/ReadingRecord.kt | 4 +- .../ReadingRecordDomainService.kt | 140 +++++++----------- .../readingrecord/ReadingRecordRepository.kt | 9 +- .../domain/userbook/UserBookDomainService.kt | 6 +- .../JpaReadingRecordQuerydslRepository.kt | 3 + .../repository/JpaReadingRecordRepository.kt | 1 + .../JpaReadingRecordQuerydslRepositoryImpl.kt | 19 ++- .../impl/ReadingRecordRepositoryImpl.kt | 5 + .../entity/ReadingRecordDetailTagEntity.kt | 4 - .../JpaReadingRecordDetailTagRepository.kt | 8 +- 18 files changed, 219 insertions(+), 148 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionDetailDto.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/PrimaryEmotionDto.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionDetailDto.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionDetailDto.kt new file mode 100644 index 00000000..d8714d12 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionDetailDto.kt @@ -0,0 +1,19 @@ +package org.yapp.apis.emotion.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.UUID + +@Schema(name = "EmotionDetailDto", description = "세부 감정") +data class EmotionDetailDto private constructor( + @field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "세부 감정 이름", example = "설레는") + val name: String +) { + companion object { + fun of(id: UUID, name: String): EmotionDetailDto { + return EmotionDetailDto(id = id, name = name) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt index 353cc947..64fdaf07 100644 --- a/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt @@ -55,18 +55,4 @@ data class EmotionListResponse private constructor( } } - @Schema(name = "EmotionDetailDto", description = "세부 감정") - data class EmotionDetailDto private constructor( - @field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000") - val id: UUID, - - @field:Schema(description = "세부 감정 이름", example = "설레는") - val name: String - ) { - companion object { - fun of(id: UUID, name: String): EmotionDetailDto { - return EmotionDetailDto(id = id, name = name) - } - } - } } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt index e4497382..0522e734 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt @@ -8,7 +8,6 @@ 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 @@ -18,6 +17,7 @@ import org.springframework.web.bind.annotation.* import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse import org.yapp.domain.readingrecord.ReadingRecordSortType import org.yapp.globalutils.exception.ErrorResponse import java.util.* @@ -116,7 +116,7 @@ interface ReadingRecordControllerApiV2 { ) sort: ReadingRecordSortType?, @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC) @Parameter(description = "페이지네이션 정보 (기본값: 10개). 정렬은 sort 파라미터로 제어되며, Pageable의 sort는 무시됩니다.") pageable: Pageable - ): ResponseEntity> + ): ResponseEntity @Operation( summary = "독서 기록 수정 (V2)", @@ -149,7 +149,7 @@ interface ReadingRecordControllerApiV2 { ): ResponseEntity @Operation( - summary = "독서 기록 삭제", + summary = "독서 기록 삭제 (V2)", description = "독서 기록을 삭제합니다." ) @ApiResponses( diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt index 5e5b507f..24655253 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt @@ -1,7 +1,6 @@ package org.yapp.apis.readingrecord.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 @@ -12,6 +11,7 @@ import org.springframework.web.bind.annotation.* import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse import org.yapp.apis.readingrecord.usecase.ReadingRecordUseCaseV2 import org.yapp.domain.readingrecord.ReadingRecordSortType import java.util.UUID @@ -55,7 +55,7 @@ class ReadingRecordControllerV2( @RequestParam(required = false) sort: ReadingRecordSortType?, @PageableDefault(size = 10, sort = ["updatedAt"], direction = Sort.Direction.DESC) pageable: Pageable - ): ResponseEntity> { + ): ResponseEntity { val response = readingRecordUseCaseV2.getReadingRecordsByUserBookId( userId = userId, userBookId = userBookId, diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/PrimaryEmotionDto.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/PrimaryEmotionDto.kt new file mode 100644 index 00000000..c71e1249 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/PrimaryEmotionDto.kt @@ -0,0 +1,18 @@ +package org.yapp.apis.readingrecord.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(name = "PrimaryEmotionDto", description = "대분류 감정") +data class PrimaryEmotionDto private constructor( + @field:Schema(description = "감정 코드", example = "JOY") + val code: String, + + @field:Schema(description = "감정 표시 이름", example = "즐거움") + val displayName: String +) { + companion object { + fun of(code: String, displayName: String): PrimaryEmotionDto { + return PrimaryEmotionDto(code = code, displayName = displayName) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt new file mode 100644 index 00000000..d59ce354 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt @@ -0,0 +1,28 @@ +package org.yapp.apis.readingrecord.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.Page + +@Schema( + name = "ReadingRecordsWithPrimaryEmotionResponse", + description = "독서 기록 목록과 대표 감정 응답" +) +data class ReadingRecordsWithPrimaryEmotionResponse private constructor( + @field:Schema(description = "해당 책의 대표(최다) 감정") + val primaryEmotion: PrimaryEmotionDto?, + + @field:Schema(description = "독서 기록 목록 (페이징)") + val records: Page +) { + companion object { + fun of( + primaryEmotion: PrimaryEmotionDto?, + records: Page + ): ReadingRecordsWithPrimaryEmotionResponse { + return ReadingRecordsWithPrimaryEmotionResponse( + primaryEmotion = primaryEmotion, + records = records + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt index 2a5ae20e..ee411245 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -5,7 +5,9 @@ import org.springframework.data.domain.Pageable import org.springframework.transaction.annotation.Transactional import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.PrimaryEmotionDto import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse import org.yapp.domain.detailtag.DetailTagDomainService import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord @@ -14,6 +16,7 @@ import org.yapp.domain.readingrecord.ReadingRecordSortType import org.yapp.domain.readingrecord.vo.ReadingRecordInfoVO import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTagDomainService import org.yapp.domain.user.UserDomainService +import org.yapp.domain.userbook.UserBookDomainService import org.yapp.globalutils.annotation.ApplicationService import java.util.* @@ -22,7 +25,8 @@ class ReadingRecordServiceV2( private val readingRecordDomainService: ReadingRecordDomainService, private val detailTagDomainService: DetailTagDomainService, private val readingRecordDetailTagDomainService: ReadingRecordDetailTagDomainService, - private val userDomainService: UserDomainService + private val userDomainService: UserDomainService, + private val userBookDomainService: UserBookDomainService ) { @Transactional fun createReadingRecord( @@ -73,36 +77,58 @@ class ReadingRecordServiceV2( userBookId: UUID, sort: ReadingRecordSortType?, pageable: Pageable - ): Page { + ): ReadingRecordsWithPrimaryEmotionResponse { + val primaryEmotion = readingRecordDomainService.findPrimaryEmotionByUserBookId(userBookId) + val primaryEmotionDto = toPrimaryEmotionDto(primaryEmotion) + val readingRecordPage = readingRecordDomainService.findByDynamicCondition(userBookId, sort, pageable) if (readingRecordPage.isEmpty) { - return Page.empty(pageable) + return ReadingRecordsWithPrimaryEmotionResponse.of( + primaryEmotion = primaryEmotionDto, + records = Page.empty(pageable) + ) } - val readingRecords = readingRecordPage.content.toList() - val readingRecordIds = readingRecords.map { it.id.value } + val readingRecordIds = readingRecordPage.content.map { it.id.value } + val detailTagsMap = buildDetailTagsMap(readingRecordIds) + val recordsPage = toResponsePage(readingRecordPage, detailTagsMap) + + return ReadingRecordsWithPrimaryEmotionResponse.of( + primaryEmotion = primaryEmotionDto, + records = recordsPage + ) + } + + private fun toPrimaryEmotionDto(primaryEmotion: PrimaryEmotion?): PrimaryEmotionDto? = + primaryEmotion?.let { PrimaryEmotionDto.of(code = it.name, displayName = it.displayName) } - // Fetch detail tags - val readingRecordDetailTags = readingRecordDetailTagDomainService.findByReadingRecordIdIn(readingRecordIds) - val detailTagIds = readingRecordDetailTags.map { it.detailTagId.value }.distinct() - val detailTagsById = detailTagDomainService.findAllById(detailTagIds).associateBy { it.id.value } + private fun buildDetailTagsMap(readingRecordIds: List): Map> { + val detailTags = readingRecordDetailTagDomainService.findByReadingRecordIdIn(readingRecordIds) + val tagLookup = detailTagDomainService + .findAllById(detailTags.map { it.detailTagId.value }.distinct()) + .associateBy { it.id.value } - val detailTagsByReadingRecordId = readingRecordDetailTags + return detailTags .groupBy { it.readingRecordId.value } .mapValues { (_, tags) -> - tags.mapNotNull { detailTagsById[it.detailTagId.value] } - .map { ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) } + tags.mapNotNull { tag -> + tagLookup[tag.detailTagId.value]?.let { + ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) + } + } } + } - return readingRecordPage.map { readingRecord -> - val detailEmotions = detailTagsByReadingRecordId[readingRecord.id.value] ?: emptyList() - ReadingRecordResponseV2.from( - ReadingRecordInfoVO.newInstance( - readingRecord = readingRecord, - detailEmotions = detailEmotions - ) + private fun toResponsePage( + readingRecordPage: Page, + detailTagsByRecordId: Map> + ): Page = readingRecordPage.map { record -> + ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = record, + detailEmotions = detailTagsByRecordId[record.id.value] ?: emptyList() ) - } + ) } @Transactional @@ -206,10 +232,16 @@ class ReadingRecordServiceV2( emptyList() } + val userBook = userBookDomainService.findById(readingRecord.userBookId.value) + return ReadingRecordResponseV2.from( ReadingRecordInfoVO.newInstance( readingRecord = readingRecord, - detailEmotions = detailEmotions + detailEmotions = detailEmotions, + bookTitle = userBook?.title, + bookPublisher = userBook?.publisher, + bookCoverImageUrl = userBook?.coverImageUrl, + author = userBook?.author ) ) } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt index da348b95..9588a277 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt @@ -1,12 +1,11 @@ package org.yapp.apis.readingrecord.usecase -import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable - import org.yapp.apis.book.service.UserBookService import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse import org.yapp.apis.readingrecord.service.ReadingRecordServiceV2 import org.yapp.apis.user.service.UserService import org.yapp.domain.readingrecord.ReadingRecordSortType @@ -52,7 +51,7 @@ class ReadingRecordUseCaseV2( userBookId: UUID, sort: ReadingRecordSortType?, pageable: Pageable - ): Page { + ): ReadingRecordsWithPrimaryEmotionResponse { userService.validateUserExists(userId) userBookService.validateUserBookExists(userBookId, userId) diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt index bac0c904..95d81a9f 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt @@ -72,9 +72,9 @@ data class ReadingRecord private constructor( emotionTags: List? ): ReadingRecord { return this.copy( - pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber, + pageNumber = pageNumber?.let { PageNumber.newInstance(it) }, quote = quote?.let { Quote.newInstance(it) } ?: this.quote, - review = if (review != null) Review.newInstance(review) else this.review, + review = review?.let { Review.newInstance(it) }, primaryEmotion = primaryEmotion ?: this.primaryEmotion, emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags, updatedAt = LocalDateTime.now() diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt index 3075f213..6a8b57e9 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt @@ -9,21 +9,20 @@ import org.yapp.domain.readingrecordtag.ReadingRecordTag import org.yapp.domain.readingrecordtag.ReadingRecordTagRepository import org.yapp.domain.tag.Tag import org.yapp.domain.tag.TagRepository +import org.yapp.domain.userbook.UserBook import org.yapp.domain.userbook.UserBookRepository +import org.yapp.domain.userbook.exception.UserBookErrorCode +import org.yapp.domain.userbook.exception.UserBookNotFoundException import org.yapp.globalutils.annotation.DomainService import java.util.UUID -import org.yapp.domain.userbook.exception.UserBookNotFoundException -import org.yapp.domain.userbook.exception.UserBookErrorCode - @DomainService -class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 제거 +class ReadingRecordDomainService( private val readingRecordRepository: ReadingRecordRepository, private val tagRepository: TagRepository, private val readingRecordTagRepository: ReadingRecordTagRepository, private val userBookRepository: UserBookRepository ) { - // ===================== V2 API (Simple CRUD) ===================== fun createReadingRecordV2( @@ -33,11 +32,7 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 review: String?, primaryEmotion: PrimaryEmotion ): ReadingRecord { - val userBook = userBookRepository.findById(userBookId) - ?: throw UserBookNotFoundException( - UserBookErrorCode.USER_BOOK_NOT_FOUND, - "User book not found with id: $userBookId" - ) + val userBook = findUserBookOrThrow(userBookId) val readingRecord = ReadingRecord.create( userBookId = userBookId, @@ -60,11 +55,7 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 review: String?, primaryEmotion: PrimaryEmotion? ): ReadingRecord { - val readingRecord = readingRecordRepository.findById(readingRecordId) - ?: throw ReadingRecordNotFoundException( - ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, - "Reading record not found with id: $readingRecordId" - ) + val readingRecord = findReadingRecordOrThrow(readingRecordId) val updatedReadingRecord = readingRecord.update( pageNumber = pageNumber, @@ -77,39 +68,26 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 return readingRecordRepository.save(updatedReadingRecord) } - fun findById(readingRecordId: UUID): ReadingRecord { - return readingRecordRepository.findById(readingRecordId) - ?: throw ReadingRecordNotFoundException( - ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, - "Reading record not found with id: $readingRecordId" - ) - } + fun findById(readingRecordId: UUID): ReadingRecord = findReadingRecordOrThrow(readingRecordId) fun findByDynamicCondition( userBookId: UUID, sort: ReadingRecordSortType?, pageable: Pageable - ): Page { - return readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable) - } + ): Page = + readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable) fun deleteReadingRecordV2(readingRecordId: UUID) { - val readingRecord = readingRecordRepository.findById(readingRecordId) - ?: throw ReadingRecordNotFoundException( - ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, - "Reading record not found with id: $readingRecordId" - ) - - val userBook = userBookRepository.findById(readingRecord.userBookId.value) - ?: throw UserBookNotFoundException( - UserBookErrorCode.USER_BOOK_NOT_FOUND, - "User book not found with id: ${readingRecord.userBookId.value}" - ) + val readingRecord = findReadingRecordOrThrow(readingRecordId) + val userBook = findUserBookOrThrow(readingRecord.userBookId.value) readingRecordRepository.deleteById(readingRecordId) userBookRepository.save(userBook.decreaseReadingRecordCount()) } + fun findPrimaryEmotionByUserBookId(userBookId: UUID): PrimaryEmotion? = + readingRecordRepository.findMostFrequentPrimaryEmotion(userBookId) + // ===================== V1 API (Legacy) ===================== fun createReadingRecord( @@ -119,15 +97,10 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 review: String?, emotionTags: List ): ReadingRecordInfoVO { - val userBook = userBookRepository.findById(userBookId) - ?: throw UserBookNotFoundException( - UserBookErrorCode.USER_BOOK_NOT_FOUND, - "User book not found with id: $userBookId" - ) + val userBook = findUserBookOrThrow(userBookId) - // Convert emotion tag to primary emotion - val primaryEmotion = emotionTags.firstOrNull()?.let { - PrimaryEmotion.fromDisplayName(it) + val primaryEmotion = emotionTags.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) } ?: PrimaryEmotion.OTHER val readingRecord = ReadingRecord.create( @@ -140,18 +113,8 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 val savedReadingRecord = readingRecordRepository.save(readingRecord) - // Also save to legacy reading_record_tags for backward compatibility - val tags = emotionTags.map { tagName -> - tagRepository.findByName(tagName) ?: tagRepository.save(Tag.create(tagName)) - } - - val readingRecordTags = tags.map { - ReadingRecordTag.create( - readingRecordId = savedReadingRecord.id.value, - tagId = it.id.value - ) - } - readingRecordTagRepository.saveAll(readingRecordTags) + val tags = findOrCreateTags(emotionTags) + saveReadingRecordTags(savedReadingRecord.id.value, tags) userBookRepository.save(userBook.increaseReadingRecordCount()) @@ -166,12 +129,7 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 } fun findReadingRecordById(readingRecordId: UUID): ReadingRecordInfoVO { - val readingRecord = readingRecordRepository.findById(readingRecordId) - ?: throw ReadingRecordNotFoundException( - ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, - "Reading record not found with id: $readingRecordId" - ) - + val readingRecord = findReadingRecordOrThrow(readingRecordId) return buildReadingRecordInfoVO(readingRecord) } @@ -201,7 +159,7 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 return Page.empty(pageable) } - val readingRecords = readingRecordPage.content + val readingRecords = readingRecordPage.content.toList() val readingRecordIds = readingRecords.map { it.id.value } val readingRecordTags = readingRecordTagRepository.findByReadingRecordIdIn(readingRecordIds) @@ -235,15 +193,10 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 review: String?, emotionTags: List? ): ReadingRecordInfoVO { - val readingRecord = readingRecordRepository.findById(readingRecordId) - ?: throw ReadingRecordNotFoundException( - ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, - "Reading record not found with id: $readingRecordId" - ) + val readingRecord = findReadingRecordOrThrow(readingRecordId) - // Convert emotion tag to primary emotion if provided - val primaryEmotion = emotionTags?.firstOrNull()?.let { - PrimaryEmotion.fromDisplayName(it) + val primaryEmotion = emotionTags?.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) } val updatedReadingRecord = readingRecord.update( @@ -256,19 +209,10 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 val savedReadingRecord = readingRecordRepository.save(updatedReadingRecord) - // Update emotion tags if (emotionTags != null) { readingRecordTagRepository.deleteAllByReadingRecordId(readingRecordId) - val tags = emotionTags.map { tagName -> - tagRepository.findByName(tagName) ?: tagRepository.save(Tag.create(tagName)) - } - val newReadingRecordTags = tags.map { - ReadingRecordTag.create( - readingRecordId = savedReadingRecord.id.value, - tagId = it.id.value - ) - } - readingRecordTagRepository.saveAll(newReadingRecordTags) + val tags = findOrCreateTags(emotionTags) + saveReadingRecordTags(savedReadingRecord.id.value, tags) } return buildReadingRecordInfoVO(savedReadingRecord) @@ -278,20 +222,38 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 readingRecordRepository.deleteAllByUserBookId(userBookId) } - fun deleteReadingRecord(readingRecordId: UUID) { - val readingRecord = readingRecordRepository.findById(readingRecordId) + /** + * V1 Legacy delete - delegates to V2 implementation + */ + fun deleteReadingRecord(readingRecordId: UUID) = deleteReadingRecordV2(readingRecordId) + + // ===================== Private Helper Methods ===================== + + private fun findReadingRecordOrThrow(id: UUID): ReadingRecord = + readingRecordRepository.findById(id) ?: throw ReadingRecordNotFoundException( ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, - "Reading record not found with id: $readingRecordId" + "Reading record not found with id: $id" ) - val userBook = userBookRepository.findById(readingRecord.userBookId.value) + private fun findUserBookOrThrow(id: UUID): UserBook = + userBookRepository.findById(id) ?: throw UserBookNotFoundException( UserBookErrorCode.USER_BOOK_NOT_FOUND, - "User book not found with id: ${readingRecord.userBookId.value}" + "User book not found with id: $id" ) - readingRecordRepository.deleteById(readingRecordId) - userBookRepository.save(userBook.decreaseReadingRecordCount()) + private fun findOrCreateTags(tagNames: List): List = + tagNames.map { tagName -> + tagRepository.findByName(tagName) ?: tagRepository.save(Tag.create(tagName)) + } + + private fun saveReadingRecordTags(readingRecordId: UUID, tags: List) { + val readingRecordTags = tags.map { + ReadingRecordTag.create(readingRecordId = readingRecordId, tagId = it.id.value) + } + readingRecordTagRepository.saveAll(readingRecordTags) } + } + diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt index 543d713c..f118fa3b 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt @@ -35,12 +35,7 @@ interface ReadingRecordRepository { fun deleteById(id: UUID) - /** - * Find reading records created after the specified time for books owned by the user - * - * @param userBookIds List of user book IDs to search in - * @param after Find records created after this time - * @return List of reading records matching the criteria - */ fun findByUserBookIdInAndCreatedAtAfter(userBookIds: List, after: LocalDateTime): List + + fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? } 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 666b0109..76b0f365 100644 --- a/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt @@ -6,7 +6,6 @@ import org.yapp.domain.userbook.vo.HomeBookVO import org.yapp.domain.userbook.vo.UserBookInfoVO import org.yapp.domain.userbook.vo.UserBookStatusCountsVO import org.yapp.globalutils.annotation.DomainService -import org.yapp.domain.readingrecord.ReadingRecordRepository import java.util.* @DomainService @@ -82,6 +81,11 @@ class UserBookDomainService( userBookRepository.deleteById(userBookId) } + fun findById(userBookId: UUID): UserBookInfoVO? { + val userBook = userBookRepository.findById(userBookId) + return userBook?.let { UserBookInfoVO.newInstance(it, it.readingRecordCount) } + } + fun findBooksWithRecordsOrderByLatest(userId: UUID): List { val resultTriples = userBookRepository.findRecordedBooksSortedByRecency(userId) diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt index 5fbbd7e5..7e111618 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt @@ -3,6 +3,7 @@ package org.yapp.infra.readingrecord.repository import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.infra.readingrecord.entity.ReadingRecordEntity import java.util.UUID @@ -13,4 +14,6 @@ interface JpaReadingRecordQuerydslRepository { sort: ReadingRecordSortType?, pageable: Pageable ): Page + + fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? } diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt index d36d3926..abc5ab39 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordRepository.kt @@ -22,3 +22,4 @@ interface JpaReadingRecordRepository : JpaRepository, fun findByUserBookIdInAndCreatedAtAfter(userBookIds: List, createdAt: LocalDateTime): List } + diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt index b710f712..43ed0ec2 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import org.springframework.stereotype.Repository +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecordSortType import org.yapp.infra.readingrecord.entity.QReadingRecordEntity import org.yapp.infra.readingrecord.entity.ReadingRecordEntity @@ -45,6 +46,22 @@ class JpaReadingRecordQuerydslRepositoryImpl( return PageImpl(results, pageable, total) } + override fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? { + return queryFactory + .select(readingRecord.primaryEmotion) + .from(readingRecord) + .where( + readingRecord.userBookId.eq(userBookId) + .and(readingRecord.deletedAt.isNull()) + ) + .groupBy(readingRecord.primaryEmotion) + .orderBy( + readingRecord.id.count().desc(), + readingRecord.createdAt.max().desc() + ) + .fetchFirst() + } + private fun createOrderSpecifiers(sort: ReadingRecordSortType?): Array> { return when (sort) { ReadingRecordSortType.PAGE_NUMBER_ASC -> arrayOf( @@ -64,5 +81,5 @@ class JpaReadingRecordQuerydslRepositoryImpl( null -> arrayOf(readingRecord.updatedAt.desc()) } } - } + diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt index d4ca7559..13b99fde 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Repository +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord import org.yapp.domain.readingrecord.ReadingRecordRepository import org.yapp.domain.readingrecord.ReadingRecordSortType @@ -66,4 +67,8 @@ class ReadingRecordRepositoryImpl( val entities = jpaReadingRecordRepository.findByUserBookIdInAndCreatedAtAfter(userBookIds, after) return entities.map { it.toDomain() } } + + override fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? { + return jpaReadingRecordRepository.findMostFrequentPrimaryEmotion(userBookId) + } } diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt index 4f5ff199..f18a6bb5 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt @@ -2,8 +2,6 @@ package org.yapp.infra.readingrecorddetailtag.entity import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode -import org.hibernate.annotations.SQLDelete -import org.hibernate.annotations.SQLRestriction import org.yapp.domain.detailtag.DetailTag import org.yapp.domain.readingrecord.ReadingRecord import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTag @@ -25,8 +23,6 @@ import java.util.* Index(name = "idx_rrdt_detail_tag_id", columnList = "detail_tag_id") ] ) -@SQLDelete(sql = "UPDATE reading_record_detail_tags SET deleted_at = NOW() WHERE id = ?") -@SQLRestriction("deleted_at IS NULL") class ReadingRecordDetailTagEntity( @Id @JdbcTypeCode(Types.VARCHAR) diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt index 0826f7e1..5191b434 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt @@ -1,12 +1,18 @@ package org.yapp.infra.readingrecorddetailtag.repository import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.yapp.infra.readingrecorddetailtag.entity.ReadingRecordDetailTagEntity import java.util.* interface JpaReadingRecordDetailTagRepository : JpaRepository { fun findByReadingRecordId(readingRecordId: UUID): List fun findByReadingRecordIdIn(readingRecordIds: List): List - fun deleteAllByReadingRecordId(readingRecordId: UUID) + + @Modifying + @Query("DELETE FROM ReadingRecordDetailTagEntity e WHERE e.readingRecordId = :readingRecordId") + fun deleteAllByReadingRecordId(@Param("readingRecordId") readingRecordId: UUID) } From a8530258ce025bd364f36ce38503f4037aefbb40 Mon Sep 17 00:00:00 2001 From: DONGHOON LEE <125895298+move-hoon@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:43:18 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20apis,=20domain=20-=20V1=20API?= =?UTF-8?q?=EC=99=80=20response=20=EA=B5=AC=EC=A1=B0=EA=B0=80=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=8F=85=EC=84=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?api=20=EC=88=98=EC=A0=95=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [BOOK-488] chore: apis, domain - V1 API와 response 구조가 호환되도록 독서 기록 목록 조회 api 수정 --- ...eadingRecordsWithPrimaryEmotionResponse.kt | 30 ++++++++++++++----- .../service/ReadingRecordServiceV2.kt | 8 ++--- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt index d59ce354..aa4db797 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt @@ -9,19 +9,35 @@ import org.springframework.data.domain.Page ) data class ReadingRecordsWithPrimaryEmotionResponse private constructor( @field:Schema(description = "해당 책의 대표(최다) 감정") - val primaryEmotion: PrimaryEmotionDto?, + val representativeEmotion: PrimaryEmotionDto?, - @field:Schema(description = "독서 기록 목록 (페이징)") - val records: Page + @field:Schema(description = "마지막 페이지 여부", example = "false") + val lastPage: Boolean, + + @field:Schema(description = "총 결과 개수", example = "42") + val totalResults: Int, + + @field:Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0") + val startIndex: Int, + + @field:Schema(description = "한 페이지당 아이템 개수", example = "10") + val itemsPerPage: Int, + + @field:Schema(description = "독서 기록 목록") + val readingRecords: List ) { companion object { fun of( - primaryEmotion: PrimaryEmotionDto?, - records: Page + representativeEmotion: PrimaryEmotionDto?, + page: Page ): ReadingRecordsWithPrimaryEmotionResponse { return ReadingRecordsWithPrimaryEmotionResponse( - primaryEmotion = primaryEmotion, - records = records + representativeEmotion = representativeEmotion, + lastPage = page.isLast, + totalResults = page.totalElements.toInt(), + startIndex = page.number, + itemsPerPage = page.size, + readingRecords = page.content ) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt index ee411245..a7c0516e 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -84,8 +84,8 @@ class ReadingRecordServiceV2( val readingRecordPage = readingRecordDomainService.findByDynamicCondition(userBookId, sort, pageable) if (readingRecordPage.isEmpty) { return ReadingRecordsWithPrimaryEmotionResponse.of( - primaryEmotion = primaryEmotionDto, - records = Page.empty(pageable) + representativeEmotion = primaryEmotionDto, + page = Page.empty(pageable) ) } @@ -94,8 +94,8 @@ class ReadingRecordServiceV2( val recordsPage = toResponsePage(readingRecordPage, detailTagsMap) return ReadingRecordsWithPrimaryEmotionResponse.of( - primaryEmotion = primaryEmotionDto, - records = recordsPage + representativeEmotion = primaryEmotionDto, + page = recordsPage ) } From 184b168402eab88a72d26993dac7930a9202d420 Mon Sep 17 00:00:00 2001 From: KIM MIN WOO <79193811+minwoo1999@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:09:30 +0900 Subject: [PATCH 5/8] Book 469 feature/#141 (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BOOK-469] feat: apis,infra google social login (#141) * [BOOK-469] fix(ci): ci 수정 - 테스트 프로필에 oauth 설정 추가 - 로컬에서 sonar task 스킵되도록 수정 * [BOOK-469] fix: apis,infra - 구글 소셜로그인 리팩토링(#142) * [BOOK-469] feat: apis,infra 구글로그인 수정(#141) --- apis/build.gradle.kts | 4 +++ .../yapp/apis/auth/exception/AuthErrorCode.kt | 1 + .../helper/google/GoogleIdTokenProcessor.kt | 34 +++++++++++++++++++ .../strategy/signin/GoogleSignInStrategy.kt | 18 +++++----- .../auth/strategy/signin/SignInCredentials.kt | 2 +- .../yapp/apis/config/GoogleOauthProperties.kt | 3 +- apis/src/main/resources/application.yml | 1 + buildSrc/src/main/kotlin/Dependencies.kt | 6 ++++ 8 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt diff --git a/apis/build.gradle.kts b/apis/build.gradle.kts index c6ab8ff1..12b005c7 100644 --- a/apis/build.gradle.kts +++ b/apis/build.gradle.kts @@ -22,6 +22,10 @@ dependencies { implementation(Dependencies.BouncyCastle.BC_PROV) implementation(Dependencies.BouncyCastle.BC_PKIX) + implementation(Dependencies.Google.API_CLIENT) + implementation(Dependencies.Google.HTTP_CLIENT_APACHE) + implementation(Dependencies.Google.HTTP_CLIENT_GSON) + kapt(Dependencies.Spring.CONFIGURATION_PROCESSOR) testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt b/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt index 3072939c..33a0c9dd 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/exception/AuthErrorCode.kt @@ -20,6 +20,7 @@ enum class AuthErrorCode( USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_08", "사용자를 찾을 수 없습니다."), EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH_400_09", "이메일을 찾을 수 없습니다."), INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_10", "유효하지 않은 Apple ID 토큰입니다."), + INVALID_GOOGLE_ID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_400_13", "유효하지 않은 Google ID 토큰입니다."), PROVIDER_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH_400_11", "요청된 공급자 타입이 실제 사용자의 공급자 타입과 일치하지 않습니다."), APPLE_REFRESH_TOKEN_MISSING(HttpStatus.BAD_REQUEST, "AUTH_400_12", "Apple 사용자 탈퇴 시 리프레시 토큰이 누락되었습니다."), KAKAO_UNLINK_FAILED(HttpStatus.BAD_REQUEST, "AUTH_400_15", "카카오 회원탈퇴 처리에 실패했습니다."), diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt b/apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt new file mode 100644 index 00000000..7531bea6 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/helper/google/GoogleIdTokenProcessor.kt @@ -0,0 +1,34 @@ +package org.yapp.apis.auth.helper.google + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.http.apache.v2.ApacheHttpTransport +import com.google.api.client.json.gson.GsonFactory +import org.yapp.apis.auth.exception.AuthErrorCode +import org.yapp.apis.auth.exception.AuthException +import org.yapp.apis.config.GoogleOauthProperties +import org.yapp.globalutils.annotation.Helper +import java.util.Collections + +@Helper +class GoogleIdTokenProcessor( + private val googleOauthProperties: GoogleOauthProperties, +) { + private val verifier: GoogleIdTokenVerifier = GoogleIdTokenVerifier.Builder( + ApacheHttpTransport(), + GsonFactory.getDefaultInstance() + ) + .setAudience(Collections.singletonList(googleOauthProperties.clientId)) + .build() + + fun parseAndValidate(idToken: String): GoogleIdToken.Payload { + try { + val googleIdToken = verifier.verify(idToken) + ?: throw AuthException(AuthErrorCode.INVALID_GOOGLE_ID_TOKEN, "Invalid ID token") + + return googleIdToken.payload + } catch (e: Exception) { + throw AuthException(AuthErrorCode.INVALID_GOOGLE_ID_TOKEN, e.message) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt index f91f956b..7d673b8a 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/GoogleSignInStrategy.kt @@ -1,18 +1,18 @@ package org.yapp.apis.auth.strategy.signin +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken import mu.KotlinLogging import org.springframework.stereotype.Component import org.yapp.apis.auth.dto.response.UserCreateInfoResponse import org.yapp.apis.auth.exception.AuthErrorCode import org.yapp.apis.auth.exception.AuthException -import org.yapp.apis.auth.manager.GoogleApiManager +import org.yapp.apis.auth.helper.google.GoogleIdTokenProcessor import org.yapp.apis.auth.util.NicknameGenerator import org.yapp.domain.user.ProviderType -import org.yapp.infra.external.oauth.google.response.GoogleUserInfo @Component class GoogleSignInStrategy( - private val googleApiManager: GoogleApiManager + private val googleIdTokenProcessor: GoogleIdTokenProcessor, ) : SignInStrategy { private val log = KotlinLogging.logger {} @@ -22,8 +22,8 @@ class GoogleSignInStrategy( override fun authenticate(credentials: SignInCredentials): UserCreateInfoResponse { return try { val googleCredentials = validateCredentials(credentials) - val googleUser = googleApiManager.getUserInfo(googleCredentials.accessToken) - createUserInfo(googleUser) + val googleUserPayload = googleIdTokenProcessor.parseAndValidate(googleCredentials.idToken) + createUserInfo(googleUserPayload) } catch (exception: Exception) { log.error("Google authentication failed", exception) when (exception) { @@ -41,13 +41,13 @@ class GoogleSignInStrategy( ) } - private fun createUserInfo(googleUser: GoogleUserInfo): UserCreateInfoResponse { + private fun createUserInfo(googleUser: GoogleIdToken.Payload): UserCreateInfoResponse { return UserCreateInfoResponse.of( - email = googleUser.email ?: ("google_${googleUser.id}@google.com"), + email = googleUser.email ?: ("google_${googleUser.subject}@google.com"), nickname = NicknameGenerator.generate(), - profileImageUrl = googleUser.picture, + profileImageUrl = googleUser["picture"] as? String, providerType = ProviderType.GOOGLE, - providerId = googleUser.id + providerId = googleUser.subject ) } } diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt index ba921e8f..3374e0c3 100644 --- a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/signin/SignInCredentials.kt @@ -20,7 +20,7 @@ data class AppleAuthCredentials( } data class GoogleAuthCredentials( - val accessToken: String, + val idToken: String, ) : SignInCredentials() { override fun getProviderType(): ProviderType { return ProviderType.GOOGLE diff --git a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt index 4e59c83b..d8a6c05d 100644 --- a/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt +++ b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt @@ -4,7 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "oauth.google") data class GoogleOauthProperties( - val url: Url + val url: Url, + val clientId: String ) data class Url( diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index aaa824b0..d9293ac7 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -65,6 +65,7 @@ oauth: google: url: user-info: https://www.googleapis.com/oauth2/v2/userinfo + client-id: ${GOOGLE_CLIENT_ID} --- spring: diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index e214e3bc..34827e6e 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -80,4 +80,10 @@ object Dependencies { object Firebase { const val FIREBASE_ADMIN = "com.google.firebase:firebase-admin:9.2.0" } + + object Google { + const val API_CLIENT = "com.google.api-client:google-api-client:2.2.0" + const val HTTP_CLIENT_APACHE = "com.google.http-client:google-http-client-apache-v2:1.43.3" + const val HTTP_CLIENT_GSON = "com.google.http-client:google-http-client-gson:1.43.3" + } } From 00e11820e538421a66bd40b89e292b3f198b9821 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Tue, 27 Jan 2026 02:08:58 +0900 Subject: [PATCH 6/8] =?UTF-8?q?hotfix:=20=EB=8F=85=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20V2?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=BC=EB=B6=80=20=EC=B1=85=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=93=A4=EC=9D=B4=20=EC=95=88=EB=82=B4=EB=A0=A4?= =?UTF-8?q?=EA=B0=80=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../readingrecord/service/ReadingRecordServiceV2.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt index a7c0516e..e6938fff 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -17,6 +17,7 @@ import org.yapp.domain.readingrecord.vo.ReadingRecordInfoVO import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTagDomainService import org.yapp.domain.user.UserDomainService import org.yapp.domain.userbook.UserBookDomainService +import org.yapp.domain.userbook.vo.UserBookInfoVO import org.yapp.globalutils.annotation.ApplicationService import java.util.* @@ -91,7 +92,8 @@ class ReadingRecordServiceV2( val readingRecordIds = readingRecordPage.content.map { it.id.value } val detailTagsMap = buildDetailTagsMap(readingRecordIds) - val recordsPage = toResponsePage(readingRecordPage, detailTagsMap) + val userBookInfoVO = userBookDomainService.findById(userBookId) + val recordsPage = toResponsePage(readingRecordPage, detailTagsMap, userBookInfoVO) return ReadingRecordsWithPrimaryEmotionResponse.of( representativeEmotion = primaryEmotionDto, @@ -121,12 +123,17 @@ class ReadingRecordServiceV2( private fun toResponsePage( readingRecordPage: Page, - detailTagsByRecordId: Map> + detailTagsByRecordId: Map>, + userBookInfoVO: UserBookInfoVO? ): Page = readingRecordPage.map { record -> ReadingRecordResponseV2.from( ReadingRecordInfoVO.newInstance( readingRecord = record, - detailEmotions = detailTagsByRecordId[record.id.value] ?: emptyList() + detailEmotions = detailTagsByRecordId[record.id.value] ?: emptyList(), + bookTitle = userBookInfoVO?.title, + bookPublisher = userBookInfoVO?.publisher, + bookCoverImageUrl = userBookInfoVO?.coverImageUrl, + author = userBookInfoVO?.author ) ) } From b308e549a4f1b6fc97e209de46aa2979b8ce69cc Mon Sep 17 00:00:00 2001 From: DONGHOON LEE <125895298+move-hoon@users.noreply.github.com> Date: Tue, 27 Jan 2026 02:55:25 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EC=94=A8=EC=95=97=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20API=20V2=EB=A5=BC=20=EC=8B=A0=EA=B7=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=98=EC=97=AC=20V2=20=EB=8F=85=EC=84=9C?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=EC=9D=B4=20=EC=94=A8=EC=95=97=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=EC=97=90=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BOOK-498] feat: apis - 씨앗 통계 V2 응답 DTO 추가 (5가지 감정 지원) - SeedStatsResponseV2 생성 (따뜻함, 즐거움, 슬픔, 깨달음, 기타) - PrimaryEmotion enum의 displayName 활용 - V1과 명확히 분리된 응답 구조 * [BOOK-498] feat: domain, infra - primary_emotion 기반 씨앗 통계 집계 쿼리 추가 - ReadingRecordRepository에 countPrimaryEmotionsByUserBookId 메서드 추가 - QueryDSL로 primary_emotion 기반 GROUP BY 쿼리 구현 - deletedAt IS NULL 조건으로 삭제된 기록 제외 - V1 + V2 데이터 모두 집계 가능 * [BOOK-498] feat: domain - 씨앗 통계 조회 도메인 서비스 메서드 추가 - countPrimaryEmotionsByUserBookId 메서드 추가 - Repository 계층으로 위임하여 Map 반환 - V2 Service에서 DTO 변환 처리 * [BOOK-498] feat: apis - 씨앗 통계 V2 서비스 및 유스케이스 구현 - ReadingRecordServiceV2에 getSeedStats 메서드 추가 - ReadingRecordUseCaseV2에 getSeedStats 메서드 추가 - 사용자 및 책 소유권 검증 포함 - SeedStatsResponseV2로 응답 변환 * [BOOK-498] feat: apis - 씨앗 통계 V2 API 엔드포인트 추가 - GET /api/v2/reading-records/{userBookId}/seed/stats 엔드포인트 추가 - 5가지 감정(따뜻함, 즐거움, 슬픔, 깨달음, 기타) 통계 제공 - V1 + V2 데이터 모두 집계 - Swagger 문서화 완료 * [BOOK-498] fix: 테스트용 구글 client-id가 없어서 CI 깨지는 문제 해결 --- .../ReadingRecordControllerApiV2.kt | 25 ++++++++++++ .../controller/ReadingRecordControllerV2.kt | 10 +++++ .../dto/response/SeedStatsResponseV2.kt | 40 +++++++++++++++++++ .../service/ReadingRecordServiceV2.kt | 7 ++++ .../usecase/ReadingRecordUseCaseV2.kt | 11 +++++ apis/src/main/resources/application.yml | 1 + .../ReadingRecordDomainService.kt | 3 ++ .../readingrecord/ReadingRecordRepository.kt | 2 + .../JpaReadingRecordQuerydslRepository.kt | 2 + .../JpaReadingRecordQuerydslRepositoryImpl.kt | 18 +++++++++ .../impl/ReadingRecordRepositoryImpl.kt | 4 ++ 11 files changed, 123 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/SeedStatsResponseV2.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt index 0522e734..2c0316ea 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt @@ -18,6 +18,7 @@ import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse +import org.yapp.apis.readingrecord.dto.response.SeedStatsResponseV2 import org.yapp.domain.readingrecord.ReadingRecordSortType import org.yapp.globalutils.exception.ErrorResponse import java.util.* @@ -172,4 +173,28 @@ interface ReadingRecordControllerApiV2 { @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, @PathVariable @Parameter(description = "삭제할 독서 기록 ID") readingRecordId: UUID ): ResponseEntity + + @Operation( + summary = "씨앗 통계 조회 (V2)", + description = "사용자의 책에 대한 감정별 씨앗 통계를 조회합니다. 5가지 감정(따뜻함, 즐거움, 슬픔, 깨달음, 기타)을 포함합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "씨앗 통계 조회 성공", + content = [Content(schema = Schema(implementation = SeedStatsResponseV2::class))] + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 책을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @GetMapping("/{userBookId}/seed/stats") + fun getSeedStats( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "씨앗 통계를 조회할 사용자 책 ID") userBookId: UUID + ): ResponseEntity } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt index 24655253..27e8ecf3 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt @@ -12,6 +12,7 @@ import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse +import org.yapp.apis.readingrecord.dto.response.SeedStatsResponseV2 import org.yapp.apis.readingrecord.usecase.ReadingRecordUseCaseV2 import org.yapp.domain.readingrecord.ReadingRecordSortType import java.util.UUID @@ -87,4 +88,13 @@ class ReadingRecordControllerV2( readingRecordUseCaseV2.deleteReadingRecord(userId, readingRecordId) return ResponseEntity.noContent().build() } + + @GetMapping("/{userBookId}/seed/stats") + override fun getSeedStats( + @AuthenticationPrincipal userId: UUID, + @PathVariable userBookId: UUID + ): ResponseEntity { + val response = readingRecordUseCaseV2.getSeedStats(userId, userBookId) + return ResponseEntity.ok(response) + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/SeedStatsResponseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/SeedStatsResponseV2.kt new file mode 100644 index 00000000..e7ab1e58 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/SeedStatsResponseV2.kt @@ -0,0 +1,40 @@ +package org.yapp.apis.readingrecord.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.yapp.domain.readingrecord.PrimaryEmotion + +@Schema( + name = "SeedStatsResponseV2", + description = "Seed statistics by emotion category (V2 - includes OTHER)" +) +data class SeedStatsResponseV2 private constructor( + @field:Schema(description = "List of statistics for each emotion category (5 categories)") + val categories: List +) { + @Schema(name = "SeedCategoryStats", description = "Statistics for a single emotion category") + data class SeedCategoryStats private constructor( + @field:Schema(description = "Emotion category name", example = "따뜻함") + val name: String, + + @field:Schema(description = "Number of seeds for this emotion category", example = "3", minimum = "0") + val count: Int + ) { + companion object { + fun of(name: String, count: Int): SeedCategoryStats { + return SeedCategoryStats(name, count) + } + } + } + + companion object { + fun from(primaryEmotionCounts: Map): SeedStatsResponseV2 { + val categories = PrimaryEmotion.entries.map { emotion -> + SeedCategoryStats.of( + name = emotion.displayName, + count = primaryEmotionCounts.getOrDefault(emotion, 0) + ) + } + return SeedStatsResponseV2(categories) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt index e6938fff..06844429 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -8,6 +8,7 @@ import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.PrimaryEmotionDto import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse +import org.yapp.apis.readingrecord.dto.response.SeedStatsResponseV2 import org.yapp.domain.detailtag.DetailTagDomainService import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord @@ -257,4 +258,10 @@ class ReadingRecordServiceV2( fun getUserBookIdByReadingRecordId(readingRecordId: UUID): UUID { return readingRecordDomainService.findById(readingRecordId).userBookId.value } + + @Transactional(readOnly = true) + fun getSeedStats(userBookId: UUID): SeedStatsResponseV2 { + val primaryEmotionCounts = readingRecordDomainService.countPrimaryEmotionsByUserBookId(userBookId) + return SeedStatsResponseV2.from(primaryEmotionCounts) + } } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt index 9588a277..3157eaa8 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt @@ -6,6 +6,7 @@ import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 import org.yapp.apis.readingrecord.dto.response.ReadingRecordsWithPrimaryEmotionResponse +import org.yapp.apis.readingrecord.dto.response.SeedStatsResponseV2 import org.yapp.apis.readingrecord.service.ReadingRecordServiceV2 import org.yapp.apis.user.service.UserService import org.yapp.domain.readingrecord.ReadingRecordSortType @@ -87,4 +88,14 @@ class ReadingRecordUseCaseV2( userBookService.validateUserBookExists(userBookId, userId) readingRecordServiceV2.deleteReadingRecord(readingRecordId) } + + fun getSeedStats( + userId: UUID, + userBookId: UUID + ): SeedStatsResponseV2 { + userService.validateUserExists(userId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.getSeedStats(userBookId) + } } diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index d9293ac7..a41c1132 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -96,3 +96,4 @@ oauth: google: url: user-info: https://www.googleapis.com/oauth2/v2/userinfo + client-id: test-client-id diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt index 6a8b57e9..63e5a7f7 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt @@ -88,6 +88,9 @@ class ReadingRecordDomainService( fun findPrimaryEmotionByUserBookId(userBookId: UUID): PrimaryEmotion? = readingRecordRepository.findMostFrequentPrimaryEmotion(userBookId) + fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map = + readingRecordRepository.countPrimaryEmotionsByUserBookId(userBookId) + // ===================== V1 API (Legacy) ===================== fun createReadingRecord( diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt index f118fa3b..17a72b74 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordRepository.kt @@ -38,4 +38,6 @@ interface ReadingRecordRepository { fun findByUserBookIdInAndCreatedAtAfter(userBookIds: List, after: LocalDateTime): List fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? + + fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map } diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt index 7e111618..baf99561 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt @@ -16,4 +16,6 @@ interface JpaReadingRecordQuerydslRepository { ): Page fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? + + fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map } diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt index 43ed0ec2..ebc12236 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/JpaReadingRecordQuerydslRepositoryImpl.kt @@ -62,6 +62,24 @@ class JpaReadingRecordQuerydslRepositoryImpl( .fetchFirst() } + override fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map { + val results = queryFactory + .select(readingRecord.primaryEmotion, readingRecord.count()) + .from(readingRecord) + .where( + readingRecord.userBookId.eq(userBookId) + .and(readingRecord.deletedAt.isNull()) + ) + .groupBy(readingRecord.primaryEmotion) + .fetch() + + return results.associate { tuple -> + val emotion = tuple[readingRecord.primaryEmotion] ?: PrimaryEmotion.OTHER + val count = tuple[readingRecord.count()]?.toInt() ?: 0 + emotion to count + } + } + private fun createOrderSpecifiers(sort: ReadingRecordSortType?): Array> { return when (sort) { ReadingRecordSortType.PAGE_NUMBER_ASC -> arrayOf( diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt index 13b99fde..00260593 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/impl/ReadingRecordRepositoryImpl.kt @@ -71,4 +71,8 @@ class ReadingRecordRepositoryImpl( override fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? { return jpaReadingRecordRepository.findMostFrequentPrimaryEmotion(userBookId) } + + override fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map { + return jpaReadingRecordRepository.countPrimaryEmotionsByUserBookId(userBookId) + } } From d1487473b94c6c895d42ce655dfdf0fbbefd8253 Mon Sep 17 00:00:00 2001 From: MinWoo Kim Date: Sun, 8 Feb 2026 18:18:47 +0900 Subject: [PATCH 8/8] =?UTF-8?q?hotfix:=20=EA=B5=AC=EA=B8=80=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=ED=83=88=ED=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../withdraw/GoogleWithdrawStrategy.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt new file mode 100644 index 00000000..e1ecb9d3 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/auth/strategy/withdraw/GoogleWithdrawStrategy.kt @@ -0,0 +1,21 @@ +package org.yapp.apis.auth.strategy.withdraw + +import jakarta.validation.Valid +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.springframework.validation.annotation.Validated +import org.yapp.apis.auth.dto.request.WithdrawStrategyRequest +import org.yapp.domain.user.ProviderType + +@Component +@Validated +class GoogleWithdrawStrategy : WithdrawStrategy { + private val log = KotlinLogging.logger {} + + override fun getProviderType() = ProviderType.GOOGLE + + override fun withdraw(@Valid request: WithdrawStrategyRequest) { + log.info("Starting Google withdrawal for user: ${request.userId}, providerId: ${request.providerId}") + log.info("Successfully processed Google withdrawal for user: ${request.userId}") + } +}