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/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/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/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..7d673b8a --- /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 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.helper.google.GoogleIdTokenProcessor +import org.yapp.apis.auth.util.NicknameGenerator +import org.yapp.domain.user.ProviderType + +@Component +class GoogleSignInStrategy( + private val googleIdTokenProcessor: GoogleIdTokenProcessor, +) : 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 googleUserPayload = googleIdTokenProcessor.parseAndValidate(googleCredentials.idToken) + createUserInfo(googleUserPayload) + } 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: GoogleIdToken.Payload): UserCreateInfoResponse { + return UserCreateInfoResponse.of( + email = googleUser.email ?: ("google_${googleUser.subject}@google.com"), + nickname = NicknameGenerator.generate(), + profileImageUrl = googleUser["picture"] as? String, + providerType = ProviderType.GOOGLE, + 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 a274481c..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 @@ -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 idToken: String, +) : SignInCredentials() { + override fun getProviderType(): ProviderType { + return ProviderType.GOOGLE + } +} + 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}") + } +} 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/config/GoogleOauthProperties.kt b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt new file mode 100644 index 00000000..d8a6c05d --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/config/GoogleOauthProperties.kt @@ -0,0 +1,13 @@ +package org.yapp.apis.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.google") +data class GoogleOauthProperties( + val url: Url, + val clientId: String +) + +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/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/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 new file mode 100644 index 00000000..64fdaf07 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt @@ -0,0 +1,58 @@ +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 + ) + } + } + } + +} 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..2c0316ea --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt @@ -0,0 +1,200 @@ +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.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.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.* + +@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 = "독서 기록 삭제 (V2)", + 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 + + @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 new file mode 100644 index 00000000..27e8ecf3 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt @@ -0,0 +1,100 @@ +package org.yapp.apis.readingrecord.controller + +import jakarta.validation.Valid +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.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 + +@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() + } + + @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/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/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/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/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt new file mode 100644 index 00000000..aa4db797 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordsWithPrimaryEmotionResponse.kt @@ -0,0 +1,44 @@ +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 representativeEmotion: PrimaryEmotionDto?, + + @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( + representativeEmotion: PrimaryEmotionDto?, + page: Page + ): ReadingRecordsWithPrimaryEmotionResponse { + return ReadingRecordsWithPrimaryEmotionResponse( + 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/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/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..06844429 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -0,0 +1,267 @@ +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.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 +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.domain.userbook.UserBookDomainService +import org.yapp.domain.userbook.vo.UserBookInfoVO +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, + private val userBookDomainService: UserBookDomainService +) { + @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 + ): ReadingRecordsWithPrimaryEmotionResponse { + val primaryEmotion = readingRecordDomainService.findPrimaryEmotionByUserBookId(userBookId) + val primaryEmotionDto = toPrimaryEmotionDto(primaryEmotion) + + val readingRecordPage = readingRecordDomainService.findByDynamicCondition(userBookId, sort, pageable) + if (readingRecordPage.isEmpty) { + return ReadingRecordsWithPrimaryEmotionResponse.of( + representativeEmotion = primaryEmotionDto, + page = Page.empty(pageable) + ) + } + + val readingRecordIds = readingRecordPage.content.map { it.id.value } + val detailTagsMap = buildDetailTagsMap(readingRecordIds) + val userBookInfoVO = userBookDomainService.findById(userBookId) + val recordsPage = toResponsePage(readingRecordPage, detailTagsMap, userBookInfoVO) + + return ReadingRecordsWithPrimaryEmotionResponse.of( + representativeEmotion = primaryEmotionDto, + page = recordsPage + ) + } + + private fun toPrimaryEmotionDto(primaryEmotion: PrimaryEmotion?): PrimaryEmotionDto? = + primaryEmotion?.let { PrimaryEmotionDto.of(code = it.name, displayName = it.displayName) } + + 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 } + + return detailTags + .groupBy { it.readingRecordId.value } + .mapValues { (_, tags) -> + tags.mapNotNull { tag -> + tagLookup[tag.detailTagId.value]?.let { + ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) + } + } + } + } + + private fun toResponsePage( + readingRecordPage: Page, + detailTagsByRecordId: Map>, + userBookInfoVO: UserBookInfoVO? + ): Page = readingRecordPage.map { record -> + ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = record, + detailEmotions = detailTagsByRecordId[record.id.value] ?: emptyList(), + bookTitle = userBookInfoVO?.title, + bookPublisher = userBookInfoVO?.publisher, + bookCoverImageUrl = userBookInfoVO?.coverImageUrl, + author = userBookInfoVO?.author + ) + ) + } + + @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() + } + + val userBook = userBookDomainService.findById(readingRecord.userBookId.value) + + return ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = readingRecord, + detailEmotions = detailEmotions, + bookTitle = userBook?.title, + bookPublisher = userBook?.publisher, + bookCoverImageUrl = userBook?.coverImageUrl, + author = userBook?.author + ) + ) + } + + @Transactional(readOnly = true) + 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/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..3157eaa8 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt @@ -0,0 +1,101 @@ +package org.yapp.apis.readingrecord.usecase + +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.dto.response.SeedStatsResponseV2 +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 + ): ReadingRecordsWithPrimaryEmotionResponse { + 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) + } + + 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 dcd73de6..a41c1132 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -61,6 +61,12 @@ swagger: description: YAPP API Documentation for Development version: v1.0.0-dev +oauth: + google: + url: + user-info: https://www.googleapis.com/oauth2/v2/userinfo + client-id: ${GOOGLE_CLIENT_ID} + --- spring: config: @@ -85,3 +91,9 @@ springdoc: enabled: false api-docs: enabled: false + +oauth: + google: + url: + user-info: https://www.googleapis.com/oauth2/v2/userinfo + client-id: test-client-id 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/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" + } } 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..95d81a9f 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, + 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 ae1e2a80..63e5a7f7 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt @@ -9,20 +9,89 @@ 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( + userBookId: UUID, + pageNumber: Int?, + quote: String, + review: String?, + primaryEmotion: PrimaryEmotion + ): ReadingRecord { + val userBook = findUserBookOrThrow(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 = findReadingRecordOrThrow(readingRecordId) + + val updatedReadingRecord = readingRecord.update( + pageNumber = pageNumber, + quote = quote, + review = review, + primaryEmotion = primaryEmotion, + emotionTags = null + ) + + return readingRecordRepository.save(updatedReadingRecord) + } + + fun findById(readingRecordId: UUID): ReadingRecord = findReadingRecordOrThrow(readingRecordId) + + fun findByDynamicCondition( + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page = + readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable) + + fun deleteReadingRecordV2(readingRecordId: UUID) { + 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) + + fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map = + readingRecordRepository.countPrimaryEmotionsByUserBookId(userBookId) + + // ===================== V1 API (Legacy) ===================== fun createReadingRecord( userBookId: UUID, @@ -31,32 +100,24 @@ 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) + + 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) - 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()) @@ -70,14 +131,8 @@ 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) } @@ -107,7 +162,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) @@ -141,34 +196,26 @@ 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) + + val primaryEmotion = emotionTags?.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) + } val updatedReadingRecord = readingRecord.update( pageNumber = pageNumber, quote = quote, review = review, + primaryEmotion = primaryEmotion, emotionTags = emotionTags ) 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) @@ -177,20 +224,39 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 fun deleteAllByUserBookId(userBookId: UUID) { 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..17a72b74 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,9 @@ 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? + + fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map } 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/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/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/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/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/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?, +) 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/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/repository/JpaReadingRecordQuerydslRepository.kt index 5fbbd7e5..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 @@ -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,8 @@ interface JpaReadingRecordQuerydslRepository { sort: ReadingRecordSortType?, pageable: Pageable ): Page + + fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? + + fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map } 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..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 @@ -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,40 @@ 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() + } + + 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( @@ -64,5 +99,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..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 @@ -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,12 @@ class ReadingRecordRepositoryImpl( val entities = jpaReadingRecordRepository.findByUserBookIdInAndCreatedAtAfter(userBookIds, after) return entities.map { it.toDomain() } } + + override fun findMostFrequentPrimaryEmotion(userBookId: UUID): PrimaryEmotion? { + return jpaReadingRecordRepository.findMostFrequentPrimaryEmotion(userBookId) + } + + override fun countPrimaryEmotionsByUserBookId(userBookId: UUID): Map { + return jpaReadingRecordRepository.countPrimaryEmotionsByUserBookId(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 new file mode 100644 index 00000000..f18a6bb5 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt @@ -0,0 +1,69 @@ +package org.yapp.infra.readingrecorddetailtag.entity + +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +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") + ] +) +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..5191b434 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt @@ -0,0 +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 + + @Modifying + @Query("DELETE FROM ReadingRecordDetailTagEntity e WHERE e.readingRecordId = :readingRecordId") + fun deleteAllByReadingRecordId(@Param("readingRecordId") 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);