From 8c9de8934f506170fbb93bf2554df98621453926 Mon Sep 17 00:00:00 2001 From: solfe Date: Tue, 23 Jun 2026 19:03:35 +0900 Subject: [PATCH] chore: apply spring java format --- .editorconfig | 3 +- .github/workflows/pr-run-test.yml | 2 +- AGENTS.md | 2 +- build.gradle | 15 +- docs/templates/repository-conventions.md | 14 +- settings.gradle | 7 + .../api/admin/controller/AdminController.java | 403 +-- .../controller/PushCampaignController.java | 45 +- .../crawling/AdminCrawlingController.java | 94 +- .../ArticleReleaseNotificationRequest.java | 60 +- .../ArticleReleaseNotificationResponse.java | 34 +- .../api/admin/dto/GrantTicketRequest.java | 23 +- .../api/admin/dto/GrantTicketResponse.java | 25 +- .../dto/NotificationBroadcastRequest.java | 48 +- .../dto/NotificationBroadcastResponse.java | 44 +- .../admin/dto/NotificationSendRequest.java | 56 +- .../admin/dto/NotificationSendResponse.java | 50 +- .../api/admin/dto/RecoverStreakRequest.java | 19 +- .../api/admin/dto/RecoverStreakResponse.java | 29 +- .../admin/dto/ResetTodayStreakRequest.java | 7 +- .../api/admin/dto/UpdateChunkRequest.java | 11 +- .../admin/feed/AdminFeedSourceController.java | 283 +-- .../BookmarkMigrationController.java | 233 +- .../UserCustomContentMigrationController.java | 190 +- .../api/admin/service/AdminService.java | 384 +-- .../admin/service/NotificationService.java | 1111 ++++---- .../api/auth/config/SecurityConfig.java | 91 +- .../SwaggerFormLoginSecurityConfig.java | 70 +- .../api/auth/controller/AuthController.java | 146 +- .../linglevel/api/auth/dto/LoginResponse.java | 14 +- .../linglevel/api/auth/dto/LogoutRequest.java | 6 +- .../api/auth/dto/LogoutResponse.java | 8 +- .../api/auth/dto/OauthLoginRequest.java | 8 +- .../api/auth/dto/RefreshTokenRequest.java | 8 +- .../api/auth/dto/RefreshTokenResponse.java | 14 +- .../api/auth/exception/AuthErrorCode.java | 21 +- .../api/auth/exception/AuthException.java | 14 +- .../filter/AdminAuthenticationFilter.java | 138 +- .../api/auth/filter/TestAuthFilter.java | 69 +- .../CustomAuthenticationEntryPoint.java | 33 +- .../com/linglevel/api/auth/jwt/JwtClaims.java | 57 +- .../com/linglevel/api/auth/jwt/JwtFilter.java | 65 +- .../linglevel/api/auth/jwt/JwtProvider.java | 92 +- .../linglevel/api/auth/jwt/JwtService.java | 25 +- .../linglevel/api/auth/jwt/RefreshToken.java | 32 +- .../api/auth/jwt/RefreshTokenService.java | 131 +- .../repository/RefreshTokenRepository.java | 14 +- .../api/auth/service/AuthService.java | 174 +- .../AdminContentBannerController.java | 256 +- .../controller/ContentBannerController.java | 91 +- .../api/banner/dto/ContentBannerResponse.java | 57 +- .../dto/CreateContentBannerRequest.java | 44 +- .../dto/GetAdminContentBannersRequest.java | 19 +- .../banner/dto/GetContentBannersRequest.java | 15 +- .../dto/UpdateContentBannerRequest.java | 21 +- .../api/banner/entity/ContentBanner.java | 31 +- .../api/banner/exception/BannerErrorCode.java | 19 +- .../api/banner/exception/BannerException.java | 19 +- .../repository/ContentBannerRepository.java | 41 +- .../banner/service/ContentBannerService.java | 318 +-- .../controller/BookmarksController.java | 187 +- .../bookmark/dto/BookmarkToggleResponse.java | 6 +- .../bookmark/dto/BookmarkedWordResponse.java | 18 +- .../dto/GetBookmarkedWordsRequest.java | 37 +- .../api/bookmark/entity/WordBookmark.java | 18 +- .../exception/BookmarksErrorCode.java | 17 +- .../exception/BookmarksException.java | 12 +- .../repository/WordBookmarkRepository.java | 13 +- .../api/bookmark/service/BookmarkService.java | 285 ++- .../api/common/config/DiscordConfig.java | 10 +- .../api/common/config/FirebaseConfig.java | 45 +- .../api/common/config/MongoConfig.java | 1 + .../api/common/config/RedisConfig.java | 87 +- .../api/common/config/SentryConfig.java | 83 +- .../api/common/config/SentryUserContext.java | 54 +- .../api/common/config/SwaggerConfig.java | 69 +- .../api/common/dto/DiscordWebhookRequest.java | 22 +- .../api/common/dto/ExceptionResponse.java | 12 +- .../api/common/dto/MessageResponse.java | 5 +- .../api/common/dto/PageResponse.java | 64 +- .../api/common/exception/CommonErrorCode.java | 21 +- .../api/common/exception/CommonException.java | 20 +- .../handler/GlobalExceptionHandler.java | 97 +- .../ratelimit/annotation/RateLimit.java | 78 +- .../ratelimit/bucket4j/Bucket4jConfig.java | 12 +- .../ratelimit/config/RateLimitConfig.java | 57 +- .../ratelimit/config/RateLimitProperties.java | 29 +- .../ratelimit/filter/RateLimitFilter.java | 207 +- .../ratelimit/filter/RateLimitResolver.java | 53 +- .../api/common/util/UrlNormalizer.java | 312 +-- .../article/controller/ArticleController.java | 165 +- .../controller/ArticleProgressController.java | 94 +- .../article/dto/ArticleChunkResponse.java | 59 +- .../article/dto/ArticleImportData.java | 95 +- .../article/dto/ArticleImportRequest.java | 7 +- .../article/dto/ArticleImportResponse.java | 7 +- .../article/dto/ArticleOriginResponse.java | 25 +- .../article/dto/ArticleProgressResponse.java | 50 +- .../dto/ArticleProgressUpdateRequest.java | 6 +- .../content/article/dto/ArticleResponse.java | 110 +- .../article/dto/GetArticleChunksRequest.java | 34 +- .../article/dto/GetArticleOriginsRequest.java | 49 +- .../article/dto/GetArticlesRequest.java | 46 +- .../api/content/article/entity/Article.java | 42 +- .../content/article/entity/ArticleChunk.java | 32 +- .../article/entity/ArticleProgress.java | 28 +- .../article/exception/ArticleErrorCode.java | 24 +- .../article/exception/ArticleException.java | 25 +- .../repository/ArticleChunkRepository.java | 15 +- .../repository/ArticleProgressRepository.java | 7 +- .../article/repository/ArticleRepository.java | 1 + .../repository/ArticleRepositoryCustom.java | 8 +- .../repository/ArticleRepositoryImpl.java | 440 ++-- .../article/service/ArticleChunkService.java | 88 +- .../article/service/ArticleImportService.java | 99 +- .../service/ArticleProgressService.java | 391 ++- .../service/ArticleReadingTimeService.java | 51 +- .../article/service/ArticleService.java | 615 ++--- .../book/controller/BooksController.java | 294 +-- .../controller/BooksProgressController.java | 94 +- .../api/content/book/dto/BookImportData.java | 102 +- .../content/book/dto/BookImportRequest.java | 9 +- .../content/book/dto/BookImportResponse.java | 9 +- .../api/content/book/dto/BookResponse.java | 92 +- .../content/book/dto/BookSummaryResponse.java | 32 +- .../book/dto/BooksProgressResponse.java | 44 +- .../book/dto/ChapterNavigationResponse.java | 26 +- .../api/content/book/dto/ChapterResponse.java | 68 +- .../book/dto/ChunkCountByLevelDto.java | 10 +- .../api/content/book/dto/ChunkResponse.java | 60 +- .../book/dto/GetBooksProgressRequest.java | 32 +- .../api/content/book/dto/GetBooksRequest.java | 66 +- .../content/book/dto/GetChaptersRequest.java | 36 +- .../content/book/dto/GetChunksRequest.java | 27 +- .../content/book/dto/ProgressResponse.java | 70 +- .../book/dto/ProgressUpdateRequest.java | 8 +- .../api/content/book/entity/Book.java | 54 +- .../api/content/book/entity/BookProgress.java | 113 +- .../api/content/book/entity/Chapter.java | 32 +- .../api/content/book/entity/Chunk.java | 46 +- .../book/exception/BooksErrorCode.java | 38 +- .../book/exception/BooksException.java | 14 +- .../repository/BookProgressRepository.java | 16 +- .../book/repository/BookRepository.java | 1 + .../book/repository/BookRepositoryCustom.java | 23 +- .../book/repository/BookRepositoryImpl.java | 354 ++- .../book/repository/ChapterRepository.java | 20 +- .../repository/ChapterRepositoryCustom.java | 4 +- .../repository/ChapterRepositoryImpl.java | 193 +- .../book/repository/ChunkRepository.java | 81 +- .../book/service/BookImportService.java | 134 +- .../book/service/BookReadingTimeService.java | 84 +- .../api/content/book/service/BookService.java | 462 ++-- .../content/book/service/ChapterService.java | 318 +-- .../content/book/service/ChunkService.java | 168 +- .../content/book/service/ProgressService.java | 612 +++-- .../api/content/common/ChunkType.java | 5 +- .../api/content/common/ContentCategory.java | 77 +- .../api/content/common/ContentType.java | 24 +- .../api/content/common/DifficultyLevel.java | 53 +- .../api/content/common/ProgressStatus.java | 49 +- .../api/content/common/TitleTranslations.java | 6 +- .../common/controller/ContentController.java | 31 +- .../common/dto/GetRecentContentsRequest.java | 19 +- .../common/dto/RecentContentResponse.java | 59 +- .../content/common/service/ContentInfo.java | 20 +- .../common/service/ContentInfoProvider.java | 17 +- .../service/ContentInfoProviderFactory.java | 70 +- .../common/service/ContentService.java | 387 +-- .../service/ProgressCalculationService.java | 84 +- .../service/ReadingCompletionService.java | 73 +- .../common/service/ReadingTimeService.java | 21 +- .../provider/ArticleContentInfoProvider.java | 48 +- .../provider/BookContentInfoProvider.java | 48 +- .../controller/CustomContentController.java | 283 +-- .../CustomContentProgressController.java | 97 +- .../CustomContentRequestController.java | 119 +- .../CustomContentWebhookController.java | 106 +- .../api/content/custom/dto/AiResultDto.java | 141 +- .../custom/dto/ContentRequestResponse.java | 91 +- .../dto/CreateContentRequestRequest.java | 51 +- .../dto/CreateContentRequestResponse.java | 43 +- .../dto/CustomContentChunkResponse.java | 46 +- .../dto/CustomContentCompletedRequest.java | 9 +- .../dto/CustomContentCompletedResponse.java | 13 +- .../dto/CustomContentFailedRequest.java | 19 +- .../dto/CustomContentProgressRequest.java | 21 +- .../CustomContentReadingProgressResponse.java | 50 +- ...omContentReadingProgressUpdateRequest.java | 6 +- .../custom/dto/CustomContentResponse.java | 121 +- .../custom/dto/GetContentRequestsRequest.java | 20 +- .../dto/GetCustomContentChunksRequest.java | 21 +- .../custom/dto/GetCustomContentsRequest.java | 32 +- .../dto/UpdateCustomContentRequest.java | 9 +- .../content/custom/entity/ContentRequest.java | 59 +- .../custom/entity/ContentRequestStatus.java | 32 +- .../content/custom/entity/ContentType.java | 30 +- .../content/custom/entity/CustomContent.java | 67 +- .../custom/entity/CustomContentChunk.java | 61 +- .../custom/entity/CustomContentProgress.java | 28 +- .../custom/entity/UserCustomContent.java | 30 +- .../exception/CustomContentErrorCode.java | 75 +- .../exception/CustomContentException.java | 20 +- .../repository/ContentRequestRepository.java | 13 +- .../CustomContentChunkRepository.java | 42 +- .../CustomContentProgressRepository.java | 7 +- .../repository/CustomContentRepository.java | 11 +- .../CustomContentRepositoryCustom.java | 10 +- .../CustomContentRepositoryImpl.java | 523 ++-- .../UserCustomContentRepository.java | 9 +- .../service/CustomContentChunkService.java | 127 +- .../service/CustomContentImportService.java | 235 +- .../CustomContentNotificationService.java | 320 +-- .../CustomContentReadingProgressService.java | 380 ++- .../CustomContentReadingTimeService.java | 50 +- .../service/CustomContentRequestService.java | 411 +-- .../custom/service/CustomContentService.java | 297 +-- .../service/CustomContentWebhookService.java | 354 +-- .../service/UserCustomContentService.java | 50 +- .../feed/controller/FeedController.java | 69 +- .../feed/dto/CreateFeedSourceRequest.java | 33 +- .../api/content/feed/dto/FeedResponse.java | 57 +- .../content/feed/dto/FeedSourceResponse.java | 45 +- .../api/content/feed/dto/GetFeedsRequest.java | 39 +- .../api/content/feed/entity/Feed.java | 45 +- .../content/feed/entity/FeedContentType.java | 14 +- .../api/content/feed/entity/FeedSource.java | 31 +- .../content/feed/exception/FeedErrorCode.java | 14 +- .../content/feed/exception/FeedException.java | 11 +- .../api/content/feed/filter/FeedFilter.java | 11 +- .../content/feed/filter/FeedFilterChain.java | 79 +- .../content/feed/filter/FeedFilterResult.java | 25 +- .../filters/ContentCrawlabilityFilter.java | 189 +- .../feed/filter/filters/LanguageFilter.java | 156 +- .../filter/filters/YouTubeShortsFilter.java | 68 +- .../feed/repository/FeedRepository.java | 9 +- .../feed/repository/FeedSourceRepository.java | 7 +- .../feed/scheduler/FeedCrawlingScheduler.java | 102 +- .../feed/service/FeedCrawlingService.java | 702 ++--- .../service/FeedRecommendationService.java | 273 +- .../api/content/feed/service/FeedService.java | 198 +- .../entity/ContentAccessLog.java | 25 +- .../entity/UserCategoryPreference.java | 23 +- .../event/ContentAccessEvent.java | 34 +- .../event/ContentAccessEventListener.java | 195 +- .../ContentAccessLogRepository.java | 6 +- .../UserCategoryPreferenceRepository.java | 3 +- .../UserPreferenceAggregationScheduler.java | 323 +-- .../controller/CrawlingController.java | 77 +- .../api/crawling/dsl/CrawlerDsl.java | 164 +- .../api/crawling/dsl/DslInterpreter.java | 21 +- .../linglevel/api/crawling/dsl/DslParser.java | 379 +-- .../api/crawling/dsl/DslTokenizer.java | 244 +- .../com/linglevel/api/crawling/dsl/Token.java | 18 +- .../linglevel/api/crawling/dsl/TokenType.java | 30 +- .../api/crawling/dsl/ast/ASTNode.java | 4 +- .../api/crawling/dsl/ast/AttrNode.java | 28 +- .../api/crawling/dsl/ast/ChainNode.java | 43 +- .../api/crawling/dsl/ast/CollectNode.java | 89 +- .../crawling/dsl/ast/CollectStatement.java | 4 +- .../api/crawling/dsl/ast/DocumentNode.java | 10 +- .../api/crawling/dsl/ast/GroupNode.java | 12 +- .../api/crawling/dsl/ast/MapEachNode.java | 146 +- .../api/crawling/dsl/ast/PlusNode.java | 79 +- .../api/crawling/dsl/ast/QuestionNode.java | 51 +- .../api/crawling/dsl/ast/Selector1Node.java | 25 +- .../api/crawling/dsl/ast/SelectorAllNode.java | 33 +- .../api/crawling/dsl/ast/TextNode.java | 20 +- .../api/crawling/dto/CreateDslRequest.java | 51 +- .../api/crawling/dto/CreateDslResponse.java | 19 +- .../api/crawling/dto/DomainsResponse.java | 25 +- .../api/crawling/dto/DslLookupResponse.java | 43 +- .../api/crawling/dto/UpdateDslRequest.java | 41 +- .../api/crawling/dto/UpdateDslResponse.java | 49 +- .../api/crawling/entity/CrawlingDsl.java | 30 +- .../crawling/exception/CrawlingErrorCode.java | 21 +- .../crawling/exception/CrawlingException.java | 27 +- .../repository/CrawlingDslRepository.java | 12 +- .../api/crawling/service/CrawlingService.java | 289 +-- .../api/fcm/controller/FcmController.java | 74 +- .../api/fcm/controller/PushLogController.java | 19 +- .../api/fcm/dto/FcmMessageRequest.java | 78 +- .../api/fcm/dto/FcmTokenCreateResponse.java | 9 +- .../api/fcm/dto/FcmTokenUpdateResponse.java | 9 +- .../api/fcm/dto/FcmTokenUpsertRequest.java | 31 +- .../api/fcm/dto/FcmTokenUpsertResult.java | 7 +- .../api/fcm/dto/NotificationMessage.java | 82 +- .../api/fcm/dto/PushCampaignStats.java | 29 +- .../api/fcm/dto/PushCampaignSummary.java | 29 +- .../api/fcm/dto/PushOpenedRequest.java | 19 +- .../linglevel/api/fcm/entity/FcmPlatform.java | 40 +- .../linglevel/api/fcm/entity/FcmToken.java | 67 +- .../com/linglevel/api/fcm/entity/PushLog.java | 39 +- .../api/fcm/exception/FcmErrorCode.java | 43 +- .../api/fcm/exception/FcmException.java | 12 +- .../fcm/repository/FcmTokenRepository.java | 15 +- .../api/fcm/repository/PushLogRepository.java | 9 +- .../api/fcm/service/FcmMessagingService.java | 469 ++-- .../api/fcm/service/FcmTokenService.java | 322 +-- .../api/fcm/service/PushCampaignService.java | 218 +- .../api/fcm/service/PushLogService.java | 118 +- .../com/linglevel/api/i18n/CountryCode.java | 11 +- .../com/linglevel/api/i18n/LanguageCode.java | 17 +- .../com/linglevel/api/s3/config/S3Config.java | 158 +- .../api/s3/service/ImageResizeService.java | 150 +- .../linglevel/api/s3/service/S3AiService.java | 173 +- .../api/s3/service/S3StaticService.java | 227 +- .../api/s3/service/S3TransferService.java | 52 +- .../api/s3/service/S3UrlService.java | 25 +- .../api/s3/strategy/ArticlePathStrategy.java | 74 +- .../api/s3/strategy/BookPathStrategy.java | 82 +- .../strategy/CustomContentPathStrategy.java | 74 +- .../api/s3/strategy/S3PathStrategy.java | 19 +- .../linglevel/api/s3/utils/S3FileUtils.java | 30 +- .../streak/controller/StreakController.java | 195 +- .../api/streak/dto/CalendarDayResponse.java | 38 +- .../api/streak/dto/CalendarResponse.java | 21 +- .../linglevel/api/streak/dto/ContentInfo.java | 33 +- .../api/streak/dto/EncouragementMessage.java | 13 +- .../streak/dto/FreezeTransactionResponse.java | 13 +- .../api/streak/dto/GetStreakInfoRequest.java | 7 +- .../api/streak/dto/ReadingSession.java | 10 +- .../streak/dto/ReadingSessionResponse.java | 37 +- .../linglevel/api/streak/dto/RewardInfo.java | 9 +- .../api/streak/dto/StreakResponse.java | 49 +- .../api/streak/dto/WeekDayResponse.java | 26 +- .../api/streak/dto/WeekStreakResponse.java | 13 +- .../api/streak/entity/CompletedContent.java | 21 +- .../api/streak/entity/DailyCompletion.java | 67 +- .../api/streak/entity/FreezeTransaction.java | 16 +- .../api/streak/entity/InspirationQuote.java | 770 ++---- .../api/streak/entity/StreakMilestone.java | 219 +- .../streak/entity/StreakReminderMessage.java | 416 ++- .../api/streak/entity/StreakStatus.java | 11 +- .../api/streak/entity/UserStudyReport.java | 35 +- .../api/streak/exception/StreakErrorCode.java | 11 +- .../api/streak/exception/StreakException.java | 12 +- .../repository/DailyCompletionRepository.java | 51 +- .../FreezeTransactionRepository.java | 7 +- .../repository/UserStudyReportRepository.java | 32 +- .../DailyStreakValidationScheduler.java | 109 +- .../LearningEncouragementScheduler.java | 780 +++--- .../PreferredStudyHourUpdateScheduler.java | 125 +- .../scheduler/StreakProtectionScheduler.java | 367 ++- .../streak/service/ReadingSessionService.java | 188 +- .../api/streak/service/StreakService.java | 2248 +++++++++-------- .../service/StudyTimeAnalysisService.java | 226 +- .../controller/SuggestionsController.java | 27 +- .../api/suggestion/dto/SuggestionRequest.java | 14 +- .../suggestion/dto/SuggestionResponse.java | 6 +- .../service/SuggestionsService.java | 79 +- .../api/user/controller/UsersController.java | 46 +- .../com/linglevel/api/user/entity/User.java | 30 +- .../linglevel/api/user/entity/UserRole.java | 13 +- .../api/user/exception/UsersErrorCode.java | 19 +- .../api/user/exception/UsersException.java | 14 +- .../api/user/repository/UserRepository.java | 4 +- .../api/user/service/UsersService.java | 46 +- .../ticket/controller/TicketsController.java | 66 +- .../dto/GetTicketTransactionsRequest.java | 19 +- .../ticket/dto/TicketBalanceResponse.java | 13 +- .../ticket/dto/TicketTransactionResponse.java | 25 +- .../user/ticket/entity/TicketTransaction.java | 33 +- .../user/ticket/entity/TransactionStatus.java | 26 +- .../api/user/ticket/entity/UserTicket.java | 37 +- .../ticket/exception/TicketErrorCode.java | 15 +- .../ticket/exception/TicketException.java | 12 +- .../TicketTransactionRepository.java | 11 +- .../repository/UserTicketRepository.java | 4 +- .../user/ticket/service/TicketService.java | 345 ++- .../version/controller/VersionController.java | 36 +- .../api/version/dto/VersionResponse.java | 12 +- .../api/version/dto/VersionUpdateRequest.java | 12 +- .../version/dto/VersionUpdateResponse.java | 18 +- .../api/version/entity/AppVersion.java | 18 +- .../version/exception/VersionErrorCode.java | 13 +- .../version/exception/VersionException.java | 12 +- .../repository/AppVersionRepository.java | 4 +- .../api/version/service/VersionService.java | 80 +- .../config/WordSingleFlightProperties.java | 7 +- .../word/controller/WordsAdminController.java | 311 ++- .../api/word/controller/WordsController.java | 57 +- .../word/dto/EssentialWordsStatsResponse.java | 13 +- .../com/linglevel/api/word/dto/Meaning.java | 26 +- .../api/word/dto/Oxford3000InitResponse.java | 37 +- .../linglevel/api/word/dto/PartOfSpeech.java | 96 +- .../linglevel/api/word/dto/RelatedForms.java | 126 +- .../linglevel/api/word/dto/VariantType.java | 49 +- .../api/word/dto/WordAnalysisResult.java | 52 +- .../linglevel/api/word/dto/WordResponse.java | 42 +- .../api/word/dto/WordSearchRequest.java | 7 +- .../api/word/dto/WordSearchResponse.java | 10 +- .../api/word/entity/InvalidWord.java | 16 +- .../com/linglevel/api/word/entity/Word.java | 76 +- .../api/word/entity/WordVariant.java | 12 +- .../api/word/exception/WordsErrorCode.java | 26 +- .../api/word/exception/WordsException.java | 27 +- .../repository/InvalidWordRepository.java | 3 +- .../api/word/repository/WordRepository.java | 67 +- .../repository/WordVariantRepository.java | 10 +- .../api/word/service/Oxford3000Service.java | 567 +++-- .../api/word/service/WordAiService.java | 757 +++--- .../word/service/WordPersistenceService.java | 460 ++-- .../api/word/service/WordService.java | 395 ++- .../WordSingleFlightRedisCoordinator.java | 485 ++-- .../api/word/service/WordVariantService.java | 28 +- .../api/word/validator/WordValidator.java | 142 +- .../bookmark/service/BookmarkServiceTest.java | 127 +- .../api/common/AbstractDatabaseTest.java | 38 +- .../api/common/AbstractRedisTest.java | 39 +- .../common/filter/RateLimitFilterTest.java | 699 ++--- .../service/ArticleProgressServiceTest.java | 156 +- .../article/service/ArticleServiceTest.java | 420 ++- .../repository/BookRepositoryImplTest.java | 298 +-- .../repository/ChapterRepositoryImplTest.java | 225 +- .../book/service/BookImportServiceTest.java | 487 ++-- .../service/BookReadingTimeServiceTest.java | 238 +- .../content/book/service/BookServiceTest.java | 907 ++++--- .../book/service/ChapterServiceTest.java | 654 +++-- .../book/service/ChunkServiceTest.java | 361 ++- .../ProgressServiceIntegrationTest.java | 349 +-- .../book/service/ProgressServiceTest.java | 672 ++--- .../CustomContentRepositoryTest.java | 786 +++--- ...stomContentReadingProgressServiceTest.java | 150 +- .../ContentCrawlabilityFilterTest.java | 268 +- .../filter/filters/LanguageFilterTest.java | 599 +++-- .../filters/YouTubeRssStructureTest.java | 188 +- .../filters/YouTubeShortsFilterTest.java | 358 +-- .../content/feed/service/DevDebugTest.java | 319 +-- .../feed/service/FeedCrawlingServiceTest.java | 830 +++--- .../FeedDescriptionExtractionTest.java | 366 +-- .../service/Formula1EspnThumbnailTest.java | 495 ++-- .../content/feed/service/MediumRssTest.java | 258 +- .../feed/service/NewFeedSourcesTest.java | 473 ++-- ...serPreferenceAggregationSchedulerTest.java | 441 ++-- .../api/crawling/dsl/CrawlerDslTest.java | 498 ++-- .../api/fcm/dto/NotificationMessageTest.java | 89 +- .../api/fcm/service/FcmTokenServiceTest.java | 165 +- .../s3/service/ImageResizeServiceTest.java | 588 ++--- .../StreakProtectionSchedulerTest.java | 921 ++++--- .../service/ReadingSessionServiceTest.java | 477 ++-- .../service/StreakServiceBackfillTest.java | 577 ++--- .../StreakServiceBusinessLogicTest.java | 735 +++--- .../StreakServiceContentCompletionTest.java | 495 ++-- .../StreakServiceFreezeAutoConsumeTest.java | 692 ++--- .../service/StreakServiceRecalculateTest.java | 631 +++-- .../service/StreakServiceRecoveryTest.java | 994 ++++---- .../service/StudyTimeAnalysisServiceTest.java | 407 ++- .../controller/TicketsControllerTest.java | 391 ++- .../TicketTransactionRepositoryTest.java | 295 +-- .../repository/UserTicketRepositoryTest.java | 196 +- .../ticket/service/TicketServiceTest.java | 517 ++-- .../service/WordAiServiceIntegrationTest.java | 1385 +++++----- .../api/word/service/WordServiceTest.java | 849 +++---- ...FlightRedisCoordinatorIntegrationTest.java | 400 ++- .../WordSingleFlightRedisCoordinatorTest.java | 519 ++-- .../word/service/WordVariantServiceTest.java | 74 +- .../api/word/validator/WordValidatorTest.java | 556 ++-- 458 files changed, 30768 insertions(+), 31145 deletions(-) diff --git a/.editorconfig b/.editorconfig index ad2ebbcb..db97da22 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,8 +7,7 @@ insert_final_newline = true trim_trailing_whitespace = true [*.java] -indent_style = space -indent_size = 4 +indent_style = tab [*.gradle] indent_style = tab diff --git a/.github/workflows/pr-run-test.yml b/.github/workflows/pr-run-test.yml index 16242186..e6a30591 100644 --- a/.github/workflows/pr-run-test.yml +++ b/.github/workflows/pr-run-test.yml @@ -25,4 +25,4 @@ jobs: uses: docker/setup-buildx-action@v2 - name: Run checks - run: ./gradlew spotlessCheck test + run: ./gradlew checkFormat test diff --git a/AGENTS.md b/AGENTS.md index 0b8529b4..e54e39ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ - README는 외부 방문자용으로 유지하고, 긴 설계 설명은 `docs` 아래에 둔다. - 문서화되지 않은 모듈은 `src/main/java/com/linglevel/api` 아래 실제 패키지와 테스트를 기준으로 맥락을 확인한다. - 커밋 메시지, 브랜치명, PR 제목과 본문은 [Repository Conventions](docs/templates/repository-conventions.md)를 따른다. -- Java 포맷은 Spotless와 google-java-format AOSP 스타일을 기준으로 하며, 코드 변경 후 필요하면 `./gradlew spotlessCheck`를 실행한다. +- Java 포맷은 Spring Java Format을 기준으로 하며, 코드 변경 후 필요하면 `./gradlew checkFormat`을 실행한다. - 구조 변경은 관련 architecture 문서와 decision 문서의 갱신 필요성을 함께 확인한다. - 운영 리스크가 있는 변경은 테스트, 로그, 메트릭, 부하 테스트 중 최소 하나로 검증 근거를 남긴다. - 외부 네트워크, AI 모델, 저장소, 푸시 알림에 의존하는 코드는 실패와 비용을 별도 리스크로 다룬다. diff --git a/build.gradle b/build.gradle index 7122a32c..aff278b5 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'org.springframework.boot' version '3.5.3' id 'io.spring.dependency-management' version '1.1.7' id "io.sentry.jvm.gradle" version "5.10.0" - id 'com.diffplug.spotless' version '8.7.0' + id 'io.spring.javaformat' version '0.0.47' } springBoot { @@ -29,15 +29,6 @@ repositories { mavenCentral() } -spotless { - ratchetFrom 'origin/develop' - - java { - googleJavaFormat('1.28.0').aosp() - formatAnnotations() - } -} - dependencies { implementation platform('org.springframework.ai:spring-ai-bom:1.0.0-M6') implementation 'org.springframework.boot:spring-boot-starter-web' @@ -82,7 +73,3 @@ dependencies { tasks.named('test') { useJUnitPlatform() } - -tasks.named('check') { - dependsOn 'spotlessCheck' -} diff --git a/docs/templates/repository-conventions.md b/docs/templates/repository-conventions.md index 0b84653b..a1ec20c9 100644 --- a/docs/templates/repository-conventions.md +++ b/docs/templates/repository-conventions.md @@ -96,19 +96,19 @@ PR 본문은 [pull_request_template.md](../../.github/pull_request_template.md) ## 코드 스타일 -Java 포맷은 Spotless와 google-java-format AOSP 스타일을 기준으로 한다. -AOSP 스타일을 사용해 기존 Java/Spring 코드의 4-space indentation을 유지한다. +Java 포맷은 Spring Java Format을 기준으로 한다. +Spring Java Format은 Spring 프로젝트 스타일에 맞춘 formatter이며 Java indentation은 tab을 사용한다. 명령: ```bash -./gradlew spotlessCheck -./gradlew spotlessApply +./gradlew checkFormat +./gradlew format ``` 작성 원칙: -- 포맷 검사는 `spotlessCheck`로 수행한다. -- 자동 포맷 적용은 `spotlessApply`로 수행한다. +- 포맷 검사는 `checkFormat`으로 수행한다. +- 자동 포맷 적용은 `format`으로 수행한다. - 포맷 변경은 기능 변경과 분리한다. -- 기존 전체 Java 파일 재포맷은 별도 PR로 분리한다. +- 대규모 포맷 변경은 별도 PR로 분리한다. diff --git a/settings.gradle b/settings.gradle index 5cd7dd3b..12f335c4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = 'api' diff --git a/src/main/java/com/linglevel/api/admin/controller/AdminController.java b/src/main/java/com/linglevel/api/admin/controller/AdminController.java index 12c04b2c..46183c56 100644 --- a/src/main/java/com/linglevel/api/admin/controller/AdminController.java +++ b/src/main/java/com/linglevel/api/admin/controller/AdminController.java @@ -55,201 +55,212 @@ @SecurityRequirement(name = "adminApiKey") public class AdminController { - private final AdminService adminService; - private final VersionService versionService; - private final NotificationService notificationService; - private final TicketService ticketService; - private final UserRepository userRepository; - private final ArticleService articleService; - private final StreakService streakService; - - @Operation(summary = "책 청크 수정", description = "어드민 권한으로 특정 책의 청크 내용을 수정합니다.") - @PutMapping("/books/{bookId}/chapters/{chapterId}/chunks/{chunkId}") - public ResponseEntity updateBookChunk( - @Parameter(description = "책 ID", required = true) @PathVariable String bookId, - @Parameter(description = "챕터 ID", required = true) @PathVariable String chapterId, - @Parameter(description = "청크 ID", required = true) @PathVariable String chunkId, - @Parameter(description = "청크 수정 요청", required = true) @Valid @RequestBody UpdateChunkRequest request) { - - log.info("Admin updating book chunk - bookId: {}, chapterId: {}, chunkId: {}", bookId, chapterId, chunkId); - - ChunkResponse response = adminService.updateBookChunk(bookId, chapterId, chunkId, request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "기사 청크 수정", description = "어드민 권한으로 특정 기사의 청크 내용을 수정합니다.") - @PutMapping("/articles/{articleId}/chunks/{chunkId}") - public ResponseEntity updateArticleChunk( - @Parameter(description = "기사 ID", required = true) @PathVariable String articleId, - @Parameter(description = "청크 ID", required = true) @PathVariable String chunkId, - @Parameter(description = "청크 수정 요청", required = true) @Valid @RequestBody UpdateChunkRequest request) { - - log.info("Admin updating article chunk - articleId: {}, chunkId: {}", articleId, chunkId); - - ArticleChunkResponse response = adminService.updateArticleChunk(articleId, chunkId, request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "책 삭제", description = "어드민 권한으로 특정 책과 관련된 모든 데이터(챕터, 청크, 진도, S3 파일 등)를 삭제합니다.") - @DeleteMapping("/books/{bookId}") - public ResponseEntity deleteBook( - @Parameter(description = "책 ID", required = true) @PathVariable String bookId) { - - log.info("Admin deleting book - bookId: {}", bookId); - - adminService.deleteBook(bookId); - return ResponseEntity.ok(new MessageResponse("Book and all related data deleted successfully.")); - } - - @Operation(summary = "기사 삭제", description = "어드민 권한으로 특정 기사와 관련된 모든 데이터(청크, S3 파일 등)를 삭제합니다.") - @DeleteMapping("/articles/{articleId}") - public ResponseEntity deleteArticle( - @Parameter(description = "기사 ID", required = true) @PathVariable String articleId) { - - log.info("Admin deleting article - articleId: {}", articleId); - - adminService.deleteArticle(articleId); - return ResponseEntity.ok(new MessageResponse("Article and all related data deleted successfully.")); - } - - @Operation(summary = "앱 버전 업데이트", description = "어드민 권한으로 앱의 최신 버전 및 최소 요구 버전을 부분 업데이트합니다.") - @PatchMapping("/version") - public ResponseEntity updateVersion( - @Parameter(description = "버전 업데이트 요청", required = true) @Valid @RequestBody VersionUpdateRequest request) { - - log.info("Admin updating app version - latestVersion: {}, minimumVersion: {}", - request.getLatestVersion(), request.getMinimumVersion()); - - VersionUpdateResponse response = versionService.updateVersion(request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "브로드캐스트 알림 전송", description = "어드민 권한으로 전체 사용자에게 FCM 푸시 알림을 브로드캐스트합니다.") - @PostMapping("/notifications/broadcast") - public ResponseEntity broadcastNotification( - @Parameter(description = "브로드캐스트 알림 전송 요청", required = true) @Valid @RequestBody NotificationBroadcastRequest request) { - NotificationBroadcastResponse response = notificationService.sendBroadcastNotification(request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "푸시 알림 전송", description = "어드민 권한으로 특정 사용자들에게 FCM 푸시 알림을 전송합니다.") - @PostMapping("/notifications/send") - public ResponseEntity sendNotification( - @Parameter(description = "알림 전송 요청", required = true) @Valid @RequestBody NotificationSendRequest request) { - - NotificationSendResponse response = notificationService.sendNotificationFromRequest(request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "아티클 출시 알림 전송", description = "AI 서버가 새 아티클 출시 시 국가와 카테고리 기반으로 타겟 사용자에게 푸시 알림을 전송합니다.") - @PostMapping("/notifications/article-release") - public ResponseEntity sendArticleReleaseNotification( - @Parameter(description = "아티클 출시 알림 전송 요청", required = true) @Valid @RequestBody ArticleReleaseNotificationRequest request) { - ArticleReleaseNotificationResponse response = notificationService.sendArticleReleaseNotification(request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "티켓 지급", description = "어드민 권한으로 사용자에게 티켓을 지급합니다.") - @PostMapping("/tickets/grant") - public ResponseEntity grantTicket( - @Parameter(description = "티켓 지급 요청", required = true) @Valid @RequestBody GrantTicketRequest request) { - - log.info("Admin granting tickets - userId: {}, amount: {}, reason: {}", - request.getUserId(), request.getAmount(), request.getReason()); - - // 사용자 존재 여부 확인 - User user = userRepository.findById(request.getUserId()) - .orElseThrow(() -> new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, "User not found.")); - - String reason = request.getReason() != null ? request.getReason() : "관리자 지급"; - int newBalance = ticketService.grantTicket(user.getId(), request.getAmount(), reason); - - GrantTicketResponse response = GrantTicketResponse.builder() - .message("Tickets granted successfully.") - .userId(request.getUserId()) - .amount(request.getAmount()) - .newBalance(newBalance) - .build(); - - return ResponseEntity.ok(response); - } - - @Operation(summary = "아티클 원본 URL 목록 조회", description = "어드민 권한으로 아티클의 원본 URL 목록을 필터링하여 조회합니다.") - @GetMapping("/articles/origins") - public ResponseEntity> getArticleOrigins( - @ParameterObject @ModelAttribute GetArticleOriginsRequest request) { - - log.info("Admin fetching article origins - tags: {}, targetLanguageCode: {}", - request.getTags(), request.getTargetLanguageCode()); - - PageResponse response = articleService.getArticleOrigins(request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "아티클 targetLanguageCode 마이그레이션", description = "targetLanguageCode가 null이거나 없는 모든 아티클에 [KO, EN, JA]를 설정합니다.") - @PostMapping("/articles/migrate/target-language-code") - public ResponseEntity migrateArticleTargetLanguageCode() { - log.info("Admin migrating article targetLanguageCode"); - - long updatedCount = articleService.migrateTargetLanguageCode(); - - String message = String.format("Successfully updated %d articles with default target language codes [KO, EN, JA]", updatedCount); - return ResponseEntity.ok(new MessageResponse(message)); - } - - @Operation(summary = "[Debug] 오늘의 스트릭 학습 상태 리셋", description = "디버깅용 API. 특정 사용자의 오늘 학습 완료 기록을 삭제하여, 스트릭을 달성하지 않은 상태로 되돌립니다.") - @PostMapping("/streaks/reset-today") - public ResponseEntity resetTodayStreak(@Valid @RequestBody ResetTodayStreakRequest request) { - adminService.resetTodayStreak(request.getUserId()); - return ResponseEntity.ok(new MessageResponse("User " + request.getUserId() + "'s streak status for today has been reset.")); - } - - @Operation(summary = "스트릭 복구", description = "어드민 권한으로 특정 사용자의 누락된 스트릭을 복구합니다. MISSED 날짜는 COMPLETED로 변경하고, FREEZE_USED는 COMPLETED로 변경하며 프리즈를 보상합니다. 복구 범위 이후 날짜들도 프리즈를 사용하여 최대한 연결합니다.") - @PostMapping("/streaks/recover") - public ResponseEntity recoverStreak( - @Parameter(description = "스트릭 복구 요청", required = true) @Valid @RequestBody RecoverStreakRequest request) { - - log.info("Admin recovering streak - userId: {}, startDate: {}, endDate: {}", - request.getUserId(), request.getStartDate(), request.getEndDate()); - - // 사용자 존재 여부 확인 - User user = userRepository.findById(request.getUserId()) - .orElseThrow(() -> new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, "User not found.")); - - // 스트릭 복구 실행 - streakService.recoverStreak(request.getUserId(), request.getStartDate(), request.getEndDate()); - - // 복구 후 UserStudyReport 조회 - UserStudyReport updatedReport = streakService.recalculateUserStudyReport(request.getUserId()); - - RecoverStreakResponse response = RecoverStreakResponse.builder() - .message("Streak recovered successfully.") - .userId(request.getUserId()) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) - .currentStreak(updatedReport.getCurrentStreak()) - .longestStreak(updatedReport.getLongestStreak()) - .lastCompletionDate(updatedReport.getLastCompletionDate()) - .build(); - - log.info("Streak recovery completed for user {} - currentStreak: {}, longestStreak: {}", - request.getUserId(), updatedReport.getCurrentStreak(), updatedReport.getLongestStreak()); - - return ResponseEntity.ok(response); - } - - @ExceptionHandler(TicketException.class) - public ResponseEntity handleTicketException(TicketException e) { - log.error("Admin Ticket Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } - - @ExceptionHandler(CommonException.class) - public ResponseEntity handleCommonException(CommonException e) { - log.error("Admin Common Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } + private final AdminService adminService; + + private final VersionService versionService; + + private final NotificationService notificationService; + + private final TicketService ticketService; + + private final UserRepository userRepository; + + private final ArticleService articleService; + + private final StreakService streakService; + + @Operation(summary = "책 청크 수정", description = "어드민 권한으로 특정 책의 청크 내용을 수정합니다.") + @PutMapping("/books/{bookId}/chapters/{chapterId}/chunks/{chunkId}") + public ResponseEntity updateBookChunk( + @Parameter(description = "책 ID", required = true) @PathVariable String bookId, + @Parameter(description = "챕터 ID", required = true) @PathVariable String chapterId, + @Parameter(description = "청크 ID", required = true) @PathVariable String chunkId, + @Parameter(description = "청크 수정 요청", required = true) @Valid @RequestBody UpdateChunkRequest request) { + + log.info("Admin updating book chunk - bookId: {}, chapterId: {}, chunkId: {}", bookId, chapterId, chunkId); + + ChunkResponse response = adminService.updateBookChunk(bookId, chapterId, chunkId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "기사 청크 수정", description = "어드민 권한으로 특정 기사의 청크 내용을 수정합니다.") + @PutMapping("/articles/{articleId}/chunks/{chunkId}") + public ResponseEntity updateArticleChunk( + @Parameter(description = "기사 ID", required = true) @PathVariable String articleId, + @Parameter(description = "청크 ID", required = true) @PathVariable String chunkId, + @Parameter(description = "청크 수정 요청", required = true) @Valid @RequestBody UpdateChunkRequest request) { + + log.info("Admin updating article chunk - articleId: {}, chunkId: {}", articleId, chunkId); + + ArticleChunkResponse response = adminService.updateArticleChunk(articleId, chunkId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "책 삭제", description = "어드민 권한으로 특정 책과 관련된 모든 데이터(챕터, 청크, 진도, S3 파일 등)를 삭제합니다.") + @DeleteMapping("/books/{bookId}") + public ResponseEntity deleteBook( + @Parameter(description = "책 ID", required = true) @PathVariable String bookId) { + + log.info("Admin deleting book - bookId: {}", bookId); + + adminService.deleteBook(bookId); + return ResponseEntity.ok(new MessageResponse("Book and all related data deleted successfully.")); + } + + @Operation(summary = "기사 삭제", description = "어드민 권한으로 특정 기사와 관련된 모든 데이터(청크, S3 파일 등)를 삭제합니다.") + @DeleteMapping("/articles/{articleId}") + public ResponseEntity deleteArticle( + @Parameter(description = "기사 ID", required = true) @PathVariable String articleId) { + + log.info("Admin deleting article - articleId: {}", articleId); + + adminService.deleteArticle(articleId); + return ResponseEntity.ok(new MessageResponse("Article and all related data deleted successfully.")); + } + + @Operation(summary = "앱 버전 업데이트", description = "어드민 권한으로 앱의 최신 버전 및 최소 요구 버전을 부분 업데이트합니다.") + @PatchMapping("/version") + public ResponseEntity updateVersion( + @Parameter(description = "버전 업데이트 요청", required = true) @Valid @RequestBody VersionUpdateRequest request) { + + log.info("Admin updating app version - latestVersion: {}, minimumVersion: {}", request.getLatestVersion(), + request.getMinimumVersion()); + + VersionUpdateResponse response = versionService.updateVersion(request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "브로드캐스트 알림 전송", description = "어드민 권한으로 전체 사용자에게 FCM 푸시 알림을 브로드캐스트합니다.") + @PostMapping("/notifications/broadcast") + public ResponseEntity broadcastNotification( + @Parameter(description = "브로드캐스트 알림 전송 요청", + required = true) @Valid @RequestBody NotificationBroadcastRequest request) { + NotificationBroadcastResponse response = notificationService.sendBroadcastNotification(request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "푸시 알림 전송", description = "어드민 권한으로 특정 사용자들에게 FCM 푸시 알림을 전송합니다.") + @PostMapping("/notifications/send") + public ResponseEntity sendNotification( + @Parameter(description = "알림 전송 요청", required = true) @Valid @RequestBody NotificationSendRequest request) { + + NotificationSendResponse response = notificationService.sendNotificationFromRequest(request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "아티클 출시 알림 전송", description = "AI 서버가 새 아티클 출시 시 국가와 카테고리 기반으로 타겟 사용자에게 푸시 알림을 전송합니다.") + @PostMapping("/notifications/article-release") + public ResponseEntity sendArticleReleaseNotification( + @Parameter(description = "아티클 출시 알림 전송 요청", + required = true) @Valid @RequestBody ArticleReleaseNotificationRequest request) { + ArticleReleaseNotificationResponse response = notificationService.sendArticleReleaseNotification(request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "티켓 지급", description = "어드민 권한으로 사용자에게 티켓을 지급합니다.") + @PostMapping("/tickets/grant") + public ResponseEntity grantTicket( + @Parameter(description = "티켓 지급 요청", required = true) @Valid @RequestBody GrantTicketRequest request) { + + log.info("Admin granting tickets - userId: {}, amount: {}, reason: {}", request.getUserId(), + request.getAmount(), request.getReason()); + + // 사용자 존재 여부 확인 + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, "User not found.")); + + String reason = request.getReason() != null ? request.getReason() : "관리자 지급"; + int newBalance = ticketService.grantTicket(user.getId(), request.getAmount(), reason); + + GrantTicketResponse response = GrantTicketResponse.builder() + .message("Tickets granted successfully.") + .userId(request.getUserId()) + .amount(request.getAmount()) + .newBalance(newBalance) + .build(); + + return ResponseEntity.ok(response); + } + + @Operation(summary = "아티클 원본 URL 목록 조회", description = "어드민 권한으로 아티클의 원본 URL 목록을 필터링하여 조회합니다.") + @GetMapping("/articles/origins") + public ResponseEntity> getArticleOrigins( + @ParameterObject @ModelAttribute GetArticleOriginsRequest request) { + + log.info("Admin fetching article origins - tags: {}, targetLanguageCode: {}", request.getTags(), + request.getTargetLanguageCode()); + + PageResponse response = articleService.getArticleOrigins(request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "아티클 targetLanguageCode 마이그레이션", + description = "targetLanguageCode가 null이거나 없는 모든 아티클에 [KO, EN, JA]를 설정합니다.") + @PostMapping("/articles/migrate/target-language-code") + public ResponseEntity migrateArticleTargetLanguageCode() { + log.info("Admin migrating article targetLanguageCode"); + + long updatedCount = articleService.migrateTargetLanguageCode(); + + String message = String + .format("Successfully updated %d articles with default target language codes [KO, EN, JA]", updatedCount); + return ResponseEntity.ok(new MessageResponse(message)); + } + + @Operation(summary = "[Debug] 오늘의 스트릭 학습 상태 리셋", + description = "디버깅용 API. 특정 사용자의 오늘 학습 완료 기록을 삭제하여, 스트릭을 달성하지 않은 상태로 되돌립니다.") + @PostMapping("/streaks/reset-today") + public ResponseEntity resetTodayStreak(@Valid @RequestBody ResetTodayStreakRequest request) { + adminService.resetTodayStreak(request.getUserId()); + return ResponseEntity + .ok(new MessageResponse("User " + request.getUserId() + "'s streak status for today has been reset.")); + } + + @Operation(summary = "스트릭 복구", + description = "어드민 권한으로 특정 사용자의 누락된 스트릭을 복구합니다. MISSED 날짜는 COMPLETED로 변경하고, FREEZE_USED는 COMPLETED로 변경하며 프리즈를 보상합니다. 복구 범위 이후 날짜들도 프리즈를 사용하여 최대한 연결합니다.") + @PostMapping("/streaks/recover") + public ResponseEntity recoverStreak( + @Parameter(description = "스트릭 복구 요청", required = true) @Valid @RequestBody RecoverStreakRequest request) { + + log.info("Admin recovering streak - userId: {}, startDate: {}, endDate: {}", request.getUserId(), + request.getStartDate(), request.getEndDate()); + + // 사용자 존재 여부 확인 + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND, "User not found.")); + + // 스트릭 복구 실행 + streakService.recoverStreak(request.getUserId(), request.getStartDate(), request.getEndDate()); + + // 복구 후 UserStudyReport 조회 + UserStudyReport updatedReport = streakService.recalculateUserStudyReport(request.getUserId()); + + RecoverStreakResponse response = RecoverStreakResponse.builder() + .message("Streak recovered successfully.") + .userId(request.getUserId()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .currentStreak(updatedReport.getCurrentStreak()) + .longestStreak(updatedReport.getLongestStreak()) + .lastCompletionDate(updatedReport.getLastCompletionDate()) + .build(); + + log.info("Streak recovery completed for user {} - currentStreak: {}, longestStreak: {}", request.getUserId(), + updatedReport.getCurrentStreak(), updatedReport.getLongestStreak()); + + return ResponseEntity.ok(response); + } + + @ExceptionHandler(TicketException.class) + public ResponseEntity handleTicketException(TicketException e) { + log.error("Admin Ticket Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + + @ExceptionHandler(CommonException.class) + public ResponseEntity handleCommonException(CommonException e) { + log.error("Admin Common Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/controller/PushCampaignController.java b/src/main/java/com/linglevel/api/admin/controller/PushCampaignController.java index 71098550..8b2f8450 100644 --- a/src/main/java/com/linglevel/api/admin/controller/PushCampaignController.java +++ b/src/main/java/com/linglevel/api/admin/controller/PushCampaignController.java @@ -23,39 +23,30 @@ @SecurityRequirement(name = "adminApiKey") public class PushCampaignController { - private final PushCampaignService pushCampaignService; + private final PushCampaignService pushCampaignService; - @GetMapping - @Operation( - summary = "캠페인 그룹 목록 조회", - description = "푸시 캠페인 그룹 목록을 조회합니다. 기간 필터링이 가능합니다." - ) - public ResponseEntity> getCampaigns( - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDateTime startDate, + @GetMapping + @Operation(summary = "캠페인 그룹 목록 조회", description = "푸시 캠페인 그룹 목록을 조회합니다. 기간 필터링이 가능합니다.") + public ResponseEntity> getCampaigns( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate, - @RequestParam(required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDateTime endDate) { + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) { - log.debug("Get campaign groups request - startDate: {}, endDate: {}", startDate, endDate); + log.debug("Get campaign groups request - startDate: {}, endDate: {}", startDate, endDate); - List summaries = pushCampaignService.getCampaignSummaries(startDate, endDate); + List summaries = pushCampaignService.getCampaignSummaries(startDate, endDate); - return ResponseEntity.ok(summaries); - } + return ResponseEntity.ok(summaries); + } - @GetMapping("/{campaignGroup}/stats") - @Operation( - summary = "캠페인 그룹 상세 통계 조회", - description = "특정 캠페인 그룹의 상세 통계를 조회합니다." - ) - public ResponseEntity getCampaignStats(@PathVariable String campaignGroup) { - log.debug("Get campaign group stats request - campaignGroup: {}", campaignGroup); + @GetMapping("/{campaignGroup}/stats") + @Operation(summary = "캠페인 그룹 상세 통계 조회", description = "특정 캠페인 그룹의 상세 통계를 조회합니다.") + public ResponseEntity getCampaignStats(@PathVariable String campaignGroup) { + log.debug("Get campaign group stats request - campaignGroup: {}", campaignGroup); - PushCampaignStats stats = pushCampaignService.getStats(campaignGroup); + PushCampaignStats stats = pushCampaignService.getStats(campaignGroup); + + return ResponseEntity.ok(stats); + } - return ResponseEntity.ok(stats); - } } diff --git a/src/main/java/com/linglevel/api/admin/crawling/AdminCrawlingController.java b/src/main/java/com/linglevel/api/admin/crawling/AdminCrawlingController.java index d8ffeded..6bff50ab 100644 --- a/src/main/java/com/linglevel/api/admin/crawling/AdminCrawlingController.java +++ b/src/main/java/com/linglevel/api/admin/crawling/AdminCrawlingController.java @@ -33,59 +33,51 @@ @SecurityRequirement(name = "adminApiKey") public class AdminCrawlingController { - private final CrawlingService crawlingService; + private final CrawlingService crawlingService; - @Operation(summary = "어드민 - DSL 생성", description = "어드민 권한으로 새로운 도메인의 제목/본문 추출 DSL을 추가합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "409", description = "도메인이 이미 존재함", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/crawling-dsl") - public ResponseEntity createDsl( - @Valid @RequestBody CreateDslRequest request) { - CreateDslResponse response = crawlingService.createDsl(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } + @Operation(summary = "어드민 - DSL 생성", description = "어드민 권한으로 새로운 도메인의 제목/본문 추출 DSL을 추가합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "409", description = "도메인이 이미 존재함", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/crawling-dsl") + public ResponseEntity createDsl(@Valid @RequestBody CreateDslRequest request) { + CreateDslResponse response = crawlingService.createDsl(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } - @Operation(summary = "어드민 - DSL 업데이트", description = "어드민 권한으로 특정 도메인의 제목/본문 추출 DSL을 업데이트합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (제목/본문 DSL 필드 누락)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PutMapping("/crawling-dsl/{domain}") - public ResponseEntity updateDsl( - @Parameter(description = "업데이트할 도메인명", example = "coupang.com") - @PathVariable String domain, - @Valid @RequestBody UpdateDslRequest request) { - UpdateDslResponse response = crawlingService.updateDsl(domain, request); - return ResponseEntity.ok(response); - } + @Operation(summary = "어드민 - DSL 업데이트", description = "어드민 권한으로 특정 도메인의 제목/본문 추출 DSL을 업데이트합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (제목/본문 DSL 필드 누락)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PutMapping("/crawling-dsl/{domain}") + public ResponseEntity updateDsl( + @Parameter(description = "업데이트할 도메인명", example = "coupang.com") @PathVariable String domain, + @Valid @RequestBody UpdateDslRequest request) { + UpdateDslResponse response = crawlingService.updateDsl(domain, request); + return ResponseEntity.ok(response); + } - @Operation(summary = "어드민 - DSL 삭제", description = "어드민 권한으로 특정 도메인과 관련된 제목/본문 추출 DSL을 삭제합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "삭제 성공", - content = @Content(schema = @Schema(implementation = MessageResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/crawling-dsl/{domain}") - public ResponseEntity deleteDsl( - @Parameter(description = "삭제할 도메인명", example = "coupang.com") - @PathVariable String domain) { - crawlingService.deleteDsl(domain); - return ResponseEntity.ok(new MessageResponse("DSL deleted successfully.")); - } + @Operation(summary = "어드민 - DSL 삭제", description = "어드민 권한으로 특정 도메인과 관련된 제목/본문 추출 DSL을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 API 키)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "도메인을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/crawling-dsl/{domain}") + public ResponseEntity deleteDsl( + @Parameter(description = "삭제할 도메인명", example = "coupang.com") @PathVariable String domain) { + crawlingService.deleteDsl(domain); + return ResponseEntity.ok(new MessageResponse("DSL deleted successfully.")); + } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationRequest.java b/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationRequest.java index ba30f9d8..991362be 100644 --- a/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationRequest.java @@ -17,33 +17,35 @@ @Schema(description = "아티클 출시 알림 전송 요청") public class ArticleReleaseNotificationRequest { - @Schema(description = "출시된 아티클 목록", required = true) - @NotEmpty(message = "Articles are required") - @Valid - private List articles; - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "아티클 정보") - public static class ArticleInfo { - - @Schema(description = "아티클 ID", example = "article-123", required = true) - @NotBlank(message = "Article ID is required") - private String articleId; - - @Schema(description = "타겟 언어 코드 목록 (null이면 모든 언어)", example = "[\"KO\", \"EN\"]") - private List targetLanguageCodes; - - @Schema(description = "타겟 카테고리 (displayName 또는 enum 이름)", example = "Technology", required = true) - @NotNull(message = "Target category is required") - private String targetCategory; - - /** - * targetCategory String을 ContentCategory enum으로 변환 - */ - public ContentCategory getTargetCategoryEnum() { - return ContentCategory.fromString(targetCategory); - } - } + @Schema(description = "출시된 아티클 목록", required = true) + @NotEmpty(message = "Articles are required") + @Valid + private List articles; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "아티클 정보") + public static class ArticleInfo { + + @Schema(description = "아티클 ID", example = "article-123", required = true) + @NotBlank(message = "Article ID is required") + private String articleId; + + @Schema(description = "타겟 언어 코드 목록 (null이면 모든 언어)", example = "[\"KO\", \"EN\"]") + private List targetLanguageCodes; + + @Schema(description = "타겟 카테고리 (displayName 또는 enum 이름)", example = "Technology", required = true) + @NotNull(message = "Target category is required") + private String targetCategory; + + /** + * targetCategory String을 ContentCategory enum으로 변환 + */ + public ContentCategory getTargetCategoryEnum() { + return ContentCategory.fromString(targetCategory); + } + + } + } diff --git a/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationResponse.java b/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationResponse.java index 3c887d92..29a50ac2 100644 --- a/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationResponse.java +++ b/src/main/java/com/linglevel/api/admin/dto/ArticleReleaseNotificationResponse.java @@ -13,25 +13,27 @@ @Schema(description = "아티클 출시 알림 전송 응답") public class ArticleReleaseNotificationResponse { - @Schema(description = "총 성공적으로 전송된 알림 수", example = "1250") - private int totalSentCount; + @Schema(description = "총 성공적으로 전송된 알림 수", example = "1250") + private int totalSentCount; - @Schema(description = "아티클별 전송 결과") - private List results; + @Schema(description = "아티클별 전송 결과") + private List results; - @Data - @Builder - @AllArgsConstructor - @Schema(description = "아티클별 전송 결과") - public static class ArticleResult { + @Data + @Builder + @AllArgsConstructor + @Schema(description = "아티클별 전송 결과") + public static class ArticleResult { - @Schema(description = "아티클 ID", example = "article-123") - private String articleId; + @Schema(description = "아티클 ID", example = "article-123") + private String articleId; - @Schema(description = "해당 아티클로 전송된 알림 수", example = "850") - private int sentCount; + @Schema(description = "해당 아티클로 전송된 알림 수", example = "850") + private int sentCount; + + @Schema(description = "타겟 사용자 수", example = "900") + private int targetUserCount; + + } - @Schema(description = "타겟 사용자 수", example = "900") - private int targetUserCount; - } } diff --git a/src/main/java/com/linglevel/api/admin/dto/GrantTicketRequest.java b/src/main/java/com/linglevel/api/admin/dto/GrantTicketRequest.java index 04c6ca90..40d016a2 100644 --- a/src/main/java/com/linglevel/api/admin/dto/GrantTicketRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/GrantTicketRequest.java @@ -12,15 +12,16 @@ @AllArgsConstructor @Schema(description = "어드민 티켓 지급 요청") public class GrantTicketRequest { - - @Schema(description = "티켓을 지급받을 사용자 ID", example = "60d0fe4f5311236168a109ca", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") - private String userId; - - @Schema(description = "지급할 티켓 수", example = "5", required = true) - @NotNull(message = "지급할 티켓 수는 필수입니다.") - private Integer amount; - - @Schema(description = "지급 사유", example = "구독 갱신 보상") - private String reason; + + @Schema(description = "티켓을 지급받을 사용자 ID", example = "60d0fe4f5311236168a109ca", required = true) + @NotBlank(message = "사용자 ID는 필수입니다.") + private String userId; + + @Schema(description = "지급할 티켓 수", example = "5", required = true) + @NotNull(message = "지급할 티켓 수는 필수입니다.") + private Integer amount; + + @Schema(description = "지급 사유", example = "구독 갱신 보상") + private String reason; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/dto/GrantTicketResponse.java b/src/main/java/com/linglevel/api/admin/dto/GrantTicketResponse.java index 6e4d435c..f763ebad 100644 --- a/src/main/java/com/linglevel/api/admin/dto/GrantTicketResponse.java +++ b/src/main/java/com/linglevel/api/admin/dto/GrantTicketResponse.java @@ -12,16 +12,17 @@ @AllArgsConstructor @Schema(description = "어드민 티켓 지급 응답") public class GrantTicketResponse { - - @Schema(description = "응답 메시지", example = "Tickets granted successfully.") - private String message; - - @Schema(description = "티켓을 지급받은 사용자 ID", example = "60d0fe4f5311236168a109ca") - private String userId; - - @Schema(description = "지급한 티켓 수", example = "5") - private Integer amount; - - @Schema(description = "지급 후 새로운 잔고", example = "8") - private Integer newBalance; + + @Schema(description = "응답 메시지", example = "Tickets granted successfully.") + private String message; + + @Schema(description = "티켓을 지급받은 사용자 ID", example = "60d0fe4f5311236168a109ca") + private String userId; + + @Schema(description = "지급한 티켓 수", example = "5") + private Integer amount; + + @Schema(description = "지급 후 새로운 잔고", example = "8") + private Integer newBalance; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastRequest.java b/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastRequest.java index 76efbbd0..02021407 100644 --- a/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastRequest.java @@ -14,25 +14,31 @@ @Schema(description = "브로드캐스트 알림 전송 요청") public class NotificationBroadcastRequest { - @Schema(description = "국가별 메시지 맵 (최소 US 필수)", example = "{\"KR\": {\"title\": \"새 기능\", \"body\": \"확인하세요\"}, \"US\": {\"title\": \"New Feature\", \"body\": \"Check it out\"}}", required = true) - @NotEmpty(message = "Messages are required") - @Valid - private Map messages; - - @Schema(description = "커스텀 데이터 (딥링크, 액션 등)", example = "{\"deeplink\": \"/announcements/123\", \"action\": \"open_announcement\"}") - private Map data; - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "국가별 메시지") - public static class LocalizedMessage { - @Schema(description = "알림 제목", example = "새로운 기능이 출시되었습니다!", required = true) - @NotBlank(message = "Title is required") - private String title; - - @Schema(description = "알림 내용", example = "지금 바로 확인해보세요.", required = true) - @NotBlank(message = "Body is required") - private String body; - } + @Schema(description = "국가별 메시지 맵 (최소 US 필수)", + example = "{\"KR\": {\"title\": \"새 기능\", \"body\": \"확인하세요\"}, \"US\": {\"title\": \"New Feature\", \"body\": \"Check it out\"}}", + required = true) + @NotEmpty(message = "Messages are required") + @Valid + private Map messages; + + @Schema(description = "커스텀 데이터 (딥링크, 액션 등)", + example = "{\"deeplink\": \"/announcements/123\", \"action\": \"open_announcement\"}") + private Map data; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "국가별 메시지") + public static class LocalizedMessage { + + @Schema(description = "알림 제목", example = "새로운 기능이 출시되었습니다!", required = true) + @NotBlank(message = "Title is required") + private String title; + + @Schema(description = "알림 내용", example = "지금 바로 확인해보세요.", required = true) + @NotBlank(message = "Body is required") + private String body; + + } + } diff --git a/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastResponse.java b/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastResponse.java index 21b0c80a..0abe3cc6 100644 --- a/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastResponse.java +++ b/src/main/java/com/linglevel/api/admin/dto/NotificationBroadcastResponse.java @@ -9,33 +9,35 @@ @Schema(description = "브로드캐스트 알림 전송 응답") public class NotificationBroadcastResponse { - @Schema(description = "응답 메시지", example = "Broadcast notification sent successfully.") - private String message; + @Schema(description = "응답 메시지", example = "Broadcast notification sent successfully.") + private String message; - @Schema(description = "알림을 전송한 총 사용자 수", example = "1500") - private int totalUsers; + @Schema(description = "알림을 전송한 총 사용자 수", example = "1500") + private int totalUsers; - @Schema(description = "성공적으로 전송된 디바이스(토큰) 수", example = "2850") - private int sentCount; + @Schema(description = "성공적으로 전송된 디바이스(토큰) 수", example = "2850") + private int sentCount; - @Schema(description = "전송 실패한 디바이스(토큰) 수", example = "150") - private int failedCount; + @Schema(description = "전송 실패한 디바이스(토큰) 수", example = "150") + private int failedCount; - @Schema(description = "전송 결과 상세 정보") - private NotificationBroadcastDetails details; + @Schema(description = "전송 결과 상세 정보") + private NotificationBroadcastDetails details; - @Data - @AllArgsConstructor - @Schema(description = "브로드캐스트 전송 결과 상세") - public static class NotificationBroadcastDetails { + @Data + @AllArgsConstructor + @Schema(description = "브로드캐스트 전송 결과 상세") + public static class NotificationBroadcastDetails { - @Schema(description = "최소 1개 이상의 디바이스에 성공적으로 전송된 사용자 수", example = "1450") - private int successfulUsers; + @Schema(description = "최소 1개 이상의 디바이스에 성공적으로 전송된 사용자 수", example = "1450") + private int successfulUsers; - @Schema(description = "모든 디바이스에 전송 실패한 사용자 수", example = "50") - private int failedUsers; + @Schema(description = "모든 디바이스에 전송 실패한 사용자 수", example = "50") + private int failedUsers; + + @Schema(description = "전송 시도한 총 토큰 수", example = "3000") + private int totalTokens; + + } - @Schema(description = "전송 시도한 총 토큰 수", example = "3000") - private int totalTokens; - } } diff --git a/src/main/java/com/linglevel/api/admin/dto/NotificationSendRequest.java b/src/main/java/com/linglevel/api/admin/dto/NotificationSendRequest.java index e6a69b6a..3c505478 100644 --- a/src/main/java/com/linglevel/api/admin/dto/NotificationSendRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/NotificationSendRequest.java @@ -15,29 +15,35 @@ @Schema(description = "알림 전송 요청") public class NotificationSendRequest { - @Schema(description = "알림을 받을 사용자 ID 목록", example = "[\"userId1\", \"userId2\"]", required = true) - @NotEmpty(message = "Targets are required") - private List targets; - - @Schema(description = "국가별 메시지 맵 (최소 US 필수)", example = "{\"KR\": {\"title\": \"이벤트\", \"body\": \"시작\"}, \"US\": {\"title\": \"Event\", \"body\": \"Started\"}}", required = true) - @NotEmpty(message = "Messages are required") - @Valid - private Map messages; - - @Schema(description = "커스텀 데이터 (딥링크, 액션 등)", example = "{\"deeplink\": \"/books/60d0fe4f5311236168a109ca\", \"action\": \"open_book\"}") - private Map data; - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "국가별 메시지") - public static class LocalizedMessage { - @Schema(description = "알림 제목", example = "이벤트 안내", required = true) - @NotBlank(message = "Title is required") - private String title; - - @Schema(description = "알림 내용", example = "새로운 이벤트가 시작되었습니다", required = true) - @NotBlank(message = "Body is required") - private String body; - } + @Schema(description = "알림을 받을 사용자 ID 목록", example = "[\"userId1\", \"userId2\"]", required = true) + @NotEmpty(message = "Targets are required") + private List targets; + + @Schema(description = "국가별 메시지 맵 (최소 US 필수)", + example = "{\"KR\": {\"title\": \"이벤트\", \"body\": \"시작\"}, \"US\": {\"title\": \"Event\", \"body\": \"Started\"}}", + required = true) + @NotEmpty(message = "Messages are required") + @Valid + private Map messages; + + @Schema(description = "커스텀 데이터 (딥링크, 액션 등)", + example = "{\"deeplink\": \"/books/60d0fe4f5311236168a109ca\", \"action\": \"open_book\"}") + private Map data; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "국가별 메시지") + public static class LocalizedMessage { + + @Schema(description = "알림 제목", example = "이벤트 안내", required = true) + @NotBlank(message = "Title is required") + private String title; + + @Schema(description = "알림 내용", example = "새로운 이벤트가 시작되었습니다", required = true) + @NotBlank(message = "Body is required") + private String body; + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/dto/NotificationSendResponse.java b/src/main/java/com/linglevel/api/admin/dto/NotificationSendResponse.java index bb9bfa74..edf5e4c4 100644 --- a/src/main/java/com/linglevel/api/admin/dto/NotificationSendResponse.java +++ b/src/main/java/com/linglevel/api/admin/dto/NotificationSendResponse.java @@ -10,28 +10,30 @@ @AllArgsConstructor @Schema(description = "알림 전송 응답") public class NotificationSendResponse { - - @Schema(description = "응답 메시지", example = "Notification sent successfully.") - private String message; - - @Schema(description = "성공적으로 전송된 디바이스 수", example = "2") - private int sentCount; - - @Schema(description = "전송 실패한 디바이스 수", example = "0") - private int failedCount; - - @Schema(description = "전송 결과 상세 정보") - private NotificationSendDetails details; - - @Data - @AllArgsConstructor - @Schema(description = "전송 결과 상세") - public static class NotificationSendDetails { - - @Schema(description = "전송 성공한 토큰들", example = "[\"token1\", \"token2\"]") - private List sentTokens; - - @Schema(description = "전송 실패한 토큰들", example = "[]") - private List failedTokens; - } + + @Schema(description = "응답 메시지", example = "Notification sent successfully.") + private String message; + + @Schema(description = "성공적으로 전송된 디바이스 수", example = "2") + private int sentCount; + + @Schema(description = "전송 실패한 디바이스 수", example = "0") + private int failedCount; + + @Schema(description = "전송 결과 상세 정보") + private NotificationSendDetails details; + + @Data + @AllArgsConstructor + @Schema(description = "전송 결과 상세") + public static class NotificationSendDetails { + + @Schema(description = "전송 성공한 토큰들", example = "[\"token1\", \"token2\"]") + private List sentTokens; + + @Schema(description = "전송 실패한 토큰들", example = "[]") + private List failedTokens; + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java index 86024d01..4297e69b 100644 --- a/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakRequest.java @@ -17,15 +17,16 @@ @Schema(description = "스트릭 복구 요청") public class RecoverStreakRequest { - @NotBlank(message = "userId는 필수입니다.") - @Schema(description = "사용자 ID", example = "user123", required = true) - private String userId; + @NotBlank(message = "userId는 필수입니다.") + @Schema(description = "사용자 ID", example = "user123", required = true) + private String userId; - @NotNull(message = "startDate는 필수입니다.") - @Schema(description = "복구 시작 날짜 (YYYY-MM-DD)", example = "2025-01-10", required = true) - private LocalDate startDate; + @NotNull(message = "startDate는 필수입니다.") + @Schema(description = "복구 시작 날짜 (YYYY-MM-DD)", example = "2025-01-10", required = true) + private LocalDate startDate; + + @NotNull(message = "endDate는 필수입니다.") + @Schema(description = "복구 종료 날짜 (YYYY-MM-DD)", example = "2025-01-15", required = true) + private LocalDate endDate; - @NotNull(message = "endDate는 필수입니다.") - @Schema(description = "복구 종료 날짜 (YYYY-MM-DD)", example = "2025-01-15", required = true) - private LocalDate endDate; } diff --git a/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java index f268e9c7..4007e4b9 100644 --- a/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java +++ b/src/main/java/com/linglevel/api/admin/dto/RecoverStreakResponse.java @@ -15,24 +15,25 @@ @Schema(description = "스트릭 복구 응답") public class RecoverStreakResponse { - @Schema(description = "응답 메시지", example = "Streak recovered successfully.") - private String message; + @Schema(description = "응답 메시지", example = "Streak recovered successfully.") + private String message; - @Schema(description = "사용자 ID", example = "user123") - private String userId; + @Schema(description = "사용자 ID", example = "user123") + private String userId; - @Schema(description = "복구 시작 날짜", example = "2025-01-10") - private LocalDate startDate; + @Schema(description = "복구 시작 날짜", example = "2025-01-10") + private LocalDate startDate; - @Schema(description = "복구 종료 날짜", example = "2025-01-15") - private LocalDate endDate; + @Schema(description = "복구 종료 날짜", example = "2025-01-15") + private LocalDate endDate; - @Schema(description = "복구 후 현재 스트릭", example = "15") - private Integer currentStreak; + @Schema(description = "복구 후 현재 스트릭", example = "15") + private Integer currentStreak; - @Schema(description = "복구 후 최장 스트릭", example = "20") - private Integer longestStreak; + @Schema(description = "복구 후 최장 스트릭", example = "20") + private Integer longestStreak; + + @Schema(description = "마지막 완료일", example = "2025-01-15") + private LocalDate lastCompletionDate; - @Schema(description = "마지막 완료일", example = "2025-01-15") - private LocalDate lastCompletionDate; } diff --git a/src/main/java/com/linglevel/api/admin/dto/ResetTodayStreakRequest.java b/src/main/java/com/linglevel/api/admin/dto/ResetTodayStreakRequest.java index 867330cb..6151cb5f 100644 --- a/src/main/java/com/linglevel/api/admin/dto/ResetTodayStreakRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/ResetTodayStreakRequest.java @@ -9,7 +9,8 @@ @NoArgsConstructor public class ResetTodayStreakRequest { - @Schema(description = "오늘의 스트릭을 리셋할 사용자의 ID", example = "60d5ec49f1b2c8a5d8e4f123", required = true) - @NotBlank(message = "userId는 필수입니다.") - private String userId; + @Schema(description = "오늘의 스트릭을 리셋할 사용자의 ID", example = "60d5ec49f1b2c8a5d8e4f123", required = true) + @NotBlank(message = "userId는 필수입니다.") + private String userId; + } diff --git a/src/main/java/com/linglevel/api/admin/dto/UpdateChunkRequest.java b/src/main/java/com/linglevel/api/admin/dto/UpdateChunkRequest.java index 2738d400..cd89ba5f 100644 --- a/src/main/java/com/linglevel/api/admin/dto/UpdateChunkRequest.java +++ b/src/main/java/com/linglevel/api/admin/dto/UpdateChunkRequest.java @@ -14,10 +14,11 @@ @Schema(description = "청크 수정 요청") public class UpdateChunkRequest { - @Schema(description = "수정할 청크 내용 (텍스트 청크의 경우 텍스트, 이미지 청크의 경우 이미지 URL)", example = "Updated chunk content...") - @NotBlank(message = "Content is required") - private String content; + @Schema(description = "수정할 청크 내용 (텍스트 청크의 경우 텍스트, 이미지 청크의 경우 이미지 URL)", example = "Updated chunk content...") + @NotBlank(message = "Content is required") + private String content; + + @Schema(description = "이미지 청크의 설명 (선택사항, 이미지 청크인 경우에만 사용)", example = "Updated description for image chunks") + private String description; - @Schema(description = "이미지 청크의 설명 (선택사항, 이미지 청크인 경우에만 사용)", example = "Updated description for image chunks") - private String description; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/feed/AdminFeedSourceController.java b/src/main/java/com/linglevel/api/admin/feed/AdminFeedSourceController.java index fc053609..ab0d71b0 100644 --- a/src/main/java/com/linglevel/api/admin/feed/AdminFeedSourceController.java +++ b/src/main/java/com/linglevel/api/admin/feed/AdminFeedSourceController.java @@ -36,150 +36,141 @@ @SecurityRequirement(name = "adminApiKey") public class AdminFeedSourceController { - private final FeedSourceRepository feedSourceRepository; - private final FeedCrawlingScheduler feedCrawlingScheduler; - private final CrawlingService crawlingService; - - @Operation(summary = "FeedSource 생성", description = "새로운 FeedSource를 등록합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping - public ResponseEntity createFeedSource(@Valid @RequestBody CreateFeedSourceRequest request) { - log.info("Creating FeedSource: {}", request.getName()); - - String domain = extractDomain(request.getUrl()); - - if (domain == null) { - throw new IllegalArgumentException("Invalid URL format"); - } - - FeedSource feedSource = FeedSource.builder() - .url(request.getUrl()) - .domain(domain) - .name(request.getName()) - .coverImageDsl(request.getCoverImageDsl()) - .contentType(request.getContentType()) - .category(request.getCategory()) - .tags(request.getTags()) - .isActive(true) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) - .build(); - - FeedSource saved = feedSourceRepository.save(feedSource); - log.info("FeedSource created: {} ({})", saved.getName(), saved.getId()); - - return ResponseEntity.status(HttpStatus.CREATED).body(mapToResponse(saved)); - } - - @Operation(summary = "FeedSource 목록 조회", description = "등록된 모든 FeedSource를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity> getAllFeedSources() { - List feedSources = feedSourceRepository.findAll(); - List responses = feedSources.stream() - .map(this::mapToResponse) - .collect(Collectors.toList()); - return ResponseEntity.ok(responses); - } - - @Operation(summary = "FeedSource 단건 조회", description = "특정 FeedSource를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{id}") - public ResponseEntity getFeedSource(@PathVariable String id) { - FeedSource feedSource = feedSourceRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("FeedSource not found: " + id)); - return ResponseEntity.ok(mapToResponse(feedSource)); - } - - @Operation(summary = "FeedSource 삭제", description = "특정 FeedSource를 삭제합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "삭제 성공", - content = @Content(schema = @Schema(implementation = MessageResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/{id}") - public ResponseEntity deleteFeedSource(@PathVariable String id) { - if (!feedSourceRepository.existsById(id)) { - throw new IllegalArgumentException("FeedSource not found: " + id); - } - feedSourceRepository.deleteById(id); - log.info("FeedSource deleted: {}", id); - return ResponseEntity.ok(new MessageResponse("FeedSource deleted successfully")); - } - - @Operation(summary = "전체 크롤링 실행", description = "모든 활성화된 FeedSource를 크롤링합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "크롤링 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/crawl-all") - public ResponseEntity> triggerCrawlAll() { - log.info("Manual crawl-all triggered"); - int count = feedCrawlingScheduler.crawlAllSources(); - return ResponseEntity.ok(Map.of("crawledCount", count)); - } - - @Operation(summary = "개별 크롤링 실행", description = "특정 FeedSource를 크롤링합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "크롤링 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/{id}/crawl") - public ResponseEntity> triggerCrawlSingle( - @Parameter(description = "크롤링할 FeedSource ID") @PathVariable String id) { - log.info("Manual crawl triggered for FeedSource: {}", id); - int count = feedCrawlingScheduler.crawlSingleSource(id); - return ResponseEntity.ok(Map.of("crawledCount", count)); - } - - private FeedSourceResponse mapToResponse(FeedSource feedSource) { - return FeedSourceResponse.builder() - .id(feedSource.getId()) - .url(feedSource.getUrl()) - .domain(feedSource.getDomain()) - .name(feedSource.getName()) - .coverImageDsl(feedSource.getCoverImageDsl()) - .contentType(feedSource.getContentType()) - .category(feedSource.getCategory()) - .tags(feedSource.getTags()) - .isActive(feedSource.getIsActive()) - .createdAt(feedSource.getCreatedAt()) - .updatedAt(feedSource.getUpdatedAt()) - .build(); - } - - private String extractDomain(String url) { - try { - java.net.URL parsedUrl = new java.net.URL(url); - String host = parsedUrl.getHost().toLowerCase(); - java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("([^.]+\\.[^.]+)$"); - java.util.regex.Matcher matcher = pattern.matcher(host); - return matcher.find() ? matcher.group(1) : host; - } catch (Exception e) { - return null; - } - } + private final FeedSourceRepository feedSourceRepository; + + private final FeedCrawlingScheduler feedCrawlingScheduler; + + private final CrawlingService crawlingService; + + @Operation(summary = "FeedSource 생성", description = "새로운 FeedSource를 등록합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping + public ResponseEntity createFeedSource(@Valid @RequestBody CreateFeedSourceRequest request) { + log.info("Creating FeedSource: {}", request.getName()); + + String domain = extractDomain(request.getUrl()); + + if (domain == null) { + throw new IllegalArgumentException("Invalid URL format"); + } + + FeedSource feedSource = FeedSource.builder() + .url(request.getUrl()) + .domain(domain) + .name(request.getName()) + .coverImageDsl(request.getCoverImageDsl()) + .contentType(request.getContentType()) + .category(request.getCategory()) + .tags(request.getTags()) + .isActive(true) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + + FeedSource saved = feedSourceRepository.save(feedSource); + log.info("FeedSource created: {} ({})", saved.getName(), saved.getId()); + + return ResponseEntity.status(HttpStatus.CREATED).body(mapToResponse(saved)); + } + + @Operation(summary = "FeedSource 목록 조회", description = "등록된 모든 FeedSource를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity> getAllFeedSources() { + List feedSources = feedSourceRepository.findAll(); + List responses = feedSources.stream().map(this::mapToResponse).collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } + + @Operation(summary = "FeedSource 단건 조회", description = "특정 FeedSource를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{id}") + public ResponseEntity getFeedSource(@PathVariable String id) { + FeedSource feedSource = feedSourceRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("FeedSource not found: " + id)); + return ResponseEntity.ok(mapToResponse(feedSource)); + } + + @Operation(summary = "FeedSource 삭제", description = "특정 FeedSource를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/{id}") + public ResponseEntity deleteFeedSource(@PathVariable String id) { + if (!feedSourceRepository.existsById(id)) { + throw new IllegalArgumentException("FeedSource not found: " + id); + } + feedSourceRepository.deleteById(id); + log.info("FeedSource deleted: {}", id); + return ResponseEntity.ok(new MessageResponse("FeedSource deleted successfully")); + } + + @Operation(summary = "전체 크롤링 실행", description = "모든 활성화된 FeedSource를 크롤링합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "크롤링 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/crawl-all") + public ResponseEntity> triggerCrawlAll() { + log.info("Manual crawl-all triggered"); + int count = feedCrawlingScheduler.crawlAllSources(); + return ResponseEntity.ok(Map.of("crawledCount", count)); + } + + @Operation(summary = "개별 크롤링 실행", description = "특정 FeedSource를 크롤링합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "크롤링 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/{id}/crawl") + public ResponseEntity> triggerCrawlSingle( + @Parameter(description = "크롤링할 FeedSource ID") @PathVariable String id) { + log.info("Manual crawl triggered for FeedSource: {}", id); + int count = feedCrawlingScheduler.crawlSingleSource(id); + return ResponseEntity.ok(Map.of("crawledCount", count)); + } + + private FeedSourceResponse mapToResponse(FeedSource feedSource) { + return FeedSourceResponse.builder() + .id(feedSource.getId()) + .url(feedSource.getUrl()) + .domain(feedSource.getDomain()) + .name(feedSource.getName()) + .coverImageDsl(feedSource.getCoverImageDsl()) + .contentType(feedSource.getContentType()) + .category(feedSource.getCategory()) + .tags(feedSource.getTags()) + .isActive(feedSource.getIsActive()) + .createdAt(feedSource.getCreatedAt()) + .updatedAt(feedSource.getUpdatedAt()) + .build(); + } + + private String extractDomain(String url) { + try { + java.net.URL parsedUrl = new java.net.URL(url); + String host = parsedUrl.getHost().toLowerCase(); + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("([^.]+\\.[^.]+)$"); + java.util.regex.Matcher matcher = pattern.matcher(host); + return matcher.find() ? matcher.group(1) : host; + } + catch (Exception e) { + return null; + } + } + } diff --git a/src/main/java/com/linglevel/api/admin/migration/BookmarkMigrationController.java b/src/main/java/com/linglevel/api/admin/migration/BookmarkMigrationController.java index 11a65514..329fbc03 100644 --- a/src/main/java/com/linglevel/api/admin/migration/BookmarkMigrationController.java +++ b/src/main/java/com/linglevel/api/admin/migration/BookmarkMigrationController.java @@ -31,115 +31,126 @@ @SecurityRequirement(name = "adminApiKey") public class BookmarkMigrationController { - private final WordBookmarkRepository wordBookmarkRepository; - private final WordService wordService; - private final WordRepository wordRepository; - private final WordVariantRepository wordVariantRepository; - - @PostMapping("/reset-and-normalize") - @Operation(summary = "단어 데이터 리셋 및 북마크 정규화", - description = "1) 단어 데이터를 먼저 삭제 2) 북마크를 순회하며 원형으로 정규화하고 단어 복원 3) 중복 발생 시 삭제") - public MigrationResult resetAndNormalizeBookmarks() { - log.warn("Starting word reset and bookmark normalization..."); - - // 1. 기존 단어 데이터 먼저 삭제 - long deletedWords = wordRepository.count(); - long deletedVariants = wordVariantRepository.count(); - - wordRepository.deleteAll(); - wordVariantRepository.deleteAll(); - - log.warn("Deleted {} words and {} variants", deletedWords, deletedVariants); - - // 2. 북마크 순회하며 정규화 및 단어 복원 (각 북마크는 독립적으로 처리) - List allBookmarks = wordBookmarkRepository.findAll(); - int total = allBookmarks.size(); - int normalized = 0; - int duplicateRemoved = 0; - int failed = 0; - java.util.Set restoredWords = new java.util.HashSet<>(); - - for (WordBookmark bookmark : allBookmarks) { - try { - String currentWord = bookmark.getWord(); - String userId = bookmark.getUserId(); - - // AI를 통해 단어 새로 생성 및 원형 획득 - log.info("Restoring and normalizing: {} (userId: {})", currentWord, userId); - WordSearchResponse searchResponse = wordService.getOrCreateWords(userId, currentWord, LanguageCode.KO); - - if (searchResponse.getResults().isEmpty()) { - log.warn("No results for word: {}", currentWord); - failed++; - continue; - } - - // 첫 번째 (가장 일반적인) 원형만 처리 - List results = searchResponse.getResults(); - log.info("Found {} original form(s) for '{}': {}", - results.size(), - currentWord, - results.stream().map(WordResponse::getOriginalForm).toList()); - - // 첫 번째 결과만 사용 (가장 일반적인 의미) - WordResponse wordResponse = results.get(0); - String originalForm = wordResponse.getOriginalForm(); - - restoredWords.add(originalForm); // 복원된 원형 단어 추적 - - // 이미 원형이면 스킵 - if (currentWord.equals(originalForm)) { - log.debug("Already in original form: {}", currentWord); - continue; - } - - log.info("Normalizing bookmark: {} -> {} (userId: {})", currentWord, originalForm, userId); - - // 기존 북마크 업데이트 - bookmark.setWord(originalForm); - try { - wordBookmarkRepository.save(bookmark); - normalized++; - log.info("Successfully normalized: {} -> {}", currentWord, originalForm); - } catch (DuplicateKeyException e) { - // 중복 발생 시 현재 북마크 삭제 (원형이 이미 북마크되어 있음) - wordBookmarkRepository.delete(bookmark); - duplicateRemoved++; - log.info("Duplicate bookmark removed: {} (already has {})", currentWord, originalForm); - } - - } catch (Exception e) { - failed++; - log.error("Failed to process bookmark: {} (userId: {})", - bookmark.getWord(), bookmark.getUserId(), e); - // 실패해도 계속 진행 - } - } - - MigrationResult result = MigrationResult.builder() - .deletedWords(deletedWords) - .deletedVariants(deletedVariants) - .totalBookmarks(total) - .restoredWords(restoredWords.size()) - .normalizedBookmarks(normalized) - .duplicateRemoved(duplicateRemoved) - .failed(failed) - .build(); - - log.warn("Migration complete: {}", result); - return result; - } - - @Data - @Builder - @AllArgsConstructor - public static class MigrationResult { - private long deletedWords; - private long deletedVariants; - private int totalBookmarks; - private int restoredWords; - private int normalizedBookmarks; - private int duplicateRemoved; - private int failed; - } + private final WordBookmarkRepository wordBookmarkRepository; + + private final WordService wordService; + + private final WordRepository wordRepository; + + private final WordVariantRepository wordVariantRepository; + + @PostMapping("/reset-and-normalize") + @Operation(summary = "단어 데이터 리셋 및 북마크 정규화", + description = "1) 단어 데이터를 먼저 삭제 2) 북마크를 순회하며 원형으로 정규화하고 단어 복원 3) 중복 발생 시 삭제") + public MigrationResult resetAndNormalizeBookmarks() { + log.warn("Starting word reset and bookmark normalization..."); + + // 1. 기존 단어 데이터 먼저 삭제 + long deletedWords = wordRepository.count(); + long deletedVariants = wordVariantRepository.count(); + + wordRepository.deleteAll(); + wordVariantRepository.deleteAll(); + + log.warn("Deleted {} words and {} variants", deletedWords, deletedVariants); + + // 2. 북마크 순회하며 정규화 및 단어 복원 (각 북마크는 독립적으로 처리) + List allBookmarks = wordBookmarkRepository.findAll(); + int total = allBookmarks.size(); + int normalized = 0; + int duplicateRemoved = 0; + int failed = 0; + java.util.Set restoredWords = new java.util.HashSet<>(); + + for (WordBookmark bookmark : allBookmarks) { + try { + String currentWord = bookmark.getWord(); + String userId = bookmark.getUserId(); + + // AI를 통해 단어 새로 생성 및 원형 획득 + log.info("Restoring and normalizing: {} (userId: {})", currentWord, userId); + WordSearchResponse searchResponse = wordService.getOrCreateWords(userId, currentWord, LanguageCode.KO); + + if (searchResponse.getResults().isEmpty()) { + log.warn("No results for word: {}", currentWord); + failed++; + continue; + } + + // 첫 번째 (가장 일반적인) 원형만 처리 + List results = searchResponse.getResults(); + log.info("Found {} original form(s) for '{}': {}", results.size(), currentWord, + results.stream().map(WordResponse::getOriginalForm).toList()); + + // 첫 번째 결과만 사용 (가장 일반적인 의미) + WordResponse wordResponse = results.get(0); + String originalForm = wordResponse.getOriginalForm(); + + restoredWords.add(originalForm); // 복원된 원형 단어 추적 + + // 이미 원형이면 스킵 + if (currentWord.equals(originalForm)) { + log.debug("Already in original form: {}", currentWord); + continue; + } + + log.info("Normalizing bookmark: {} -> {} (userId: {})", currentWord, originalForm, userId); + + // 기존 북마크 업데이트 + bookmark.setWord(originalForm); + try { + wordBookmarkRepository.save(bookmark); + normalized++; + log.info("Successfully normalized: {} -> {}", currentWord, originalForm); + } + catch (DuplicateKeyException e) { + // 중복 발생 시 현재 북마크 삭제 (원형이 이미 북마크되어 있음) + wordBookmarkRepository.delete(bookmark); + duplicateRemoved++; + log.info("Duplicate bookmark removed: {} (already has {})", currentWord, originalForm); + } + + } + catch (Exception e) { + failed++; + log.error("Failed to process bookmark: {} (userId: {})", bookmark.getWord(), bookmark.getUserId(), e); + // 실패해도 계속 진행 + } + } + + MigrationResult result = MigrationResult.builder() + .deletedWords(deletedWords) + .deletedVariants(deletedVariants) + .totalBookmarks(total) + .restoredWords(restoredWords.size()) + .normalizedBookmarks(normalized) + .duplicateRemoved(duplicateRemoved) + .failed(failed) + .build(); + + log.warn("Migration complete: {}", result); + return result; + } + + @Data + @Builder + @AllArgsConstructor + public static class MigrationResult { + + private long deletedWords; + + private long deletedVariants; + + private int totalBookmarks; + + private int restoredWords; + + private int normalizedBookmarks; + + private int duplicateRemoved; + + private int failed; + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/migration/UserCustomContentMigrationController.java b/src/main/java/com/linglevel/api/admin/migration/UserCustomContentMigrationController.java index 2394da2f..2054960f 100644 --- a/src/main/java/com/linglevel/api/admin/migration/UserCustomContentMigrationController.java +++ b/src/main/java/com/linglevel/api/admin/migration/UserCustomContentMigrationController.java @@ -21,8 +21,7 @@ import java.util.List; /** - * UserCustomContent 마이그레이션 컨트롤러 - * 기존 CustomContent의 userId를 기반으로 UserCustomContent 매핑 생성 + * UserCustomContent 마이그레이션 컨트롤러 기존 CustomContent의 userId를 기반으로 UserCustomContent 매핑 생성 */ @RestController @RequestMapping("/api/v1/admin/migration") @@ -32,94 +31,101 @@ @SecurityRequirement(name = "adminApiKey") public class UserCustomContentMigrationController { - private final CustomContentRepository customContentRepository; - private final UserCustomContentRepository userCustomContentRepository; - - @PostMapping("/custom-content-to-user-mapping") - @Operation( - summary = "CustomContent → UserCustomContent 마이그레이션", - description = "기존 CustomContent의 userId를 기반으로 UserCustomContent 매핑 생성. " + - "이미 삭제된 콘텐츠는 제외하고 처리됩니다." - ) - @Transactional - public MigrationResult migrateCustomContentToUserMapping() { - log.warn("Starting CustomContent to UserCustomContent migration..."); - - // 삭제되지 않은 모든 CustomContent 조회 - List allContents = customContentRepository.findAll().stream() - .filter(content -> content.getIsDeleted() == null || !content.getIsDeleted()) - .toList(); - - int total = allContents.size(); - int created = 0; - int skipped = 0; - int failed = 0; - - log.info("Found {} active CustomContent records to migrate", total); - - for (CustomContent content : allContents) { - try { - String userId = content.getUserId(); - String customContentId = content.getId(); - String contentRequestId = content.getContentRequestId(); - - // userId가 없는 경우 스킵 (데이터 무결성 문제) - if (userId == null || userId.isBlank()) { - log.warn("Skipping content {} - missing userId", customContentId); - skipped++; - continue; - } - - // 이미 매핑이 존재하는지 확인 - boolean exists = userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId); - if (exists) { - log.debug("Mapping already exists for user: {} and content: {}", userId, customContentId); - skipped++; - continue; - } - - // UserCustomContent 생성 - UserCustomContent userCustomContent = UserCustomContent.builder() - .userId(userId) - .customContentId(customContentId) - .contentRequestId(contentRequestId) - .build(); - - try { - userCustomContentRepository.save(userCustomContent); - created++; - log.debug("Created mapping for user: {} and content: {}", userId, customContentId); - } catch (DuplicateKeyException e) { - // Compound Index의 unique 제약 조건 위반 (거의 발생하지 않음) - log.info("Duplicate mapping detected for user: {} and content: {} - skipping", userId, customContentId); - skipped++; - } - - } catch (Exception e) { - failed++; - log.error("Failed to migrate content: {} (userId: {})", - content.getId(), content.getUserId(), e); - } - } - - MigrationResult result = MigrationResult.builder() - .totalContents(total) - .createdMappings(created) - .skippedMappings(skipped) - .failedMappings(failed) - .build(); - - log.warn("Migration complete: {}", result); - return result; - } - - @Data - @Builder - @AllArgsConstructor - public static class MigrationResult { - private int totalContents; - private int createdMappings; - private int skippedMappings; - private int failedMappings; - } + private final CustomContentRepository customContentRepository; + + private final UserCustomContentRepository userCustomContentRepository; + + @PostMapping("/custom-content-to-user-mapping") + @Operation(summary = "CustomContent → UserCustomContent 마이그레이션", + description = "기존 CustomContent의 userId를 기반으로 UserCustomContent 매핑 생성. " + "이미 삭제된 콘텐츠는 제외하고 처리됩니다.") + @Transactional + public MigrationResult migrateCustomContentToUserMapping() { + log.warn("Starting CustomContent to UserCustomContent migration..."); + + // 삭제되지 않은 모든 CustomContent 조회 + List allContents = customContentRepository.findAll() + .stream() + .filter(content -> content.getIsDeleted() == null || !content.getIsDeleted()) + .toList(); + + int total = allContents.size(); + int created = 0; + int skipped = 0; + int failed = 0; + + log.info("Found {} active CustomContent records to migrate", total); + + for (CustomContent content : allContents) { + try { + String userId = content.getUserId(); + String customContentId = content.getId(); + String contentRequestId = content.getContentRequestId(); + + // userId가 없는 경우 스킵 (데이터 무결성 문제) + if (userId == null || userId.isBlank()) { + log.warn("Skipping content {} - missing userId", customContentId); + skipped++; + continue; + } + + // 이미 매핑이 존재하는지 확인 + boolean exists = userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId); + if (exists) { + log.debug("Mapping already exists for user: {} and content: {}", userId, customContentId); + skipped++; + continue; + } + + // UserCustomContent 생성 + UserCustomContent userCustomContent = UserCustomContent.builder() + .userId(userId) + .customContentId(customContentId) + .contentRequestId(contentRequestId) + .build(); + + try { + userCustomContentRepository.save(userCustomContent); + created++; + log.debug("Created mapping for user: {} and content: {}", userId, customContentId); + } + catch (DuplicateKeyException e) { + // Compound Index의 unique 제약 조건 위반 (거의 발생하지 않음) + log.info("Duplicate mapping detected for user: {} and content: {} - skipping", userId, + customContentId); + skipped++; + } + + } + catch (Exception e) { + failed++; + log.error("Failed to migrate content: {} (userId: {})", content.getId(), content.getUserId(), e); + } + } + + MigrationResult result = MigrationResult.builder() + .totalContents(total) + .createdMappings(created) + .skippedMappings(skipped) + .failedMappings(failed) + .build(); + + log.warn("Migration complete: {}", result); + return result; + } + + @Data + @Builder + @AllArgsConstructor + public static class MigrationResult { + + private int totalContents; + + private int createdMappings; + + private int skippedMappings; + + private int failedMappings; + + } + } diff --git a/src/main/java/com/linglevel/api/admin/service/AdminService.java b/src/main/java/com/linglevel/api/admin/service/AdminService.java index 192fa786..911fd92b 100644 --- a/src/main/java/com/linglevel/api/admin/service/AdminService.java +++ b/src/main/java/com/linglevel/api/admin/service/AdminService.java @@ -40,191 +40,201 @@ @Transactional public class AdminService { - private final ChunkRepository chunkRepository; - private final ChapterRepository chapterRepository; - private final BookRepository bookRepository; - private final BookProgressRepository bookProgressRepository; - private final ArticleRepository articleRepository; - private final ArticleChunkRepository articleChunkRepository; - private final S3StaticService s3StaticService; - private final BookPathStrategy bookPathStrategy; - private final ArticlePathStrategy articlePathStrategy; - private final DailyCompletionRepository dailyCompletionRepository; - private final UserStudyReportRepository userStudyReportRepository; - - public ChunkResponse updateBookChunk(String bookId, String chapterId, String chunkId, UpdateChunkRequest request) { - log.info("Updating book chunk - bookId: {}, chapterId: {}, chunkId: {}", bookId, chapterId, chunkId); - - // 책 존재 확인 - Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); - - // 챕터 존재 확인 - Chapter chapter = chapterRepository.findById(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - - // 챕터가 해당 책에 속하는지 확인 - if (!chapter.getBookId().equals(bookId)) { - throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND); - } - - // 청크 존재 확인 - Chunk chunk = chunkRepository.findById(chunkId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); - - // 청크가 해당 챕터에 속하는지 확인 - if (!chunk.getChapterId().equals(chapterId)) { - throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND); - } - - // 청크 내용 수정 - chunk.updateContent(request.getContent(), request.getDescription()); - Chunk updatedChunk = chunkRepository.save(chunk); - - log.info("Book chunk updated successfully - chunkId: {}", chunkId); - - return ChunkResponse.from(updatedChunk); - } - - public ArticleChunkResponse updateArticleChunk(String articleId, String chunkId, UpdateChunkRequest request) { - log.info("Updating article chunk - articleId: {}, chunkId: {}", articleId, chunkId); - - // 기사 존재 확인 - Article article = articleRepository.findById(articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); - - // 청크 존재 확인 - ArticleChunk chunk = articleChunkRepository.findById(chunkId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); - - // 청크가 해당 기사에 속하는지 확인 - if (!chunk.getArticleId().equals(articleId)) { - throw new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND); - } - - // 청크 내용 수정 - chunk.updateContent(request.getContent(), request.getDescription()); - ArticleChunk updatedChunk = articleChunkRepository.save(chunk); - - log.info("Article chunk updated successfully - chunkId: {}", chunkId); - - return ArticleChunkResponse.from(updatedChunk); - } - - public void deleteBook(String bookId) { - log.info("Starting book deletion - bookId: {}", bookId); - - // 책 존재 확인 - Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); - - try { - // 1. S3에서 책 관련 파일들 삭제 - s3StaticService.deleteFiles(bookId, bookPathStrategy); - log.info("S3 book files deleted successfully - bookId: {}", bookId); - - // 2. 책과 관련된 진도 기록 삭제 - List progressList = bookProgressRepository.findByBookId(bookId); - if (!progressList.isEmpty()) { - bookProgressRepository.deleteAll(progressList); - log.info("Book progress records deleted - count: {}", progressList.size()); - } - - // 3. 청크 삭제 - List chapters = chapterRepository.findByBookIdOrderByChapterNumber(bookId); - for (Chapter chapter : chapters) { - List chunks = chunkRepository.findByChapterIdOrderByChunkNumber(chapter.getId()); - if (!chunks.isEmpty()) { - chunkRepository.deleteAll(chunks); - log.info("Chunks deleted for chapter - chapterId: {}, count: {}", chapter.getId(), chunks.size()); - } - } - - // 4. 챕터 삭제 - if (!chapters.isEmpty()) { - chapterRepository.deleteAll(chapters); - log.info("Chapters deleted - count: {}", chapters.size()); - } - - // 5. 책 삭제 - bookRepository.delete(book); - log.info("Book deleted successfully - bookId: {}", bookId); - - } catch (Exception e) { - log.error("Error during book deletion - bookId: {}, error: {}", bookId, e.getMessage(), e); - throw new BooksException(BooksErrorCode.BOOK_DELETION_FAILED); - } - } - - public void deleteArticle(String articleId) { - log.info("Starting article deletion - articleId: {}", articleId); - - // 기사 존재 확인 - Article article = articleRepository.findById(articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); - - try { - // 1. S3에서 기사 관련 파일들 삭제 - s3StaticService.deleteFiles(articleId, articlePathStrategy); - log.info("S3 article files deleted successfully - articleId: {}", articleId); - - // 2. 기사 청크 삭제 - List chunks = articleChunkRepository.findByArticleIdAndDifficultyLevelOrderByChunkNumber( - articleId, article.getDifficultyLevel(), PageRequest.of(0, Integer.MAX_VALUE)).getContent(); - if (!chunks.isEmpty()) { - articleChunkRepository.deleteAll(chunks); - log.info("Article chunks deleted - count: {}", chunks.size()); - } - - // 3. 기사 삭제 - articleRepository.delete(article); - log.info("Article deleted successfully - articleId: {}", articleId); - - } catch (Exception e) { - log.error("Error during article deletion - articleId: {}, error: {}", articleId, e.getMessage(), e); - throw new ArticleException(ArticleErrorCode.ARTICLE_DELETION_FAILED); - } - } - - public void resetTodayStreak(String userId) { - log.info("Admin resetting today's streak for user: {}", userId); - - LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); - - dailyCompletionRepository.findByUserIdAndCompletionDate(userId, today) - .ifPresent(todayCompletion -> { - List todayContentIds = todayCompletion.getCompletedContents() != null - ? todayCompletion.getCompletedContents().stream() - .map(c -> c.getContentId()) - .toList() - : List.of(); - - dailyCompletionRepository.delete(todayCompletion); - log.info("Deleted today's DailyCompletion for user: {}", userId); - - userStudyReportRepository.findByUserId(userId).ifPresent(report -> { - if (report.getCompletedContentIds() != null && !todayContentIds.isEmpty()) { - report.getCompletedContentIds().removeAll(todayContentIds); - } - - if (report.getLastCompletionDate() != null && report.getLastCompletionDate().isEqual(today)) { - dailyCompletionRepository.findTopByUserIdAndCompletionDateBeforeOrderByCompletionDateDesc(userId, today) - .ifPresentOrElse( - lastCompletion -> { - report.setLastCompletionDate(lastCompletion.getCompletionDate()); - report.setCurrentStreak(lastCompletion.getStreakCount() != null ? lastCompletion.getStreakCount() : 0); - }, - () -> { - report.setLastCompletionDate(null); - report.setCurrentStreak(0); - report.setStreakStartDate(null); - } - ); - } - - userStudyReportRepository.save(report); - log.info("UserStudyReport reverted for user: {}", userId); - }); - }); - } + private final ChunkRepository chunkRepository; + + private final ChapterRepository chapterRepository; + + private final BookRepository bookRepository; + + private final BookProgressRepository bookProgressRepository; + + private final ArticleRepository articleRepository; + + private final ArticleChunkRepository articleChunkRepository; + + private final S3StaticService s3StaticService; + + private final BookPathStrategy bookPathStrategy; + + private final ArticlePathStrategy articlePathStrategy; + + private final DailyCompletionRepository dailyCompletionRepository; + + private final UserStudyReportRepository userStudyReportRepository; + + public ChunkResponse updateBookChunk(String bookId, String chapterId, String chunkId, UpdateChunkRequest request) { + log.info("Updating book chunk - bookId: {}, chapterId: {}, chunkId: {}", bookId, chapterId, chunkId); + + // 책 존재 확인 + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); + + // 챕터 존재 확인 + Chapter chapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + + // 챕터가 해당 책에 속하는지 확인 + if (!chapter.getBookId().equals(bookId)) { + throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND); + } + + // 청크 존재 확인 + Chunk chunk = chunkRepository.findById(chunkId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); + + // 청크가 해당 챕터에 속하는지 확인 + if (!chunk.getChapterId().equals(chapterId)) { + throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND); + } + + // 청크 내용 수정 + chunk.updateContent(request.getContent(), request.getDescription()); + Chunk updatedChunk = chunkRepository.save(chunk); + + log.info("Book chunk updated successfully - chunkId: {}", chunkId); + + return ChunkResponse.from(updatedChunk); + } + + public ArticleChunkResponse updateArticleChunk(String articleId, String chunkId, UpdateChunkRequest request) { + log.info("Updating article chunk - articleId: {}, chunkId: {}", articleId, chunkId); + + // 기사 존재 확인 + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + + // 청크 존재 확인 + ArticleChunk chunk = articleChunkRepository.findById(chunkId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); + + // 청크가 해당 기사에 속하는지 확인 + if (!chunk.getArticleId().equals(articleId)) { + throw new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND); + } + + // 청크 내용 수정 + chunk.updateContent(request.getContent(), request.getDescription()); + ArticleChunk updatedChunk = articleChunkRepository.save(chunk); + + log.info("Article chunk updated successfully - chunkId: {}", chunkId); + + return ArticleChunkResponse.from(updatedChunk); + } + + public void deleteBook(String bookId) { + log.info("Starting book deletion - bookId: {}", bookId); + + // 책 존재 확인 + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); + + try { + // 1. S3에서 책 관련 파일들 삭제 + s3StaticService.deleteFiles(bookId, bookPathStrategy); + log.info("S3 book files deleted successfully - bookId: {}", bookId); + + // 2. 책과 관련된 진도 기록 삭제 + List progressList = bookProgressRepository.findByBookId(bookId); + if (!progressList.isEmpty()) { + bookProgressRepository.deleteAll(progressList); + log.info("Book progress records deleted - count: {}", progressList.size()); + } + + // 3. 청크 삭제 + List chapters = chapterRepository.findByBookIdOrderByChapterNumber(bookId); + for (Chapter chapter : chapters) { + List chunks = chunkRepository.findByChapterIdOrderByChunkNumber(chapter.getId()); + if (!chunks.isEmpty()) { + chunkRepository.deleteAll(chunks); + log.info("Chunks deleted for chapter - chapterId: {}, count: {}", chapter.getId(), chunks.size()); + } + } + + // 4. 챕터 삭제 + if (!chapters.isEmpty()) { + chapterRepository.deleteAll(chapters); + log.info("Chapters deleted - count: {}", chapters.size()); + } + + // 5. 책 삭제 + bookRepository.delete(book); + log.info("Book deleted successfully - bookId: {}", bookId); + + } + catch (Exception e) { + log.error("Error during book deletion - bookId: {}, error: {}", bookId, e.getMessage(), e); + throw new BooksException(BooksErrorCode.BOOK_DELETION_FAILED); + } + } + + public void deleteArticle(String articleId) { + log.info("Starting article deletion - articleId: {}", articleId); + + // 기사 존재 확인 + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + + try { + // 1. S3에서 기사 관련 파일들 삭제 + s3StaticService.deleteFiles(articleId, articlePathStrategy); + log.info("S3 article files deleted successfully - articleId: {}", articleId); + + // 2. 기사 청크 삭제 + List chunks = articleChunkRepository + .findByArticleIdAndDifficultyLevelOrderByChunkNumber(articleId, article.getDifficultyLevel(), + PageRequest.of(0, Integer.MAX_VALUE)) + .getContent(); + if (!chunks.isEmpty()) { + articleChunkRepository.deleteAll(chunks); + log.info("Article chunks deleted - count: {}", chunks.size()); + } + + // 3. 기사 삭제 + articleRepository.delete(article); + log.info("Article deleted successfully - articleId: {}", articleId); + + } + catch (Exception e) { + log.error("Error during article deletion - articleId: {}, error: {}", articleId, e.getMessage(), e); + throw new ArticleException(ArticleErrorCode.ARTICLE_DELETION_FAILED); + } + } + + public void resetTodayStreak(String userId) { + log.info("Admin resetting today's streak for user: {}", userId); + + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + + dailyCompletionRepository.findByUserIdAndCompletionDate(userId, today).ifPresent(todayCompletion -> { + List todayContentIds = todayCompletion.getCompletedContents() != null + ? todayCompletion.getCompletedContents().stream().map(c -> c.getContentId()).toList() : List.of(); + + dailyCompletionRepository.delete(todayCompletion); + log.info("Deleted today's DailyCompletion for user: {}", userId); + + userStudyReportRepository.findByUserId(userId).ifPresent(report -> { + if (report.getCompletedContentIds() != null && !todayContentIds.isEmpty()) { + report.getCompletedContentIds().removeAll(todayContentIds); + } + + if (report.getLastCompletionDate() != null && report.getLastCompletionDate().isEqual(today)) { + dailyCompletionRepository + .findTopByUserIdAndCompletionDateBeforeOrderByCompletionDateDesc(userId, today) + .ifPresentOrElse(lastCompletion -> { + report.setLastCompletionDate(lastCompletion.getCompletionDate()); + report.setCurrentStreak( + lastCompletion.getStreakCount() != null ? lastCompletion.getStreakCount() : 0); + }, () -> { + report.setLastCompletionDate(null); + report.setCurrentStreak(0); + report.setStreakStartDate(null); + }); + } + + userStudyReportRepository.save(report); + log.info("UserStudyReport reverted for user: {}", userId); + }); + }); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/admin/service/NotificationService.java b/src/main/java/com/linglevel/api/admin/service/NotificationService.java index 07b30384..c39e9f7f 100644 --- a/src/main/java/com/linglevel/api/admin/service/NotificationService.java +++ b/src/main/java/com/linglevel/api/admin/service/NotificationService.java @@ -32,567 +32,552 @@ @Slf4j public class NotificationService { - private final FcmMessagingService fcmMessagingService; - private final FcmTokenRepository fcmTokenRepository; - private final ArticleRepository articleRepository; - private final UserCategoryPreferenceRepository userCategoryPreferenceRepository; - - public NotificationSendResponse sendNotificationFromRequest(NotificationSendRequest request) { - return sendLocalizedNotification(request.getTargets(), request.getMessages(), request.getData()); - } - - - private void deactivateTokenByFcmToken(String fcmToken) { - try { - Optional tokenEntity = fcmTokenRepository.findByFcmToken(fcmToken); - if (tokenEntity.isPresent()) { - FcmToken token = tokenEntity.get(); - token.setIsActive(false); - token.setUpdatedAt(LocalDateTime.now()); - fcmTokenRepository.save(token); - log.info("Deactivated invalid FCM token for user: {}, device: {}", - token.getUserId(), token.getDeviceId()); - } else { - log.warn("FCM token not found in database: {}", maskToken(fcmToken)); - } - } catch (Exception e) { - log.error("Failed to deactivate token: {}", maskToken(fcmToken), e); - } - } - - private String maskToken(String token) { - if (token == null || token.length() < 8) { - return "***"; - } - return token.substring(0, 4) + "***" + token.substring(token.length() - 4); - } - - /** - * 국가별 메시지를 전송합니다. - */ - private NotificationSendResponse sendLocalizedNotification( - List targetUserIds, - Map messages, - Map data) { - - log.info("Starting localized notification send to {} users", targetUserIds.size()); - - // 대상 사용자들의 활성 FCM 토큰 조회 - List allTokens = new ArrayList<>(); - for (String userId : targetUserIds) { - List activeTokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); - allTokens.addAll(activeTokens); - } - - if (allTokens.isEmpty()) { - log.warn("No FCM tokens found for users: {}", targetUserIds); - return new NotificationSendResponse( - "No FCM tokens found for users.", - 0, 0, - new NotificationSendResponse.NotificationSendDetails( - Collections.emptyList(), - Collections.emptyList() - ) - ); - } - - // 국가별로 토큰 그룹핑 - Map> tokensByCountry = allTokens.stream() - .collect(Collectors.groupingBy( - token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US - )); - - List sentTokens = new ArrayList<>(); - List failedTokens = new ArrayList<>(); - - // 국가별로 메시지 전송 - tokensByCountry.forEach((countryCode, tokens) -> { - NotificationSendRequest.LocalizedMessage message = messages.get(countryCode.getCode()); - - // 해당 국가 메시지가 없으면 US 기본값 사용 - if (message == null) { - message = messages.get("US"); - } - - // US 메시지도 없으면 스킵 - if (message == null) { - log.warn("No message found for country: {} and no fallback (US) message", countryCode); - tokens.forEach(token -> failedTokens.add(token.getFcmToken())); - return; - } - - FcmMessageRequest fcmRequest = FcmMessageRequest.builder() - .title(message.getTitle()) - .body(message.getBody()) - .campaignId("admin-targeted") - .data(data) - .build(); - - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), fcmRequest); - sentTokens.add(fcmTokens.get(0)); - log.debug("Sent localized message (country: {}) to token: {}", countryCode, maskToken(fcmTokens.get(0))); - } else if (!fcmTokens.isEmpty()) { - BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, fcmRequest); - - // 개별 응답 처리 - for (int i = 0; i < response.getResponses().size(); i++) { - String token = fcmTokens.get(i); - if (response.getResponses().get(i).isSuccessful()) { - sentTokens.add(token); - } else { - failedTokens.add(token); - log.warn("Failed to send localized message to token: {}, error: {}", - maskToken(token), response.getResponses().get(i).getException().getMessage()); - deactivateTokenByFcmToken(token); - } - } - log.debug("Sent multicast message (country: {}) - Success: {}, Failed: {}", - countryCode, response.getSuccessCount(), response.getFailureCount()); - } - } catch (Exception e) { - log.error("Failed to send localized message batch (country: {}), error: {}", countryCode, e.getMessage()); - for (String token : fcmTokens) { - failedTokens.add(token); - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - deactivateTokenByFcmToken(token); - } - } - } - }); - - log.info("Localized notification send completed - Success: {}, Failed: {}", - sentTokens.size(), failedTokens.size()); - - return new NotificationSendResponse( - "Localized notification sent successfully.", - sentTokens.size(), - failedTokens.size(), - new NotificationSendResponse.NotificationSendDetails(sentTokens, failedTokens) - ); - } - - public NotificationBroadcastResponse sendBroadcastNotification(NotificationBroadcastRequest request) { - return sendLocalizedBroadcast(request.getMessages(), request.getData()); - } - - /** - * 국가별 메시지 브로드캐스트 - */ - private NotificationBroadcastResponse sendLocalizedBroadcast( - Map messages, - Map data) { - - log.info("Starting localized broadcast notification"); - - // 모든 활성 FCM 토큰 조회 - List allActiveTokens = fcmTokenRepository.findByIsActive(true); - - if (allActiveTokens.isEmpty()) { - log.warn("No FCM tokens found for broadcast"); - return new NotificationBroadcastResponse( - "No FCM tokens found for broadcast.", - 0, 0, 0, - new NotificationBroadcastResponse.NotificationBroadcastDetails(0, 0, 0) - ); - } - - // 국가별로 토큰 그룹핑 - Map> tokensByCountry = allActiveTokens.stream() - .collect(Collectors.groupingBy( - token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US - )); - - int totalTokens = allActiveTokens.size(); - int totalSentCount = 0; - int totalFailedCount = 0; - Set successfulUserIds = new HashSet<>(); - Set failedUserIds = new HashSet<>(); - - // 국가별로 메시지 전송 - for (Map.Entry> entry : tokensByCountry.entrySet()) { - CountryCode countryCode = entry.getKey(); - List tokens = entry.getValue(); - - NotificationBroadcastRequest.LocalizedMessage message = messages.get(countryCode.getCode()); - - // 해당 국가 메시지가 없으면 US 기본값 사용 - if (message == null) { - message = messages.get("US"); - } - - // US 메시지도 없으면 스킵 - if (message == null) { - log.warn("No message found for country: {} and no fallback (US) message", countryCode); - for (FcmToken token : tokens) { - failedUserIds.add(token.getUserId()); - totalFailedCount++; - } - continue; - } - - FcmMessageRequest fcmRequest = FcmMessageRequest.builder() - .title(message.getTitle()) - .body(message.getBody()) - .campaignId("admin-broadcast") - .data(data) - .build(); - - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - Map tokenToUserId = tokens.stream() - .collect(Collectors.toMap(FcmToken::getFcmToken, FcmToken::getUserId, (a, b) -> a)); - - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), fcmRequest); - successfulUserIds.add(tokenToUserId.get(fcmTokens.get(0))); - totalSentCount++; - log.debug("Broadcast localized message (country: {}) sent to user: {}, token: {}", - countryCode, tokenToUserId.get(fcmTokens.get(0)), maskToken(fcmTokens.get(0))); - } else if (!fcmTokens.isEmpty()) { - BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, fcmRequest); - - // 개별 응답 처리 - for (int i = 0; i < response.getResponses().size(); i++) { - String token = fcmTokens.get(i); - String userId = tokenToUserId.get(token); - - if (response.getResponses().get(i).isSuccessful()) { - successfulUserIds.add(userId); - totalSentCount++; - } else { - failedUserIds.add(userId); - totalFailedCount++; - log.warn("Failed to send localized broadcast to user: {}, token: {}, error: {}", - userId, maskToken(token), response.getResponses().get(i).getException().getMessage()); - deactivateTokenByFcmToken(token); - } - } - log.debug("Broadcast multicast message (country: {}) - Success: {}, Failed: {}", - countryCode, response.getSuccessCount(), response.getFailureCount()); - } - } catch (Exception e) { - log.error("Failed to send broadcast message batch (country: {}), error: {}", countryCode, e.getMessage()); - for (String token : fcmTokens) { - failedUserIds.add(tokenToUserId.get(token)); - totalFailedCount++; - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - deactivateTokenByFcmToken(token); - } - } - } - } - - // 실패만 한 사용자 계산 - int failedOnlyUsers = (int) failedUserIds.stream() - .filter(userId -> !successfulUserIds.contains(userId)) - .count(); - - int totalUsers = (int) allActiveTokens.stream() - .map(FcmToken::getUserId) - .distinct() - .count(); - - log.info("Localized broadcast completed - Total users: {}, Successful users: {}, Failed users: {}, " + - "Total sent: {}, Total failed: {}", - totalUsers, successfulUserIds.size(), failedOnlyUsers, totalSentCount, totalFailedCount); - - return new NotificationBroadcastResponse( - "Localized broadcast notification sent successfully.", - totalUsers, - totalSentCount, - totalFailedCount, - new NotificationBroadcastResponse.NotificationBroadcastDetails( - successfulUserIds.size(), - failedOnlyUsers, - totalTokens - ) - ); - } - - /** - * 아티클 출시 알림 전송 - */ - public ArticleReleaseNotificationResponse sendArticleReleaseNotification(ArticleReleaseNotificationRequest request) { - log.info("Starting article release notification for {} articles", request.getArticles().size()); - - int totalSentCount = 0; - List results = new ArrayList<>(); - Map> userArticleMatches = new HashMap<>(); - - // 1. 각 아티클별로 타겟 사용자 필터링 - for (ArticleReleaseNotificationRequest.ArticleInfo articleInfo : request.getArticles()) { - List targetTokens = filterTargetTokens(articleInfo); - - log.info("Article {} matched {} tokens", articleInfo.getArticleId(), targetTokens.size()); - - // 각 토큰의 사용자에 대해 매칭 정보 저장 - for (FcmToken token : targetTokens) { - String userId = token.getUserId(); - LanguageCode userLanguage = convertCountryCodeToLanguageCode(token.getCountryCode()); - - int priority = calculatePriority(token, articleInfo, userLanguage); - - MatchedArticle matchedArticle = new MatchedArticle( - articleInfo.getArticleId(), - articleInfo.getTargetCategoryEnum(), - priority, - userLanguage - ); - - userArticleMatches.computeIfAbsent(userId, k -> new ArrayList<>()).add(matchedArticle); - } - } - - // 2. 각 사용자별로 최고 우선순위 아티클 1개만 선택하여 알림 전송 - Map articleSentCounts = new HashMap<>(); - Map articleTargetCounts = new HashMap<>(); - - for (Map.Entry> entry : userArticleMatches.entrySet()) { - String userId = entry.getKey(); - List matches = entry.getValue(); - - // 우선순위가 가장 높은 아티클 선택 (priority 값이 낮을수록 우선순위 높음) - MatchedArticle topMatch = matches.stream() - .min(Comparator.comparingInt(MatchedArticle::getPriority)) - .orElse(null); - - if (topMatch != null) { - String articleId = topMatch.getArticleId(); - - articleTargetCounts.merge(articleId, 1, Integer::sum); - - List userTokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); - - if (!userTokens.isEmpty()) { - Optional
articleOpt = articleRepository.findById(articleId); - if (articleOpt.isPresent()) { - Article article = articleOpt.get(); - - String localizedTitle = getLocalizedNotificationTitle(topMatch.getUserLanguage()); - String categoryName = article.getCategory() != null - ? article.getCategory().name().toLowerCase() - : "unknown"; - String campaignId = "newArticle-" + categoryName; - - FcmMessageRequest fcmRequest = FcmMessageRequest.builder() - .title(localizedTitle) - .body(article.getTitle()) - .type("ARTICLE_RELEASE") - .deepLink("linglevel:///articles/" + article.getId()) - .campaignId(campaignId) - .build(); - - Map additionalData = new HashMap<>(); - additionalData.put("articleId", article.getId()); - fcmRequest.setAdditionalData(additionalData); - - List fcmTokens = userTokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - boolean sent = false; - - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), fcmRequest); - sent = true; - log.debug("Sent article notification to user: {}, article: {}", userId, articleId); - } else if (!fcmTokens.isEmpty()) { - BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, fcmRequest); - - // 개별 응답 처리 - for (int i = 0; i < response.getResponses().size(); i++) { - String token = fcmTokens.get(i); - if (response.getResponses().get(i).isSuccessful()) { - sent = true; - } else { - log.warn("Failed to send article notification to user: {}, token: {}, error: {}", - userId, maskToken(token), response.getResponses().get(i).getException().getMessage()); - deactivateTokenByFcmToken(token); - } - } - - if (sent) { - log.debug("Sent article notification multicast to user: {} (Success: {}, Failed: {}), article: {}", - userId, response.getSuccessCount(), response.getFailureCount(), articleId); - } - } - } catch (Exception e) { - log.error("Failed to send article notification to user: {}, article: {}, error: {}", - userId, articleId, e.getMessage()); - for (String token : fcmTokens) { - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - deactivateTokenByFcmToken(token); - } - } - } - - if (sent) { - articleSentCounts.merge(articleId, 1, Integer::sum); - totalSentCount++; - } - } - } - } - } - - // 3. 응답 생성 - for (ArticleReleaseNotificationRequest.ArticleInfo articleInfo : request.getArticles()) { - String articleId = articleInfo.getArticleId(); - ArticleReleaseNotificationResponse.ArticleResult result = ArticleReleaseNotificationResponse.ArticleResult.builder() - .articleId(articleId) - .sentCount(articleSentCounts.getOrDefault(articleId, 0)) - .targetUserCount(articleTargetCounts.getOrDefault(articleId, 0)) - .build(); - results.add(result); - } - - log.info("Article release notification completed - Total sent: {}", totalSentCount); - - return ArticleReleaseNotificationResponse.builder() - .totalSentCount(totalSentCount) - .results(results) - .build(); - } - - /** - * 타겟 토큰 필터링 - */ - private List filterTargetTokens(ArticleReleaseNotificationRequest.ArticleInfo articleInfo) { - List allActiveTokens = fcmTokenRepository.findByIsActive(true); - - return allActiveTokens.stream() - .filter(token -> { - LanguageCode userLanguage = convertCountryCodeToLanguageCode(token.getCountryCode()); - - // targetLanguageCodes가 null이면 모든 언어 매칭 - if (articleInfo.getTargetLanguageCodes() != null && - !articleInfo.getTargetLanguageCodes().isEmpty()) { - if (!articleInfo.getTargetLanguageCodes().contains(userLanguage)) { - return false; - } - } - - return true; - }) - .collect(Collectors.toList()); - } - - /** - * 우선순위 계산 - * Priority 1 (값: 1): 언어 AND 카테고리 모두 매칭 - * Priority 2 (값: 2): 언어만 매칭 - */ - private int calculatePriority(FcmToken token, ArticleReleaseNotificationRequest.ArticleInfo articleInfo, LanguageCode userLanguage) { - Optional preferenceOpt = userCategoryPreferenceRepository.findByUserId(token.getUserId()); - - boolean categoryMatch = false; - if (preferenceOpt.isPresent() && preferenceOpt.get().getPrimaryCategory() != null) { - ContentCategory targetCategory = articleInfo.getTargetCategoryEnum(); - categoryMatch = preferenceOpt.get().getPrimaryCategory().equals(targetCategory); - } else { - categoryMatch = true; - } - - // 언어는 이미 filterTargetTokens에서 필터링되었으므로 항상 매칭됨 - boolean languageMatch = true; - - if (languageMatch && categoryMatch) { - return 1; - } else if (languageMatch) { - return 2; - } else { - return 999; - } - } - - /** - * CountryCode를 LanguageCode로 변환 - */ - private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { - if (countryCode == null) { - return LanguageCode.EN; - } - - switch (countryCode) { - case KR: - return LanguageCode.KO; - case JP: - return LanguageCode.JA; - case US: - default: - return LanguageCode.EN; - } - } - - /** - * 아티클 알림 전송 - */ - private void sendArticleNotification(FcmToken token, Article article, LanguageCode userLanguage) { - String localizedTitle = getLocalizedNotificationTitle(userLanguage); - - // campaignId 생성: "newArticle-{category}" - String categoryName = article.getCategory() != null - ? article.getCategory().name().toLowerCase() - : "unknown"; - String campaignId = "newArticle-" + categoryName; - - FcmMessageRequest fcmRequest = FcmMessageRequest.builder() - .title(localizedTitle) - .body(article.getTitle()) - .type("ARTICLE_RELEASE") - .deepLink("linglevel:///articles/" + article.getId()) - .campaignId(campaignId) - .build(); - - Map additionalData = new HashMap<>(); - additionalData.put("articleId", article.getId()); - fcmRequest.setAdditionalData(additionalData); - - fcmMessagingService.sendMessage(token.getFcmToken(), fcmRequest); - } - - /** - * 언어별 알림 제목 로컬라이징 - */ - private String getLocalizedNotificationTitle(LanguageCode languageCode) { - switch (languageCode) { - case KO: - return "💌 오늘의 아티클 도착"; - case JA: - return "💌 本日の記事が届きました"; - case EN: - default: - return "💌 Today's Article Has Arrived"; - } - } - - /** - * 매칭된 아티클 정보를 담는 내부 클래스 - */ - @Getter - private static class MatchedArticle { - private final String articleId; - private final ContentCategory category; - private final int priority; - private final LanguageCode userLanguage; - - public MatchedArticle(String articleId, ContentCategory category, - int priority, LanguageCode userLanguage) { - this.articleId = articleId; - this.category = category; - this.priority = priority; - this.userLanguage = userLanguage; - } - - } + private final FcmMessagingService fcmMessagingService; + + private final FcmTokenRepository fcmTokenRepository; + + private final ArticleRepository articleRepository; + + private final UserCategoryPreferenceRepository userCategoryPreferenceRepository; + + public NotificationSendResponse sendNotificationFromRequest(NotificationSendRequest request) { + return sendLocalizedNotification(request.getTargets(), request.getMessages(), request.getData()); + } + + private void deactivateTokenByFcmToken(String fcmToken) { + try { + Optional tokenEntity = fcmTokenRepository.findByFcmToken(fcmToken); + if (tokenEntity.isPresent()) { + FcmToken token = tokenEntity.get(); + token.setIsActive(false); + token.setUpdatedAt(LocalDateTime.now()); + fcmTokenRepository.save(token); + log.info("Deactivated invalid FCM token for user: {}, device: {}", token.getUserId(), + token.getDeviceId()); + } + else { + log.warn("FCM token not found in database: {}", maskToken(fcmToken)); + } + } + catch (Exception e) { + log.error("Failed to deactivate token: {}", maskToken(fcmToken), e); + } + } + + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "***"; + } + return token.substring(0, 4) + "***" + token.substring(token.length() - 4); + } + + /** + * 국가별 메시지를 전송합니다. + */ + private NotificationSendResponse sendLocalizedNotification(List targetUserIds, + Map messages, Map data) { + + log.info("Starting localized notification send to {} users", targetUserIds.size()); + + // 대상 사용자들의 활성 FCM 토큰 조회 + List allTokens = new ArrayList<>(); + for (String userId : targetUserIds) { + List activeTokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); + allTokens.addAll(activeTokens); + } + + if (allTokens.isEmpty()) { + log.warn("No FCM tokens found for users: {}", targetUserIds); + return new NotificationSendResponse("No FCM tokens found for users.", 0, 0, + new NotificationSendResponse.NotificationSendDetails(Collections.emptyList(), + Collections.emptyList())); + } + + // 국가별로 토큰 그룹핑 + Map> tokensByCountry = allTokens.stream() + .collect(Collectors + .groupingBy(token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US)); + + List sentTokens = new ArrayList<>(); + List failedTokens = new ArrayList<>(); + + // 국가별로 메시지 전송 + tokensByCountry.forEach((countryCode, tokens) -> { + NotificationSendRequest.LocalizedMessage message = messages.get(countryCode.getCode()); + + // 해당 국가 메시지가 없으면 US 기본값 사용 + if (message == null) { + message = messages.get("US"); + } + + // US 메시지도 없으면 스킵 + if (message == null) { + log.warn("No message found for country: {} and no fallback (US) message", countryCode); + tokens.forEach(token -> failedTokens.add(token.getFcmToken())); + return; + } + + FcmMessageRequest fcmRequest = FcmMessageRequest.builder() + .title(message.getTitle()) + .body(message.getBody()) + .campaignId("admin-targeted") + .data(data) + .build(); + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), fcmRequest); + sentTokens.add(fcmTokens.get(0)); + log.debug("Sent localized message (country: {}) to token: {}", countryCode, + maskToken(fcmTokens.get(0))); + } + else if (!fcmTokens.isEmpty()) { + BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, fcmRequest); + + // 개별 응답 처리 + for (int i = 0; i < response.getResponses().size(); i++) { + String token = fcmTokens.get(i); + if (response.getResponses().get(i).isSuccessful()) { + sentTokens.add(token); + } + else { + failedTokens.add(token); + log.warn("Failed to send localized message to token: {}, error: {}", maskToken(token), + response.getResponses().get(i).getException().getMessage()); + deactivateTokenByFcmToken(token); + } + } + log.debug("Sent multicast message (country: {}) - Success: {}, Failed: {}", countryCode, + response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + log.error("Failed to send localized message batch (country: {}), error: {}", countryCode, + e.getMessage()); + for (String token : fcmTokens) { + failedTokens.add(token); + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + deactivateTokenByFcmToken(token); + } + } + } + }); + + log.info("Localized notification send completed - Success: {}, Failed: {}", sentTokens.size(), + failedTokens.size()); + + return new NotificationSendResponse("Localized notification sent successfully.", sentTokens.size(), + failedTokens.size(), new NotificationSendResponse.NotificationSendDetails(sentTokens, failedTokens)); + } + + public NotificationBroadcastResponse sendBroadcastNotification(NotificationBroadcastRequest request) { + return sendLocalizedBroadcast(request.getMessages(), request.getData()); + } + + /** + * 국가별 메시지 브로드캐스트 + */ + private NotificationBroadcastResponse sendLocalizedBroadcast( + Map messages, Map data) { + + log.info("Starting localized broadcast notification"); + + // 모든 활성 FCM 토큰 조회 + List allActiveTokens = fcmTokenRepository.findByIsActive(true); + + if (allActiveTokens.isEmpty()) { + log.warn("No FCM tokens found for broadcast"); + return new NotificationBroadcastResponse("No FCM tokens found for broadcast.", 0, 0, 0, + new NotificationBroadcastResponse.NotificationBroadcastDetails(0, 0, 0)); + } + + // 국가별로 토큰 그룹핑 + Map> tokensByCountry = allActiveTokens.stream() + .collect(Collectors + .groupingBy(token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US)); + + int totalTokens = allActiveTokens.size(); + int totalSentCount = 0; + int totalFailedCount = 0; + Set successfulUserIds = new HashSet<>(); + Set failedUserIds = new HashSet<>(); + + // 국가별로 메시지 전송 + for (Map.Entry> entry : tokensByCountry.entrySet()) { + CountryCode countryCode = entry.getKey(); + List tokens = entry.getValue(); + + NotificationBroadcastRequest.LocalizedMessage message = messages.get(countryCode.getCode()); + + // 해당 국가 메시지가 없으면 US 기본값 사용 + if (message == null) { + message = messages.get("US"); + } + + // US 메시지도 없으면 스킵 + if (message == null) { + log.warn("No message found for country: {} and no fallback (US) message", countryCode); + for (FcmToken token : tokens) { + failedUserIds.add(token.getUserId()); + totalFailedCount++; + } + continue; + } + + FcmMessageRequest fcmRequest = FcmMessageRequest.builder() + .title(message.getTitle()) + .body(message.getBody()) + .campaignId("admin-broadcast") + .data(data) + .build(); + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + Map tokenToUserId = tokens.stream() + .collect(Collectors.toMap(FcmToken::getFcmToken, FcmToken::getUserId, (a, b) -> a)); + + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), fcmRequest); + successfulUserIds.add(tokenToUserId.get(fcmTokens.get(0))); + totalSentCount++; + log.debug("Broadcast localized message (country: {}) sent to user: {}, token: {}", countryCode, + tokenToUserId.get(fcmTokens.get(0)), maskToken(fcmTokens.get(0))); + } + else if (!fcmTokens.isEmpty()) { + BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, fcmRequest); + + // 개별 응답 처리 + for (int i = 0; i < response.getResponses().size(); i++) { + String token = fcmTokens.get(i); + String userId = tokenToUserId.get(token); + + if (response.getResponses().get(i).isSuccessful()) { + successfulUserIds.add(userId); + totalSentCount++; + } + else { + failedUserIds.add(userId); + totalFailedCount++; + log.warn("Failed to send localized broadcast to user: {}, token: {}, error: {}", userId, + maskToken(token), response.getResponses().get(i).getException().getMessage()); + deactivateTokenByFcmToken(token); + } + } + log.debug("Broadcast multicast message (country: {}) - Success: {}, Failed: {}", countryCode, + response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + log.error("Failed to send broadcast message batch (country: {}), error: {}", countryCode, + e.getMessage()); + for (String token : fcmTokens) { + failedUserIds.add(tokenToUserId.get(token)); + totalFailedCount++; + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + deactivateTokenByFcmToken(token); + } + } + } + } + + // 실패만 한 사용자 계산 + int failedOnlyUsers = (int) failedUserIds.stream() + .filter(userId -> !successfulUserIds.contains(userId)) + .count(); + + int totalUsers = (int) allActiveTokens.stream().map(FcmToken::getUserId).distinct().count(); + + log.info( + "Localized broadcast completed - Total users: {}, Successful users: {}, Failed users: {}, " + + "Total sent: {}, Total failed: {}", + totalUsers, successfulUserIds.size(), failedOnlyUsers, totalSentCount, totalFailedCount); + + return new NotificationBroadcastResponse("Localized broadcast notification sent successfully.", totalUsers, + totalSentCount, totalFailedCount, new NotificationBroadcastResponse.NotificationBroadcastDetails( + successfulUserIds.size(), failedOnlyUsers, totalTokens)); + } + + /** + * 아티클 출시 알림 전송 + */ + public ArticleReleaseNotificationResponse sendArticleReleaseNotification( + ArticleReleaseNotificationRequest request) { + log.info("Starting article release notification for {} articles", request.getArticles().size()); + + int totalSentCount = 0; + List results = new ArrayList<>(); + Map> userArticleMatches = new HashMap<>(); + + // 1. 각 아티클별로 타겟 사용자 필터링 + for (ArticleReleaseNotificationRequest.ArticleInfo articleInfo : request.getArticles()) { + List targetTokens = filterTargetTokens(articleInfo); + + log.info("Article {} matched {} tokens", articleInfo.getArticleId(), targetTokens.size()); + + // 각 토큰의 사용자에 대해 매칭 정보 저장 + for (FcmToken token : targetTokens) { + String userId = token.getUserId(); + LanguageCode userLanguage = convertCountryCodeToLanguageCode(token.getCountryCode()); + + int priority = calculatePriority(token, articleInfo, userLanguage); + + MatchedArticle matchedArticle = new MatchedArticle(articleInfo.getArticleId(), + articleInfo.getTargetCategoryEnum(), priority, userLanguage); + + userArticleMatches.computeIfAbsent(userId, k -> new ArrayList<>()).add(matchedArticle); + } + } + + // 2. 각 사용자별로 최고 우선순위 아티클 1개만 선택하여 알림 전송 + Map articleSentCounts = new HashMap<>(); + Map articleTargetCounts = new HashMap<>(); + + for (Map.Entry> entry : userArticleMatches.entrySet()) { + String userId = entry.getKey(); + List matches = entry.getValue(); + + // 우선순위가 가장 높은 아티클 선택 (priority 값이 낮을수록 우선순위 높음) + MatchedArticle topMatch = matches.stream() + .min(Comparator.comparingInt(MatchedArticle::getPriority)) + .orElse(null); + + if (topMatch != null) { + String articleId = topMatch.getArticleId(); + + articleTargetCounts.merge(articleId, 1, Integer::sum); + + List userTokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); + + if (!userTokens.isEmpty()) { + Optional
articleOpt = articleRepository.findById(articleId); + if (articleOpt.isPresent()) { + Article article = articleOpt.get(); + + String localizedTitle = getLocalizedNotificationTitle(topMatch.getUserLanguage()); + String categoryName = article.getCategory() != null ? article.getCategory().name().toLowerCase() + : "unknown"; + String campaignId = "newArticle-" + categoryName; + + FcmMessageRequest fcmRequest = FcmMessageRequest.builder() + .title(localizedTitle) + .body(article.getTitle()) + .type("ARTICLE_RELEASE") + .deepLink("linglevel:///articles/" + article.getId()) + .campaignId(campaignId) + .build(); + + Map additionalData = new HashMap<>(); + additionalData.put("articleId", article.getId()); + fcmRequest.setAdditionalData(additionalData); + + List fcmTokens = userTokens.stream() + .map(FcmToken::getFcmToken) + .collect(Collectors.toList()); + + boolean sent = false; + + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), fcmRequest); + sent = true; + log.debug("Sent article notification to user: {}, article: {}", userId, articleId); + } + else if (!fcmTokens.isEmpty()) { + BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, + fcmRequest); + + // 개별 응답 처리 + for (int i = 0; i < response.getResponses().size(); i++) { + String token = fcmTokens.get(i); + if (response.getResponses().get(i).isSuccessful()) { + sent = true; + } + else { + log.warn( + "Failed to send article notification to user: {}, token: {}, error: {}", + userId, maskToken(token), + response.getResponses().get(i).getException().getMessage()); + deactivateTokenByFcmToken(token); + } + } + + if (sent) { + log.debug( + "Sent article notification multicast to user: {} (Success: {}, Failed: {}), article: {}", + userId, response.getSuccessCount(), response.getFailureCount(), articleId); + } + } + } + catch (Exception e) { + log.error("Failed to send article notification to user: {}, article: {}, error: {}", userId, + articleId, e.getMessage()); + for (String token : fcmTokens) { + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + deactivateTokenByFcmToken(token); + } + } + } + + if (sent) { + articleSentCounts.merge(articleId, 1, Integer::sum); + totalSentCount++; + } + } + } + } + } + + // 3. 응답 생성 + for (ArticleReleaseNotificationRequest.ArticleInfo articleInfo : request.getArticles()) { + String articleId = articleInfo.getArticleId(); + ArticleReleaseNotificationResponse.ArticleResult result = ArticleReleaseNotificationResponse.ArticleResult + .builder() + .articleId(articleId) + .sentCount(articleSentCounts.getOrDefault(articleId, 0)) + .targetUserCount(articleTargetCounts.getOrDefault(articleId, 0)) + .build(); + results.add(result); + } + + log.info("Article release notification completed - Total sent: {}", totalSentCount); + + return ArticleReleaseNotificationResponse.builder().totalSentCount(totalSentCount).results(results).build(); + } + + /** + * 타겟 토큰 필터링 + */ + private List filterTargetTokens(ArticleReleaseNotificationRequest.ArticleInfo articleInfo) { + List allActiveTokens = fcmTokenRepository.findByIsActive(true); + + return allActiveTokens.stream().filter(token -> { + LanguageCode userLanguage = convertCountryCodeToLanguageCode(token.getCountryCode()); + + // targetLanguageCodes가 null이면 모든 언어 매칭 + if (articleInfo.getTargetLanguageCodes() != null && !articleInfo.getTargetLanguageCodes().isEmpty()) { + if (!articleInfo.getTargetLanguageCodes().contains(userLanguage)) { + return false; + } + } + + return true; + }).collect(Collectors.toList()); + } + + /** + * 우선순위 계산 Priority 1 (값: 1): 언어 AND 카테고리 모두 매칭 Priority 2 (값: 2): 언어만 매칭 + */ + private int calculatePriority(FcmToken token, ArticleReleaseNotificationRequest.ArticleInfo articleInfo, + LanguageCode userLanguage) { + Optional preferenceOpt = userCategoryPreferenceRepository + .findByUserId(token.getUserId()); + + boolean categoryMatch = false; + if (preferenceOpt.isPresent() && preferenceOpt.get().getPrimaryCategory() != null) { + ContentCategory targetCategory = articleInfo.getTargetCategoryEnum(); + categoryMatch = preferenceOpt.get().getPrimaryCategory().equals(targetCategory); + } + else { + categoryMatch = true; + } + + // 언어는 이미 filterTargetTokens에서 필터링되었으므로 항상 매칭됨 + boolean languageMatch = true; + + if (languageMatch && categoryMatch) { + return 1; + } + else if (languageMatch) { + return 2; + } + else { + return 999; + } + } + + /** + * CountryCode를 LanguageCode로 변환 + */ + private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { + if (countryCode == null) { + return LanguageCode.EN; + } + + switch (countryCode) { + case KR: + return LanguageCode.KO; + case JP: + return LanguageCode.JA; + case US: + default: + return LanguageCode.EN; + } + } + + /** + * 아티클 알림 전송 + */ + private void sendArticleNotification(FcmToken token, Article article, LanguageCode userLanguage) { + String localizedTitle = getLocalizedNotificationTitle(userLanguage); + + // campaignId 생성: "newArticle-{category}" + String categoryName = article.getCategory() != null ? article.getCategory().name().toLowerCase() : "unknown"; + String campaignId = "newArticle-" + categoryName; + + FcmMessageRequest fcmRequest = FcmMessageRequest.builder() + .title(localizedTitle) + .body(article.getTitle()) + .type("ARTICLE_RELEASE") + .deepLink("linglevel:///articles/" + article.getId()) + .campaignId(campaignId) + .build(); + + Map additionalData = new HashMap<>(); + additionalData.put("articleId", article.getId()); + fcmRequest.setAdditionalData(additionalData); + + fcmMessagingService.sendMessage(token.getFcmToken(), fcmRequest); + } + + /** + * 언어별 알림 제목 로컬라이징 + */ + private String getLocalizedNotificationTitle(LanguageCode languageCode) { + switch (languageCode) { + case KO: + return "💌 오늘의 아티클 도착"; + case JA: + return "💌 本日の記事が届きました"; + case EN: + default: + return "💌 Today's Article Has Arrived"; + } + } + + /** + * 매칭된 아티클 정보를 담는 내부 클래스 + */ + @Getter + private static class MatchedArticle { + + private final String articleId; + + private final ContentCategory category; + + private final int priority; + + private final LanguageCode userLanguage; + + public MatchedArticle(String articleId, ContentCategory category, int priority, LanguageCode userLanguage) { + this.articleId = articleId; + this.category = category; + this.priority = priority; + this.userLanguage = userLanguage; + } + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/config/SecurityConfig.java b/src/main/java/com/linglevel/api/auth/config/SecurityConfig.java index cc5b695e..5998a33e 100644 --- a/src/main/java/com/linglevel/api/auth/config/SecurityConfig.java +++ b/src/main/java/com/linglevel/api/auth/config/SecurityConfig.java @@ -26,52 +26,59 @@ @RequiredArgsConstructor public class SecurityConfig { - private final CustomAuthenticationEntryPoint authenticationEntryPoint; - private final JwtFilter jwtFilter; - private final AdminAuthenticationFilter adminAuthenticationFilter; - private final RateLimitFilter rateLimitFilter; + private final CustomAuthenticationEntryPoint authenticationEntryPoint; - @Autowired(required = false) - private TestAuthFilter testAuthFilter; + private final JwtFilter jwtFilter; - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.disable()) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authz -> authz - .requestMatchers("/actuator/prometheus").hasRole("ADMIN") // 프로메테우스 엔드포인트만 어드민 권한 필요 - .requestMatchers("/actuator/**").permitAll() // 다른 actuator 엔드포인트들은 공개 - .requestMatchers("/api/v1/version").permitAll() - .requestMatchers("/api/v1/auth/oauth/login").permitAll() - .requestMatchers("/api/v1/auth/refresh").permitAll() - .requestMatchers("/api/v1/push-logs/opened").permitAll() - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - .requestMatchers("/api/v1/custom-contents/webhooks/**").hasRole("ADMIN") - .anyRequest().authenticated() - ) - .exceptionHandling(exceptions -> exceptions - .authenticationEntryPoint(authenticationEntryPoint) - ); + private final AdminAuthenticationFilter adminAuthenticationFilter; - if (testAuthFilter != null) { - http.addFilterBefore(testAuthFilter, UsernamePasswordAuthenticationFilter.class); - } + private final RateLimitFilter rateLimitFilter; - http.addFilterBefore(adminAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); - http.addFilterAfter(rateLimitFilter, JwtFilter.class); - return http.build(); - } + @Autowired(required = false) + private TestAuthFilter testAuthFilter; - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz.requestMatchers("/actuator/prometheus") + .hasRole("ADMIN") // 프로메테우스 엔드포인트만 어드민 권한 필요 + .requestMatchers("/actuator/**") + .permitAll() // 다른 actuator 엔드포인트들은 공개 + .requestMatchers("/api/v1/version") + .permitAll() + .requestMatchers("/api/v1/auth/oauth/login") + .permitAll() + .requestMatchers("/api/v1/auth/refresh") + .permitAll() + .requestMatchers("/api/v1/push-logs/opened") + .permitAll() + .requestMatchers("/api/v1/admin/**") + .hasRole("ADMIN") + .requestMatchers("/api/v1/custom-contents/webhooks/**") + .hasRole("ADMIN") + .anyRequest() + .authenticated()) + .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(authenticationEntryPoint)); + + if (testAuthFilter != null) { + http.addFilterBefore(testAuthFilter, UsernamePasswordAuthenticationFilter.class); + } + + http.addFilterBefore(adminAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterAfter(rateLimitFilter, JwtFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { - return authConfig.getAuthenticationManager(); - } } diff --git a/src/main/java/com/linglevel/api/auth/config/SwaggerFormLoginSecurityConfig.java b/src/main/java/com/linglevel/api/auth/config/SwaggerFormLoginSecurityConfig.java index abad9e1d..7df58220 100644 --- a/src/main/java/com/linglevel/api/auth/config/SwaggerFormLoginSecurityConfig.java +++ b/src/main/java/com/linglevel/api/auth/config/SwaggerFormLoginSecurityConfig.java @@ -15,50 +15,46 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; -@Profile({"dev", "local"}) +@Profile({ "dev", "local" }) @Configuration @EnableWebSecurity public class SwaggerFormLoginSecurityConfig { - private final PasswordEncoder passwordEncoder; - @Value("${api.docs.user.username}") - private String username; - @Value("${api.docs.user.password}") - private String password; + private final PasswordEncoder passwordEncoder; - public SwaggerFormLoginSecurityConfig(PasswordEncoder passwordEncoder) { - this.passwordEncoder = passwordEncoder; - } + @Value("${api.docs.user.username}") + private String username; - @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) - public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception { - - http.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**", "/login") - .authorizeHttpRequests(authorize -> authorize - .anyRequest().authenticated() - ) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - ) - .csrf(csrf -> csrf.disable()) - .formLogin(form -> form - .defaultSuccessUrl("/swagger-ui/index.html", true) - .permitAll() - ) - .userDetailsService(swaggerUserDetailsService()); + @Value("${api.docs.user.password}") + private String password; - return http.build(); - } + public SwaggerFormLoginSecurityConfig(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; + } - @Bean - public InMemoryUserDetailsManager swaggerUserDetailsService() { - UserDetails user = User.builder() - .username(username) - .password(passwordEncoder.encode(password)) - .roles("SWAGGER") - .build(); + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception { + + http.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**", "/login") + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.defaultSuccessUrl("/swagger-ui/index.html", true).permitAll()) + .userDetailsService(swaggerUserDetailsService()); + + return http.build(); + } + + @Bean + public InMemoryUserDetailsManager swaggerUserDetailsService() { + UserDetails user = User.builder() + .username(username) + .password(passwordEncoder.encode(password)) + .roles("SWAGGER") + .build(); + + return new InMemoryUserDetailsManager(user); + } - return new InMemoryUserDetailsManager(user); - } } diff --git a/src/main/java/com/linglevel/api/auth/controller/AuthController.java b/src/main/java/com/linglevel/api/auth/controller/AuthController.java index 510f1ee9..869f81bf 100644 --- a/src/main/java/com/linglevel/api/auth/controller/AuthController.java +++ b/src/main/java/com/linglevel/api/auth/controller/AuthController.java @@ -26,88 +26,80 @@ @Tag(name = "Authentication", description = "인증 관련 API") public class AuthController { - private final AuthService authService; - private final JwtService jwtService; + private final AuthService authService; - @Operation(summary = "Firebase OAuth 로그인", description = "Firebase OAuth를 통해 소셜 로그인하고 JWT 토큰을 발급받습니다.", - security = @SecurityRequirement(name = "")) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 성공", - content = @Content(schema = @Schema(implementation = LoginResponse.class))), - @ApiResponse(responseCode = "401", description = "Firebase 토큰 인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/oauth/login") - public ResponseEntity oauthLogin(@RequestBody OauthLoginRequest request) { - LoginResponse response = authService.authenticateWithFirebase(request.getAuthCode()); - return ResponseEntity.ok(response); - } + private final JwtService jwtService; - @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급받습니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", - content = @Content(schema = @Schema(implementation = RefreshTokenResponse.class))), - @ApiResponse(responseCode = "401", description = "리프레시 토큰 유효하지 않음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/refresh") - public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request) { - RefreshTokenResponse response = authService.refreshToken(request.getRefreshToken()); - return ResponseEntity.ok(response); - } + @Operation(summary = "Firebase OAuth 로그인", description = "Firebase OAuth를 통해 소셜 로그인하고 JWT 토큰을 발급받습니다.", + security = @SecurityRequirement(name = "")) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = LoginResponse.class))), + @ApiResponse(responseCode = "401", description = "Firebase 토큰 인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/oauth/login") + public ResponseEntity oauthLogin(@RequestBody OauthLoginRequest request) { + LoginResponse response = authService.authenticateWithFirebase(request.getAuthCode()); + return ResponseEntity.ok(response); + } - @Operation(summary = "로그아웃", description = "현재 기기에서 로그아웃합니다. 다른 기기는 로그인 상태를 유지합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그아웃 성공", - content = @Content(schema = @Schema(implementation = LogoutResponse.class))), - @ApiResponse(responseCode = "401", description = "토큰 유효하지 않음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/logout") - public ResponseEntity logout(@RequestBody LogoutRequest request) { - authService.logout(request.getRefreshToken()); + @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급받습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", + content = @Content(schema = @Schema(implementation = RefreshTokenResponse.class))), + @ApiResponse(responseCode = "401", description = "리프레시 토큰 유효하지 않음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/refresh") + public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request) { + RefreshTokenResponse response = authService.refreshToken(request.getRefreshToken()); + return ResponseEntity.ok(response); + } - LogoutResponse response = LogoutResponse.builder() - .message("Successfully logged out") - .build(); - return ResponseEntity.ok(response); - } + @Operation(summary = "로그아웃", description = "현재 기기에서 로그아웃합니다. 다른 기기는 로그인 상태를 유지합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", + content = @Content(schema = @Schema(implementation = LogoutResponse.class))), + @ApiResponse(responseCode = "401", description = "토큰 유효하지 않음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/logout") + public ResponseEntity logout(@RequestBody LogoutRequest request) { + authService.logout(request.getRefreshToken()); - @Operation(summary = "모든 기기에서 로그아웃", description = "모든 기기에서 로그아웃하여 모든 토큰을 무효화합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그아웃 성공", - content = @Content(schema = @Schema(implementation = LogoutResponse.class))), - @ApiResponse(responseCode = "401", description = "토큰 유효하지 않음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/logout/all") - public ResponseEntity logoutAll(HttpServletRequest request) { - JwtClaims claims = jwtService.extractJwtClaimsFromRequest(request); - authService.logoutAll(claims.getId()); + LogoutResponse response = LogoutResponse.builder().message("Successfully logged out").build(); + return ResponseEntity.ok(response); + } - LogoutResponse response = LogoutResponse.builder() - .message("Successfully logged out from all devices") - .build(); - return ResponseEntity.ok(response); - } + @Operation(summary = "모든 기기에서 로그아웃", description = "모든 기기에서 로그아웃하여 모든 토큰을 무효화합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", + content = @Content(schema = @Schema(implementation = LogoutResponse.class))), + @ApiResponse(responseCode = "401", description = "토큰 유효하지 않음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/logout/all") + public ResponseEntity logoutAll(HttpServletRequest request) { + JwtClaims claims = jwtService.extractJwtClaimsFromRequest(request); + authService.logoutAll(claims.getId()); - @Operation(summary = "현재 사용자 정보 조회", description = "현재 Access Token에 포함된 JWT Claims 정보를 추출하여 반환합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공", - content = @Content(schema = @Schema(implementation = JwtClaims.class))), - @ApiResponse(responseCode = "401", description = "토큰 유효하지 않음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/me") - public ResponseEntity getCurrentUser(HttpServletRequest request) { - JwtClaims claims = jwtService.extractJwtClaimsFromRequest(request); - return ResponseEntity.ok(claims); - } + LogoutResponse response = LogoutResponse.builder().message("Successfully logged out from all devices").build(); + return ResponseEntity.ok(response); + } - @ExceptionHandler(AuthException.class) - public ResponseEntity handleAuthException(AuthException e) { - log.error("Auth Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } -} \ No newline at end of file + @Operation(summary = "현재 사용자 정보 조회", description = "현재 Access Token에 포함된 JWT Claims 정보를 추출하여 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공", + content = @Content(schema = @Schema(implementation = JwtClaims.class))), + @ApiResponse(responseCode = "401", description = "토큰 유효하지 않음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/me") + public ResponseEntity getCurrentUser(HttpServletRequest request) { + JwtClaims claims = jwtService.extractJwtClaimsFromRequest(request); + return ResponseEntity.ok(claims); + } + + @ExceptionHandler(AuthException.class) + public ResponseEntity handleAuthException(AuthException e) { + log.error("Auth Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/dto/LoginResponse.java b/src/main/java/com/linglevel/api/auth/dto/LoginResponse.java index 1f386e68..a2489125 100644 --- a/src/main/java/com/linglevel/api/auth/dto/LoginResponse.java +++ b/src/main/java/com/linglevel/api/auth/dto/LoginResponse.java @@ -12,9 +12,11 @@ @AllArgsConstructor @Schema(description = "로그인 응답") public class LoginResponse { - @Schema(description = "Access Token") - private String accessToken; - - @Schema(description = "Refresh Token") - private String refreshToken; -} \ No newline at end of file + + @Schema(description = "Access Token") + private String accessToken; + + @Schema(description = "Refresh Token") + private String refreshToken; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/dto/LogoutRequest.java b/src/main/java/com/linglevel/api/auth/dto/LogoutRequest.java index 1b72b130..f71b590b 100644 --- a/src/main/java/com/linglevel/api/auth/dto/LogoutRequest.java +++ b/src/main/java/com/linglevel/api/auth/dto/LogoutRequest.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "로그아웃 요청") public class LogoutRequest { - @Schema(description = "Refresh Token") - private String refreshToken; + + @Schema(description = "Refresh Token") + private String refreshToken; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/dto/LogoutResponse.java b/src/main/java/com/linglevel/api/auth/dto/LogoutResponse.java index f22c2885..b97dc52b 100644 --- a/src/main/java/com/linglevel/api/auth/dto/LogoutResponse.java +++ b/src/main/java/com/linglevel/api/auth/dto/LogoutResponse.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "로그아웃 응답") public class LogoutResponse { - @Schema(description = "결과 메시지", example = "Successfully logged out.") - private String message; -} \ No newline at end of file + + @Schema(description = "결과 메시지", example = "Successfully logged out.") + private String message; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/dto/OauthLoginRequest.java b/src/main/java/com/linglevel/api/auth/dto/OauthLoginRequest.java index 05446d14..e224a9bc 100644 --- a/src/main/java/com/linglevel/api/auth/dto/OauthLoginRequest.java +++ b/src/main/java/com/linglevel/api/auth/dto/OauthLoginRequest.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "OAuth 로그인 요청") public class OauthLoginRequest { - @Schema(description = "Firebase Auth Code", example = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...") - private String authCode; -} \ No newline at end of file + + @Schema(description = "Firebase Auth Code", example = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...") + private String authCode; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/dto/RefreshTokenRequest.java b/src/main/java/com/linglevel/api/auth/dto/RefreshTokenRequest.java index c81b2471..8f78ec60 100644 --- a/src/main/java/com/linglevel/api/auth/dto/RefreshTokenRequest.java +++ b/src/main/java/com/linglevel/api/auth/dto/RefreshTokenRequest.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "리프레시 토큰 요청") public class RefreshTokenRequest { - @Schema(description = "Refresh Token") - private String refreshToken; -} \ No newline at end of file + + @Schema(description = "Refresh Token") + private String refreshToken; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/dto/RefreshTokenResponse.java b/src/main/java/com/linglevel/api/auth/dto/RefreshTokenResponse.java index 10fc045f..c2421ae1 100644 --- a/src/main/java/com/linglevel/api/auth/dto/RefreshTokenResponse.java +++ b/src/main/java/com/linglevel/api/auth/dto/RefreshTokenResponse.java @@ -12,9 +12,11 @@ @AllArgsConstructor @Schema(description = "리프레시 토큰 응답") public class RefreshTokenResponse { - @Schema(description = "새로운 Access Token") - private String accessToken; - - @Schema(description = "새로운 Refresh Token") - private String refreshToken; -} \ No newline at end of file + + @Schema(description = "새로운 Access Token") + private String accessToken; + + @Schema(description = "새로운 Refresh Token") + private String refreshToken; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/exception/AuthErrorCode.java b/src/main/java/com/linglevel/api/auth/exception/AuthErrorCode.java index d9c12386..3c5f7c82 100644 --- a/src/main/java/com/linglevel/api/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/linglevel/api/auth/exception/AuthErrorCode.java @@ -7,12 +7,15 @@ @Getter @AllArgsConstructor public enum AuthErrorCode { - INVALID_FIREBASE_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid Firebase token."), - INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid access token."), - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid refresh token."), - EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Access token has expired."), - EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Refresh token has expired."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file + + INVALID_FIREBASE_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid Firebase token."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid access token."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid refresh token."), + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Access token has expired."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Refresh token has expired."); + + private final HttpStatus status; + + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/exception/AuthException.java b/src/main/java/com/linglevel/api/auth/exception/AuthException.java index 19c6a389..594249a5 100644 --- a/src/main/java/com/linglevel/api/auth/exception/AuthException.java +++ b/src/main/java/com/linglevel/api/auth/exception/AuthException.java @@ -5,10 +5,12 @@ @Getter public class AuthException extends RuntimeException { - private final HttpStatus status; - public AuthException(AuthErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } -} \ No newline at end of file + private final HttpStatus status; + + public AuthException(AuthErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/filter/AdminAuthenticationFilter.java b/src/main/java/com/linglevel/api/auth/filter/AdminAuthenticationFilter.java index ccf24c78..0a443577 100644 --- a/src/main/java/com/linglevel/api/auth/filter/AdminAuthenticationFilter.java +++ b/src/main/java/com/linglevel/api/auth/filter/AdminAuthenticationFilter.java @@ -23,73 +23,73 @@ @Slf4j public class AdminAuthenticationFilter extends OncePerRequestFilter { - @Value("${import.api.key}") - private String importApiKey; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - String apiKey = request.getHeader("X-API-Key"); - String authHeader = request.getHeader("Authorization"); - - if (isValidAdminCredentials(apiKey, authHeader)) { - SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority(UserRole.ADMIN.getSecurityRole()); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken("admin", null, List.of(adminAuthority)); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - filterChain.doFilter(request, response); - } - - - // Admin 인증을 위한 종합적인 검증 (X-API-Key 또는 Authorization 헤더) - private boolean isValidAdminCredentials(String apiKey, String authHeader) { - // X-API-Key 헤더로 인증 시도 - if (isValidApiKey(apiKey)) { - return true; - } - - // Authorization 헤더로 인증 시도 (Prometheus용) - return isValidMonitoringToken(authHeader); - } - - // X-API-Key 헤더 검증 (Timing Attack 방지) - private boolean isValidApiKey(String providedApiKey) { - if (providedApiKey == null || importApiKey == null) { - return false; - } - - // Constant-time 비교를 위해 MessageDigest.isEqual() 사용 - byte[] expected = importApiKey.getBytes(); - byte[] provided = providedApiKey.getBytes(); - - return MessageDigest.isEqual(expected, provided); - } - - // Authorization 헤더 검증 (Prometheus monitoring용) - private boolean isValidMonitoringToken(String authHeader) { - if (authHeader == null || !authHeader.startsWith("llvk ")) { - return false; - } - - String token = authHeader.substring(5); // "llvk " 제거 - - // Import API Key와 동일한 토큰 검증 - return isSecureEquals(importApiKey, token); - } - - // Timing Attack을 방지하는 안전한 문자열 비교 - private boolean isSecureEquals(String expected, String provided) { - if (expected == null || provided == null) { - return false; - } - - byte[] expectedBytes = expected.getBytes(); - byte[] providedBytes = provided.getBytes(); - - return MessageDigest.isEqual(expectedBytes, providedBytes); - } + @Value("${import.api.key}") + private String importApiKey; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String apiKey = request.getHeader("X-API-Key"); + String authHeader = request.getHeader("Authorization"); + + if (isValidAdminCredentials(apiKey, authHeader)) { + SimpleGrantedAuthority adminAuthority = new SimpleGrantedAuthority(UserRole.ADMIN.getSecurityRole()); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("admin", null, + List.of(adminAuthority)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + // Admin 인증을 위한 종합적인 검증 (X-API-Key 또는 Authorization 헤더) + private boolean isValidAdminCredentials(String apiKey, String authHeader) { + // X-API-Key 헤더로 인증 시도 + if (isValidApiKey(apiKey)) { + return true; + } + + // Authorization 헤더로 인증 시도 (Prometheus용) + return isValidMonitoringToken(authHeader); + } + + // X-API-Key 헤더 검증 (Timing Attack 방지) + private boolean isValidApiKey(String providedApiKey) { + if (providedApiKey == null || importApiKey == null) { + return false; + } + + // Constant-time 비교를 위해 MessageDigest.isEqual() 사용 + byte[] expected = importApiKey.getBytes(); + byte[] provided = providedApiKey.getBytes(); + + return MessageDigest.isEqual(expected, provided); + } + + // Authorization 헤더 검증 (Prometheus monitoring용) + private boolean isValidMonitoringToken(String authHeader) { + if (authHeader == null || !authHeader.startsWith("llvk ")) { + return false; + } + + String token = authHeader.substring(5); // "llvk " 제거 + + // Import API Key와 동일한 토큰 검증 + return isSecureEquals(importApiKey, token); + } + + // Timing Attack을 방지하는 안전한 문자열 비교 + private boolean isSecureEquals(String expected, String provided) { + if (expected == null || provided == null) { + return false; + } + + byte[] expectedBytes = expected.getBytes(); + byte[] providedBytes = provided.getBytes(); + + return MessageDigest.isEqual(expectedBytes, providedBytes); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/filter/TestAuthFilter.java b/src/main/java/com/linglevel/api/auth/filter/TestAuthFilter.java index d146342d..cce26f4b 100644 --- a/src/main/java/com/linglevel/api/auth/filter/TestAuthFilter.java +++ b/src/main/java/com/linglevel/api/auth/filter/TestAuthFilter.java @@ -20,45 +20,46 @@ import java.util.Optional; @Component -@Profile({"local"}) +@Profile({ "local" }) public class TestAuthFilter extends OncePerRequestFilter { - private final UserRepository userRepository; + private final UserRepository userRepository; - public TestAuthFilter(UserRepository userRepository) { - this.userRepository = userRepository; - } + public TestAuthFilter(UserRepository userRepository) { + this.userRepository = userRepository; + } - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) - throws ServletException, IOException { - - String testUsername = request.getHeader("X-Test-Username"); - - if (testUsername != null && !testUsername.trim().isEmpty()) { - Optional userOptional = userRepository.findByUsername(testUsername); + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { - if (userOptional.isPresent()) { - User user = userOptional.get(); + String testUsername = request.getHeader("X-Test-Username"); - // JwtFilter와 일관성을 위해 JwtClaims 객체를 Principal로 사용 - JwtClaims claims = JwtClaims.builder() - .id(user.getId()) - .username(user.getUsername()) - .email(user.getEmail()) - .role(user.getRole()) - .provider(user.getProvider()) - .displayName(user.getDisplayName()) - .issuedAt(new Date()) - .expiresAt(new Date(System.currentTimeMillis() + 3600000)) // 1시간 후 만료 - .build(); + if (testUsername != null && !testUsername.trim().isEmpty()) { + Optional userOptional = userRepository.findByUsername(testUsername); + + if (userOptional.isPresent()) { + User user = userOptional.get(); + + // JwtFilter와 일관성을 위해 JwtClaims 객체를 Principal로 사용 + JwtClaims claims = JwtClaims.builder() + .id(user.getId()) + .username(user.getUsername()) + .email(user.getEmail()) + .role(user.getRole()) + .provider(user.getProvider()) + .displayName(user.getDisplayName()) + .issuedAt(new Date()) + .expiresAt(new Date(System.currentTimeMillis() + 3600000)) // 1시간 후 만료 + .build(); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims, + null, List.of(new SimpleGrantedAuthority(user.getRole().getSecurityRole()))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + chain.doFilter(request, response); + } - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(claims, null, List.of(new SimpleGrantedAuthority(user.getRole().getSecurityRole()))); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } - - chain.doFilter(request, response); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/linglevel/api/auth/handler/CustomAuthenticationEntryPoint.java index b3a0bdd2..6523d721 100644 --- a/src/main/java/com/linglevel/api/auth/handler/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/linglevel/api/auth/handler/CustomAuthenticationEntryPoint.java @@ -19,20 +19,21 @@ @Slf4j public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException { - - CommonException commonException = new CommonException(CommonErrorCode.UNAUTHORIZED); - ExceptionResponse errorResponse = new ExceptionResponse(commonException); - - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setCharacterEncoding(StandardCharsets.UTF_8.name()); - - String jsonResponse = objectMapper.writeValueAsString(errorResponse); - response.getWriter().write(jsonResponse); - } + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + + CommonException commonException = new CommonException(CommonErrorCode.UNAUTHORIZED); + ExceptionResponse errorResponse = new ExceptionResponse(commonException); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/jwt/JwtClaims.java b/src/main/java/com/linglevel/api/auth/jwt/JwtClaims.java index 6bed9951..12f4513b 100644 --- a/src/main/java/com/linglevel/api/auth/jwt/JwtClaims.java +++ b/src/main/java/com/linglevel/api/auth/jwt/JwtClaims.java @@ -14,32 +14,33 @@ @NoArgsConstructor @AllArgsConstructor public class JwtClaims { - - @Schema(description = "사용자 고유 식별자", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "사용자명 (제공자_UID 형식)", example = "google_123456789") - private String username; - - @Schema(description = "사용자 이메일", example = "user@example.com") - private String email; - - @Schema(description = "사용자 역할", example = "USER") - private UserRole role; - - @Schema(description = "OAuth 제공자", example = "google") - private String provider; - - @Schema(description = "사용자 표시 이름", example = "홍길동") - private String displayName; - - @Schema(description = "토큰 발급 시간", example = "2025-08-04T09:30:00") - private Date issuedAt; - - @Schema(description = "토큰 만료 시간", example = "2025-08-04T19:30:00") - private Date expiresAt; - - public boolean isExpired() { - return expiresAt.before(new Date()); - } + + @Schema(description = "사용자 고유 식별자", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "사용자명 (제공자_UID 형식)", example = "google_123456789") + private String username; + + @Schema(description = "사용자 이메일", example = "user@example.com") + private String email; + + @Schema(description = "사용자 역할", example = "USER") + private UserRole role; + + @Schema(description = "OAuth 제공자", example = "google") + private String provider; + + @Schema(description = "사용자 표시 이름", example = "홍길동") + private String displayName; + + @Schema(description = "토큰 발급 시간", example = "2025-08-04T09:30:00") + private Date issuedAt; + + @Schema(description = "토큰 만료 시간", example = "2025-08-04T19:30:00") + private Date expiresAt; + + public boolean isExpired() { + return expiresAt.before(new Date()); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/jwt/JwtFilter.java b/src/main/java/com/linglevel/api/auth/jwt/JwtFilter.java index 7e9ccb32..76b089b3 100644 --- a/src/main/java/com/linglevel/api/auth/jwt/JwtFilter.java +++ b/src/main/java/com/linglevel/api/auth/jwt/JwtFilter.java @@ -24,37 +24,36 @@ @Slf4j public class JwtFilter extends OncePerRequestFilter { - private final JwtService jwtService; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws SecurityException, IOException, ServletException { - - try { - JwtClaims claims = jwtService.extractJwtClaimsFromRequest(request); - - if (claims.isExpired()) { - throw new AuthException(AuthErrorCode.EXPIRED_ACCESS_TOKEN); - } - - // Principal로 JwtClaims 객체 전체를 저장하여 컨트롤러에서 DB 조회 없이 사용자 정보에 접근 가능 - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( - claims, - null, - List.of(new SimpleGrantedAuthority(claims.getRole().getSecurityRole())) - ); - - authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authenticationToken); - - // Sentry 사용자 컨텍스트 설정 - SentryUserContext.setSentryUser(); - - } catch (Exception e) { - filterChain.doFilter(request, response); - return; - } - - filterChain.doFilter(request, response); - } + private final JwtService jwtService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws SecurityException, IOException, ServletException { + + try { + JwtClaims claims = jwtService.extractJwtClaimsFromRequest(request); + + if (claims.isExpired()) { + throw new AuthException(AuthErrorCode.EXPIRED_ACCESS_TOKEN); + } + + // Principal로 JwtClaims 객체 전체를 저장하여 컨트롤러에서 DB 조회 없이 사용자 정보에 접근 가능 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(claims, + null, List.of(new SimpleGrantedAuthority(claims.getRole().getSecurityRole()))); + + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + + // Sentry 사용자 컨텍스트 설정 + SentryUserContext.setSentryUser(); + + } + catch (Exception e) { + filterChain.doFilter(request, response); + return; + } + + filterChain.doFilter(request, response); + } + } diff --git a/src/main/java/com/linglevel/api/auth/jwt/JwtProvider.java b/src/main/java/com/linglevel/api/auth/jwt/JwtProvider.java index d10acfa2..bf6555a2 100644 --- a/src/main/java/com/linglevel/api/auth/jwt/JwtProvider.java +++ b/src/main/java/com/linglevel/api/auth/jwt/JwtProvider.java @@ -19,59 +19,57 @@ @Slf4j public class JwtProvider { - @Value("${jwt.secret}") - private String jwtSecret; + @Value("${jwt.secret}") + private String jwtSecret; - @Value("${jwt.access-token-expiration}") - private long accessTokenExpiration; + @Value("${jwt.access-token-expiration}") + private long accessTokenExpiration; - public String createToken(User user) { - Claims claims = Jwts.claims() - .add("id", user.getId()) - .add("email", user.getEmail()) - .add("role", user.getRole().name()) - .add("provider", user.getProvider()) - .add("display_name", user.getDisplayName()) - .build(); + public String createToken(User user) { + Claims claims = Jwts.claims() + .add("id", user.getId()) + .add("email", user.getEmail()) + .add("role", user.getRole().name()) + .add("provider", user.getProvider()) + .add("display_name", user.getDisplayName()) + .build(); - SecretKey key = getSecretKey(); + SecretKey key = getSecretKey(); - return Jwts.builder() - .claims(claims) - .subject(user.getUsername()) - .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) - .signWith(key) - .compact(); - } + return Jwts.builder() + .claims(claims) + .subject(user.getUsername()) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration)) + .signWith(key) + .compact(); + } - public JwtClaims parseTokenToJwtClaims(String token) { - try { - SecretKey key = getSecretKey(); + public JwtClaims parseTokenToJwtClaims(String token) { + try { + SecretKey key = getSecretKey(); - Claims claims = Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token) - .getPayload(); + Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload(); - return JwtClaims.builder() - .username(claims.getSubject()) - .id(claims.get("id", String.class)) - .email(claims.get("email", String.class)) - .role(UserRole.valueOf(claims.get("role", String.class))) - .provider(claims.get("provider", String.class)) - .displayName(claims.get("display_name", String.class)) - .issuedAt(claims.getIssuedAt()) - .expiresAt(claims.getExpiration()) - .build(); - } catch (Exception e) { - throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); - } - } + return JwtClaims.builder() + .username(claims.getSubject()) + .id(claims.get("id", String.class)) + .email(claims.get("email", String.class)) + .role(UserRole.valueOf(claims.get("role", String.class))) + .provider(claims.get("provider", String.class)) + .displayName(claims.get("display_name", String.class)) + .issuedAt(claims.getIssuedAt()) + .expiresAt(claims.getExpiration()) + .build(); + } + catch (Exception e) { + throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); + } + } + + private SecretKey getSecretKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); + return Keys.hmacShaKeyFor(keyBytes); + } - private SecretKey getSecretKey() { - byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); - return Keys.hmacShaKeyFor(keyBytes); - } } diff --git a/src/main/java/com/linglevel/api/auth/jwt/JwtService.java b/src/main/java/com/linglevel/api/auth/jwt/JwtService.java index d0defc52..5ed992fd 100644 --- a/src/main/java/com/linglevel/api/auth/jwt/JwtService.java +++ b/src/main/java/com/linglevel/api/auth/jwt/JwtService.java @@ -12,20 +12,21 @@ @Slf4j public class JwtService { - private final JwtProvider jwtProvider; + private final JwtProvider jwtProvider; - public JwtClaims extractJwtClaimsFromRequest(HttpServletRequest request) { - String token = extractTokenFromRequest(request); - return jwtProvider.parseTokenToJwtClaims(token); - } + public JwtClaims extractJwtClaimsFromRequest(HttpServletRequest request) { + String token = extractTokenFromRequest(request); + return jwtProvider.parseTokenToJwtClaims(token); + } - public String extractTokenFromRequest(HttpServletRequest request) { - String authorizationHeader = request.getHeader("Authorization"); + public String extractTokenFromRequest(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { - return authorizationHeader.substring(7); - } + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); + } + + throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); + } - throw new AuthException(AuthErrorCode.INVALID_ACCESS_TOKEN); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/jwt/RefreshToken.java b/src/main/java/com/linglevel/api/auth/jwt/RefreshToken.java index f0b0ea9b..55b85b1d 100644 --- a/src/main/java/com/linglevel/api/auth/jwt/RefreshToken.java +++ b/src/main/java/com/linglevel/api/auth/jwt/RefreshToken.java @@ -16,19 +16,21 @@ @AllArgsConstructor @Builder public class RefreshToken { - @Id - private String id; - - @Indexed(unique = true) - private String tokenId; - - @Indexed - private String userId; - - @Indexed(name = "ttl_expires_at", expireAfter = "0s") - private LocalDateTime expiresAt; - - public boolean isExpired() { - return expiresAt.isBefore(LocalDateTime.now()); - } + + @Id + private String id; + + @Indexed(unique = true) + private String tokenId; + + @Indexed + private String userId; + + @Indexed(name = "ttl_expires_at", expireAfter = "0s") + private LocalDateTime expiresAt; + + public boolean isExpired() { + return expiresAt.isBefore(LocalDateTime.now()); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/jwt/RefreshTokenService.java b/src/main/java/com/linglevel/api/auth/jwt/RefreshTokenService.java index 2718e243..929a46db 100644 --- a/src/main/java/com/linglevel/api/auth/jwt/RefreshTokenService.java +++ b/src/main/java/com/linglevel/api/auth/jwt/RefreshTokenService.java @@ -21,72 +21,67 @@ @RequiredArgsConstructor @Slf4j public class RefreshTokenService { - - private final RefreshTokenRepository refreshTokenRepository; - private final UserRepository userRepository; - private final JwtProvider jwtProvider; - - @Value("${jwt.refresh-token-expiration}") - private long refreshTokenExpirationMs; - - @Transactional - public String createRefreshToken(String userId) { - String tokenId = UUID.randomUUID().toString(); - LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(refreshTokenExpirationMs / 1000); - - RefreshToken refreshToken = RefreshToken.builder() - .tokenId(tokenId) - .userId(userId) - .expiresAt(expiresAt) - .build(); - - refreshTokenRepository.save(refreshToken); - log.info("Refresh token created for user: {}", userId); - - return tokenId; - } - - @Transactional - public RefreshTokenResponse refreshAccessToken(String refreshTokenId) { - RefreshToken storedToken = refreshTokenRepository.findByTokenId(refreshTokenId) - .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN)); - - if (storedToken.isExpired()) { - refreshTokenRepository.delete(storedToken); - throw new AuthException(AuthErrorCode.EXPIRED_REFRESH_TOKEN); - } - - User user = userRepository.findById(storedToken.getUserId()) - .orElseThrow(() -> new UsersException(UsersErrorCode.USER_NOT_FOUND)); - - if (user.getDeleted() != null && user.getDeleted()) { - refreshTokenRepository.delete(storedToken); - throw new UsersException(UsersErrorCode.USER_ACCOUNT_DELETED); - } - - String newAccessToken = jwtProvider.createToken(user); - String newRefreshToken = createRefreshToken(user.getId()); - - log.info("Access token refreshed for user: {}", user.getId()); - - return RefreshTokenResponse.builder() - .accessToken(newAccessToken) - .refreshToken(newRefreshToken) - .build(); - } - - @Transactional - public void deleteRefreshToken(String tokenId) { - refreshTokenRepository.findByTokenId(tokenId) - .ifPresent(token -> { - refreshTokenRepository.delete(token); - log.info("Refresh token deleted: {} for user: {}", tokenId, token.getUserId()); - }); - } - - @Transactional - public void deleteAllRefreshTokens(String userId) { - refreshTokenRepository.deleteByUserId(userId); - log.info("All refresh tokens deleted for user: {}", userId); - } + + private final RefreshTokenRepository refreshTokenRepository; + + private final UserRepository userRepository; + + private final JwtProvider jwtProvider; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpirationMs; + + @Transactional + public String createRefreshToken(String userId) { + String tokenId = UUID.randomUUID().toString(); + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(refreshTokenExpirationMs / 1000); + + RefreshToken refreshToken = RefreshToken.builder().tokenId(tokenId).userId(userId).expiresAt(expiresAt).build(); + + refreshTokenRepository.save(refreshToken); + log.info("Refresh token created for user: {}", userId); + + return tokenId; + } + + @Transactional + public RefreshTokenResponse refreshAccessToken(String refreshTokenId) { + RefreshToken storedToken = refreshTokenRepository.findByTokenId(refreshTokenId) + .orElseThrow(() -> new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + + if (storedToken.isExpired()) { + refreshTokenRepository.delete(storedToken); + throw new AuthException(AuthErrorCode.EXPIRED_REFRESH_TOKEN); + } + + User user = userRepository.findById(storedToken.getUserId()) + .orElseThrow(() -> new UsersException(UsersErrorCode.USER_NOT_FOUND)); + + if (user.getDeleted() != null && user.getDeleted()) { + refreshTokenRepository.delete(storedToken); + throw new UsersException(UsersErrorCode.USER_ACCOUNT_DELETED); + } + + String newAccessToken = jwtProvider.createToken(user); + String newRefreshToken = createRefreshToken(user.getId()); + + log.info("Access token refreshed for user: {}", user.getId()); + + return RefreshTokenResponse.builder().accessToken(newAccessToken).refreshToken(newRefreshToken).build(); + } + + @Transactional + public void deleteRefreshToken(String tokenId) { + refreshTokenRepository.findByTokenId(tokenId).ifPresent(token -> { + refreshTokenRepository.delete(token); + log.info("Refresh token deleted: {} for user: {}", tokenId, token.getUserId()); + }); + } + + @Transactional + public void deleteAllRefreshTokens(String userId) { + refreshTokenRepository.deleteByUserId(userId); + log.info("All refresh tokens deleted for user: {}", userId); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java b/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java index d702cabc..870167b7 100644 --- a/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/linglevel/api/auth/repository/RefreshTokenRepository.java @@ -8,11 +8,11 @@ @Repository public interface RefreshTokenRepository extends MongoRepository { - - Optional findByTokenId(String tokenId); - - Optional findByUserId(String userId); - - void deleteByUserId(String userId); - + + Optional findByTokenId(String tokenId); + + Optional findByUserId(String userId); + + void deleteByUserId(String userId); + } diff --git a/src/main/java/com/linglevel/api/auth/service/AuthService.java b/src/main/java/com/linglevel/api/auth/service/AuthService.java index dcb809d5..c115bec3 100644 --- a/src/main/java/com/linglevel/api/auth/service/AuthService.java +++ b/src/main/java/com/linglevel/api/auth/service/AuthService.java @@ -26,89 +26,93 @@ @Slf4j public class AuthService { - private final FirebaseAuth firebaseAuth; - private final UserRepository userRepository; - private final JwtProvider jwtTokenProvider; - private final RefreshTokenService refreshTokenService; - private final FcmTokenService fcmTokenService; - - public LoginResponse authenticateWithFirebase(String authCode) { - try { - FirebaseToken decodedToken = firebaseAuth.verifyIdToken(authCode); - - String email = decodedToken.getEmail(); - String provider = getProviderFromToken(decodedToken); - String username = provider + "_" + decodedToken.getUid(); - - User user = findOrCreateUser(username, email, provider); - - String accessToken = jwtTokenProvider.createToken(user); - String refreshToken = refreshTokenService.createRefreshToken(user.getId()); - - return LoginResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - - } catch (FirebaseAuthException e) { - log.error("Firebase authentication failed: {}", e.getMessage()); - throw new AuthException(AuthErrorCode.INVALID_FIREBASE_TOKEN); - } - } - - private User findOrCreateUser(String username, String email, String provider) { - Optional existingUser = userRepository.findByUsername(username); - - if (existingUser.isPresent()) { - User user = existingUser.get(); - user.setEmail(email); - return userRepository.save(user); - } else { - User newUser = User.builder() - .username(username) - .provider(provider) - .email(email) - .displayName(email) - .role(UserRole.USER) - .deleted(false) - .createdAt(LocalDateTime.now()) - .build(); - - log.info("Creating new user: {}", email); - return userRepository.save(newUser); - } - } - - private String getProviderFromToken(FirebaseToken token) { - Map claims = token.getClaims(); - Object firebase = claims.get("firebase"); - - if (firebase instanceof Map firebaseMap) { - Object signInProviderObj = firebaseMap.get("sign_in_provider"); - - if (signInProviderObj instanceof String signInProvider) { - return switch (signInProvider) { - case "google.com" -> "google"; - case "apple.com" -> "apple"; - default -> signInProvider; - }; - } - } - - return "unknown"; - } - - public RefreshTokenResponse refreshToken(String refreshToken) { - return refreshTokenService.refreshAccessToken(refreshToken); - } - - public void logout(String refreshToken) { - refreshTokenService.deleteRefreshToken(refreshToken); - } - - public void logoutAll(String userId) { - refreshTokenService.deleteAllRefreshTokens(userId); - fcmTokenService.deactivateAllTokens(userId); - log.info("Logged out all devices and deactivated all FCM tokens for user: {}", userId); - } + private final FirebaseAuth firebaseAuth; + + private final UserRepository userRepository; + + private final JwtProvider jwtTokenProvider; + + private final RefreshTokenService refreshTokenService; + + private final FcmTokenService fcmTokenService; + + public LoginResponse authenticateWithFirebase(String authCode) { + try { + FirebaseToken decodedToken = firebaseAuth.verifyIdToken(authCode); + + String email = decodedToken.getEmail(); + String provider = getProviderFromToken(decodedToken); + String username = provider + "_" + decodedToken.getUid(); + + User user = findOrCreateUser(username, email, provider); + + String accessToken = jwtTokenProvider.createToken(user); + String refreshToken = refreshTokenService.createRefreshToken(user.getId()); + + return LoginResponse.builder().accessToken(accessToken).refreshToken(refreshToken).build(); + + } + catch (FirebaseAuthException e) { + log.error("Firebase authentication failed: {}", e.getMessage()); + throw new AuthException(AuthErrorCode.INVALID_FIREBASE_TOKEN); + } + } + + private User findOrCreateUser(String username, String email, String provider) { + Optional existingUser = userRepository.findByUsername(username); + + if (existingUser.isPresent()) { + User user = existingUser.get(); + user.setEmail(email); + return userRepository.save(user); + } + else { + User newUser = User.builder() + .username(username) + .provider(provider) + .email(email) + .displayName(email) + .role(UserRole.USER) + .deleted(false) + .createdAt(LocalDateTime.now()) + .build(); + + log.info("Creating new user: {}", email); + return userRepository.save(newUser); + } + } + + private String getProviderFromToken(FirebaseToken token) { + Map claims = token.getClaims(); + Object firebase = claims.get("firebase"); + + if (firebase instanceof Map firebaseMap) { + Object signInProviderObj = firebaseMap.get("sign_in_provider"); + + if (signInProviderObj instanceof String signInProvider) { + return switch (signInProvider) { + case "google.com" -> "google"; + case "apple.com" -> "apple"; + default -> signInProvider; + }; + } + } + + return "unknown"; + } + + public RefreshTokenResponse refreshToken(String refreshToken) { + return refreshTokenService.refreshAccessToken(refreshToken); + } + + public void logout(String refreshToken) { + refreshTokenService.deleteRefreshToken(refreshToken); + } + + public void logoutAll(String userId) { + refreshTokenService.deleteAllRefreshTokens(userId); + fcmTokenService.deactivateAllTokens(userId); + log.info("Logged out all devices and deactivated all FCM tokens for user: {}", userId); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/controller/AdminContentBannerController.java b/src/main/java/com/linglevel/api/banner/controller/AdminContentBannerController.java index 583ac662..49e25540 100644 --- a/src/main/java/com/linglevel/api/banner/controller/AdminContentBannerController.java +++ b/src/main/java/com/linglevel/api/banner/controller/AdminContentBannerController.java @@ -35,139 +35,125 @@ @PreAuthorize("hasRole('ADMIN')") public class AdminContentBannerController { - private final ContentBannerService contentBannerService; - - @Operation(summary = "콘텐츠 배너 생성", - description = "새로운 콘텐츠 배너를 생성합니다. contentId와 contentType을 통해 실제 콘텐츠 정보를 자동으로 조회하여 설정합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "지원하지 않는 콘텐츠 타입", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/content-banners") - public ResponseEntity createContentBanner( - @Valid @RequestBody CreateContentBannerRequest request) { - - log.info("Creating content banner for content: {} ({})", request.getContentId(), request.getContentType()); - - ContentBannerResponse response = contentBannerService.createBanner(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @Operation(summary = "관리자용 콘텐츠 배너 목록 조회", - description = "관리자용 콘텐츠 배너 목록을 조회합니다. 모든 배너를 조회하며 국가별 필터링이 가능합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/content-banners") - public ResponseEntity> getAdminContentBanners( - @ParameterObject @Valid @ModelAttribute GetAdminContentBannersRequest request) { - - log.info("Getting admin content banners for country: {}, page: {}, size: {}", - request.getCountryCode(), request.getPage(), request.getLimit()); - - org.springframework.data.domain.Page bannerPage = - contentBannerService.getBanners(request.getCountryCode(), request.getPage() - 1, request.getLimit()); - - PageResponse response = PageResponse.of(bannerPage, bannerPage.getContent()); - - return ResponseEntity.ok(response); - } - - @Operation(summary = "콘텐츠 배너 상세 조회", - description = "특정 콘텐츠 배너의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "배너를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/content-banners/{bannerId}") - public ResponseEntity getContentBanner( - @Parameter(description = "조회할 배너의 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bannerId) { - - log.info("Getting content banner: {}", bannerId); - - ContentBannerResponse response = contentBannerService.getBanner(bannerId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "콘텐츠 배너 수정", - description = "콘텐츠 배너의 정보를 부분 업데이트합니다. 제목, 설명, 순서, 활성화 상태 등을 변경할 수 있습니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "배너를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "최소 한 개의 필드가 필요함", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PatchMapping("/content-banners/{bannerId}") - public ResponseEntity updateContentBanner( - @Parameter(description = "수정할 배너의 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bannerId, - @Valid @RequestBody UpdateContentBannerRequest request) { - - log.info("Updating content banner: {}", bannerId); - - ContentBannerResponse response = contentBannerService.updateBanner(bannerId, request); - return ResponseEntity.ok(response); - } - - @Operation(summary = "콘텐츠 배너 삭제", - description = "콘텐츠 배너를 삭제합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "삭제 성공", - content = @Content(schema = @Schema(implementation = DeleteResponse.class))), - @ApiResponse(responseCode = "404", description = "배너를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/content-banners/{bannerId}") - public ResponseEntity deleteContentBanner( - @Parameter(description = "삭제할 배너의 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bannerId) { - - log.info("Deleting content banner: {}", bannerId); - - contentBannerService.deleteBanner(bannerId); - DeleteResponse response = new DeleteResponse("Banner deleted successfully."); - return ResponseEntity.ok(response); - } - - @ExceptionHandler(BannerException.class) - public ResponseEntity handleBannerException(BannerException e) { - log.info("Banner Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e.getMessage())); - } - - // 삭제 응답용 내부 클래스 - @Schema(description = "삭제 응답") - public static class DeleteResponse { - @Schema(description = "응답 메시지", example = "Banner deleted successfully.") - private String message; - - public DeleteResponse(String message) { - this.message = message; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - } + private final ContentBannerService contentBannerService; + + @Operation(summary = "콘텐츠 배너 생성", + description = "새로운 콘텐츠 배너를 생성합니다. contentId와 contentType을 통해 실제 콘텐츠 정보를 자동으로 조회하여 설정합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "지원하지 않는 콘텐츠 타입", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/content-banners") + public ResponseEntity createContentBanner( + @Valid @RequestBody CreateContentBannerRequest request) { + + log.info("Creating content banner for content: {} ({})", request.getContentId(), request.getContentType()); + + ContentBannerResponse response = contentBannerService.createBanner(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "관리자용 콘텐츠 배너 목록 조회", description = "관리자용 콘텐츠 배너 목록을 조회합니다. 모든 배너를 조회하며 국가별 필터링이 가능합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/content-banners") + public ResponseEntity> getAdminContentBanners( + @ParameterObject @Valid @ModelAttribute GetAdminContentBannersRequest request) { + + log.info("Getting admin content banners for country: {}, page: {}, size: {}", request.getCountryCode(), + request.getPage(), request.getLimit()); + + org.springframework.data.domain.Page bannerPage = contentBannerService + .getBanners(request.getCountryCode(), request.getPage() - 1, request.getLimit()); + + PageResponse response = PageResponse.of(bannerPage, bannerPage.getContent()); + + return ResponseEntity.ok(response); + } + + @Operation(summary = "콘텐츠 배너 상세 조회", description = "특정 콘텐츠 배너의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "배너를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/content-banners/{bannerId}") + public ResponseEntity getContentBanner(@Parameter(description = "조회할 배너의 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String bannerId) { + + log.info("Getting content banner: {}", bannerId); + + ContentBannerResponse response = contentBannerService.getBanner(bannerId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "콘텐츠 배너 수정", description = "콘텐츠 배너의 정보를 부분 업데이트합니다. 제목, 설명, 순서, 활성화 상태 등을 변경할 수 있습니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "배너를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "최소 한 개의 필드가 필요함", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PatchMapping("/content-banners/{bannerId}") + public ResponseEntity updateContentBanner( + @Parameter(description = "수정할 배너의 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bannerId, + @Valid @RequestBody UpdateContentBannerRequest request) { + + log.info("Updating content banner: {}", bannerId); + + ContentBannerResponse response = contentBannerService.updateBanner(bannerId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "콘텐츠 배너 삭제", description = "콘텐츠 배너를 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", + content = @Content(schema = @Schema(implementation = DeleteResponse.class))), + @ApiResponse(responseCode = "404", description = "배너를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/content-banners/{bannerId}") + public ResponseEntity deleteContentBanner(@Parameter(description = "삭제할 배너의 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String bannerId) { + + log.info("Deleting content banner: {}", bannerId); + + contentBannerService.deleteBanner(bannerId); + DeleteResponse response = new DeleteResponse("Banner deleted successfully."); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(BannerException.class) + public ResponseEntity handleBannerException(BannerException e) { + log.info("Banner Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e.getMessage())); + } + + // 삭제 응답용 내부 클래스 + @Schema(description = "삭제 응답") + public static class DeleteResponse { + + @Schema(description = "응답 메시지", example = "Banner deleted successfully.") + private String message; + + public DeleteResponse(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/controller/ContentBannerController.java b/src/main/java/com/linglevel/api/banner/controller/ContentBannerController.java index 790fa97e..f02ba230 100644 --- a/src/main/java/com/linglevel/api/banner/controller/ContentBannerController.java +++ b/src/main/java/com/linglevel/api/banner/controller/ContentBannerController.java @@ -30,49 +30,50 @@ @Tag(name = "Content Banners", description = "콘텐츠 배너 관련 API") public class ContentBannerController { - private final ContentBannerService contentBannerService; - - @Operation(summary = "콘텐츠 배너 목록 조회", - description = "메인 페이지에 노출할 활성화된 콘텐츠 배너 목록을 조회합니다. 국가별로 필터링 가능하며, 표시 순서에 따라 정렬됩니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", - content = @Content(schema = @Schema(implementation = ContentBannerListResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/content-banners") - public ResponseEntity getContentBanners( - @ParameterObject @Valid @ModelAttribute GetContentBannersRequest request) { - - log.info("Getting content banners for country: {}", request.getCountryCode()); - - List banners = contentBannerService.getActiveBanners(request.getCountryCode()); - - ContentBannerListResponse response = new ContentBannerListResponse(); - response.setData(banners); - - return ResponseEntity.ok(response); - } - - @ExceptionHandler(BannerException.class) - public ResponseEntity handleBannerException(BannerException e) { - log.info("Banner Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e.getMessage())); - } - - // 내부 응답 클래스 - 리스트 래핑용 - @Schema(description = "콘텐츠 배너 목록 응답") - public static class ContentBannerListResponse { - @Schema(description = "배너 목록") - private List data; - - public List getData() { - return data; - } - - public void setData(List data) { - this.data = data; - } - } + private final ContentBannerService contentBannerService; + + @Operation(summary = "콘텐츠 배너 목록 조회", + description = "메인 페이지에 노출할 활성화된 콘텐츠 배너 목록을 조회합니다. 국가별로 필터링 가능하며, 표시 순서에 따라 정렬됩니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = ContentBannerListResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/content-banners") + public ResponseEntity getContentBanners( + @ParameterObject @Valid @ModelAttribute GetContentBannersRequest request) { + + log.info("Getting content banners for country: {}", request.getCountryCode()); + + List banners = contentBannerService.getActiveBanners(request.getCountryCode()); + + ContentBannerListResponse response = new ContentBannerListResponse(); + response.setData(banners); + + return ResponseEntity.ok(response); + } + + @ExceptionHandler(BannerException.class) + public ResponseEntity handleBannerException(BannerException e) { + log.info("Banner Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e.getMessage())); + } + + // 내부 응답 클래스 - 리스트 래핑용 + @Schema(description = "콘텐츠 배너 목록 응답") + public static class ContentBannerListResponse { + + @Schema(description = "배너 목록") + private List data; + + public List getData() { + return data; + } + + public void setData(List data) { + this.data = data; + } + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/dto/ContentBannerResponse.java b/src/main/java/com/linglevel/api/banner/dto/ContentBannerResponse.java index dbd73181..788ad278 100644 --- a/src/main/java/com/linglevel/api/banner/dto/ContentBannerResponse.java +++ b/src/main/java/com/linglevel/api/banner/dto/ContentBannerResponse.java @@ -13,45 +13,46 @@ @Schema(description = "콘텐츠 배너 응답") public class ContentBannerResponse { - @Schema(description = "배너 ID", example = "60d0fe4f5311236168a109ca") - private String id; + @Schema(description = "배너 ID", example = "60d0fe4f5311236168a109ca") + private String id; - @Schema(description = "국가 코드", example = "KR") - private CountryCode countryCode; + @Schema(description = "국가 코드", example = "KR") + private CountryCode countryCode; - @Schema(description = "콘텐츠 ID", example = "60d0fe4f5311236168a109cb") - private String contentId; + @Schema(description = "콘텐츠 ID", example = "60d0fe4f5311236168a109cb") + private String contentId; - @Schema(description = "콘텐츠 타입", example = "BOOK") - private ContentType contentType; + @Schema(description = "콘텐츠 타입", example = "BOOK") + private ContentType contentType; - @Schema(description = "콘텐츠 제목", example = "The Little Prince") - private String contentTitle; + @Schema(description = "콘텐츠 제목", example = "The Little Prince") + private String contentTitle; - @Schema(description = "콘텐츠 작가", example = "Antoine de Saint-Exupéry") - private String contentAuthor; + @Schema(description = "콘텐츠 작가", example = "Antoine de Saint-Exupéry") + private String contentAuthor; - @Schema(description = "콘텐츠 커버 이미지 URL", example = "https://path/to/cover.jpg") - private String contentCoverImageUrl; + @Schema(description = "콘텐츠 커버 이미지 URL", example = "https://path/to/cover.jpg") + private String contentCoverImageUrl; - @Schema(description = "콘텐츠 읽기 시간(분)", example = "120") - private Integer contentReadingTime; + @Schema(description = "콘텐츠 읽기 시간(분)", example = "120") + private Integer contentReadingTime; - @Schema(description = "배너 부제목", example = "세계에서 가장 사랑받는 소설") - private String subtitle; + @Schema(description = "배너 부제목", example = "세계에서 가장 사랑받는 소설") + private String subtitle; - @Schema(description = "배너 제목", example = "어린왕자와 함께하는 영어 공부") - private String title; + @Schema(description = "배너 제목", example = "어린왕자와 함께하는 영어 공부") + private String title; - @Schema(description = "배너 설명", example = "프랑스 문학의 걸작을 쉬운 영어로 만나보세요. A1부터 C2까지 다양한 난이도로 제공됩니다.") - private String description; + @Schema(description = "배너 설명", example = "프랑스 문학의 걸작을 쉬운 영어로 만나보세요. A1부터 C2까지 다양한 난이도로 제공됩니다.") + private String description; - @Schema(description = "표시 순서", example = "1") - private Integer displayOrder; + @Schema(description = "표시 순서", example = "1") + private Integer displayOrder; - @Schema(description = "활성화 상태", example = "true") - private Boolean isActive; + @Schema(description = "활성화 상태", example = "true") + private Boolean isActive; + + @Schema(description = "생성 날짜", example = "2024-01-15T00:00:00") + private LocalDateTime createdAt; - @Schema(description = "생성 날짜", example = "2024-01-15T00:00:00") - private LocalDateTime createdAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/dto/CreateContentBannerRequest.java b/src/main/java/com/linglevel/api/banner/dto/CreateContentBannerRequest.java index e7d8521d..85282ef1 100644 --- a/src/main/java/com/linglevel/api/banner/dto/CreateContentBannerRequest.java +++ b/src/main/java/com/linglevel/api/banner/dto/CreateContentBannerRequest.java @@ -14,32 +14,34 @@ @Schema(description = "콘텐츠 배너 생성 요청") public class CreateContentBannerRequest { - @Schema(description = "국가 코드", example = "KR", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "CountryCode is required.") - private CountryCode countryCode; + @Schema(description = "국가 코드", example = "KR", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "CountryCode is required.") + private CountryCode countryCode; - @Schema(description = "콘텐츠 ID", example = "60d0fe4f5311236168a109cb", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "ContentId is required.") - private String contentId; + @Schema(description = "콘텐츠 ID", example = "60d0fe4f5311236168a109cb", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "ContentId is required.") + private String contentId; - @Schema(description = "콘텐츠 타입", example = "BOOK", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "ContentType is required.") - private ContentType contentType; + @Schema(description = "콘텐츠 타입", example = "BOOK", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "ContentType is required.") + private ContentType contentType; - @Schema(description = "배너 부제목", example = "세계에서 가장 사랑받는 소설") - private String subtitle; + @Schema(description = "배너 부제목", example = "세계에서 가장 사랑받는 소설") + private String subtitle; - @Schema(description = "배너 제목", example = "어린왕자와 함께하는 영어 공부", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "Title is required.") - private String title; + @Schema(description = "배너 제목", example = "어린왕자와 함께하는 영어 공부", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Title is required.") + private String title; - @Schema(description = "배너 설명", example = "프랑스 문학의 걸작을 쉬운 영어로 만나보세요. A1부터 C2까지 다양한 난이도로 제공됩니다.", requiredMode = Schema.RequiredMode.REQUIRED) - @NotBlank(message = "Description is required.") - private String description; + @Schema(description = "배너 설명", example = "프랑스 문학의 걸작을 쉬운 영어로 만나보세요. A1부터 C2까지 다양한 난이도로 제공됩니다.", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "Description is required.") + private String description; - @Schema(description = "표시 순서", example = "1", defaultValue = "9") - private Integer displayOrder = 9; + @Schema(description = "표시 순서", example = "1", defaultValue = "9") + private Integer displayOrder = 9; + + @Schema(description = "활성화 상태", example = "true", defaultValue = "true") + private Boolean isActive = true; - @Schema(description = "활성화 상태", example = "true", defaultValue = "true") - private Boolean isActive = true; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/dto/GetAdminContentBannersRequest.java b/src/main/java/com/linglevel/api/banner/dto/GetAdminContentBannersRequest.java index 70cbd108..3c30543d 100644 --- a/src/main/java/com/linglevel/api/banner/dto/GetAdminContentBannersRequest.java +++ b/src/main/java/com/linglevel/api/banner/dto/GetAdminContentBannersRequest.java @@ -13,15 +13,16 @@ @Schema(description = "관리자용 콘텐츠 배너 목록 조회 요청") public class GetAdminContentBannersRequest { - @Schema(description = "국가 코드로 필터링", example = "KR") - private CountryCode countryCode; + @Schema(description = "국가 코드로 필터링", example = "KR") + private CountryCode countryCode; - @Schema(description = "페이지 번호", example = "1", defaultValue = "1") - @Min(value = 1, message = "Page must be at least 1") - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", defaultValue = "1") + @Min(value = 1, message = "Page must be at least 1") + private Integer page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10") + @Min(value = 1, message = "Limit must be at least 1") + @Max(value = 100, message = "Limit cannot exceed 100") + private Integer limit = 10; - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10") - @Min(value = 1, message = "Limit must be at least 1") - @Max(value = 100, message = "Limit cannot exceed 100") - private Integer limit = 10; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/dto/GetContentBannersRequest.java b/src/main/java/com/linglevel/api/banner/dto/GetContentBannersRequest.java index 02144322..041e5c0d 100644 --- a/src/main/java/com/linglevel/api/banner/dto/GetContentBannersRequest.java +++ b/src/main/java/com/linglevel/api/banner/dto/GetContentBannersRequest.java @@ -14,12 +14,13 @@ @Schema(description = "콘텐츠 배너 목록 조회 요청") public class GetContentBannersRequest { - @Schema(description = "국가 코드 (필수)", example = "KR", requiredMode = Schema.RequiredMode.REQUIRED) - @NotNull(message = "CountryCode is required.") - private CountryCode countryCode; + @Schema(description = "국가 코드 (필수)", example = "KR", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "CountryCode is required.") + private CountryCode countryCode; + + @Schema(description = "반환할 배너 수", example = "5", defaultValue = "5") + @Min(value = 1, message = "Limit must be at least 1") + @Max(value = 10, message = "Limit cannot exceed 10") + private Integer limit = 5; - @Schema(description = "반환할 배너 수", example = "5", defaultValue = "5") - @Min(value = 1, message = "Limit must be at least 1") - @Max(value = 10, message = "Limit cannot exceed 10") - private Integer limit = 5; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/dto/UpdateContentBannerRequest.java b/src/main/java/com/linglevel/api/banner/dto/UpdateContentBannerRequest.java index a2a00e7b..393ada0d 100644 --- a/src/main/java/com/linglevel/api/banner/dto/UpdateContentBannerRequest.java +++ b/src/main/java/com/linglevel/api/banner/dto/UpdateContentBannerRequest.java @@ -9,18 +9,19 @@ @Schema(description = "콘텐츠 배너 수정 요청") public class UpdateContentBannerRequest { - @Schema(description = "배너 제목", example = "업데이트된 배너 제목") - private String title; + @Schema(description = "배너 제목", example = "업데이트된 배너 제목") + private String title; - @Schema(description = "배너 부제목", example = "업데이트된 부제목") - private String subtitle; + @Schema(description = "배너 부제목", example = "업데이트된 부제목") + private String subtitle; - @Schema(description = "배너 설명", example = "업데이트된 설명") - private String description; + @Schema(description = "배너 설명", example = "업데이트된 설명") + private String description; - @Schema(description = "표시 순서", example = "2") - private Integer displayOrder; + @Schema(description = "표시 순서", example = "2") + private Integer displayOrder; + + @Schema(description = "활성화 상태", example = "false") + private Boolean isActive; - @Schema(description = "활성화 상태", example = "false") - private Boolean isActive; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/entity/ContentBanner.java b/src/main/java/com/linglevel/api/banner/entity/ContentBanner.java index 6929a9f1..1fe6d576 100644 --- a/src/main/java/com/linglevel/api/banner/entity/ContentBanner.java +++ b/src/main/java/com/linglevel/api/banner/entity/ContentBanner.java @@ -15,32 +15,33 @@ @Document(collection = "contentBanners") public class ContentBanner { - @Id - private String id; + @Id + private String id; - private CountryCode countryCode; + private CountryCode countryCode; - private String contentId; + private String contentId; - private ContentType contentType; + private ContentType contentType; - private String contentTitle; + private String contentTitle; - private String contentAuthor; + private String contentAuthor; - private String contentCoverImageUrl; + private String contentCoverImageUrl; - private Integer contentReadingTime; + private Integer contentReadingTime; - private String subtitle; + private String subtitle; - private String title; + private String title; - private String description; + private String description; - private Integer displayOrder = 9; + private Integer displayOrder = 9; - private Boolean isActive = true; + private Boolean isActive = true; + + private LocalDateTime createdAt; - private LocalDateTime createdAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/exception/BannerErrorCode.java b/src/main/java/com/linglevel/api/banner/exception/BannerErrorCode.java index d8cc4d31..b9953ff1 100644 --- a/src/main/java/com/linglevel/api/banner/exception/BannerErrorCode.java +++ b/src/main/java/com/linglevel/api/banner/exception/BannerErrorCode.java @@ -7,13 +7,16 @@ @Getter @RequiredArgsConstructor public enum BannerErrorCode { - BANNER_NOT_FOUND(HttpStatus.NOT_FOUND, "Banner not found."), - CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "Content not found."), - INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "Unsupported content type. Must be BOOK or ARTICLE."), - COUNTRY_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "CountryCode is required."), - INVALID_API_KEY(HttpStatus.UNAUTHORIZED, "Invalid API key."), - UPDATE_FIELD_REQUIRED(HttpStatus.BAD_REQUEST, "At least one field must be provided for update."); - private final HttpStatus status; - private final String message; + BANNER_NOT_FOUND(HttpStatus.NOT_FOUND, "Banner not found."), + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "Content not found."), + INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "Unsupported content type. Must be BOOK or ARTICLE."), + COUNTRY_CODE_REQUIRED(HttpStatus.BAD_REQUEST, "CountryCode is required."), + INVALID_API_KEY(HttpStatus.UNAUTHORIZED, "Invalid API key."), + UPDATE_FIELD_REQUIRED(HttpStatus.BAD_REQUEST, "At least one field must be provided for update."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/exception/BannerException.java b/src/main/java/com/linglevel/api/banner/exception/BannerException.java index c76b8c3c..5169cbd3 100644 --- a/src/main/java/com/linglevel/api/banner/exception/BannerException.java +++ b/src/main/java/com/linglevel/api/banner/exception/BannerException.java @@ -6,15 +6,16 @@ @Getter public class BannerException extends RuntimeException { - private final HttpStatus status; + private final HttpStatus status; - public BannerException(BannerErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + public BannerException(BannerErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + + public BannerException(BannerErrorCode errorCode, String customMessage) { + super(customMessage); + this.status = errorCode.getStatus(); + } - public BannerException(BannerErrorCode errorCode, String customMessage) { - super(customMessage); - this.status = errorCode.getStatus(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/repository/ContentBannerRepository.java b/src/main/java/com/linglevel/api/banner/repository/ContentBannerRepository.java index 09a3b5bf..33356346 100644 --- a/src/main/java/com/linglevel/api/banner/repository/ContentBannerRepository.java +++ b/src/main/java/com/linglevel/api/banner/repository/ContentBannerRepository.java @@ -14,24 +14,25 @@ @Repository public interface ContentBannerRepository extends MongoRepository { - /** - * 활성화된 배너를 국가별, 표시순서로 조회 - */ - List findByCountryCodeAndIsActiveTrueOrderByDisplayOrderAsc(CountryCode countryCode); - - /** - * 국가별 배너 페이지네이션 조회 - */ - Page findByCountryCode(CountryCode countryCode, Pageable pageable); - - /** - * 특정 표시순서가 이미 사용되었는지 확인 - */ - boolean existsByCountryCodeAndDisplayOrder(CountryCode countryCode, Integer displayOrder); - - /** - * 국가별 최대 표시순서 조회 - */ - @Query("{ 'countryCode': ?0 }") - List findByCountryCodeOrderByDisplayOrderDesc(CountryCode countryCode); + /** + * 활성화된 배너를 국가별, 표시순서로 조회 + */ + List findByCountryCodeAndIsActiveTrueOrderByDisplayOrderAsc(CountryCode countryCode); + + /** + * 국가별 배너 페이지네이션 조회 + */ + Page findByCountryCode(CountryCode countryCode, Pageable pageable); + + /** + * 특정 표시순서가 이미 사용되었는지 확인 + */ + boolean existsByCountryCodeAndDisplayOrder(CountryCode countryCode, Integer displayOrder); + + /** + * 국가별 최대 표시순서 조회 + */ + @Query("{ 'countryCode': ?0 }") + List findByCountryCodeOrderByDisplayOrderDesc(CountryCode countryCode); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/banner/service/ContentBannerService.java b/src/main/java/com/linglevel/api/banner/service/ContentBannerService.java index bf05610a..25dc8cb9 100644 --- a/src/main/java/com/linglevel/api/banner/service/ContentBannerService.java +++ b/src/main/java/com/linglevel/api/banner/service/ContentBannerService.java @@ -27,163 +27,163 @@ @RequiredArgsConstructor public class ContentBannerService { - private final ContentBannerRepository contentBannerRepository; - private final ContentInfoProviderFactory contentInfoProviderFactory; - - /** - * 활성화된 배너 목록 조회 (국가별, 표시순서) - */ - public List getActiveBanners(CountryCode countryCode) { - log.debug("Getting active banners for country: {}", countryCode); - - List banners = contentBannerRepository - .findByCountryCodeAndIsActiveTrueOrderByDisplayOrderAsc(countryCode); - - return banners.stream() - .map(this::convertToResponse) - .collect(Collectors.toList()); - } - - /** - * 배너 목록 조회 (페이지네이션) - */ - public Page getBanners(CountryCode countryCode, int page, int size) { - log.debug("Getting banners for country: {}, page: {}, size: {}", countryCode, page, size); - - Pageable pageable = PageRequest.of(page, size, Sort.by("displayOrder").ascending()); - Page bannerPage = contentBannerRepository.findByCountryCode(countryCode, pageable); - - return bannerPage.map(this::convertToResponse); - } - - /** - * 특정 배너 조회 - */ - public ContentBannerResponse getBanner(String bannerId) { - log.debug("Getting banner by id: {}", bannerId); - - ContentBanner banner = contentBannerRepository.findById(bannerId) - .orElseThrow(() -> new BannerException(BannerErrorCode.BANNER_NOT_FOUND)); - - return convertToResponse(banner); - } - - /** - * 배너 생성 - */ - public ContentBannerResponse createBanner(CreateContentBannerRequest request) { - log.info("Creating new banner for content: {} ({})", request.getContentId(), request.getContentType()); - - // displayOrder가 제공되지 않았거나 중복인 경우 처리 - Integer displayOrder = request.getDisplayOrder(); - if (displayOrder == null || contentBannerRepository.existsByCountryCodeAndDisplayOrder(request.getCountryCode(), displayOrder)) { - displayOrder = getNextDisplayOrder(request.getCountryCode()); - log.debug("Using next available display order: {}", displayOrder); - } - - ContentBanner banner = new ContentBanner(); - banner.setCountryCode(request.getCountryCode()); - banner.setContentId(request.getContentId()); - banner.setContentType(request.getContentType()); - banner.setSubtitle(request.getSubtitle()); - banner.setTitle(request.getTitle()); - banner.setDescription(request.getDescription()); - banner.setDisplayOrder(displayOrder); - banner.setIsActive(request.getIsActive()); - banner.setCreatedAt(LocalDateTime.now()); - - ContentInfo contentInfo = contentInfoProviderFactory.getContentInfo( - request.getContentId(), request.getContentType()); - - banner.setContentTitle(contentInfo.getTitle()); - banner.setContentAuthor(contentInfo.getAuthor()); - banner.setContentCoverImageUrl(contentInfo.getCoverImageUrl()); - banner.setContentReadingTime(contentInfo.getReadingTime()); - - ContentBanner savedBanner = contentBannerRepository.save(banner); - log.info("Banner created successfully with id: {}", savedBanner.getId()); - - return convertToResponse(savedBanner); - } - - /** - * 배너 수정 - */ - public ContentBannerResponse updateBanner(String bannerId, UpdateContentBannerRequest request) { - log.info("Updating banner: {}", bannerId); - - ContentBanner banner = contentBannerRepository.findById(bannerId) - .orElseThrow(() -> new BannerException(BannerErrorCode.BANNER_NOT_FOUND)); - - if (request.getTitle() != null) { - banner.setTitle(request.getTitle()); - } - if (request.getSubtitle() != null) { - banner.setSubtitle(request.getSubtitle()); - } - if (request.getDescription() != null) { - banner.setDescription(request.getDescription()); - } - if (request.getDisplayOrder() != null) { - banner.setDisplayOrder(request.getDisplayOrder()); - } - if (request.getIsActive() != null) { - banner.setIsActive(request.getIsActive()); - } - - ContentBanner updatedBanner = contentBannerRepository.save(banner); - log.info("Banner updated successfully: {}", bannerId); - - return convertToResponse(updatedBanner); - } - - /** - * 배너 삭제 - */ - public void deleteBanner(String bannerId) { - log.info("Deleting banner: {}", bannerId); - - if (!contentBannerRepository.existsById(bannerId)) { - throw new BannerException(BannerErrorCode.BANNER_NOT_FOUND); - } - - contentBannerRepository.deleteById(bannerId); - log.info("Banner deleted successfully: {}", bannerId); - } - - /** - * 다음 사용 가능한 표시순서 조회 - */ - private Integer getNextDisplayOrder(CountryCode countryCode) { - List banners = contentBannerRepository - .findByCountryCodeOrderByDisplayOrderDesc(countryCode); - - if (banners.isEmpty()) { - return 1; - } - - return banners.get(0).getDisplayOrder() + 1; - } - - /** - * Entity를 Response DTO로 변환 - */ - private ContentBannerResponse convertToResponse(ContentBanner banner) { - ContentBannerResponse response = new ContentBannerResponse(); - response.setId(banner.getId()); - response.setCountryCode(banner.getCountryCode()); - response.setContentId(banner.getContentId()); - response.setContentType(banner.getContentType()); - response.setContentTitle(banner.getContentTitle()); - response.setContentAuthor(banner.getContentAuthor()); - response.setContentCoverImageUrl(banner.getContentCoverImageUrl()); - response.setContentReadingTime(banner.getContentReadingTime()); - response.setSubtitle(banner.getSubtitle()); - response.setTitle(banner.getTitle()); - response.setDescription(banner.getDescription()); - response.setDisplayOrder(banner.getDisplayOrder()); - response.setIsActive(banner.getIsActive()); - response.setCreatedAt(banner.getCreatedAt()); - return response; - } + private final ContentBannerRepository contentBannerRepository; + + private final ContentInfoProviderFactory contentInfoProviderFactory; + + /** + * 활성화된 배너 목록 조회 (국가별, 표시순서) + */ + public List getActiveBanners(CountryCode countryCode) { + log.debug("Getting active banners for country: {}", countryCode); + + List banners = contentBannerRepository + .findByCountryCodeAndIsActiveTrueOrderByDisplayOrderAsc(countryCode); + + return banners.stream().map(this::convertToResponse).collect(Collectors.toList()); + } + + /** + * 배너 목록 조회 (페이지네이션) + */ + public Page getBanners(CountryCode countryCode, int page, int size) { + log.debug("Getting banners for country: {}, page: {}, size: {}", countryCode, page, size); + + Pageable pageable = PageRequest.of(page, size, Sort.by("displayOrder").ascending()); + Page bannerPage = contentBannerRepository.findByCountryCode(countryCode, pageable); + + return bannerPage.map(this::convertToResponse); + } + + /** + * 특정 배너 조회 + */ + public ContentBannerResponse getBanner(String bannerId) { + log.debug("Getting banner by id: {}", bannerId); + + ContentBanner banner = contentBannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BannerErrorCode.BANNER_NOT_FOUND)); + + return convertToResponse(banner); + } + + /** + * 배너 생성 + */ + public ContentBannerResponse createBanner(CreateContentBannerRequest request) { + log.info("Creating new banner for content: {} ({})", request.getContentId(), request.getContentType()); + + // displayOrder가 제공되지 않았거나 중복인 경우 처리 + Integer displayOrder = request.getDisplayOrder(); + if (displayOrder == null + || contentBannerRepository.existsByCountryCodeAndDisplayOrder(request.getCountryCode(), displayOrder)) { + displayOrder = getNextDisplayOrder(request.getCountryCode()); + log.debug("Using next available display order: {}", displayOrder); + } + + ContentBanner banner = new ContentBanner(); + banner.setCountryCode(request.getCountryCode()); + banner.setContentId(request.getContentId()); + banner.setContentType(request.getContentType()); + banner.setSubtitle(request.getSubtitle()); + banner.setTitle(request.getTitle()); + banner.setDescription(request.getDescription()); + banner.setDisplayOrder(displayOrder); + banner.setIsActive(request.getIsActive()); + banner.setCreatedAt(LocalDateTime.now()); + + ContentInfo contentInfo = contentInfoProviderFactory.getContentInfo(request.getContentId(), + request.getContentType()); + + banner.setContentTitle(contentInfo.getTitle()); + banner.setContentAuthor(contentInfo.getAuthor()); + banner.setContentCoverImageUrl(contentInfo.getCoverImageUrl()); + banner.setContentReadingTime(contentInfo.getReadingTime()); + + ContentBanner savedBanner = contentBannerRepository.save(banner); + log.info("Banner created successfully with id: {}", savedBanner.getId()); + + return convertToResponse(savedBanner); + } + + /** + * 배너 수정 + */ + public ContentBannerResponse updateBanner(String bannerId, UpdateContentBannerRequest request) { + log.info("Updating banner: {}", bannerId); + + ContentBanner banner = contentBannerRepository.findById(bannerId) + .orElseThrow(() -> new BannerException(BannerErrorCode.BANNER_NOT_FOUND)); + + if (request.getTitle() != null) { + banner.setTitle(request.getTitle()); + } + if (request.getSubtitle() != null) { + banner.setSubtitle(request.getSubtitle()); + } + if (request.getDescription() != null) { + banner.setDescription(request.getDescription()); + } + if (request.getDisplayOrder() != null) { + banner.setDisplayOrder(request.getDisplayOrder()); + } + if (request.getIsActive() != null) { + banner.setIsActive(request.getIsActive()); + } + + ContentBanner updatedBanner = contentBannerRepository.save(banner); + log.info("Banner updated successfully: {}", bannerId); + + return convertToResponse(updatedBanner); + } + + /** + * 배너 삭제 + */ + public void deleteBanner(String bannerId) { + log.info("Deleting banner: {}", bannerId); + + if (!contentBannerRepository.existsById(bannerId)) { + throw new BannerException(BannerErrorCode.BANNER_NOT_FOUND); + } + + contentBannerRepository.deleteById(bannerId); + log.info("Banner deleted successfully: {}", bannerId); + } + + /** + * 다음 사용 가능한 표시순서 조회 + */ + private Integer getNextDisplayOrder(CountryCode countryCode) { + List banners = contentBannerRepository.findByCountryCodeOrderByDisplayOrderDesc(countryCode); + + if (banners.isEmpty()) { + return 1; + } + + return banners.get(0).getDisplayOrder() + 1; + } + + /** + * Entity를 Response DTO로 변환 + */ + private ContentBannerResponse convertToResponse(ContentBanner banner) { + ContentBannerResponse response = new ContentBannerResponse(); + response.setId(banner.getId()); + response.setCountryCode(banner.getCountryCode()); + response.setContentId(banner.getContentId()); + response.setContentType(banner.getContentType()); + response.setContentTitle(banner.getContentTitle()); + response.setContentAuthor(banner.getContentAuthor()); + response.setContentCoverImageUrl(banner.getContentCoverImageUrl()); + response.setContentReadingTime(banner.getContentReadingTime()); + response.setSubtitle(banner.getSubtitle()); + response.setTitle(banner.getTitle()); + response.setDescription(banner.getDescription()); + response.setDisplayOrder(banner.getDisplayOrder()); + response.setIsActive(banner.getIsActive()); + response.setCreatedAt(banner.getCreatedAt()); + return response; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/controller/BookmarksController.java b/src/main/java/com/linglevel/api/bookmark/controller/BookmarksController.java index e3078f4e..e5e53584 100644 --- a/src/main/java/com/linglevel/api/bookmark/controller/BookmarksController.java +++ b/src/main/java/com/linglevel/api/bookmark/controller/BookmarksController.java @@ -35,110 +35,99 @@ @Tag(name = "Bookmarks", description = "북마크 관련 API") public class BookmarksController { - private final BookmarkService bookmarkService; - private final WordValidator wordValidator; + private final BookmarkService bookmarkService; - @Operation(summary = "북마크된 단어 목록 조회", description = "현재 사용자가 북마크한 단어 목록을 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/words") - public ResponseEntity> getBookmarkedWords( - @ParameterObject @Valid @ModelAttribute GetBookmarkedWordsRequest request, - @AuthenticationPrincipal JwtClaims claims) { - var bookmarkedWords = bookmarkService.getBookmarkedWords(claims.getId(), request.getPage(), request.getLimit(), request.getSearch()); - return ResponseEntity.ok(new PageResponse<>(bookmarkedWords.getContent(), bookmarkedWords)); - } + private final WordValidator wordValidator; - @Operation(summary = "단어 북마크 추가", description = "특정 단어를 북마크에 추가합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "북마크 추가 성공", - content = @Content(schema = @Schema(implementation = MessageResponse.class))), - @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "409", description = "이미 북마크된 단어", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) - @PostMapping("/words/{word}") - public ResponseEntity addWordBookmark( - @Parameter(description = "북마크할 단어", example = "magnificent") - @PathVariable String word, - @AuthenticationPrincipal JwtClaims claims) { - String validatedWord = wordValidator.validateAndPreprocess(word); - bookmarkService.addWordBookmark(claims.getId(), validatedWord); - return ResponseEntity.ok(new MessageResponse("Word bookmarked successfully.")); - } + @Operation(summary = "북마크된 단어 목록 조회", description = "현재 사용자가 북마크한 단어 목록을 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/words") + public ResponseEntity> getBookmarkedWords( + @ParameterObject @Valid @ModelAttribute GetBookmarkedWordsRequest request, + @AuthenticationPrincipal JwtClaims claims) { + var bookmarkedWords = bookmarkService.getBookmarkedWords(claims.getId(), request.getPage(), request.getLimit(), + request.getSearch()); + return ResponseEntity.ok(new PageResponse<>(bookmarkedWords.getContent(), bookmarkedWords)); + } - @Operation(summary = "단어 북마크 제거", description = "특정 단어를 북마크에서 제거합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "북마크 제거 성공", - content = @Content(schema = @Schema(implementation = MessageResponse.class))), - @ApiResponse(responseCode = "404", description = "단어 또는 북마크를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/words/{word}") - public ResponseEntity removeWordBookmark( - @Parameter(description = "북마크 해제할 단어", example = "magnificent") - @PathVariable String word, - @AuthenticationPrincipal JwtClaims claims) { - String validatedWord = wordValidator.validateAndPreprocess(word); - bookmarkService.removeWordBookmark(claims.getId(), validatedWord); - return ResponseEntity.ok(new MessageResponse("Word bookmark removed successfully.")); - } + @Operation(summary = "단어 북마크 추가", description = "특정 단어를 북마크에 추가합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "북마크 추가 성공", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 북마크된 단어", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) + @PostMapping("/words/{word}") + public ResponseEntity addWordBookmark( + @Parameter(description = "북마크할 단어", example = "magnificent") @PathVariable String word, + @AuthenticationPrincipal JwtClaims claims) { + String validatedWord = wordValidator.validateAndPreprocess(word); + bookmarkService.addWordBookmark(claims.getId(), validatedWord); + return ResponseEntity.ok(new MessageResponse("Word bookmarked successfully.")); + } - @Operation(summary = "단어 북마크 토글", description = "특정 단어의 북마크 상태를 토글합니다. 북마크되어 있으면 제거하고, 북마크되어 있지 않으면 추가합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "토글 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) - @PutMapping("/words/{word}/toggle") - public ResponseEntity toggleWordBookmark( - @Parameter(description = "토글할 단어", example = "magnificent") - @PathVariable String word, - @AuthenticationPrincipal JwtClaims claims) { - String validatedWord = wordValidator.validateAndPreprocess(word); - boolean bookmarked = bookmarkService.toggleWordBookmark(claims.getId(), validatedWord); - return ResponseEntity.ok(new BookmarkToggleResponse(bookmarked)); - } + @Operation(summary = "단어 북마크 제거", description = "특정 단어를 북마크에서 제거합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "북마크 제거 성공", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "404", description = "단어 또는 북마크를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/words/{word}") + public ResponseEntity removeWordBookmark( + @Parameter(description = "북마크 해제할 단어", example = "magnificent") @PathVariable String word, + @AuthenticationPrincipal JwtClaims claims) { + String validatedWord = wordValidator.validateAndPreprocess(word); + bookmarkService.removeWordBookmark(claims.getId(), validatedWord); + return ResponseEntity.ok(new MessageResponse("Word bookmark removed successfully.")); + } - @Operation(summary = "단어 ID로 북마크 토글", description = "특정 단어 ID의 북마크 상태를 토글합니다. 북마크되어 있으면 제거하고, 북마크되어 있지 않으면 추가합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "토글 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) - @PutMapping("/by-id/{wordId}/toggle") - public ResponseEntity toggleWordBookmarkById( - @Parameter(description = "토글할 단어의 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String wordId, - @AuthenticationPrincipal JwtClaims claims) { - boolean bookmarked = bookmarkService.toggleWordBookmarkById(claims.getId(), wordId); - return ResponseEntity.ok(new BookmarkToggleResponse(bookmarked)); - } + @Operation(summary = "단어 북마크 토글", description = "특정 단어의 북마크 상태를 토글합니다. 북마크되어 있으면 제거하고, 북마크되어 있지 않으면 추가합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "토글 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) + @PutMapping("/words/{word}/toggle") + public ResponseEntity toggleWordBookmark( + @Parameter(description = "토글할 단어", example = "magnificent") @PathVariable String word, + @AuthenticationPrincipal JwtClaims claims) { + String validatedWord = wordValidator.validateAndPreprocess(word); + boolean bookmarked = bookmarkService.toggleWordBookmark(claims.getId(), validatedWord); + return ResponseEntity.ok(new BookmarkToggleResponse(bookmarked)); + } - @ExceptionHandler(BookmarksException.class) - public ResponseEntity handleBookmarksException(BookmarksException e) { - log.info("Bookmarks Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } + @Operation(summary = "단어 ID로 북마크 토글", description = "특정 단어 ID의 북마크 상태를 토글합니다. 북마크되어 있으면 제거하고, 북마크되어 있지 않으면 추가합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "토글 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) + @PutMapping("/by-id/{wordId}/toggle") + public ResponseEntity toggleWordBookmarkById( + @Parameter(description = "토글할 단어의 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String wordId, + @AuthenticationPrincipal JwtClaims claims) { + boolean bookmarked = bookmarkService.toggleWordBookmarkById(claims.getId(), wordId); + return ResponseEntity.ok(new BookmarkToggleResponse(bookmarked)); + } + + @ExceptionHandler(BookmarksException.class) + public ResponseEntity handleBookmarksException(BookmarksException e) { + log.info("Bookmarks Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + + @ExceptionHandler(WordsException.class) + public ResponseEntity handleWordsException(WordsException e) { + log.info("Words Exception in Bookmarks: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(WordsException.class) - public ResponseEntity handleWordsException(WordsException e) { - log.info("Words Exception in Bookmarks: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/dto/BookmarkToggleResponse.java b/src/main/java/com/linglevel/api/bookmark/dto/BookmarkToggleResponse.java index b9ecb538..8b192dee 100644 --- a/src/main/java/com/linglevel/api/bookmark/dto/BookmarkToggleResponse.java +++ b/src/main/java/com/linglevel/api/bookmark/dto/BookmarkToggleResponse.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "북마크 토글 응답") public class BookmarkToggleResponse { - @Schema(description = "토글 후의 북마크 상태 (true: 북마크됨, false: 북마크 해제됨)", example = "true") - private boolean bookmarked; + + @Schema(description = "토글 후의 북마크 상태 (true: 북마크됨, false: 북마크 해제됨)", example = "true") + private boolean bookmarked; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/dto/BookmarkedWordResponse.java b/src/main/java/com/linglevel/api/bookmark/dto/BookmarkedWordResponse.java index 41b13877..6ccd3991 100644 --- a/src/main/java/com/linglevel/api/bookmark/dto/BookmarkedWordResponse.java +++ b/src/main/java/com/linglevel/api/bookmark/dto/BookmarkedWordResponse.java @@ -14,12 +14,14 @@ @AllArgsConstructor @Schema(description = "북마크된 단어 응답") public class BookmarkedWordResponse { - @Schema(description = "단어 ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "단어", example = "magnificent") - private String word; - - @Schema(description = "북마크된 시간", example = "2024-01-15T10:30:00") - private LocalDateTime bookmarkedAt; + + @Schema(description = "단어 ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "단어", example = "magnificent") + private String word; + + @Schema(description = "북마크된 시간", example = "2024-01-15T10:30:00") + private LocalDateTime bookmarkedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/dto/GetBookmarkedWordsRequest.java b/src/main/java/com/linglevel/api/bookmark/dto/GetBookmarkedWordsRequest.java index a816cc25..c79ab832 100644 --- a/src/main/java/com/linglevel/api/bookmark/dto/GetBookmarkedWordsRequest.java +++ b/src/main/java/com/linglevel/api/bookmark/dto/GetBookmarkedWordsRequest.java @@ -14,26 +14,19 @@ @AllArgsConstructor @Schema(description = "북마크된 단어 목록 조회 요청") public class GetBookmarkedWordsRequest { - - @Schema(description = "페이지 번호", - example = "1", - minimum = "1", - defaultValue = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @Builder.Default - private Integer page = 1; - - @Schema(description = "페이지 크기", - example = "10", - minimum = "1", - maximum = "200", - defaultValue = "10") - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") - @Builder.Default - private Integer limit = 10; - - @Schema(description = "검색 키워드 (단어 부분 일치 검색)", - example = "magn") - private String search; + + @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @Builder.Default + private Integer page = 1; + + @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") + @Builder.Default + private Integer limit = 10; + + @Schema(description = "검색 키워드 (단어 부분 일치 검색)", example = "magn") + private String search; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/entity/WordBookmark.java b/src/main/java/com/linglevel/api/bookmark/entity/WordBookmark.java index ebde6894..1f5f1163 100644 --- a/src/main/java/com/linglevel/api/bookmark/entity/WordBookmark.java +++ b/src/main/java/com/linglevel/api/bookmark/entity/WordBookmark.java @@ -15,12 +15,14 @@ @Document(collection = "wordBookmarks") @CompoundIndex(name = "userId_word_unique", def = "{'userId': 1, 'word': 1}", unique = true) public class WordBookmark { - @Id - private String id; - - private String userId; - - private String word; - - private LocalDateTime bookmarkedAt; + + @Id + private String id; + + private String userId; + + private String word; + + private LocalDateTime bookmarkedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/exception/BookmarksErrorCode.java b/src/main/java/com/linglevel/api/bookmark/exception/BookmarksErrorCode.java index 6153ae94..4cee0545 100644 --- a/src/main/java/com/linglevel/api/bookmark/exception/BookmarksErrorCode.java +++ b/src/main/java/com/linglevel/api/bookmark/exception/BookmarksErrorCode.java @@ -7,11 +7,14 @@ @Getter @AllArgsConstructor public enum BookmarksErrorCode { - WORD_NOT_FOUND(HttpStatus.NOT_FOUND, "Word not found."), - WORD_ALREADY_BOOKMARKED(HttpStatus.CONFLICT, "Word is already bookmarked."), - WORD_BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "Word bookmark not found."), - INVALID_PAGINATION(HttpStatus.BAD_REQUEST, "Invalid pagination parameters."); - - private final HttpStatus status; - private final String message; + + WORD_NOT_FOUND(HttpStatus.NOT_FOUND, "Word not found."), + WORD_ALREADY_BOOKMARKED(HttpStatus.CONFLICT, "Word is already bookmarked."), + WORD_BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "Word bookmark not found."), + INVALID_PAGINATION(HttpStatus.BAD_REQUEST, "Invalid pagination parameters."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/exception/BookmarksException.java b/src/main/java/com/linglevel/api/bookmark/exception/BookmarksException.java index 6f38ef83..95521f2f 100644 --- a/src/main/java/com/linglevel/api/bookmark/exception/BookmarksException.java +++ b/src/main/java/com/linglevel/api/bookmark/exception/BookmarksException.java @@ -5,10 +5,12 @@ @Getter public class BookmarksException extends RuntimeException { - private final HttpStatus status; - public BookmarksException(BookmarksErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public BookmarksException(BookmarksErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/repository/WordBookmarkRepository.java b/src/main/java/com/linglevel/api/bookmark/repository/WordBookmarkRepository.java index 5eec9aad..4eef6591 100644 --- a/src/main/java/com/linglevel/api/bookmark/repository/WordBookmarkRepository.java +++ b/src/main/java/com/linglevel/api/bookmark/repository/WordBookmarkRepository.java @@ -8,12 +8,13 @@ @Repository public interface WordBookmarkRepository extends MongoRepository { - - boolean existsByUserIdAndWord(String userId, String word); - Page findByUserId(String userId, Pageable pageable); + boolean existsByUserIdAndWord(String userId, String word); + + Page findByUserId(String userId, Pageable pageable); + + Page findByUserIdAndWordIn(String userId, java.util.List words, Pageable pageable); + + void deleteByUserIdAndWord(String userId, String word); - Page findByUserIdAndWordIn(String userId, java.util.List words, Pageable pageable); - - void deleteByUserIdAndWord(String userId, String word); } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/bookmark/service/BookmarkService.java b/src/main/java/com/linglevel/api/bookmark/service/BookmarkService.java index a731e512..f0405132 100644 --- a/src/main/java/com/linglevel/api/bookmark/service/BookmarkService.java +++ b/src/main/java/com/linglevel/api/bookmark/service/BookmarkService.java @@ -30,144 +30,149 @@ @Slf4j public class BookmarkService { - private final WordBookmarkRepository wordBookmarkRepository; - private final WordRepository wordRepository; - private final WordVariantService wordVariantService; - private final WordService wordService; - - public Page getBookmarkedWords(String userId, int page, int limit, String search) { - Pageable pageable = PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "bookmarkedAt")); - - if (search != null && !search.trim().isEmpty()) { - // 검색어가 있는 경우: 단어를 먼저 검색한 후 북마크 필터링 - List matchingWords = wordRepository.findByWordContainingIgnoreCase(search.trim(), PageRequest.of(0, 1000)).getContent(); - List words = matchingWords.stream().map(Word::getWord).collect(Collectors.toList()); - - if (words.isEmpty()) { - return new PageImpl<>(new ArrayList<>(), pageable, 0); - } - - Page bookmarks = wordBookmarkRepository.findByUserIdAndWordIn(userId, words, pageable); - return convertToBookmarkedWordResponseDirect(bookmarks); - } else { - // 검색어가 없는 경우: 모든 북마크 조회 - Page bookmarks = wordBookmarkRepository.findByUserId(userId, pageable); - return convertToBookmarkedWordResponseDirect(bookmarks); - } - } - - public void addWordBookmark(String userId, String wordStr) { - var wordSearchResponse = wordService.getOrCreateWords(userId, wordStr, LanguageCode.KO); - String originalForm = resolveFirstOriginalForm(wordSearchResponse); - - if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { - throw new BookmarksException(BookmarksErrorCode.WORD_ALREADY_BOOKMARKED); - } - - WordBookmark bookmark = WordBookmark.builder() - .userId(userId) - .word(originalForm) - .bookmarkedAt(LocalDateTime.now()) - .build(); - - wordBookmarkRepository.save(bookmark); - log.info("Bookmark added: userId={}, word={}", userId, originalForm); - } - - public void removeWordBookmark(String userId, String wordStr) { - List originalForms = wordVariantService.getOriginalForms(wordStr); - String bookmarkedWord = resolveBookmarkedWord(userId, wordStr, originalForms); - - wordBookmarkRepository.deleteByUserIdAndWord(userId, bookmarkedWord); - } - - public boolean toggleWordBookmark(String userId, String wordStr) { - var wordSearchResponse = wordService.getOrCreateWords(userId, wordStr, LanguageCode.KO); - String originalForm = resolveFirstOriginalForm(wordSearchResponse); - boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm); - - if (isBookmarked) { - // 북마크 해제 - wordBookmarkRepository.deleteByUserIdAndWord(userId, originalForm); - log.info("Bookmark removed: userId={}, word={}", userId, originalForm); - return false; - } - - // 북마크 추가 - WordBookmark bookmark = WordBookmark.builder() - .userId(userId) - .word(originalForm) - .bookmarkedAt(LocalDateTime.now()) - .build(); - wordBookmarkRepository.save(bookmark); - log.info("Bookmark added: userId={}, word={}", userId, originalForm); - return true; - } - - public boolean toggleWordBookmarkById(String userId, String wordId) { - Word word = wordRepository.findById(wordId) - .orElseThrow(() -> new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND)); - - String originalForm = word.getWord(); - boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm); - - if (isBookmarked) { - wordBookmarkRepository.deleteByUserIdAndWord(userId, originalForm); - log.info("Bookmark removed: userId={}, wordId={}", userId, wordId); - return false; - } else { - WordBookmark bookmark = WordBookmark.builder() - .userId(userId) - .word(originalForm) - .bookmarkedAt(LocalDateTime.now()) - .build(); - wordBookmarkRepository.save(bookmark); - log.info("Bookmark added: userId={}, wordId={}", userId, wordId); - return true; - } - } - - private Page convertToBookmarkedWordResponseDirect(Page bookmarks) { - List responses = new ArrayList<>(); - - for (WordBookmark bookmark : bookmarks.getContent()) { - responses.add(BookmarkedWordResponse.builder() - .id(bookmark.getId()) - .word(bookmark.getWord()) - .bookmarkedAt(bookmark.getBookmarkedAt()) - .build()); - } - - return new PageImpl<>(responses, bookmarks.getPageable(), bookmarks.getTotalElements()); - } - - private String resolveFirstOriginalForm(WordSearchResponse wordSearchResponse) { - List originalForms = extractDistinctOriginalForms(wordSearchResponse); - if (originalForms.isEmpty()) { - throw new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND); - } - - return originalForms.get(0); - } - - private String resolveBookmarkedWord(String userId, String wordStr, List originalForms) { - if (wordBookmarkRepository.existsByUserIdAndWord(userId, wordStr)) { - return wordStr; - } - - for (String originalForm : originalForms) { - if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { - return originalForm; - } - } - - throw new BookmarksException(BookmarksErrorCode.WORD_BOOKMARK_NOT_FOUND); - } - - private List extractDistinctOriginalForms(WordSearchResponse wordSearchResponse) { - return wordSearchResponse.getResults().stream() - .map(result -> result.getOriginalForm()) - .distinct() - .toList(); - } + private final WordBookmarkRepository wordBookmarkRepository; + + private final WordRepository wordRepository; + + private final WordVariantService wordVariantService; + + private final WordService wordService; + + public Page getBookmarkedWords(String userId, int page, int limit, String search) { + Pageable pageable = PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "bookmarkedAt")); + + if (search != null && !search.trim().isEmpty()) { + // 검색어가 있는 경우: 단어를 먼저 검색한 후 북마크 필터링 + List matchingWords = wordRepository + .findByWordContainingIgnoreCase(search.trim(), PageRequest.of(0, 1000)) + .getContent(); + List words = matchingWords.stream().map(Word::getWord).collect(Collectors.toList()); + + if (words.isEmpty()) { + return new PageImpl<>(new ArrayList<>(), pageable, 0); + } + + Page bookmarks = wordBookmarkRepository.findByUserIdAndWordIn(userId, words, pageable); + return convertToBookmarkedWordResponseDirect(bookmarks); + } + else { + // 검색어가 없는 경우: 모든 북마크 조회 + Page bookmarks = wordBookmarkRepository.findByUserId(userId, pageable); + return convertToBookmarkedWordResponseDirect(bookmarks); + } + } + + public void addWordBookmark(String userId, String wordStr) { + var wordSearchResponse = wordService.getOrCreateWords(userId, wordStr, LanguageCode.KO); + String originalForm = resolveFirstOriginalForm(wordSearchResponse); + + if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { + throw new BookmarksException(BookmarksErrorCode.WORD_ALREADY_BOOKMARKED); + } + + WordBookmark bookmark = WordBookmark.builder() + .userId(userId) + .word(originalForm) + .bookmarkedAt(LocalDateTime.now()) + .build(); + + wordBookmarkRepository.save(bookmark); + log.info("Bookmark added: userId={}, word={}", userId, originalForm); + } + + public void removeWordBookmark(String userId, String wordStr) { + List originalForms = wordVariantService.getOriginalForms(wordStr); + String bookmarkedWord = resolveBookmarkedWord(userId, wordStr, originalForms); + + wordBookmarkRepository.deleteByUserIdAndWord(userId, bookmarkedWord); + } + + public boolean toggleWordBookmark(String userId, String wordStr) { + var wordSearchResponse = wordService.getOrCreateWords(userId, wordStr, LanguageCode.KO); + String originalForm = resolveFirstOriginalForm(wordSearchResponse); + boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm); + + if (isBookmarked) { + // 북마크 해제 + wordBookmarkRepository.deleteByUserIdAndWord(userId, originalForm); + log.info("Bookmark removed: userId={}, word={}", userId, originalForm); + return false; + } + + // 북마크 추가 + WordBookmark bookmark = WordBookmark.builder() + .userId(userId) + .word(originalForm) + .bookmarkedAt(LocalDateTime.now()) + .build(); + wordBookmarkRepository.save(bookmark); + log.info("Bookmark added: userId={}, word={}", userId, originalForm); + return true; + } + + public boolean toggleWordBookmarkById(String userId, String wordId) { + Word word = wordRepository.findById(wordId) + .orElseThrow(() -> new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND)); + + String originalForm = word.getWord(); + boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm); + + if (isBookmarked) { + wordBookmarkRepository.deleteByUserIdAndWord(userId, originalForm); + log.info("Bookmark removed: userId={}, wordId={}", userId, wordId); + return false; + } + else { + WordBookmark bookmark = WordBookmark.builder() + .userId(userId) + .word(originalForm) + .bookmarkedAt(LocalDateTime.now()) + .build(); + wordBookmarkRepository.save(bookmark); + log.info("Bookmark added: userId={}, wordId={}", userId, wordId); + return true; + } + } + + private Page convertToBookmarkedWordResponseDirect(Page bookmarks) { + List responses = new ArrayList<>(); + + for (WordBookmark bookmark : bookmarks.getContent()) { + responses.add(BookmarkedWordResponse.builder() + .id(bookmark.getId()) + .word(bookmark.getWord()) + .bookmarkedAt(bookmark.getBookmarkedAt()) + .build()); + } + + return new PageImpl<>(responses, bookmarks.getPageable(), bookmarks.getTotalElements()); + } + + private String resolveFirstOriginalForm(WordSearchResponse wordSearchResponse) { + List originalForms = extractDistinctOriginalForms(wordSearchResponse); + if (originalForms.isEmpty()) { + throw new BookmarksException(BookmarksErrorCode.WORD_NOT_FOUND); + } + + return originalForms.get(0); + } + + private String resolveBookmarkedWord(String userId, String wordStr, List originalForms) { + if (wordBookmarkRepository.existsByUserIdAndWord(userId, wordStr)) { + return wordStr; + } + + for (String originalForm : originalForms) { + if (wordBookmarkRepository.existsByUserIdAndWord(userId, originalForm)) { + return originalForm; + } + } + + throw new BookmarksException(BookmarksErrorCode.WORD_BOOKMARK_NOT_FOUND); + } + + private List extractDistinctOriginalForms(WordSearchResponse wordSearchResponse) { + return wordSearchResponse.getResults().stream().map(result -> result.getOriginalForm()).distinct().toList(); + } + } diff --git a/src/main/java/com/linglevel/api/common/config/DiscordConfig.java b/src/main/java/com/linglevel/api/common/config/DiscordConfig.java index b885f5e0..1a79dcb5 100644 --- a/src/main/java/com/linglevel/api/common/config/DiscordConfig.java +++ b/src/main/java/com/linglevel/api/common/config/DiscordConfig.java @@ -6,8 +6,10 @@ @Configuration public class DiscordConfig { - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/config/FirebaseConfig.java b/src/main/java/com/linglevel/api/common/config/FirebaseConfig.java index f63b4fa0..e7e9723c 100644 --- a/src/main/java/com/linglevel/api/common/config/FirebaseConfig.java +++ b/src/main/java/com/linglevel/api/common/config/FirebaseConfig.java @@ -15,28 +15,25 @@ @Configuration public class FirebaseConfig { - - @Value("${firebase.config}") - private String firebaseConfig; - - @Bean - public FirebaseAuth firebaseAuth() throws IOException { - byte[] decodedConfig = Base64.getDecoder().decode(firebaseConfig); - - GoogleCredentials credentials = GoogleCredentials.fromStream( - new ByteArrayInputStream(decodedConfig) - ); - - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(credentials) - .build(); - - FirebaseApp.initializeApp(options); - return FirebaseAuth.getInstance(FirebaseApp.getInstance()); - } - - @Bean - public FirebaseMessaging firebaseMessaging(FirebaseAuth firebaseAuth) { - return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); - } + + @Value("${firebase.config}") + private String firebaseConfig; + + @Bean + public FirebaseAuth firebaseAuth() throws IOException { + byte[] decodedConfig = Base64.getDecoder().decode(firebaseConfig); + + GoogleCredentials credentials = GoogleCredentials.fromStream(new ByteArrayInputStream(decodedConfig)); + + FirebaseOptions options = FirebaseOptions.builder().setCredentials(credentials).build(); + + FirebaseApp.initializeApp(options); + return FirebaseAuth.getInstance(FirebaseApp.getInstance()); + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseAuth firebaseAuth) { + return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/config/MongoConfig.java b/src/main/java/com/linglevel/api/common/config/MongoConfig.java index d1444fb9..3e1015be 100644 --- a/src/main/java/com/linglevel/api/common/config/MongoConfig.java +++ b/src/main/java/com/linglevel/api/common/config/MongoConfig.java @@ -6,4 +6,5 @@ @Configuration @EnableMongoAuditing public class MongoConfig { + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/config/RedisConfig.java b/src/main/java/com/linglevel/api/common/config/RedisConfig.java index 90bf0106..8b24d695 100644 --- a/src/main/java/com/linglevel/api/common/config/RedisConfig.java +++ b/src/main/java/com/linglevel/api/common/config/RedisConfig.java @@ -18,58 +18,57 @@ @Configuration public class RedisConfig { - @Value("${spring.data.redis.host}") - private String host; + @Value("${spring.data.redis.host}") + private String host; - @Value("${spring.data.redis.port}") - private int port; + @Value("${spring.data.redis.port}") + private int port; - @Value("${spring.data.redis.ssl.enabled}") - private boolean ssl; + @Value("${spring.data.redis.ssl.enabled}") + private boolean ssl; - @Bean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); - JedisClientConfiguration clientConfig; - if (ssl) { - clientConfig = JedisClientConfiguration.builder() - .useSsl() - .build(); - } else { - clientConfig = JedisClientConfiguration.builder().build(); - } + JedisClientConfiguration clientConfig; + if (ssl) { + clientConfig = JedisClientConfiguration.builder().useSsl().build(); + } + else { + clientConfig = JedisClientConfiguration.builder().build(); + } - return new JedisConnectionFactory(config, clientConfig); - } + return new JedisConnectionFactory(config, clientConfig); + } - @Bean - public RedisTemplate redisTemplate() { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(redisConnectionFactory()); + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory()); - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); - template.afterPropertiesSet(); - return template; - } + template.afterPropertiesSet(); + return template; + } - @Bean - public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) { - RedisMessageListenerContainer container = new RedisMessageListenerContainer(); - container.setConnectionFactory(redisConnectionFactory); - return container; - } + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory); + return container; + } + + @Bean(destroyMethod = "shutdown") + public RedissonClient redissonClient() { + Config config = new Config(); + String scheme = ssl ? "rediss://" : "redis://"; + config.useSingleServer().setAddress(scheme + host + ":" + port); + return Redisson.create(config); + } - @Bean(destroyMethod = "shutdown") - public RedissonClient redissonClient() { - Config config = new Config(); - String scheme = ssl ? "rediss://" : "redis://"; - config.useSingleServer() - .setAddress(scheme + host + ":" + port); - return Redisson.create(config); - } } diff --git a/src/main/java/com/linglevel/api/common/config/SentryConfig.java b/src/main/java/com/linglevel/api/common/config/SentryConfig.java index 8a81517d..a85cbff2 100644 --- a/src/main/java/com/linglevel/api/common/config/SentryConfig.java +++ b/src/main/java/com/linglevel/api/common/config/SentryConfig.java @@ -13,53 +13,52 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.servlet.resource.NoResourceFoundException; - @Configuration public class SentryConfig { - @Bean - public TracesSamplerCallback tracesSamplerCallback() { - return (context) -> { - HttpServletRequest request = (HttpServletRequest) context.getCustomSamplingContext().get("request"); - if (request != null) { - String path = request.getRequestURI(); - if (path != null && (path.startsWith("/actuator"))) { - return 0.0; // 샘플링 비율 0% - } - } - // 그 외의 경우는 application.properties 설정을 따름 - return null; - }; - } + @Bean + public TracesSamplerCallback tracesSamplerCallback() { + return (context) -> { + HttpServletRequest request = (HttpServletRequest) context.getCustomSamplingContext().get("request"); + if (request != null) { + String path = request.getRequestURI(); + if (path != null && (path.startsWith("/actuator"))) { + return 0.0; // 샘플링 비율 0% + } + } + // 그 외의 경우는 application.properties 설정을 따름 + return null; + }; + } + + @Bean + public SentryOptions.BeforeSendCallback beforeSendCallback() { + return (event, hint) -> { + // event 객체에서 직접 예외를 가져옵니다. + Throwable throwable = event.getThrowable(); + if (throwable == null) { + return event; + } - @Bean - public SentryOptions.BeforeSendCallback beforeSendCallback() { - return (event, hint) -> { - // event 객체에서 직접 예외를 가져옵니다. - Throwable throwable = event.getThrowable(); - if (throwable == null) { - return event; - } + // 1. CommonException 및 하위 예외가 4xx 상태를 가지면 무시합니다. + if (throwable instanceof CommonException) { + if (((CommonException) throwable).getStatus().is4xxClientError()) { + return null; + } + } - // 1. CommonException 및 하위 예외가 4xx 상태를 가지면 무시합니다. - if (throwable instanceof CommonException) { - if (((CommonException) throwable).getStatus().is4xxClientError()) { - return null; - } - } + // 2. 그 외 표준적인 스프링 예외 중 4xx를 유발하는 것들을 무시합니다. + if (throwable instanceof AuthenticationException || throwable instanceof AccessDeniedException + || throwable instanceof MethodArgumentNotValidException + || throwable instanceof ConstraintViolationException + || throwable instanceof NoResourceFoundException + || throwable instanceof HttpMessageNotReadableException) { + return null; + } - // 2. 그 외 표준적인 스프링 예외 중 4xx를 유발하는 것들을 무시합니다. - if (throwable instanceof AuthenticationException || - throwable instanceof AccessDeniedException || - throwable instanceof MethodArgumentNotValidException || - throwable instanceof ConstraintViolationException || - throwable instanceof NoResourceFoundException || - throwable instanceof HttpMessageNotReadableException) { - return null; - } + // 그 외 모든 예외(주로 5xx)는 Sentry로 전송합니다. + return event; + }; + } - // 그 외 모든 예외(주로 5xx)는 Sentry로 전송합니다. - return event; - }; - } } diff --git a/src/main/java/com/linglevel/api/common/config/SentryUserContext.java b/src/main/java/com/linglevel/api/common/config/SentryUserContext.java index 303d45e1..6bf0ecdb 100644 --- a/src/main/java/com/linglevel/api/common/config/SentryUserContext.java +++ b/src/main/java/com/linglevel/api/common/config/SentryUserContext.java @@ -11,30 +11,32 @@ @Component public class SentryUserContext { - /** - * 현재 로그인한 사용자 정보를 Sentry에 설정 - */ - public static void setSentryUser() { - try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - - if (auth != null && auth.isAuthenticated() && !auth.getPrincipal().equals("anonymousUser")) { - String username = auth.getName(); - - User sentryUser = new User(); - sentryUser.setUsername(username); - - Sentry.setUser(sentryUser); - } - } catch (Exception e) { - // 에러가 나더라도 무시 - } - } - - /** - * Sentry 사용자 정보 제거 - */ - public static void clearSentryUser() { - Sentry.setUser(null); - } + /** + * 현재 로그인한 사용자 정보를 Sentry에 설정 + */ + public static void setSentryUser() { + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth != null && auth.isAuthenticated() && !auth.getPrincipal().equals("anonymousUser")) { + String username = auth.getName(); + + User sentryUser = new User(); + sentryUser.setUsername(username); + + Sentry.setUser(sentryUser); + } + } + catch (Exception e) { + // 에러가 나더라도 무시 + } + } + + /** + * Sentry 사용자 정보 제거 + */ + public static void clearSentryUser() { + Sentry.setUser(null); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/config/SwaggerConfig.java b/src/main/java/com/linglevel/api/common/config/SwaggerConfig.java index 3d87db25..c764b674 100644 --- a/src/main/java/com/linglevel/api/common/config/SwaggerConfig.java +++ b/src/main/java/com/linglevel/api/common/config/SwaggerConfig.java @@ -16,43 +16,36 @@ @Configuration public class SwaggerConfig { - @Value("${swagger.servers}") - private String serverUrl; + @Value("${swagger.servers}") + private String serverUrl; - @Bean - public GroupedOpenApi publicApiV1() { - return GroupedOpenApi.builder() - .group("v1") - .pathsToMatch("/api/v1/**") - .build(); - } + @Bean + public GroupedOpenApi publicApiV1() { + return GroupedOpenApi.builder().group("v1").pathsToMatch("/api/v1/**").build(); + } - @Bean - public OpenAPI customOpenAPI() { - return new OpenAPI() - .info(new Info() - .title("Ling Level API")) - .servers(List.of( - new Server().url(serverUrl).description("Current Environment Server") - )) - .components(new Components() - .addSecuritySchemes("bearerAuth", new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .description("JWT 토큰을 사용한 인증. `/api/v1/auth/oauth/login`을 제외한 모든 API는 인증이 필요합니다.")) - .addSecuritySchemes("testAuth", new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .in(SecurityScheme.In.HEADER) - .name("X-Test-Username") - .description("테스트용 인증 (dev, local 환경만 사용 가능). 사용자의 username을 입력하세요.")) - .addSecuritySchemes("adminApiKey", new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .in(SecurityScheme.In.HEADER) - .name("X-API-Key") - .description("어드민 API 인증키."))) - .security(List.of( - new SecurityRequirement().addList("bearerAuth"), - new SecurityRequirement().addList("testAuth"))); - } -} \ No newline at end of file + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI().info(new Info().title("Ling Level API")) + .servers(List.of(new Server().url(serverUrl).description("Current Environment Server"))) + .components(new Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme().type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 토큰을 사용한 인증. `/api/v1/auth/oauth/login`을 제외한 모든 API는 인증이 필요합니다.")) + .addSecuritySchemes("testAuth", + new SecurityScheme().type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("X-Test-Username") + .description("테스트용 인증 (dev, local 환경만 사용 가능). 사용자의 username을 입력하세요.")) + .addSecuritySchemes("adminApiKey", + new SecurityScheme().type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("X-API-Key") + .description("어드민 API 인증키."))) + .security(List.of(new SecurityRequirement().addList("bearerAuth"), + new SecurityRequirement().addList("testAuth"))); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/dto/DiscordWebhookRequest.java b/src/main/java/com/linglevel/api/common/dto/DiscordWebhookRequest.java index 26d077f5..62bd907e 100644 --- a/src/main/java/com/linglevel/api/common/dto/DiscordWebhookRequest.java +++ b/src/main/java/com/linglevel/api/common/dto/DiscordWebhookRequest.java @@ -1,17 +1,19 @@ package com.linglevel.api.common.dto; public class DiscordWebhookRequest { - private String content; - public DiscordWebhookRequest(String content) { - this.content = content; - } + private String content; - public String getContent() { - return content; - } + public DiscordWebhookRequest(String content) { + this.content = content; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } - public void setContent(String content) { - this.content = content; - } } diff --git a/src/main/java/com/linglevel/api/common/dto/ExceptionResponse.java b/src/main/java/com/linglevel/api/common/dto/ExceptionResponse.java index f1906540..a37330bd 100644 --- a/src/main/java/com/linglevel/api/common/dto/ExceptionResponse.java +++ b/src/main/java/com/linglevel/api/common/dto/ExceptionResponse.java @@ -12,10 +12,12 @@ @AllArgsConstructor @Schema(description = "공통 예외처리 응답") public class ExceptionResponse { - @Schema(description = "에러 메시지", example = "error message") - private String message; - public ExceptionResponse(Exception exception) { - this.message = exception.getMessage(); - } + @Schema(description = "에러 메시지", example = "error message") + private String message; + + public ExceptionResponse(Exception exception) { + this.message = exception.getMessage(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/dto/MessageResponse.java b/src/main/java/com/linglevel/api/common/dto/MessageResponse.java index a2920196..0094248d 100644 --- a/src/main/java/com/linglevel/api/common/dto/MessageResponse.java +++ b/src/main/java/com/linglevel/api/common/dto/MessageResponse.java @@ -9,6 +9,7 @@ @Schema(description = "메시지 응답") public class MessageResponse { - @Schema(description = "응답 메시지", example = "User account deleted successfully.") - private String message; + @Schema(description = "응답 메시지", example = "User account deleted successfully.") + private String message; + } diff --git a/src/main/java/com/linglevel/api/common/dto/PageResponse.java b/src/main/java/com/linglevel/api/common/dto/PageResponse.java index 6bdb1e26..a2eb25e5 100644 --- a/src/main/java/com/linglevel/api/common/dto/PageResponse.java +++ b/src/main/java/com/linglevel/api/common/dto/PageResponse.java @@ -15,34 +15,36 @@ @AllArgsConstructor @Schema(description = "공통 페이지네이션 응답") public class PageResponse { - @Schema(description = "응답 데이터") - private List data; - - @Schema(description = "현재 페이지", example = "1") - private int currentPage; - - @Schema(description = "전체 페이지", example = "10") - private int totalPages; - - @Schema(description = "전체 항목 수", example = "100") - private int totalCount; - - @Schema(description = "다음 페이지 존재 여부", example = "true") - private boolean hasNext; - - @Schema(description = "이전 페이지 존재 여부", example = "false") - private boolean hasPrevious; - - public PageResponse(List data, Page page) { - this.data = data; - this.currentPage = page.getNumber() + 1; // 0-based to 1-based - this.totalPages = page.getTotalPages(); - this.totalCount = (int) page.getTotalElements(); // 총 항목 수 - this.hasNext = page.hasNext(); - this.hasPrevious = page.hasPrevious(); - } - - public static PageResponse of(Page page, List data) { - return new PageResponse<>(data, page); - } -} \ No newline at end of file + + @Schema(description = "응답 데이터") + private List data; + + @Schema(description = "현재 페이지", example = "1") + private int currentPage; + + @Schema(description = "전체 페이지", example = "10") + private int totalPages; + + @Schema(description = "전체 항목 수", example = "100") + private int totalCount; + + @Schema(description = "다음 페이지 존재 여부", example = "true") + private boolean hasNext; + + @Schema(description = "이전 페이지 존재 여부", example = "false") + private boolean hasPrevious; + + public PageResponse(List data, Page page) { + this.data = data; + this.currentPage = page.getNumber() + 1; // 0-based to 1-based + this.totalPages = page.getTotalPages(); + this.totalCount = (int) page.getTotalElements(); // 총 항목 수 + this.hasNext = page.hasNext(); + this.hasPrevious = page.hasPrevious(); + } + + public static PageResponse of(Page page, List data) { + return new PageResponse<>(data, page); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/exception/CommonErrorCode.java b/src/main/java/com/linglevel/api/common/exception/CommonErrorCode.java index 56ae6f90..e23bea54 100644 --- a/src/main/java/com/linglevel/api/common/exception/CommonErrorCode.java +++ b/src/main/java/com/linglevel/api/common/exception/CommonErrorCode.java @@ -7,14 +7,15 @@ @Getter @AllArgsConstructor public enum CommonErrorCode { - INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력입니다"), - RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다"), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다"), - FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"), - REQUEST_CONFLICT(HttpStatus.CONFLICT, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다"), - NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "미구현된 기능입니다"); - - private final HttpStatus status; - private final String message; + + INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력입니다"), RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다"), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다"), FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다"), + REQUEST_CONFLICT(HttpStatus.CONFLICT, "요청 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다"), + NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "미구현된 기능입니다"); + + private final HttpStatus status; + + private final String message; + } diff --git a/src/main/java/com/linglevel/api/common/exception/CommonException.java b/src/main/java/com/linglevel/api/common/exception/CommonException.java index 6d99ad4c..d5b5cb96 100644 --- a/src/main/java/com/linglevel/api/common/exception/CommonException.java +++ b/src/main/java/com/linglevel/api/common/exception/CommonException.java @@ -5,15 +5,17 @@ @Getter public class CommonException extends RuntimeException { - private final HttpStatus status; - public CommonException(CommonErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public CommonException(CommonErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + + public CommonException(CommonErrorCode errorCode, String customMessage) { + super(customMessage); + this.status = errorCode.getStatus(); + } - public CommonException(CommonErrorCode errorCode, String customMessage) { - super(customMessage); - this.status = errorCode.getStatus(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java b/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java index af08512e..ba9b40ac 100644 --- a/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/linglevel/api/common/handler/GlobalExceptionHandler.java @@ -16,58 +16,57 @@ @Slf4j public class GlobalExceptionHandler { - @ExceptionHandler(CommonException.class) - public ResponseEntity handleCommonException(CommonException e) { - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } + @ExceptionHandler(CommonException.class) + public ResponseEntity handleCommonException(CommonException e) { + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(NoResourceFoundException.class) - public ResponseEntity handleNoHandlerFoundException(NoResourceFoundException e) { - CommonException commonException = new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND); - return ResponseEntity.status(commonException.getStatus()) - .body(new ExceptionResponse(commonException)); - } + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoHandlerFoundException(NoResourceFoundException e) { + CommonException commonException = new CommonException(CommonErrorCode.RESOURCE_NOT_FOUND); + return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); + } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { - String specificError = e.getBindingResult().getFieldErrors().stream() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .findFirst() - .orElse("입력 값 검증에 실패했습니다"); - - CommonException commonException = new CommonException(CommonErrorCode.INVALID_INPUT, specificError); - log.warn("Validation error: {}", specificError); - return ResponseEntity.status(commonException.getStatus()) - .body(new ExceptionResponse(commonException)); - } + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + String specificError = e.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .findFirst() + .orElse("입력 값 검증에 실패했습니다"); - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { - String specificError = e.getConstraintViolations().stream() - .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) - .findFirst() - .orElse("입력 값 제약 조건 위반"); - - CommonException commonException = new CommonException(CommonErrorCode.INVALID_INPUT, specificError); - log.warn("Constraint violation: {}", specificError); - return ResponseEntity.status(commonException.getStatus()) - .body(new ExceptionResponse(commonException)); - } + CommonException commonException = new CommonException(CommonErrorCode.INVALID_INPUT, specificError); + log.warn("Validation error: {}", specificError); + return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); + } - @ExceptionHandler(OptimisticLockingFailureException.class) - public ResponseEntity handleOptimisticLockingFailureException(OptimisticLockingFailureException e) { - CommonException commonException = new CommonException(CommonErrorCode.REQUEST_CONFLICT); - log.warn("Optimistic locking failure occurred: {}", e.getMessage()); - return ResponseEntity.status(commonException.getStatus()) - .body(new ExceptionResponse(commonException)); - } + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String specificError = e.getConstraintViolations() + .stream() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .findFirst() + .orElse("입력 값 제약 조건 위반"); + + CommonException commonException = new CommonException(CommonErrorCode.INVALID_INPUT, specificError); + log.warn("Constraint violation: {}", specificError); + return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); + } + + @ExceptionHandler(OptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLockingFailureException( + OptimisticLockingFailureException e) { + CommonException commonException = new CommonException(CommonErrorCode.REQUEST_CONFLICT); + log.warn("Optimistic locking failure occurred: {}", e.getMessage()); + return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception e) { + CommonException commonException = new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR); + log.error("Unexpected error occurred", e); + return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException)); + } - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception e) { - CommonException commonException = new CommonException(CommonErrorCode.INTERNAL_SERVER_ERROR); - log.error("Unexpected error occurred", e); - return ResponseEntity.status(commonException.getStatus()) - .body(new ExceptionResponse(commonException)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/common/ratelimit/annotation/RateLimit.java b/src/main/java/com/linglevel/api/common/ratelimit/annotation/RateLimit.java index e48fd746..113f6d5f 100644 --- a/src/main/java/com/linglevel/api/common/ratelimit/annotation/RateLimit.java +++ b/src/main/java/com/linglevel/api/common/ratelimit/annotation/RateLimit.java @@ -6,47 +6,49 @@ import java.lang.annotation.Target; /** - * Rate limiting annotation for API endpoints. - * When applied to a controller method, it overrides the global rate limit configuration. + * Rate limiting annotation for API endpoints. When applied to a controller method, it + * overrides the global rate limit configuration. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { - /** - * Maximum number of requests allowed within the refill period. - */ - int capacity(); - - /** - * Refill period in minutes. - */ - long refillMinutes(); - - /** - * Key type for rate limiting. - */ - KeyType keyType() default KeyType.AUTO; - - /** - * Key types for rate limiting bucket identification. - */ - enum KeyType { - /** - * Automatically determines key type based on authentication: - * - Authenticated users: user ID - * - Unauthenticated users: IP address - */ - AUTO, - - /** - * Always use user ID (requires authentication). - */ - USER, - - /** - * Always use IP address. - */ - IP - } + /** + * Maximum number of requests allowed within the refill period. + */ + int capacity(); + + /** + * Refill period in minutes. + */ + long refillMinutes(); + + /** + * Key type for rate limiting. + */ + KeyType keyType() default KeyType.AUTO; + + /** + * Key types for rate limiting bucket identification. + */ + enum KeyType { + + /** + * Automatically determines key type based on authentication: - Authenticated + * users: user ID - Unauthenticated users: IP address + */ + AUTO, + + /** + * Always use user ID (requires authentication). + */ + USER, + + /** + * Always use IP address. + */ + IP + + } + } diff --git a/src/main/java/com/linglevel/api/common/ratelimit/bucket4j/Bucket4jConfig.java b/src/main/java/com/linglevel/api/common/ratelimit/bucket4j/Bucket4jConfig.java index 7a344a37..a5d65932 100644 --- a/src/main/java/com/linglevel/api/common/ratelimit/bucket4j/Bucket4jConfig.java +++ b/src/main/java/com/linglevel/api/common/ratelimit/bucket4j/Bucket4jConfig.java @@ -13,10 +13,10 @@ @Configuration public class Bucket4jConfig { - @Bean - public ProxyManager proxyManager(RedissonClient redissonClient) { - Redisson redisson = (Redisson) redissonClient; - return Bucket4jRedisson.casBasedBuilder(redisson.getCommandExecutor()) - .build(); - } + @Bean + public ProxyManager proxyManager(RedissonClient redissonClient) { + Redisson redisson = (Redisson) redissonClient; + return Bucket4jRedisson.casBasedBuilder(redisson.getCommandExecutor()).build(); + } + } diff --git a/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitConfig.java b/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitConfig.java index 797b2696..2497e340 100644 --- a/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitConfig.java +++ b/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitConfig.java @@ -5,36 +5,39 @@ import lombok.Getter; /** - * Rate limit configuration DTO. - * Used to represent rate limit settings from either annotation or global properties. + * Rate limit configuration DTO. Used to represent rate limit settings from either + * annotation or global properties. */ @Getter @Builder public class RateLimitConfig { - private final int capacity; - private final long refillMinutes; - private final RateLimit.KeyType keyType; - - /** - * Creates a configuration from the global properties. - */ - public static RateLimitConfig fromProperties(RateLimitProperties properties) { - return RateLimitConfig.builder() - .capacity(properties.getCapacity()) - .refillMinutes(properties.getRefill().getDuration().getMinutes()) - .keyType(RateLimit.KeyType.AUTO) - .build(); - } - - /** - * Creates a configuration from the annotation. - */ - public static RateLimitConfig fromAnnotation(RateLimit annotation) { - return RateLimitConfig.builder() - .capacity(annotation.capacity()) - .refillMinutes(annotation.refillMinutes()) - .keyType(annotation.keyType()) - .build(); - } + private final int capacity; + + private final long refillMinutes; + + private final RateLimit.KeyType keyType; + + /** + * Creates a configuration from the global properties. + */ + public static RateLimitConfig fromProperties(RateLimitProperties properties) { + return RateLimitConfig.builder() + .capacity(properties.getCapacity()) + .refillMinutes(properties.getRefill().getDuration().getMinutes()) + .keyType(RateLimit.KeyType.AUTO) + .build(); + } + + /** + * Creates a configuration from the annotation. + */ + public static RateLimitConfig fromAnnotation(RateLimit annotation) { + return RateLimitConfig.builder() + .capacity(annotation.capacity()) + .refillMinutes(annotation.refillMinutes()) + .keyType(annotation.keyType()) + .build(); + } + } diff --git a/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitProperties.java b/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitProperties.java index cd054c71..828462e0 100644 --- a/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitProperties.java +++ b/src/main/java/com/linglevel/api/common/ratelimit/config/RateLimitProperties.java @@ -11,23 +11,26 @@ @ConfigurationProperties(prefix = "rate.limit") public class RateLimitProperties { - private boolean enabled = true; + private boolean enabled = true; - private int capacity; + private int capacity; - private Refill refill = new Refill(); + private Refill refill = new Refill(); - @Getter - @Setter - public static class Refill { + @Getter + @Setter + public static class Refill { - private Duration duration = new Duration(); + private Duration duration = new Duration(); - @Getter - @Setter - public static class Duration { + @Getter + @Setter + public static class Duration { + + private long minutes; + + } + + } - private long minutes; - } - } } diff --git a/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitFilter.java b/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitFilter.java index c67754a5..aef51796 100644 --- a/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitFilter.java +++ b/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitFilter.java @@ -26,107 +26,108 @@ @RequiredArgsConstructor public class RateLimitFilter implements Filter { - private final ProxyManager proxyManager; - private final RateLimitProperties rateLimitProperties; - private final RateLimitResolver rateLimitResolver; - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) - throws IOException, ServletException { - - if (!rateLimitProperties.isEnabled()) { - chain.doFilter(request, response); - return; - } - - HttpServletRequest httpRequest = (HttpServletRequest) request; - HttpServletResponse httpResponse = (HttpServletResponse) response; - - // 1. Check for @RateLimit annotation first (higher priority) - RateLimit rateLimitAnnotation = rateLimitResolver.resolveRateLimit(httpRequest); - RateLimitConfig config; - - if (rateLimitAnnotation != null) { - // Use annotation-based configuration - config = RateLimitConfig.fromAnnotation(rateLimitAnnotation); - log.debug("Using annotation-based rate limit config: capacity={}, refillMinutes={}", - config.getCapacity(), config.getRefillMinutes()); - } else { - // Fallback to global configuration - config = RateLimitConfig.fromProperties(rateLimitProperties); - log.debug("Using global rate limit config: capacity={}, refillMinutes={}", - config.getCapacity(), config.getRefillMinutes()); - } - - String bucketKey = getBucketKey(httpRequest, config.getKeyType()); - - var bucket = proxyManager.builder().build(bucketKey, getBucketConfiguration(config)); - - if (bucket.tryConsume(1)) { - chain.doFilter(request, response); - } else { - log.warn("Rate limit exceeded for key: {}", bucketKey); - httpResponse.setStatus(429); - httpResponse.setContentType("application/json"); - httpResponse.getWriter().write("{\"error\":\"Too many requests. Please try again later.\"}"); - } - } - - private Supplier getBucketConfiguration(RateLimitConfig config) { - return () -> { - int capacity = config.getCapacity(); - Duration refillDuration = Duration.ofMinutes(config.getRefillMinutes()); - - Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillDuration)); - return BucketConfiguration.builder() - .addLimit(limit) - .build(); - }; - } - - /** - * Generates bucket key based on the key type strategy. - * - * @param request HTTP request - * @param keyType Key type strategy (AUTO, USER, IP) - * @return Bucket key string - */ - private String getBucketKey(HttpServletRequest request, RateLimit.KeyType keyType) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - boolean isAuthenticated = authentication != null - && authentication.isAuthenticated() - && authentication.getPrincipal() instanceof JwtClaims; - - switch (keyType) { - case USER: - // Force user-based key (requires authentication) - if (!isAuthenticated) { - log.warn("USER key type requested but user is not authenticated, falling back to IP"); - return "rate_limit:ip:" + getClientIP(request); - } - JwtClaims userClaims = (JwtClaims) authentication.getPrincipal(); - return "rate_limit:user:" + userClaims.getId(); - - case IP: - // Force IP-based key - return "rate_limit:ip:" + getClientIP(request); - - case AUTO: - default: - // Auto: use user ID if authenticated, otherwise use IP - if (isAuthenticated) { - JwtClaims claims = (JwtClaims) authentication.getPrincipal(); - return "rate_limit:user:" + claims.getId(); - } - return "rate_limit:ip:" + getClientIP(request); - } - } - - private String getClientIP(HttpServletRequest request) { - String xfHeader = request.getHeader("X-Forwarded-For"); - if (xfHeader == null || xfHeader.isEmpty()) { - return request.getRemoteAddr(); - } - return xfHeader.split(",")[0].trim(); - } + private final ProxyManager proxyManager; + + private final RateLimitProperties rateLimitProperties; + + private final RateLimitResolver rateLimitResolver; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (!rateLimitProperties.isEnabled()) { + chain.doFilter(request, response); + return; + } + + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + // 1. Check for @RateLimit annotation first (higher priority) + RateLimit rateLimitAnnotation = rateLimitResolver.resolveRateLimit(httpRequest); + RateLimitConfig config; + + if (rateLimitAnnotation != null) { + // Use annotation-based configuration + config = RateLimitConfig.fromAnnotation(rateLimitAnnotation); + log.debug("Using annotation-based rate limit config: capacity={}, refillMinutes={}", config.getCapacity(), + config.getRefillMinutes()); + } + else { + // Fallback to global configuration + config = RateLimitConfig.fromProperties(rateLimitProperties); + log.debug("Using global rate limit config: capacity={}, refillMinutes={}", config.getCapacity(), + config.getRefillMinutes()); + } + + String bucketKey = getBucketKey(httpRequest, config.getKeyType()); + + var bucket = proxyManager.builder().build(bucketKey, getBucketConfiguration(config)); + + if (bucket.tryConsume(1)) { + chain.doFilter(request, response); + } + else { + log.warn("Rate limit exceeded for key: {}", bucketKey); + httpResponse.setStatus(429); + httpResponse.setContentType("application/json"); + httpResponse.getWriter().write("{\"error\":\"Too many requests. Please try again later.\"}"); + } + } + + private Supplier getBucketConfiguration(RateLimitConfig config) { + return () -> { + int capacity = config.getCapacity(); + Duration refillDuration = Duration.ofMinutes(config.getRefillMinutes()); + + Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillDuration)); + return BucketConfiguration.builder().addLimit(limit).build(); + }; + } + + /** + * Generates bucket key based on the key type strategy. + * @param request HTTP request + * @param keyType Key type strategy (AUTO, USER, IP) + * @return Bucket key string + */ + private String getBucketKey(HttpServletRequest request, RateLimit.KeyType keyType) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + boolean isAuthenticated = authentication != null && authentication.isAuthenticated() + && authentication.getPrincipal() instanceof JwtClaims; + + switch (keyType) { + case USER: + // Force user-based key (requires authentication) + if (!isAuthenticated) { + log.warn("USER key type requested but user is not authenticated, falling back to IP"); + return "rate_limit:ip:" + getClientIP(request); + } + JwtClaims userClaims = (JwtClaims) authentication.getPrincipal(); + return "rate_limit:user:" + userClaims.getId(); + + case IP: + // Force IP-based key + return "rate_limit:ip:" + getClientIP(request); + + case AUTO: + default: + // Auto: use user ID if authenticated, otherwise use IP + if (isAuthenticated) { + JwtClaims claims = (JwtClaims) authentication.getPrincipal(); + return "rate_limit:user:" + claims.getId(); + } + return "rate_limit:ip:" + getClientIP(request); + } + } + + private String getClientIP(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + if (xfHeader == null || xfHeader.isEmpty()) { + return request.getRemoteAddr(); + } + return xfHeader.split(",")[0].trim(); + } + } diff --git a/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitResolver.java b/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitResolver.java index 0b01882e..7e52bb82 100644 --- a/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitResolver.java +++ b/src/main/java/com/linglevel/api/common/ratelimit/filter/RateLimitResolver.java @@ -16,33 +16,34 @@ @Component public class RateLimitResolver { - private final RequestMappingHandlerMapping handlerMapping; + private final RequestMappingHandlerMapping handlerMapping; - public RateLimitResolver(@Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping) { - this.handlerMapping = handlerMapping; - } + public RateLimitResolver(@Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping) { + this.handlerMapping = handlerMapping; + } - /** - * Extracts @RateLimit annotation from the handler method if present. - * - * @param request HTTP request - * @return RateLimit annotation if found, null otherwise - */ - public RateLimit resolveRateLimit(HttpServletRequest request) { - try { - HandlerExecutionChain handlerChain = handlerMapping.getHandler(request); - if (handlerChain == null) { - return null; - } + /** + * Extracts @RateLimit annotation from the handler method if present. + * @param request HTTP request + * @return RateLimit annotation if found, null otherwise + */ + public RateLimit resolveRateLimit(HttpServletRequest request) { + try { + HandlerExecutionChain handlerChain = handlerMapping.getHandler(request); + if (handlerChain == null) { + return null; + } + + Object handler = handlerChain.getHandler(); + if (handler instanceof HandlerMethod) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + return handlerMethod.getMethodAnnotation(RateLimit.class); + } + } + catch (Exception e) { + log.debug("Failed to resolve handler method for rate limit annotation", e); + } + return null; + } - Object handler = handlerChain.getHandler(); - if (handler instanceof HandlerMethod) { - HandlerMethod handlerMethod = (HandlerMethod) handler; - return handlerMethod.getMethodAnnotation(RateLimit.class); - } - } catch (Exception e) { - log.debug("Failed to resolve handler method for rate limit annotation", e); - } - return null; - } } diff --git a/src/main/java/com/linglevel/api/common/util/UrlNormalizer.java b/src/main/java/com/linglevel/api/common/util/UrlNormalizer.java index 9d4cba5a..a8754c9e 100644 --- a/src/main/java/com/linglevel/api/common/util/UrlNormalizer.java +++ b/src/main/java/com/linglevel/api/common/util/UrlNormalizer.java @@ -12,159 +12,161 @@ @Slf4j public class UrlNormalizer { - /** - * URL을 정규화하여 반환 - * - * @param url 원본 URL - * @return 정규화된 URL - */ - public static String normalize(String url) { - if (url == null || url.isBlank()) { - return url; - } - - try { - String decodedUrl = decodeUrl(url.trim()); - - URI uri = new URI(decodedUrl); - - // 프로토콜 정규화 (https로 통일) - String scheme = normalizeScheme(uri.getScheme()); - - // 호스트 정규화 (소문자, www 제거) - String host = normalizeHost(uri.getHost()); - - // 포트 정규화 (기본 포트는 제거) - int port = normalizePort(uri.getPort(), scheme); - - // 경로 정규화 (트레일링 슬래시 제거, 중복 슬래시 제거) - String path = normalizePath(uri.getPath()); - - // 쿼리 파라미터 정규화 (정렬) - String query = normalizeQuery(uri.getQuery()); - - // 정규화된 URL 구성 - StringBuilder normalized = new StringBuilder(); - normalized.append(scheme).append("://").append(host); - - if (port != -1) { - normalized.append(":").append(port); - } - - if (path != null && !path.isEmpty()) { - normalized.append(path); - } - - if (query != null && !query.isEmpty()) { - normalized.append("?").append(query); - } - - return normalized.toString(); - - } catch (URISyntaxException e) { - log.warn("Failed to normalize URL: {}", url, e); - return url; // 정규화 실패 시 원본 반환 - } - } - - /** - * URL 디코딩 (재귀적으로 완전히 디코딩) - */ - private static String decodeUrl(String url) { - try { - String decoded = URLDecoder.decode(url, StandardCharsets.UTF_8); - // 디코딩 결과가 동일하면 더 이상 인코딩되지 않음 - if (decoded.equals(url)) { - return url; - } - return decodeUrl(decoded); - } catch (Exception e) { - return url; - } - } - - /** - * 스키마 정규화 (https로 통일) - */ - private static String normalizeScheme(String scheme) { - if (scheme == null) { - return "https"; - } - - scheme = scheme.toLowerCase(); - - // http를 https로 변환 - if ("http".equals(scheme)) { - return "https"; - } - - return scheme; - } - - /** - * 호스트 정규화 (소문자 변환, www 제거) - */ - private static String normalizeHost(String host) { - if (host == null) { - return null; - } - - host = host.toLowerCase(); - - // www 제거 - if (host.startsWith("www.")) { - host = host.substring(4); - } - - return host; - } - - /** - * 포트 정규화 (기본 포트 제거) - */ - private static int normalizePort(int port, String scheme) { - // 기본 포트인 경우 -1 반환 (URL에서 제외) - if (port == 80 && "http".equals(scheme)) { - return -1; - } - if (port == 443 && "https".equals(scheme)) { - return -1; - } - - return port; - } - - /** - * 경로 정규화 - */ - private static String normalizePath(String path) { - if (path == null || path.isEmpty() || "/".equals(path)) { - return ""; - } - - // 트레일링 슬래시 제거 - if (path.endsWith("/") && path.length() > 1) { - path = path.substring(0, path.length() - 1); - } - - // 중복 슬래시 제거 - path = path.replaceAll("/+", "/"); - - return path; - } - - /** - * 쿼리 파라미터 정규화 (알파벳 순 정렬) - */ - private static String normalizeQuery(String query) { - if (query == null || query.isEmpty()) { - return null; - } - - // 쿼리 파라미터를 & 기준으로 분리하고 정렬 - return Arrays.stream(query.split("&")) - .filter(param -> !param.isEmpty()) - .sorted() - .collect(Collectors.joining("&")); - } + /** + * URL을 정규화하여 반환 + * @param url 원본 URL + * @return 정규화된 URL + */ + public static String normalize(String url) { + if (url == null || url.isBlank()) { + return url; + } + + try { + String decodedUrl = decodeUrl(url.trim()); + + URI uri = new URI(decodedUrl); + + // 프로토콜 정규화 (https로 통일) + String scheme = normalizeScheme(uri.getScheme()); + + // 호스트 정규화 (소문자, www 제거) + String host = normalizeHost(uri.getHost()); + + // 포트 정규화 (기본 포트는 제거) + int port = normalizePort(uri.getPort(), scheme); + + // 경로 정규화 (트레일링 슬래시 제거, 중복 슬래시 제거) + String path = normalizePath(uri.getPath()); + + // 쿼리 파라미터 정규화 (정렬) + String query = normalizeQuery(uri.getQuery()); + + // 정규화된 URL 구성 + StringBuilder normalized = new StringBuilder(); + normalized.append(scheme).append("://").append(host); + + if (port != -1) { + normalized.append(":").append(port); + } + + if (path != null && !path.isEmpty()) { + normalized.append(path); + } + + if (query != null && !query.isEmpty()) { + normalized.append("?").append(query); + } + + return normalized.toString(); + + } + catch (URISyntaxException e) { + log.warn("Failed to normalize URL: {}", url, e); + return url; // 정규화 실패 시 원본 반환 + } + } + + /** + * URL 디코딩 (재귀적으로 완전히 디코딩) + */ + private static String decodeUrl(String url) { + try { + String decoded = URLDecoder.decode(url, StandardCharsets.UTF_8); + // 디코딩 결과가 동일하면 더 이상 인코딩되지 않음 + if (decoded.equals(url)) { + return url; + } + return decodeUrl(decoded); + } + catch (Exception e) { + return url; + } + } + + /** + * 스키마 정규화 (https로 통일) + */ + private static String normalizeScheme(String scheme) { + if (scheme == null) { + return "https"; + } + + scheme = scheme.toLowerCase(); + + // http를 https로 변환 + if ("http".equals(scheme)) { + return "https"; + } + + return scheme; + } + + /** + * 호스트 정규화 (소문자 변환, www 제거) + */ + private static String normalizeHost(String host) { + if (host == null) { + return null; + } + + host = host.toLowerCase(); + + // www 제거 + if (host.startsWith("www.")) { + host = host.substring(4); + } + + return host; + } + + /** + * 포트 정규화 (기본 포트 제거) + */ + private static int normalizePort(int port, String scheme) { + // 기본 포트인 경우 -1 반환 (URL에서 제외) + if (port == 80 && "http".equals(scheme)) { + return -1; + } + if (port == 443 && "https".equals(scheme)) { + return -1; + } + + return port; + } + + /** + * 경로 정규화 + */ + private static String normalizePath(String path) { + if (path == null || path.isEmpty() || "/".equals(path)) { + return ""; + } + + // 트레일링 슬래시 제거 + if (path.endsWith("/") && path.length() > 1) { + path = path.substring(0, path.length() - 1); + } + + // 중복 슬래시 제거 + path = path.replaceAll("/+", "/"); + + return path; + } + + /** + * 쿼리 파라미터 정규화 (알파벳 순 정렬) + */ + private static String normalizeQuery(String query) { + if (query == null || query.isEmpty()) { + return null; + } + + // 쿼리 파라미터를 & 기준으로 분리하고 정렬 + return Arrays.stream(query.split("&")) + .filter(param -> !param.isEmpty()) + .sorted() + .collect(Collectors.joining("&")); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/controller/ArticleController.java b/src/main/java/com/linglevel/api/content/article/controller/ArticleController.java index 401e8fe7..68599ff5 100644 --- a/src/main/java/com/linglevel/api/content/article/controller/ArticleController.java +++ b/src/main/java/com/linglevel/api/content/article/controller/ArticleController.java @@ -34,106 +34,87 @@ @Tag(name = "Articles", description = "기사 관련 API") public class ArticleController { - private final ArticleService articleService; - private final ArticleChunkService articleChunkService; - private final ReadingSessionService readingSessionService; + private final ArticleService articleService; + private final ArticleChunkService articleChunkService; - @Operation(summary = "기사 목록 조회", description = "기사 목록을 조건에 따라 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity> getArticles( - @ParameterObject @ModelAttribute GetArticlesRequest request, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - PageResponse response = articleService.getArticles(request, userId); - return ResponseEntity.ok(response); - } + private final ReadingSessionService readingSessionService; - @Operation(summary = "단일 기사 조회", description = "특정 기사의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{articleId}") - public ResponseEntity getArticle( - @Parameter(description = "기사 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String articleId, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - ArticleResponse response = articleService.getArticle(articleId, userId); - return ResponseEntity.ok(response); - } + @Operation(summary = "기사 목록 조회", description = "기사 목록을 조건에 따라 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity> getArticles( + @ParameterObject @ModelAttribute GetArticlesRequest request, @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + PageResponse response = articleService.getArticles(request, userId); + return ResponseEntity.ok(response); + } - @Operation(summary = "기사 청크 목록 조회", description = "특정 기사의 청크 목록을 난이도별로 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 난이도 레벨", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{articleId}/chunks") - public ResponseEntity> getArticleChunks( - @Parameter(description = "기사 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String articleId, - @ParameterObject @Valid @ModelAttribute GetArticleChunksRequest request, - @AuthenticationPrincipal JwtClaims claims) { + @Operation(summary = "단일 기사 조회", description = "특정 기사의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{articleId}") + public ResponseEntity getArticle( + @Parameter(description = "기사 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String articleId, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + ArticleResponse response = articleService.getArticle(articleId, userId); + return ResponseEntity.ok(response); + } - if (claims != null) { - readingSessionService.startReadingSession( - claims.getId(), - ContentType.ARTICLE, - articleId - ); - } + @Operation(summary = "기사 청크 목록 조회", description = "특정 기사의 청크 목록을 난이도별로 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "기사를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 난이도 레벨", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{articleId}/chunks") + public ResponseEntity> getArticleChunks( + @Parameter(description = "기사 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String articleId, + @ParameterObject @Valid @ModelAttribute GetArticleChunksRequest request, + @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - PageResponse response = articleChunkService.getArticleChunks(articleId, request, userId); - return ResponseEntity.ok(response); - } + if (claims != null) { + readingSessionService.startReadingSession(claims.getId(), ContentType.ARTICLE, articleId); + } - @Operation(summary = "단일 기사 청크 조회", description = "특정 기사 청크의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "기사 또는 청크를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{articleId}/chunks/{chunkId}") - public ResponseEntity getArticleChunk( - @Parameter(description = "기사 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String articleId, - @Parameter(description = "청크 ID", example = "60d0fe4f5311236168a109cd") - @PathVariable String chunkId) { - ArticleChunkResponse response = articleChunkService.getArticleChunk(articleId, chunkId); - return ResponseEntity.ok(response); - } + String userId = claims != null ? claims.getId() : null; + PageResponse response = articleChunkService.getArticleChunks(articleId, request, userId); + return ResponseEntity.ok(response); + } - @Operation(summary = "기사 데이터 import", description = "S3에 저장된 JSON 파일을 읽어서 새로운 기사와 관련 청크 데이터를 생성합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "500", description = "import 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @SecurityRequirement(name = "adminApiKey") - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/import") - public ResponseEntity importArticle( - @RequestBody ArticleImportRequest request) { + @Operation(summary = "단일 기사 청크 조회", description = "특정 기사 청크의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "기사 또는 청크를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{articleId}/chunks/{chunkId}") + public ResponseEntity getArticleChunk( + @Parameter(description = "기사 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String articleId, + @Parameter(description = "청크 ID", example = "60d0fe4f5311236168a109cd") @PathVariable String chunkId) { + ArticleChunkResponse response = articleChunkService.getArticleChunk(articleId, chunkId); + return ResponseEntity.ok(response); + } - ArticleImportResponse response = articleService.importArticle(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } + @Operation(summary = "기사 데이터 import", description = "S3에 저장된 JSON 파일을 읽어서 새로운 기사와 관련 청크 데이터를 생성합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "500", description = "import 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @SecurityRequirement(name = "adminApiKey") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/import") + public ResponseEntity importArticle(@RequestBody ArticleImportRequest request) { + + ArticleImportResponse response = articleService.importArticle(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @ExceptionHandler(ArticleException.class) + public ResponseEntity handleArticleException(ArticleException e) { + log.info("Article Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(ArticleException.class) - public ResponseEntity handleArticleException(ArticleException e) { - log.info("Article Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/controller/ArticleProgressController.java b/src/main/java/com/linglevel/api/content/article/controller/ArticleProgressController.java index d38cd772..3381cf91 100644 --- a/src/main/java/com/linglevel/api/content/article/controller/ArticleProgressController.java +++ b/src/main/java/com/linglevel/api/content/article/controller/ArticleProgressController.java @@ -27,60 +27,50 @@ @Tag(name = "Articles Progress", description = "아티클 진도 관리 API") public class ArticleProgressController { - private final ArticleProgressService articleProgressService; + private final ArticleProgressService articleProgressService; - @Operation(summary = "아티클 읽기 진도 업데이트", description = "사용자의 아티클 읽기 진도를 업데이트합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "아티클 또는 청크를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 청크 ID", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PutMapping("/{articleId}/progress") - public ResponseEntity updateProgress( - @Parameter(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String articleId, - @Valid @RequestBody ArticleProgressUpdateRequest request, - @AuthenticationPrincipal JwtClaims claims) { - ArticleProgressResponse response = articleProgressService.updateProgress(articleId, request, claims.getId()); - return ResponseEntity.ok(response); - } + @Operation(summary = "아티클 읽기 진도 업데이트", description = "사용자의 아티클 읽기 진도를 업데이트합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "아티클 또는 청크를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 청크 ID", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PutMapping("/{articleId}/progress") + public ResponseEntity updateProgress( + @Parameter(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String articleId, + @Valid @RequestBody ArticleProgressUpdateRequest request, @AuthenticationPrincipal JwtClaims claims) { + ArticleProgressResponse response = articleProgressService.updateProgress(articleId, request, claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "아티클 읽기 진도 조회", description = "특정 아티클에 대한 사용자의 읽기 진도를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "아티클을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{articleId}/progress") - public ResponseEntity getProgress( - @Parameter(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String articleId, - @AuthenticationPrincipal JwtClaims claims) { - ArticleProgressResponse response = articleProgressService.getProgress(articleId, claims.getId()); - return ResponseEntity.ok(response); - } + @Operation(summary = "아티클 읽기 진도 조회", description = "특정 아티클에 대한 사용자의 읽기 진도를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "아티클을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{articleId}/progress") + public ResponseEntity getProgress( + @Parameter(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String articleId, + @AuthenticationPrincipal JwtClaims claims) { + ArticleProgressResponse response = articleProgressService.getProgress(articleId, claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "아티클 읽기 진도 삭제", description = "사용자의 읽기 진도 기록을 완전히 삭제합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "404", description = "아티클 또는 진도 기록을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/{articleId}/progress") - public ResponseEntity deleteProgress( - @Parameter(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String articleId, - @AuthenticationPrincipal JwtClaims claims) { - articleProgressService.deleteProgress(articleId, claims.getId()); - return ResponseEntity.noContent().build(); - } + @Operation(summary = "아티클 읽기 진도 삭제", description = "사용자의 읽기 진도 기록을 완전히 삭제합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse(responseCode = "404", description = "아티클 또는 진도 기록을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/{articleId}/progress") + public ResponseEntity deleteProgress( + @Parameter(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String articleId, + @AuthenticationPrincipal JwtClaims claims) { + articleProgressService.deleteProgress(articleId, claims.getId()); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(ArticleException.class) + public ResponseEntity handleArticleException(ArticleException e) { + log.info("Article Progress Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(ArticleException.class) - public ResponseEntity handleArticleException(ArticleException e) { - log.info("Article Progress Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleChunkResponse.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleChunkResponse.java index 6adc7cdb..cebeb663 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleChunkResponse.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleChunkResponse.java @@ -17,33 +17,34 @@ @AllArgsConstructor @Schema(description = "기사 청크 응답") public class ArticleChunkResponse { - - @Schema(description = "청크 ID", example = "60d0fe4f5311236168a109cd") - private String id; - - @Schema(description = "청크 번호", example = "1") - private Integer chunkNumber; - - @Schema(description = "난이도", example = "A1") - private DifficultyLevel difficultyLevel; - - @Schema(description = "청크 타입", example = "TEXT") - private ChunkType type; - - @Schema(description = "내용 (텍스트 또는 이미지 URL)", example = "You have a phone. The phone has a sign...") - private String content; - - @Schema(description = "설명 (이미지인 경우)", example = "Bluetooth logo symbol") - private String description; - - public static ArticleChunkResponse from(ArticleChunk chunk) { - return ArticleChunkResponse.builder() - .id(chunk.getId()) - .chunkNumber(chunk.getChunkNumber()) - .difficultyLevel(chunk.getDifficultyLevel()) - .type(chunk.getType()) - .content(chunk.getContent()) - .description(chunk.getDescription()) - .build(); - } + + @Schema(description = "청크 ID", example = "60d0fe4f5311236168a109cd") + private String id; + + @Schema(description = "청크 번호", example = "1") + private Integer chunkNumber; + + @Schema(description = "난이도", example = "A1") + private DifficultyLevel difficultyLevel; + + @Schema(description = "청크 타입", example = "TEXT") + private ChunkType type; + + @Schema(description = "내용 (텍스트 또는 이미지 URL)", example = "You have a phone. The phone has a sign...") + private String content; + + @Schema(description = "설명 (이미지인 경우)", example = "Bluetooth logo symbol") + private String description; + + public static ArticleChunkResponse from(ArticleChunk chunk) { + return ArticleChunkResponse.builder() + .id(chunk.getId()) + .chunkNumber(chunk.getChunkNumber()) + .difficultyLevel(chunk.getDifficultyLevel()) + .type(chunk.getType()) + .content(chunk.getContent()) + .description(chunk.getDescription()) + .build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleImportData.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleImportData.java index ea8509c0..a27772a1 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleImportData.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleImportData.java @@ -7,41 +7,62 @@ @Data public class ArticleImportData { - - private String id; - @JsonProperty("content_type") - private String contentType; - private String title; - private String author; - @JsonProperty("cover_image_url") - private String coverImageUrl; - @JsonProperty("original_text_level") - private String originalTextLevel; - private List tags; - @JsonProperty("target_language_code") - private List targetLanguageCode; - @JsonProperty("origin_url") - private String originUrl; - @JsonProperty("leveled_results") - private List leveledResults; - - @Data - public static class TextLevelData { - private String textLevel; - private List chapters; - } - - @Data - public static class ChapterData { - private int chapterNum; - private List chunks; - } - - @Data - public static class ChunkData { - private int chunkNum; - private String chunkText; - private Boolean isImage; - private String description; - } + + private String id; + + @JsonProperty("content_type") + private String contentType; + + private String title; + + private String author; + + @JsonProperty("cover_image_url") + private String coverImageUrl; + + @JsonProperty("original_text_level") + private String originalTextLevel; + + private List tags; + + @JsonProperty("target_language_code") + private List targetLanguageCode; + + @JsonProperty("origin_url") + private String originUrl; + + @JsonProperty("leveled_results") + private List leveledResults; + + @Data + public static class TextLevelData { + + private String textLevel; + + private List chapters; + + } + + @Data + public static class ChapterData { + + private int chapterNum; + + private List chunks; + + } + + @Data + public static class ChunkData { + + private int chunkNum; + + private String chunkText; + + private Boolean isImage; + + private String description; + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleImportRequest.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleImportRequest.java index ef0cea1e..6f81c73d 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleImportRequest.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleImportRequest.java @@ -8,7 +8,8 @@ @Setter @Schema(description = "기사 임포트 요청") public class ArticleImportRequest { - - @Schema(description = "S3에 저장된 JSON 파일의 식별자", example = "86781f8a-cb42-4fa1-865e-0e8e20d903d8") - private String id; + + @Schema(description = "S3에 저장된 JSON 파일의 식별자", example = "86781f8a-cb42-4fa1-865e-0e8e20d903d8") + private String id; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleImportResponse.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleImportResponse.java index df1c672a..eeecef3d 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleImportResponse.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleImportResponse.java @@ -8,7 +8,8 @@ @Setter @Schema(description = "기사 임포트 응답") public class ArticleImportResponse { - - @Schema(description = "생성된 기사 ID", example = "60d0fe4f5311236168a109ca") - private String id; + + @Schema(description = "생성된 기사 ID", example = "60d0fe4f5311236168a109ca") + private String id; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleOriginResponse.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleOriginResponse.java index 1114cdca..9ebb2852 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleOriginResponse.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleOriginResponse.java @@ -13,21 +13,22 @@ @Schema(description = "아티클 원본 URL 응답") public class ArticleOriginResponse { - @Schema(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") - private String id; + @Schema(description = "아티클 ID", example = "60d0fe4f5311236168a109ca") + private String id; - @Schema(description = "아티클 제목", example = "Viking King's Bizarre Legacy") - private String title; + @Schema(description = "아티클 제목", example = "Viking King's Bizarre Legacy") + private String title; - @Schema(description = "원본 URL", example = "https://example.com/article") - private String originUrl; + @Schema(description = "원본 URL", example = "https://example.com/article") + private String originUrl; - @Schema(description = "타깃 언어 코드 목록", example = "[\"KO\", \"EN\", \"JA\"]") - private List targetLanguageCode; + @Schema(description = "타깃 언어 코드 목록", example = "[\"KO\", \"EN\", \"JA\"]") + private List targetLanguageCode; - @Schema(description = "카테고리", example = "TECH") - private ContentCategory category; + @Schema(description = "카테고리", example = "TECH") + private ContentCategory category; + + @Schema(description = "태그 목록", example = "[\"technology\", \"history\"]") + private List tags; - @Schema(description = "태그 목록", example = "[\"technology\", \"history\"]") - private List tags; } diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressResponse.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressResponse.java index 96cf0b1c..d3bb5e35 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressResponse.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressResponse.java @@ -15,39 +15,41 @@ @AllArgsConstructor @Schema(description = "아티클 읽기 진도 정보 응답") public class ArticleProgressResponse { - @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") - private String id; - @Schema(description = "사용자 ID", example = "60d0fe4f5311236168a109ca") - private String userId; + @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") + private String id; - @Schema(description = "아티클 ID", example = "60d0fe4f5311236168a109cb") - private String articleId; + @Schema(description = "사용자 ID", example = "60d0fe4f5311236168a109ca") + private String userId; - @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") - private String chunkId; + @Schema(description = "아티클 ID", example = "60d0fe4f5311236168a109cb") + private String articleId; - @Schema(description = "현재 읽은 청크 번호", example = "5") - private Integer currentReadChunkNumber; + @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") + private String chunkId; - @Schema(description = "최대 읽은 청크 번호", example = "8") - private Integer maxReadChunkNumber; + @Schema(description = "현재 읽은 청크 번호", example = "5") + private Integer currentReadChunkNumber; - @Schema(description = "완료 여부", example = "false") - private Boolean isCompleted; + @Schema(description = "최대 읽은 청크 번호", example = "8") + private Integer maxReadChunkNumber; - @Schema(description = "현재 난이도", example = "EASY") - private DifficultyLevel currentDifficultyLevel; + @Schema(description = "완료 여부", example = "false") + private Boolean isCompleted; - @Schema(description = "정규화된 현재 진행률 (%)", example = "75.5") - private Double normalizedProgress; + @Schema(description = "현재 난이도", example = "EASY") + private DifficultyLevel currentDifficultyLevel; - @Schema(description = "정규화된 최대 진행률 (%)", example = "85.2") - private Double maxNormalizedProgress; + @Schema(description = "정규화된 현재 진행률 (%)", example = "75.5") + private Double normalizedProgress; - @Schema(description = "스트릭이 업데이트되었는지 여부 (완료 시 true)", example = "true") - private Boolean streakUpdated; + @Schema(description = "정규화된 최대 진행률 (%)", example = "85.2") + private Double maxNormalizedProgress; + + @Schema(description = "스트릭이 업데이트되었는지 여부 (완료 시 true)", example = "true") + private Boolean streakUpdated; + + @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") + private Instant updatedAt; - @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressUpdateRequest.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressUpdateRequest.java index 9920a0bc..5cb75575 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressUpdateRequest.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleProgressUpdateRequest.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "아티클 읽기 진도 업데이트 요청") public class ArticleProgressUpdateRequest { - @Schema(description = "청크 ID", example = "60d0fe4f5311236168c172db") - private String chunkId; + + @Schema(description = "청크 ID", example = "60d0fe4f5311236168c172db") + private String chunkId; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/ArticleResponse.java b/src/main/java/com/linglevel/api/content/article/dto/ArticleResponse.java index d86bf127..816ce3ef 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/ArticleResponse.java +++ b/src/main/java/com/linglevel/api/content/article/dto/ArticleResponse.java @@ -14,58 +14,60 @@ @Setter @Schema(description = "기사 응답") public class ArticleResponse { - - @Schema(description = "기사 ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "기사 제목", example = "Viking King's Bizarre Legacy: The Shocking Truth Behind Your Phone's Most Mysterious Feature!") - private String title; - - @Schema(description = "작가", example = "") - private String author; - - @Schema(description = "커버 이미지 URL", example = "https://path/to/cover.jpg") - private String coverImageUrl; - - @Schema(description = "난이도 레벨", example = "C1") - private DifficultyLevel difficultyLevel; - - @Schema(description = "청크 개수", example = "15") - private Integer chunkCount; - - @Schema(description = "현재 읽은 청크 번호", example = "7") - private Integer currentReadChunkNumber; - - @Schema(description = "진행률", example = "46.7") - private Double progressPercentage; - - @Schema(description = "현재 선택한 난이도", example = "EASY") - private DifficultyLevel currentDifficultyLevel; - - @Schema(description = "완료 여부", example = "false") - private Boolean isCompleted; - - @Schema(description = "읽기 시간(분)", example = "8") - private Integer readingTime; - - @Schema(description = "평균 평점", example = "4.5") - private Double averageRating; - - @Schema(description = "리뷰 개수", example = "230") - private Integer reviewCount; - - @Schema(description = "조회수", example = "15000") - private Integer viewCount; - - @Schema(description = "카테고리", example = "TECH") - private ContentCategory category; - - @Schema(description = "태그 목록", example = "[\"technology\", \"history\"]") - private List tags; - - @Schema(description = "타깃 언어 코드 목록", example = "[\"KO\", \"EN\", \"JA\"]") - private List targetLanguageCode; - - @Schema(description = "생성 날짜", example = "2024-01-15T00:00:00Z") - private Instant createdAt; + + @Schema(description = "기사 ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "기사 제목", + example = "Viking King's Bizarre Legacy: The Shocking Truth Behind Your Phone's Most Mysterious Feature!") + private String title; + + @Schema(description = "작가", example = "") + private String author; + + @Schema(description = "커버 이미지 URL", example = "https://path/to/cover.jpg") + private String coverImageUrl; + + @Schema(description = "난이도 레벨", example = "C1") + private DifficultyLevel difficultyLevel; + + @Schema(description = "청크 개수", example = "15") + private Integer chunkCount; + + @Schema(description = "현재 읽은 청크 번호", example = "7") + private Integer currentReadChunkNumber; + + @Schema(description = "진행률", example = "46.7") + private Double progressPercentage; + + @Schema(description = "현재 선택한 난이도", example = "EASY") + private DifficultyLevel currentDifficultyLevel; + + @Schema(description = "완료 여부", example = "false") + private Boolean isCompleted; + + @Schema(description = "읽기 시간(분)", example = "8") + private Integer readingTime; + + @Schema(description = "평균 평점", example = "4.5") + private Double averageRating; + + @Schema(description = "리뷰 개수", example = "230") + private Integer reviewCount; + + @Schema(description = "조회수", example = "15000") + private Integer viewCount; + + @Schema(description = "카테고리", example = "TECH") + private ContentCategory category; + + @Schema(description = "태그 목록", example = "[\"technology\", \"history\"]") + private List tags; + + @Schema(description = "타깃 언어 코드 목록", example = "[\"KO\", \"EN\", \"JA\"]") + private List targetLanguageCode; + + @Schema(description = "생성 날짜", example = "2024-01-15T00:00:00Z") + private Instant createdAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/GetArticleChunksRequest.java b/src/main/java/com/linglevel/api/content/article/dto/GetArticleChunksRequest.java index e0194b3b..88a11cf9 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/GetArticleChunksRequest.java +++ b/src/main/java/com/linglevel/api/content/article/dto/GetArticleChunksRequest.java @@ -17,25 +17,19 @@ @Schema(description = "아티클 청크 목록 조회 요청") public class GetArticleChunksRequest { - @Schema(description = "청크의 난이도", example = "A1", required = true) - @NotNull(message = "난이도는 필수입니다.") - private DifficultyLevel difficultyLevel; - - @Schema(description = "페이지 번호", - example = "1", - minimum = "1", - defaultValue = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @Builder.Default - private Integer page = 1; + @Schema(description = "청크의 난이도", example = "A1", required = true) + @NotNull(message = "난이도는 필수입니다.") + private DifficultyLevel difficultyLevel; + + @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @Builder.Default + private Integer page = 1; + + @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이진 크기는 200 이하여야 합니다.") + @Builder.Default + private Integer limit = 10; - @Schema(description = "페이지 크기", - example = "10", - minimum = "1", - maximum = "200", - defaultValue = "10") - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이진 크기는 200 이하여야 합니다.") - @Builder.Default - private Integer limit = 10; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/dto/GetArticleOriginsRequest.java b/src/main/java/com/linglevel/api/content/article/dto/GetArticleOriginsRequest.java index 1f1ef168..c3bfe124 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/GetArticleOriginsRequest.java +++ b/src/main/java/com/linglevel/api/content/article/dto/GetArticleOriginsRequest.java @@ -12,28 +12,29 @@ @Setter public class GetArticleOriginsRequest { - @Schema(description = "카테고리 필터 (displayName 또는 enum 이름)", example = "Technology") - private String category; - - @Schema(description = "태그 필터 (쉼표로 구분)", example = "technology,business") - private String tags; - - @Schema(description = "타깃 언어 코드 필터", example = "KO") - private LanguageCode targetLanguageCode; - - @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; - - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private Integer limit = 10; - - /** - * category String을 ContentCategory enum으로 변환 - */ - public ContentCategory getCategoryEnum() { - return ContentCategory.fromString(category); - } + @Schema(description = "카테고리 필터 (displayName 또는 enum 이름)", example = "Technology") + private String category; + + @Schema(description = "태그 필터 (쉼표로 구분)", example = "technology,business") + private String tags; + + @Schema(description = "타깃 언어 코드 필터", example = "KO") + private LanguageCode targetLanguageCode; + + @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private Integer limit = 10; + + /** + * category String을 ContentCategory enum으로 변환 + */ + public ContentCategory getCategoryEnum() { + return ContentCategory.fromString(category); + } + } diff --git a/src/main/java/com/linglevel/api/content/article/dto/GetArticlesRequest.java b/src/main/java/com/linglevel/api/content/article/dto/GetArticlesRequest.java index e2703c66..a16a2b4e 100644 --- a/src/main/java/com/linglevel/api/content/article/dto/GetArticlesRequest.java +++ b/src/main/java/com/linglevel/api/content/article/dto/GetArticlesRequest.java @@ -16,34 +16,36 @@ @Setter public class GetArticlesRequest { - @Schema(description = "정렬 기준", example = "created_at", defaultValue = "created_at", allowableValues = {"view_count", "average_rating", "created_at"}) - private String sortBy = "created_at"; + @Schema(description = "정렬 기준", example = "created_at", defaultValue = "created_at", + allowableValues = { "view_count", "average_rating", "created_at" }) + private String sortBy = "created_at"; - @Schema(description = "카테고리 필터", example = "TECH") - private ContentCategory category; + @Schema(description = "카테고리 필터", example = "TECH") + private ContentCategory category; - @Schema(description = "태그 필터 (쉼표로 구분)", example = "technology,business") - private String tags; + @Schema(description = "태그 필터 (쉼표로 구분)", example = "technology,business") + private String tags; - @Schema(description = "키워드 검색", example = "viking") - private String keyword; + @Schema(description = "키워드 검색", example = "viking") + private String keyword; - @Schema(description = "진도별 필터링", example = "IN_PROGRESS") - private ProgressStatus progress; + @Schema(description = "진도별 필터링", example = "IN_PROGRESS") + private ProgressStatus progress; - @Schema(description = "타깃 언어 코드 필터", example = "KO") - private LanguageCode targetLanguageCode; + @Schema(description = "타깃 언어 코드 필터", example = "KO") + private LanguageCode targetLanguageCode; - @Schema(description = "생성 시간 필터 (해당 시간 이후)", example = "2024-01-01T00:00:00") - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - private LocalDateTime createdAfter; + @Schema(description = "생성 시간 필터 (해당 시간 이후)", example = "2024-01-01T00:00:00") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime createdAfter; - @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private Integer limit = 10; - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private Integer limit = 10; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/entity/Article.java b/src/main/java/com/linglevel/api/content/article/entity/Article.java index 45f5456e..2224d110 100644 --- a/src/main/java/com/linglevel/api/content/article/entity/Article.java +++ b/src/main/java/com/linglevel/api/content/article/entity/Article.java @@ -16,32 +16,34 @@ @AllArgsConstructor @Document(collection = "articles") public class Article { - @Id - private String id; - private String title; + @Id + private String id; - private String author; + private String title; - private String coverImageUrl; + private String author; - private String originUrl; - - private DifficultyLevel difficultyLevel; - - private Integer readingTime; - - private Double averageRating; - - private Integer reviewCount; - - private Integer viewCount; + private String coverImageUrl; - private ContentCategory category; + private String originUrl; - private List tags; + private DifficultyLevel difficultyLevel; - private List targetLanguageCode; + private Integer readingTime; + + private Double averageRating; + + private Integer reviewCount; + + private Integer viewCount; + + private ContentCategory category; + + private List tags; + + private List targetLanguageCode; + + private Instant createdAt; - private Instant createdAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/entity/ArticleChunk.java b/src/main/java/com/linglevel/api/content/article/entity/ArticleChunk.java index 08f6091b..c674ae56 100644 --- a/src/main/java/com/linglevel/api/content/article/entity/ArticleChunk.java +++ b/src/main/java/com/linglevel/api/content/article/entity/ArticleChunk.java @@ -14,25 +14,27 @@ @Document(collection = "articleChunks") @CompoundIndex(name = "article_difficulty_chunk_idx", def = "{'articleId': 1, 'difficultyLevel': 1, 'chunkNumber': 1}") public class ArticleChunk { - @Id - private String id; - private String articleId; + @Id + private String id; - private Integer chunkNumber; + private String articleId; - private DifficultyLevel difficultyLevel; + private Integer chunkNumber; - private ChunkType type; + private DifficultyLevel difficultyLevel; - private String content; + private ChunkType type; + + private String content; + + private String description; + + public void updateContent(String content, String description) { + this.content = content; + if (description != null) { + this.description = description; + } + } - private String description; - - public void updateContent(String content, String description) { - this.content = content; - if (description != null) { - this.description = description; - } - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/entity/ArticleProgress.java b/src/main/java/com/linglevel/api/content/article/entity/ArticleProgress.java index c4aeb4ca..d9f46d78 100644 --- a/src/main/java/com/linglevel/api/content/article/entity/ArticleProgress.java +++ b/src/main/java/com/linglevel/api/content/article/entity/ArticleProgress.java @@ -19,26 +19,28 @@ @Document(collection = "articleProgress") @CompoundIndex(name = "idx_user_article_progress", def = "{'userId': 1, 'articleId': 1}", unique = true) public class ArticleProgress { - @Id - private String id; - private String userId; + @Id + private String id; - private String articleId; + private String userId; - private String chunkId; + private String articleId; - // V2 Progress Fields - private Double normalizedProgress; + private String chunkId; - private Double maxNormalizedProgress; + // V2 Progress Fields + private Double normalizedProgress; - private DifficultyLevel currentDifficultyLevel; + private Double maxNormalizedProgress; - private Boolean isCompleted = false; + private DifficultyLevel currentDifficultyLevel; - private Instant completedAt; + private Boolean isCompleted = false; + + private Instant completedAt; + + @LastModifiedDate + private Instant updatedAt; - @LastModifiedDate - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/exception/ArticleErrorCode.java b/src/main/java/com/linglevel/api/content/article/exception/ArticleErrorCode.java index 3bc1dce0..fd56e5fc 100644 --- a/src/main/java/com/linglevel/api/content/article/exception/ArticleErrorCode.java +++ b/src/main/java/com/linglevel/api/content/article/exception/ArticleErrorCode.java @@ -7,15 +7,19 @@ @Getter @RequiredArgsConstructor public enum ArticleErrorCode { - ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "Article not found."), - CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "Chunk not found."), - CHUNK_NOT_FOUND_IN_ARTICLE(HttpStatus.NOT_FOUND, "Chunk not found in this article."), - INVALID_SORT_BY(HttpStatus.BAD_REQUEST, "Invalid sort_by parameter. Must be one of: view_count, average_rating, created_at."), - INVALID_TAGS_FORMAT(HttpStatus.BAD_REQUEST, "Invalid tags format. Tags should be comma-separated strings."), - INVALID_DIFFICULTY_LEVEL(HttpStatus.BAD_REQUEST, "Invalid difficulty level."), - PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "Progress not found."), - ARTICLE_DELETION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete article and related data."); - private final HttpStatus status; - private final String message; + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "Article not found."), + CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "Chunk not found."), + CHUNK_NOT_FOUND_IN_ARTICLE(HttpStatus.NOT_FOUND, "Chunk not found in this article."), + INVALID_SORT_BY(HttpStatus.BAD_REQUEST, + "Invalid sort_by parameter. Must be one of: view_count, average_rating, created_at."), + INVALID_TAGS_FORMAT(HttpStatus.BAD_REQUEST, "Invalid tags format. Tags should be comma-separated strings."), + INVALID_DIFFICULTY_LEVEL(HttpStatus.BAD_REQUEST, "Invalid difficulty level."), + PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "Progress not found."), + ARTICLE_DELETION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete article and related data."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/exception/ArticleException.java b/src/main/java/com/linglevel/api/content/article/exception/ArticleException.java index 0d7e6145..f99d4d8f 100644 --- a/src/main/java/com/linglevel/api/content/article/exception/ArticleException.java +++ b/src/main/java/com/linglevel/api/content/article/exception/ArticleException.java @@ -5,16 +5,17 @@ @Getter public class ArticleException extends RuntimeException { - - private final HttpStatus status; - - public ArticleException(ArticleErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } - - public ArticleException(ArticleErrorCode errorCode, String customMessage) { - super(customMessage); - this.status = errorCode.getStatus(); - } + + private final HttpStatus status; + + public ArticleException(ArticleErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + + public ArticleException(ArticleErrorCode errorCode, String customMessage) { + super(customMessage); + this.status = errorCode.getStatus(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/repository/ArticleChunkRepository.java b/src/main/java/com/linglevel/api/content/article/repository/ArticleChunkRepository.java index 05934ad5..e66dba68 100644 --- a/src/main/java/com/linglevel/api/content/article/repository/ArticleChunkRepository.java +++ b/src/main/java/com/linglevel/api/content/article/repository/ArticleChunkRepository.java @@ -9,10 +9,15 @@ import java.util.Optional; public interface ArticleChunkRepository extends MongoRepository { - Page findByArticleIdAndDifficultyLevelOrderByChunkNumber(String articleId, DifficultyLevel difficultyLevel, Pageable pageable); - Optional findByArticleIdAndId(String articleId, String chunkId); - Optional findFirstByArticleIdOrderByChunkNumber(String articleId); - // V2 Progress: Count chunks by difficulty level - long countByArticleIdAndDifficultyLevel(String articleId, DifficultyLevel difficultyLevel); + Page findByArticleIdAndDifficultyLevelOrderByChunkNumber(String articleId, + DifficultyLevel difficultyLevel, Pageable pageable); + + Optional findByArticleIdAndId(String articleId, String chunkId); + + Optional findFirstByArticleIdOrderByChunkNumber(String articleId); + + // V2 Progress: Count chunks by difficulty level + long countByArticleIdAndDifficultyLevel(String articleId, DifficultyLevel difficultyLevel); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/repository/ArticleProgressRepository.java b/src/main/java/com/linglevel/api/content/article/repository/ArticleProgressRepository.java index 714735af..b5aa8838 100644 --- a/src/main/java/com/linglevel/api/content/article/repository/ArticleProgressRepository.java +++ b/src/main/java/com/linglevel/api/content/article/repository/ArticleProgressRepository.java @@ -9,6 +9,9 @@ @Repository public interface ArticleProgressRepository extends MongoRepository { - Optional findByUserIdAndArticleId(String userId, String articleId); - List findAllByUserId(String userId); + + Optional findByUserIdAndArticleId(String userId, String articleId); + + List findAllByUserId(String userId); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java index 08948d3d..d2bd40c2 100644 --- a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java +++ b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.mongodb.repository.MongoRepository; public interface ArticleRepository extends MongoRepository, ArticleRepositoryCustom { + } diff --git a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryCustom.java b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryCustom.java index 1717a067..9592cb6f 100644 --- a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryCustom.java +++ b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryCustom.java @@ -7,9 +7,11 @@ import org.springframework.data.domain.Pageable; public interface ArticleRepositoryCustom { - Page
findArticlesWithFilters(GetArticlesRequest request, String userId, Pageable pageable); - Page
findArticleOriginsWithFilters(GetArticleOriginsRequest request, Pageable pageable); + Page
findArticlesWithFilters(GetArticlesRequest request, String userId, Pageable pageable); + + Page
findArticleOriginsWithFilters(GetArticleOriginsRequest request, Pageable pageable); + + void incrementViewCount(String articleId); - void incrementViewCount(String articleId); } diff --git a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryImpl.java b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryImpl.java index 4faf06a0..c35d2874 100644 --- a/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryImpl.java +++ b/src/main/java/com/linglevel/api/content/article/repository/ArticleRepositoryImpl.java @@ -22,226 +22,222 @@ @RequiredArgsConstructor public class ArticleRepositoryImpl implements ArticleRepositoryCustom { - private final MongoTemplate mongoTemplate; - - @Override - public Page
findArticlesWithFilters(GetArticlesRequest request, String userId, Pageable pageable) { - Query query = buildQuery(request, userId); - - // 총 개수 조회 (필터링 적용 후) - long total = mongoTemplate.count(query, Article.class); - - // 페이지네이션 적용 - query.with(pageable); - - // 데이터 조회 - List
articles = mongoTemplate.find(query, Article.class); - - return new PageImpl<>(articles, pageable, total); - } - - /** - * 동적 쿼리 빌드 - */ - private Query buildQuery(GetArticlesRequest request, String userId) { - Query query = new Query(); - - // 각 필터를 독립적인 메서드로 분리 - applyCategoryFilter(query, request.getCategory()); - applyTagsFilter(query, request.getTags()); - applyKeywordFilter(query, request.getKeyword()); - applyProgressFilter(query, request.getProgress(), userId); - applyTargetLanguageCodeFilter(query, request.getTargetLanguageCode()); - applyCreatedAfterFilter(query, request.getCreatedAfter()); - - return query; - } - - /** - * 카테고리 필터 적용 - */ - private void applyCategoryFilter(Query query, ContentCategory category) { - if (category == null) { - return; - } - - query.addCriteria(Criteria.where("category").is(category)); - } - - /** - * 태그 필터 적용 - */ - private void applyTagsFilter(Query query, String tags) { - if (!StringUtils.hasText(tags)) { - return; - } - - List tagList = Arrays.asList(tags.split(",")); - query.addCriteria(Criteria.where("tags").in(tagList)); - } - - /** - * 키워드 필터 적용 (제목 또는 작가) - */ - private void applyKeywordFilter(Query query, String keyword) { - if (!StringUtils.hasText(keyword)) { - return; - } - - Criteria keywordCriteria = new Criteria().orOperator( - Criteria.where("title").regex(keyword, "i"), - Criteria.where("author").regex(keyword, "i") - ); - query.addCriteria(keywordCriteria); - } - - /** - * 진도 필터 적용 - */ - private void applyProgressFilter(Query query, ProgressStatus progress, String userId) { - if (progress == null || userId == null) { - return; - } - - List articleIds = getArticleIdsByProgress(userId, progress); - if (!articleIds.isEmpty()) { - query.addCriteria(Criteria.where("id").in(articleIds)); - } else { - // 조건에 맞는 아티클이 없으면 빈 결과 반환 - query.addCriteria(Criteria.where("_id").is(null)); - } - } - - /** - * 타깃 언어 코드 필터 적용 - */ - private void applyTargetLanguageCodeFilter(Query query, LanguageCode targetLanguageCode) { - if (targetLanguageCode == null) { - return; - } - - query.addCriteria(Criteria.where("targetLanguageCode").in(targetLanguageCode)); - } - - /** - * 생성 시간 필터 적용 (해당 시간 이후) - */ - private void applyCreatedAfterFilter(Query query, java.time.LocalDateTime createdAfter) { - if (createdAfter == null) { - return; - } - - query.addCriteria(Criteria.where("createdAt").gte(createdAfter)); - } - - /** - * 진도 상태별 아티클 ID 목록 조회 - */ - private List getArticleIdsByProgress(String userId, ProgressStatus progressStatus) { - return switch (progressStatus) { - case NOT_STARTED -> getNotStartedArticleIds(userId); - case IN_PROGRESS -> getInProgressArticleIds(userId); - case COMPLETED -> getCompletedArticleIds(userId); - }; - } - - /** - * 시작하지 않은 아티클 ID 목록 조회 - */ - private List getNotStartedArticleIds(String userId) { - // 모든 아티클 ID 조회 - List allArticleIds = mongoTemplate.findAll(Article.class).stream() - .map(Article::getId) - .toList(); - - // 진도가 있는 아티클 ID 조회 - List progressArticleIds = findProgressArticleIds(userId); - - // 진도가 없는 아티클만 반환 - return allArticleIds.stream() - .filter(articleId -> !progressArticleIds.contains(articleId)) - .toList(); - } - - /** - * 진행 중인 아티클 ID 목록 조회 - */ - private List getInProgressArticleIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(Criteria.where("isCompleted").is(false)); - query.addCriteria(Criteria.where("currentReadChunkNumber").gt(0)); - - return findArticleIdsFromProgress(query); - } - - /** - * 완료한 아티클 ID 목록 조회 - */ - private List getCompletedArticleIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(Criteria.where("isCompleted").is(true)); - - return findArticleIdsFromProgress(query); - } - - /** - * 특정 사용자의 모든 진도 아티클 ID 조회 - */ - private List findProgressArticleIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - - return findArticleIdsFromProgress(query); - } - - /** - * ArticleProgress 컬렉션에서 articleId 추출 - */ - private List findArticleIdsFromProgress(Query query) { - return mongoTemplate.find(query, org.bson.Document.class, "articleProgress") - .stream() - .map(doc -> doc.getString("articleId")) - .toList(); - } - - @Override - public Page
findArticleOriginsWithFilters(GetArticleOriginsRequest request, Pageable pageable) { - Query query = buildOriginQuery(request); - - // 총 개수 조회 - long total = mongoTemplate.count(query, Article.class); - - // 페이지네이션 적용 - query.with(pageable); - - // 데이터 조회 - List
articles = mongoTemplate.find(query, Article.class); - - return new PageImpl<>(articles, pageable, total); - } - - /** - * originUrl 조회용 동적 쿼리 빌드 - */ - private Query buildOriginQuery(GetArticleOriginsRequest request) { - Query query = new Query(); - - // originUrl이 null이 아닌 것만 조회 - query.addCriteria(Criteria.where("originUrl").ne(null)); - - applyCategoryFilter(query, request.getCategoryEnum()); - applyTagsFilter(query, request.getTags()); - applyTargetLanguageCodeFilter(query, request.getTargetLanguageCode()); - - return query; - } - - @Override - public void incrementViewCount(String articleId) { - Query query = new Query(Criteria.where("id").is(articleId)); - Update update = new Update().inc("viewCount", 1); - mongoTemplate.updateFirst(query, update, Article.class); - } + private final MongoTemplate mongoTemplate; + + @Override + public Page
findArticlesWithFilters(GetArticlesRequest request, String userId, Pageable pageable) { + Query query = buildQuery(request, userId); + + // 총 개수 조회 (필터링 적용 후) + long total = mongoTemplate.count(query, Article.class); + + // 페이지네이션 적용 + query.with(pageable); + + // 데이터 조회 + List
articles = mongoTemplate.find(query, Article.class); + + return new PageImpl<>(articles, pageable, total); + } + + /** + * 동적 쿼리 빌드 + */ + private Query buildQuery(GetArticlesRequest request, String userId) { + Query query = new Query(); + + // 각 필터를 독립적인 메서드로 분리 + applyCategoryFilter(query, request.getCategory()); + applyTagsFilter(query, request.getTags()); + applyKeywordFilter(query, request.getKeyword()); + applyProgressFilter(query, request.getProgress(), userId); + applyTargetLanguageCodeFilter(query, request.getTargetLanguageCode()); + applyCreatedAfterFilter(query, request.getCreatedAfter()); + + return query; + } + + /** + * 카테고리 필터 적용 + */ + private void applyCategoryFilter(Query query, ContentCategory category) { + if (category == null) { + return; + } + + query.addCriteria(Criteria.where("category").is(category)); + } + + /** + * 태그 필터 적용 + */ + private void applyTagsFilter(Query query, String tags) { + if (!StringUtils.hasText(tags)) { + return; + } + + List tagList = Arrays.asList(tags.split(",")); + query.addCriteria(Criteria.where("tags").in(tagList)); + } + + /** + * 키워드 필터 적용 (제목 또는 작가) + */ + private void applyKeywordFilter(Query query, String keyword) { + if (!StringUtils.hasText(keyword)) { + return; + } + + Criteria keywordCriteria = new Criteria().orOperator(Criteria.where("title").regex(keyword, "i"), + Criteria.where("author").regex(keyword, "i")); + query.addCriteria(keywordCriteria); + } + + /** + * 진도 필터 적용 + */ + private void applyProgressFilter(Query query, ProgressStatus progress, String userId) { + if (progress == null || userId == null) { + return; + } + + List articleIds = getArticleIdsByProgress(userId, progress); + if (!articleIds.isEmpty()) { + query.addCriteria(Criteria.where("id").in(articleIds)); + } + else { + // 조건에 맞는 아티클이 없으면 빈 결과 반환 + query.addCriteria(Criteria.where("_id").is(null)); + } + } + + /** + * 타깃 언어 코드 필터 적용 + */ + private void applyTargetLanguageCodeFilter(Query query, LanguageCode targetLanguageCode) { + if (targetLanguageCode == null) { + return; + } + + query.addCriteria(Criteria.where("targetLanguageCode").in(targetLanguageCode)); + } + + /** + * 생성 시간 필터 적용 (해당 시간 이후) + */ + private void applyCreatedAfterFilter(Query query, java.time.LocalDateTime createdAfter) { + if (createdAfter == null) { + return; + } + + query.addCriteria(Criteria.where("createdAt").gte(createdAfter)); + } + + /** + * 진도 상태별 아티클 ID 목록 조회 + */ + private List getArticleIdsByProgress(String userId, ProgressStatus progressStatus) { + return switch (progressStatus) { + case NOT_STARTED -> getNotStartedArticleIds(userId); + case IN_PROGRESS -> getInProgressArticleIds(userId); + case COMPLETED -> getCompletedArticleIds(userId); + }; + } + + /** + * 시작하지 않은 아티클 ID 목록 조회 + */ + private List getNotStartedArticleIds(String userId) { + // 모든 아티클 ID 조회 + List allArticleIds = mongoTemplate.findAll(Article.class).stream().map(Article::getId).toList(); + + // 진도가 있는 아티클 ID 조회 + List progressArticleIds = findProgressArticleIds(userId); + + // 진도가 없는 아티클만 반환 + return allArticleIds.stream().filter(articleId -> !progressArticleIds.contains(articleId)).toList(); + } + + /** + * 진행 중인 아티클 ID 목록 조회 + */ + private List getInProgressArticleIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(Criteria.where("isCompleted").is(false)); + query.addCriteria(Criteria.where("currentReadChunkNumber").gt(0)); + + return findArticleIdsFromProgress(query); + } + + /** + * 완료한 아티클 ID 목록 조회 + */ + private List getCompletedArticleIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(Criteria.where("isCompleted").is(true)); + + return findArticleIdsFromProgress(query); + } + + /** + * 특정 사용자의 모든 진도 아티클 ID 조회 + */ + private List findProgressArticleIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + + return findArticleIdsFromProgress(query); + } + + /** + * ArticleProgress 컬렉션에서 articleId 추출 + */ + private List findArticleIdsFromProgress(Query query) { + return mongoTemplate.find(query, org.bson.Document.class, "articleProgress") + .stream() + .map(doc -> doc.getString("articleId")) + .toList(); + } + + @Override + public Page
findArticleOriginsWithFilters(GetArticleOriginsRequest request, Pageable pageable) { + Query query = buildOriginQuery(request); + + // 총 개수 조회 + long total = mongoTemplate.count(query, Article.class); + + // 페이지네이션 적용 + query.with(pageable); + + // 데이터 조회 + List
articles = mongoTemplate.find(query, Article.class); + + return new PageImpl<>(articles, pageable, total); + } + + /** + * originUrl 조회용 동적 쿼리 빌드 + */ + private Query buildOriginQuery(GetArticleOriginsRequest request) { + Query query = new Query(); + + // originUrl이 null이 아닌 것만 조회 + query.addCriteria(Criteria.where("originUrl").ne(null)); + + applyCategoryFilter(query, request.getCategoryEnum()); + applyTagsFilter(query, request.getTags()); + applyTargetLanguageCodeFilter(query, request.getTargetLanguageCode()); + + return query; + } + + @Override + public void incrementViewCount(String articleId) { + Query query = new Query(Criteria.where("id").is(articleId)); + Update update = new Update().inc("viewCount", 1); + mongoTemplate.updateFirst(query, update, Article.class); + } + } diff --git a/src/main/java/com/linglevel/api/content/article/service/ArticleChunkService.java b/src/main/java/com/linglevel/api/content/article/service/ArticleChunkService.java index 3e693d70..84aa8c3d 100644 --- a/src/main/java/com/linglevel/api/content/article/service/ArticleChunkService.java +++ b/src/main/java/com/linglevel/api/content/article/service/ArticleChunkService.java @@ -26,59 +26,63 @@ @Slf4j public class ArticleChunkService { - private final ArticleChunkRepository articleChunkRepository; - private final ArticleRepository articleRepository; + private final ArticleChunkRepository articleChunkRepository; - public PageResponse getArticleChunks(String articleId, GetArticleChunksRequest request, String userId) { - articleRepository.incrementViewCount(articleId); + private final ArticleRepository articleRepository; - DifficultyLevel difficulty = request.getDifficultyLevel(); + public PageResponse getArticleChunks(String articleId, GetArticleChunksRequest request, + String userId) { + articleRepository.incrementViewCount(articleId); - validatePaginationRequest(request); - Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit()); + DifficultyLevel difficulty = request.getDifficultyLevel(); - Page chunksPage = articleChunkRepository.findByArticleIdAndDifficultyLevelOrderByChunkNumber( - articleId, difficulty, pageable); + validatePaginationRequest(request); + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit()); - List chunkResponses = chunksPage.getContent().stream() - .map(this::convertToArticleChunkResponse) - .toList(); + Page chunksPage = articleChunkRepository + .findByArticleIdAndDifficultyLevelOrderByChunkNumber(articleId, difficulty, pageable); - return PageResponse.of(chunksPage, chunkResponses); - } + List chunkResponses = chunksPage.getContent() + .stream() + .map(this::convertToArticleChunkResponse) + .toList(); - public ArticleChunkResponse getArticleChunk(String articleId, String chunkId) { - validateArticleExists(articleId); + return PageResponse.of(chunksPage, chunkResponses); + } - ArticleChunk chunk = articleChunkRepository.findByArticleIdAndId(articleId, chunkId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); - - return convertToArticleChunkResponse(chunk); - } + public ArticleChunkResponse getArticleChunk(String articleId, String chunkId) { + validateArticleExists(articleId); - private void validateArticleExists(String articleId) { - if (!articleRepository.existsById(articleId)) { - throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); - } - } + ArticleChunk chunk = articleChunkRepository.findByArticleIdAndId(articleId, chunkId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); - private void validatePaginationRequest(GetArticleChunksRequest request) { - if (request.getLimit() != null && request.getLimit() > 100) { - request.setLimit(100); - } - } + return convertToArticleChunkResponse(chunk); + } - public ArticleChunk findById(String chunkId) { - return articleChunkRepository.findById(chunkId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); - } + private void validateArticleExists(String articleId) { + if (!articleRepository.existsById(articleId)) { + throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); + } + } - public ArticleChunk findFirstByArticleId(String articleId) { - return articleChunkRepository.findFirstByArticleIdOrderByChunkNumber(articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); - } + private void validatePaginationRequest(GetArticleChunksRequest request) { + if (request.getLimit() != null && request.getLimit() > 100) { + request.setLimit(100); + } + } + + public ArticleChunk findById(String chunkId) { + return articleChunkRepository.findById(chunkId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); + } + + public ArticleChunk findFirstByArticleId(String articleId) { + return articleChunkRepository.findFirstByArticleIdOrderByChunkNumber(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND)); + } + + private ArticleChunkResponse convertToArticleChunkResponse(ArticleChunk chunk) { + return ArticleChunkResponse.from(chunk); + } - private ArticleChunkResponse convertToArticleChunkResponse(ArticleChunk chunk) { - return ArticleChunkResponse.from(chunk); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/service/ArticleImportService.java b/src/main/java/com/linglevel/api/content/article/service/ArticleImportService.java index d78d173f..d6defcb9 100644 --- a/src/main/java/com/linglevel/api/content/article/service/ArticleImportService.java +++ b/src/main/java/com/linglevel/api/content/article/service/ArticleImportService.java @@ -19,51 +19,56 @@ @Slf4j public class ArticleImportService { - private final ArticleChunkRepository articleChunkRepository; - private final S3UrlService s3UrlService; - private final ArticlePathStrategy articlePathStrategy; - - public void createChunksFromLeveledResults(ArticleImportData importData, String articleId) { - log.info("Creating chunks for article: {}", articleId); - - List allChunks = new ArrayList<>(); - - for (ArticleImportData.TextLevelData levelData : importData.getLeveledResults()) { - DifficultyLevel difficulty = DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()); - - // 챕터는 1개가 보장되므로 첫 번째 챕터만 사용 - if (!levelData.getChapters().isEmpty()) { - ArticleImportData.ChapterData chapterData = levelData.getChapters().get(0); - - int chunkCounter = 1; - for (ArticleImportData.ChunkData chunkData : chapterData.getChunks()) { - ArticleChunk chunk = createArticleChunk(chunkData, articleId, difficulty, chunkCounter++); - allChunks.add(chunk); - } - } - } - - List savedChunks = articleChunkRepository.saveAll(allChunks); - log.info("Successfully created {} chunks for article: {}", savedChunks.size(), articleId); - } - - private ArticleChunk createArticleChunk(ArticleImportData.ChunkData chunkData, String articleId, DifficultyLevel difficulty, int chunkNumber) { - ArticleChunk chunk = new ArticleChunk(); - chunk.setArticleId(articleId); - chunk.setChunkNumber(chunkNumber); - chunk.setDifficultyLevel(difficulty); - - if (Boolean.TRUE.equals(chunkData.getIsImage())) { - chunk.setType(ChunkType.IMAGE); - String imageUrl = s3UrlService.buildImageUrl(articleId, chunkData.getChunkText(), articlePathStrategy); - chunk.setContent(imageUrl); - chunk.setDescription(chunkData.getDescription()); - } else { - chunk.setType(ChunkType.TEXT); - chunk.setContent(chunkData.getChunkText()); - chunk.setDescription(null); - } - - return chunk; - } + private final ArticleChunkRepository articleChunkRepository; + + private final S3UrlService s3UrlService; + + private final ArticlePathStrategy articlePathStrategy; + + public void createChunksFromLeveledResults(ArticleImportData importData, String articleId) { + log.info("Creating chunks for article: {}", articleId); + + List allChunks = new ArrayList<>(); + + for (ArticleImportData.TextLevelData levelData : importData.getLeveledResults()) { + DifficultyLevel difficulty = DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()); + + // 챕터는 1개가 보장되므로 첫 번째 챕터만 사용 + if (!levelData.getChapters().isEmpty()) { + ArticleImportData.ChapterData chapterData = levelData.getChapters().get(0); + + int chunkCounter = 1; + for (ArticleImportData.ChunkData chunkData : chapterData.getChunks()) { + ArticleChunk chunk = createArticleChunk(chunkData, articleId, difficulty, chunkCounter++); + allChunks.add(chunk); + } + } + } + + List savedChunks = articleChunkRepository.saveAll(allChunks); + log.info("Successfully created {} chunks for article: {}", savedChunks.size(), articleId); + } + + private ArticleChunk createArticleChunk(ArticleImportData.ChunkData chunkData, String articleId, + DifficultyLevel difficulty, int chunkNumber) { + ArticleChunk chunk = new ArticleChunk(); + chunk.setArticleId(articleId); + chunk.setChunkNumber(chunkNumber); + chunk.setDifficultyLevel(difficulty); + + if (Boolean.TRUE.equals(chunkData.getIsImage())) { + chunk.setType(ChunkType.IMAGE); + String imageUrl = s3UrlService.buildImageUrl(articleId, chunkData.getChunkText(), articlePathStrategy); + chunk.setContent(imageUrl); + chunk.setDescription(chunkData.getDescription()); + } + else { + chunk.setType(ChunkType.TEXT); + chunk.setContent(chunkData.getChunkText()); + chunk.setDescription(null); + } + + return chunk; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/service/ArticleProgressService.java b/src/main/java/com/linglevel/api/content/article/service/ArticleProgressService.java index 2a49e28b..fb4e9246 100644 --- a/src/main/java/com/linglevel/api/content/article/service/ArticleProgressService.java +++ b/src/main/java/com/linglevel/api/content/article/service/ArticleProgressService.java @@ -23,202 +23,197 @@ @Slf4j public class ArticleProgressService { - private final ArticleService articleService; - private final ArticleChunkService articleChunkService; - private final ArticleProgressRepository articleProgressRepository; - private final ArticleChunkRepository articleChunkRepository; - private final ProgressCalculationService progressCalculationService; - private final ReadingCompletionService readingCompletionService; - private final StreakService streakService; - - @Transactional - public ArticleProgressResponse updateProgress(String articleId, ArticleProgressUpdateRequest request, String userId) { - // 아티클 존재 여부 확인 - if (!articleService.existsById(articleId)) { - throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); - } - - // chunkId로부터 chunk 정보 조회 - ArticleChunk chunk = articleChunkService.findById(request.getChunkId()); - - // chunk가 해당 article에 속하는지 검증 - if (chunk.getArticleId() == null || !chunk.getArticleId().equals(articleId)) { - throw new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND_IN_ARTICLE); - } - - ArticleProgress articleProgress = articleProgressRepository.findByUserIdAndArticleId(userId, articleId) - .orElse(new ArticleProgress()); - - // [MIGRATION] V2 진행률 필드 마이그레이션 - ensureMigrated(articleProgress, chunk); - - // Null 체크 - if (chunk.getChunkNumber() == null) { - throw new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND); - } - - articleProgress.setUserId(userId); - articleProgress.setArticleId(articleId); - articleProgress.setChunkId(request.getChunkId()); - - // [V2_CORE] V2 필드: 정규화된 진행률 계산 - long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel( - articleId, chunk.getDifficultyLevel() - ); - double normalizedProgress = progressCalculationService.calculateNormalizedProgress( - chunk.getChunkNumber(), totalChunks - ); - - articleProgress.setNormalizedProgress(normalizedProgress); - articleProgress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); - - // maxNormalizedProgress 업데이트 (누적 최대값) - if (progressCalculationService.shouldUpdateMaxProgress( - articleProgress.getMaxNormalizedProgress(), normalizedProgress)) { - articleProgress.setMaxNormalizedProgress(normalizedProgress); - } - - // 읽기 완료 처리 (30초 이상 읽은 경우 이벤트 발행 + 세션 삭제) - Article article = articleService.findById(articleId); - Long readTimeSeconds = readingCompletionService.processReadingCompletion( - userId, - ContentType.ARTICLE, - articleId, - article.getCategory() - ); - - // 스트릭 검사 및 완료 처리 로직 - boolean streakUpdated = false; - if (isLastChunk(chunk)) { - // 첫 완료 시에만 isCompleted와 completedAt 설정 - if (articleProgress.getCompletedAt() == null) { - articleProgress.setIsCompleted(true); - articleProgress.setCompletedAt(java.time.Instant.now()); - } - - // 스트릭 업데이트 (30초 이상 읽은 경우에만) - if (readTimeSeconds != null && readTimeSeconds >= 30) { - streakService.addStudyTime(userId, readTimeSeconds); - streakUpdated = streakService.updateStreak(userId, ContentType.ARTICLE, articleId); - streakService.addCompletedContent(userId, ContentType.ARTICLE, articleId, streakUpdated); - } - } - - articleProgressRepository.save(articleProgress); - - return convertToArticleProgressResponse(articleProgress, streakUpdated); - } - - private boolean isLastChunk(ArticleChunk chunk) { - long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel( - chunk.getArticleId(), chunk.getDifficultyLevel() - ); - return chunk.getChunkNumber() >= totalChunks; - } - - /** - * V2 마이그레이션 보장 - * updateProgress 시점에 한 번만 실행 - */ - private void ensureMigrated(ArticleProgress progress, ArticleChunk chunk) { - boolean needsMigration = false; - - // V2 필드 초기화 - if (progress.getNormalizedProgress() == null) { - long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel( - chunk.getArticleId(), chunk.getDifficultyLevel() - ); - double normalizedProgress = progressCalculationService.calculateNormalizedProgress( - chunk.getChunkNumber(), totalChunks - ); - progress.setNormalizedProgress(normalizedProgress); - progress.setMaxNormalizedProgress(normalizedProgress); - needsMigration = true; - } - - if (progress.getCurrentDifficultyLevel() == null) { - progress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); - needsMigration = true; - } - - if (needsMigration) { - log.info("V2 migration completed for ArticleProgress id={}, userId={}", - progress.getId(), progress.getUserId()); - } - } - - - @Transactional(readOnly = true) - public ArticleProgressResponse getProgress(String articleId, String userId) { - // 아티클 존재 여부 확인 - if (!articleService.existsById(articleId)) { - throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); - } - - ArticleProgress articleProgress = articleProgressRepository.findByUserIdAndArticleId(userId, articleId) - .orElseGet(() -> initializeProgress(userId, articleId)); - - return convertToArticleProgressResponse(articleProgress, false); - } - - private ArticleProgress initializeProgress(String userId, String articleId) { - // 첫 번째 청크로 초기화 - ArticleChunk firstChunk = articleChunkService.findFirstByArticleId(articleId); - - ArticleProgress newProgress = new ArticleProgress(); - newProgress.setUserId(userId); - newProgress.setArticleId(articleId); - newProgress.setChunkId(firstChunk.getId()); - - // [V2_CORE] V2 필드: 초기 진행률 계산 - long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel( - articleId, firstChunk.getDifficultyLevel() - ); - double initialProgress = progressCalculationService.calculateNormalizedProgress( - firstChunk.getChunkNumber(), totalChunks - ); - - newProgress.setNormalizedProgress(initialProgress); - newProgress.setMaxNormalizedProgress(initialProgress); - newProgress.setCurrentDifficultyLevel(firstChunk.getDifficultyLevel()); - - return articleProgressRepository.save(newProgress); - } - - @Transactional - public void deleteProgress(String articleId, String userId) { - if (!articleService.existsById(articleId)) { - throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); - } - - ArticleProgress articleProgress = articleProgressRepository.findByUserIdAndArticleId(userId, articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.PROGRESS_NOT_FOUND)); - - articleProgressRepository.delete(articleProgress); - } - - private ArticleProgressResponse convertToArticleProgressResponse(ArticleProgress progress, boolean streakUpdated) { - // [DTO_MAPPING] chunk에서 chunkNumber 조회 - ArticleChunk chunk = articleChunkService.findById(progress.getChunkId()); - - // [SAFETY] 마이그레이션이 안 되어 있는 경우 경고 로그 - if (progress.getNormalizedProgress() == null || progress.getCurrentDifficultyLevel() == null) { - log.warn("ArticleProgress {} not migrated yet - this should only happen on read-only access", - progress.getId()); - } - - return ArticleProgressResponse.builder() - .id(progress.getId()) - .userId(progress.getUserId()) - .articleId(progress.getArticleId()) - .chunkId(progress.getChunkId()) - .currentReadChunkNumber(chunk.getChunkNumber()) - .isCompleted(progress.getIsCompleted()) - .currentDifficultyLevel(progress.getCurrentDifficultyLevel()) - .normalizedProgress(progress.getNormalizedProgress()) - .maxNormalizedProgress(progress.getMaxNormalizedProgress()) - .streakUpdated(streakUpdated) - .updatedAt(progress.getUpdatedAt()) - .build(); - } + private final ArticleService articleService; + + private final ArticleChunkService articleChunkService; + + private final ArticleProgressRepository articleProgressRepository; + + private final ArticleChunkRepository articleChunkRepository; + + private final ProgressCalculationService progressCalculationService; + + private final ReadingCompletionService readingCompletionService; + + private final StreakService streakService; + + @Transactional + public ArticleProgressResponse updateProgress(String articleId, ArticleProgressUpdateRequest request, + String userId) { + // 아티클 존재 여부 확인 + if (!articleService.existsById(articleId)) { + throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); + } + + // chunkId로부터 chunk 정보 조회 + ArticleChunk chunk = articleChunkService.findById(request.getChunkId()); + + // chunk가 해당 article에 속하는지 검증 + if (chunk.getArticleId() == null || !chunk.getArticleId().equals(articleId)) { + throw new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND_IN_ARTICLE); + } + + ArticleProgress articleProgress = articleProgressRepository.findByUserIdAndArticleId(userId, articleId) + .orElse(new ArticleProgress()); + + // [MIGRATION] V2 진행률 필드 마이그레이션 + ensureMigrated(articleProgress, chunk); + + // Null 체크 + if (chunk.getChunkNumber() == null) { + throw new ArticleException(ArticleErrorCode.CHUNK_NOT_FOUND); + } + + articleProgress.setUserId(userId); + articleProgress.setArticleId(articleId); + articleProgress.setChunkId(request.getChunkId()); + + // [V2_CORE] V2 필드: 정규화된 진행률 계산 + long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel(articleId, + chunk.getDifficultyLevel()); + double normalizedProgress = progressCalculationService.calculateNormalizedProgress(chunk.getChunkNumber(), + totalChunks); + + articleProgress.setNormalizedProgress(normalizedProgress); + articleProgress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); + + // maxNormalizedProgress 업데이트 (누적 최대값) + if (progressCalculationService.shouldUpdateMaxProgress(articleProgress.getMaxNormalizedProgress(), + normalizedProgress)) { + articleProgress.setMaxNormalizedProgress(normalizedProgress); + } + + // 읽기 완료 처리 (30초 이상 읽은 경우 이벤트 발행 + 세션 삭제) + Article article = articleService.findById(articleId); + Long readTimeSeconds = readingCompletionService.processReadingCompletion(userId, ContentType.ARTICLE, articleId, + article.getCategory()); + + // 스트릭 검사 및 완료 처리 로직 + boolean streakUpdated = false; + if (isLastChunk(chunk)) { + // 첫 완료 시에만 isCompleted와 completedAt 설정 + if (articleProgress.getCompletedAt() == null) { + articleProgress.setIsCompleted(true); + articleProgress.setCompletedAt(java.time.Instant.now()); + } + + // 스트릭 업데이트 (30초 이상 읽은 경우에만) + if (readTimeSeconds != null && readTimeSeconds >= 30) { + streakService.addStudyTime(userId, readTimeSeconds); + streakUpdated = streakService.updateStreak(userId, ContentType.ARTICLE, articleId); + streakService.addCompletedContent(userId, ContentType.ARTICLE, articleId, streakUpdated); + } + } + + articleProgressRepository.save(articleProgress); + + return convertToArticleProgressResponse(articleProgress, streakUpdated); + } + + private boolean isLastChunk(ArticleChunk chunk) { + long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel(chunk.getArticleId(), + chunk.getDifficultyLevel()); + return chunk.getChunkNumber() >= totalChunks; + } + + /** + * V2 마이그레이션 보장 updateProgress 시점에 한 번만 실행 + */ + private void ensureMigrated(ArticleProgress progress, ArticleChunk chunk) { + boolean needsMigration = false; + + // V2 필드 초기화 + if (progress.getNormalizedProgress() == null) { + long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel(chunk.getArticleId(), + chunk.getDifficultyLevel()); + double normalizedProgress = progressCalculationService.calculateNormalizedProgress(chunk.getChunkNumber(), + totalChunks); + progress.setNormalizedProgress(normalizedProgress); + progress.setMaxNormalizedProgress(normalizedProgress); + needsMigration = true; + } + + if (progress.getCurrentDifficultyLevel() == null) { + progress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); + needsMigration = true; + } + + if (needsMigration) { + log.info("V2 migration completed for ArticleProgress id={}, userId={}", progress.getId(), + progress.getUserId()); + } + } + + @Transactional(readOnly = true) + public ArticleProgressResponse getProgress(String articleId, String userId) { + // 아티클 존재 여부 확인 + if (!articleService.existsById(articleId)) { + throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); + } + + ArticleProgress articleProgress = articleProgressRepository.findByUserIdAndArticleId(userId, articleId) + .orElseGet(() -> initializeProgress(userId, articleId)); + + return convertToArticleProgressResponse(articleProgress, false); + } + + private ArticleProgress initializeProgress(String userId, String articleId) { + // 첫 번째 청크로 초기화 + ArticleChunk firstChunk = articleChunkService.findFirstByArticleId(articleId); + + ArticleProgress newProgress = new ArticleProgress(); + newProgress.setUserId(userId); + newProgress.setArticleId(articleId); + newProgress.setChunkId(firstChunk.getId()); + + // [V2_CORE] V2 필드: 초기 진행률 계산 + long totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel(articleId, + firstChunk.getDifficultyLevel()); + double initialProgress = progressCalculationService.calculateNormalizedProgress(firstChunk.getChunkNumber(), + totalChunks); + + newProgress.setNormalizedProgress(initialProgress); + newProgress.setMaxNormalizedProgress(initialProgress); + newProgress.setCurrentDifficultyLevel(firstChunk.getDifficultyLevel()); + + return articleProgressRepository.save(newProgress); + } + + @Transactional + public void deleteProgress(String articleId, String userId) { + if (!articleService.existsById(articleId)) { + throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); + } + + ArticleProgress articleProgress = articleProgressRepository.findByUserIdAndArticleId(userId, articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.PROGRESS_NOT_FOUND)); + + articleProgressRepository.delete(articleProgress); + } + + private ArticleProgressResponse convertToArticleProgressResponse(ArticleProgress progress, boolean streakUpdated) { + // [DTO_MAPPING] chunk에서 chunkNumber 조회 + ArticleChunk chunk = articleChunkService.findById(progress.getChunkId()); + + // [SAFETY] 마이그레이션이 안 되어 있는 경우 경고 로그 + if (progress.getNormalizedProgress() == null || progress.getCurrentDifficultyLevel() == null) { + log.warn("ArticleProgress {} not migrated yet - this should only happen on read-only access", + progress.getId()); + } + + return ArticleProgressResponse.builder() + .id(progress.getId()) + .userId(progress.getUserId()) + .articleId(progress.getArticleId()) + .chunkId(progress.getChunkId()) + .currentReadChunkNumber(chunk.getChunkNumber()) + .isCompleted(progress.getIsCompleted()) + .currentDifficultyLevel(progress.getCurrentDifficultyLevel()) + .normalizedProgress(progress.getNormalizedProgress()) + .maxNormalizedProgress(progress.getMaxNormalizedProgress()) + .streakUpdated(streakUpdated) + .updatedAt(progress.getUpdatedAt()) + .build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/service/ArticleReadingTimeService.java b/src/main/java/com/linglevel/api/content/article/service/ArticleReadingTimeService.java index a9511337..fc398d51 100644 --- a/src/main/java/com/linglevel/api/content/article/service/ArticleReadingTimeService.java +++ b/src/main/java/com/linglevel/api/content/article/service/ArticleReadingTimeService.java @@ -14,28 +14,31 @@ @RequiredArgsConstructor public class ArticleReadingTimeService { - private final ArticleRepository articleRepository; - private final ReadingTimeService readingTimeService; - - public void updateReadingTime(String articleId, ArticleImportData importData) { - Article article = articleRepository.findById(articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); - - int articleReadingTime = calculateArticleReadingTime(article.getDifficultyLevel(), importData); - article.setReadingTime(articleReadingTime); - - articleRepository.save(article); - } - - private int calculateArticleReadingTime(DifficultyLevel difficultyLevel, ArticleImportData importData) { - int totalCharacters = importData.getLeveledResults().stream() - .filter(levelData -> DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()) == difficultyLevel) - .flatMap(levelData -> levelData.getChapters().stream()) - .flatMap(chapterData -> chapterData.getChunks().stream()) - .filter(chunkData -> !Boolean.TRUE.equals(chunkData.getIsImage())) - .mapToInt(chunkData -> chunkData.getChunkText().length()) - .sum(); - - return readingTimeService.calculateReadingTimeFromCharacters(totalCharacters); - } + private final ArticleRepository articleRepository; + + private final ReadingTimeService readingTimeService; + + public void updateReadingTime(String articleId, ArticleImportData importData) { + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + + int articleReadingTime = calculateArticleReadingTime(article.getDifficultyLevel(), importData); + article.setReadingTime(articleReadingTime); + + articleRepository.save(article); + } + + private int calculateArticleReadingTime(DifficultyLevel difficultyLevel, ArticleImportData importData) { + int totalCharacters = importData.getLeveledResults() + .stream() + .filter(levelData -> DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()) == difficultyLevel) + .flatMap(levelData -> levelData.getChapters().stream()) + .flatMap(chapterData -> chapterData.getChunks().stream()) + .filter(chunkData -> !Boolean.TRUE.equals(chunkData.getIsImage())) + .mapToInt(chunkData -> chunkData.getChunkText().length()) + .sum(); + + return readingTimeService.calculateReadingTimeFromCharacters(totalCharacters); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/article/service/ArticleService.java b/src/main/java/com/linglevel/api/content/article/service/ArticleService.java index a40355a3..119d6750 100644 --- a/src/main/java/com/linglevel/api/content/article/service/ArticleService.java +++ b/src/main/java/com/linglevel/api/content/article/service/ArticleService.java @@ -39,306 +39,317 @@ @Slf4j public class ArticleService { - private final ArticleRepository articleRepository; - private final ArticleProgressRepository articleProgressRepository; - private final ArticleChunkRepository articleChunkRepository; - private final ArticleImportService articleImportService; - private final ArticleReadingTimeService articleReadingTimeService; - private final ArticleChunkService articleChunkService; - private final S3AiService s3AiService; - private final S3TransferService s3TransferService; - private final S3UrlService s3UrlService; - private final ImageResizeService imageResizeService; - private final ArticlePathStrategy articlePathStrategy; - - public PageResponse getArticles(GetArticlesRequest request, String userId) { - validateGetArticlesRequest(request); - - Pageable pageable = createPageable(request); - - // Custom Repository 사용 - 필터링 + 페이지네이션 통합 처리 - Page
articlePage = articleRepository.findArticlesWithFilters(request, userId, pageable); - - List articleResponses = articlePage.getContent().stream() - .map(article -> convertToArticleResponse(article, userId)) - .collect(Collectors.toList()); - - return PageResponse.of(articlePage, articleResponses); - } - - public ArticleResponse getArticle(String articleId, String userId) { - Article article = articleRepository.findById(articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); - - return convertToArticleResponse(article, userId); - } - - @Transactional - public ArticleImportResponse importArticle(ArticleImportRequest request) { - log.info("Starting article import for file: {}", request.getId()); - - ArticleImportData importData = s3AiService.downloadJsonFile(request.getId(), ArticleImportData.class, articlePathStrategy); - - Article article = createArticle(importData, request.getId()); - Article savedArticle = articleRepository.save(article); - - s3TransferService.transferImagesFromAiToStatic(request.getId(), savedArticle.getId(), articlePathStrategy); - - String coverImageUrl = s3UrlService.getCoverImageUrl(savedArticle.getId(), articlePathStrategy); - savedArticle.setCoverImageUrl(coverImageUrl); - - if (StringUtils.hasText(coverImageUrl)) { - try { - log.info("Auto-processing cover image for imported article: {}", savedArticle.getId()); - - String originalCoverS3Key = articlePathStrategy.generateCoverImagePath(savedArticle.getId()); - String smallImageUrl = imageResizeService.createSmallImage(originalCoverS3Key); - - savedArticle.setCoverImageUrl(smallImageUrl); - log.info("Successfully auto-processed cover image: {} → {}", savedArticle.getId(), smallImageUrl); - - } catch (Exception e) { - log.warn("Failed to auto-process cover image for article: {}, keeping original URL", savedArticle.getId(), e); - } - } - - articleRepository.save(savedArticle); - - articleImportService.createChunksFromLeveledResults(importData, savedArticle.getId()); - - articleReadingTimeService.updateReadingTime(savedArticle.getId(), importData); - - log.info("Successfully imported article with id: {}", savedArticle.getId()); - - ArticleImportResponse response = new ArticleImportResponse(); - response.setId(savedArticle.getId()); - return response; - } - - private void validateGetArticlesRequest(GetArticlesRequest request) { - if (request.getSortBy() != null) { - if (!isValidSortBy(request.getSortBy())) { - throw new ArticleException(ArticleErrorCode.INVALID_SORT_BY); - } - } - - if (request.getLimit() != null && request.getLimit() > 100) { - request.setLimit(100); - } - } - - private boolean isValidSortBy(String sortBy) { - return "view_count".equals(sortBy) || - "average_rating".equals(sortBy) || - "created_at".equals(sortBy); - } - - private Pageable createPageable(GetArticlesRequest request) { - Sort sort = createSort(request.getSortBy()); - return PageRequest.of(request.getPage() - 1, request.getLimit(), sort); - } - - private Sort createSort(String sortBy) { - return switch (sortBy) { - case "view_count" -> Sort.by(Sort.Direction.DESC, "viewCount"); - case "average_rating" -> Sort.by(Sort.Direction.DESC, "averageRating"); - default -> Sort.by(Sort.Direction.DESC, "createdAt"); - }; - } - - private Article createArticle(ArticleImportData importData, String requestId) { - Article article = new Article(); - article.setTitle(importData.getTitle()); - article.setAuthor(importData.getAuthor()); - - DifficultyLevel difficultyLevel = DifficultyLevel.valueOf( - importData.getOriginalTextLevel().toUpperCase()); - article.setDifficultyLevel(difficultyLevel); - - String coverImageUrl = s3UrlService.getCoverImageUrl(requestId, articlePathStrategy); - article.setCoverImageUrl(coverImageUrl); - - article.setReadingTime(0); - article.setAverageRating(0.0); - article.setReviewCount(0); - article.setViewCount(0); - - // 카테고리와 태그 파싱 - parseCategoryAndTags(article, importData.getTags()); - - // targetLanguageCode 매핑 - if (importData.getTargetLanguageCode() != null && !importData.getTargetLanguageCode().isEmpty()) { - List targetLanguageCodes = importData.getTargetLanguageCode().stream() - .map(code -> LanguageCode.valueOf(code.toUpperCase())) - .collect(Collectors.toList()); - article.setTargetLanguageCode(targetLanguageCodes); - } else { - // null이거나 빈 리스트면 모든 언어 코드로 설정 - article.setTargetLanguageCode(LanguageCode.getAllCodes()); - } - - article.setOriginUrl(importData.getOriginUrl()); - article.setCreatedAt(Instant.now()); - - return article; - } - - /** - * AI가 제공한 태그 리스트에서 카테고리 추출 - * - 5개 특별 태그(Sports, Science, Tech, Business, Culture) 중 하나가 있으면 category로 설정 - * - 카테고리는 tags 리스트에도 그대로 유지 (중복 허용) - * - 유저 선호도 분석 등에 활용 - */ - private void parseCategoryAndTags(Article article, List importedTags) { - if (importedTags == null || importedTags.isEmpty()) { - article.setCategory(null); - article.setTags(List.of()); - return; - } - - ContentCategory foundCategory = null; - - for (String tag : importedTags) { - ContentCategory category = ContentCategory.fromString(tag); - if (category != null && foundCategory == null) { - // 첫 번째로 발견된 카테고리 태그를 사용 - foundCategory = category; - } - } - - article.setCategory(foundCategory); - // 모든 태그를 그대로 유지 - article.setTags(importedTags); - } - - private ArticleResponse convertToArticleResponse(Article article, String userId) { - // 진도 정보 조회 - int currentReadChunkNumber = 0; - double progressPercentage = 0.0; - boolean isCompleted = false; - DifficultyLevel currentDifficultyLevel = article.getDifficultyLevel(); // Fallback: Article의 난이도 - - if (userId != null) { - ArticleProgress progress = articleProgressRepository - .findByUserIdAndArticleId(userId, article.getId()) - .orElse(null); - - if (progress != null) { - // [DTO_MAPPING] chunk에서 chunkNumber 조회 (안전하게 처리) - try { - ArticleChunk chunk = articleChunkService.findById(progress.getChunkId()); - currentReadChunkNumber = chunk.getChunkNumber() != null ? chunk.getChunkNumber() : 0; - } catch (Exception e) { - log.warn("Failed to find chunk for progress: {}", progress.getChunkId(), e); - currentReadChunkNumber = 0; - } - - // Progress가 있으면 currentDifficultyLevel 사용 - if (progress.getCurrentDifficultyLevel() != null) { - currentDifficultyLevel = progress.getCurrentDifficultyLevel(); - } - - // V2: 현재 난이도 기준으로 동적으로 청크 수 계산 - long totalChunksForLevel = articleChunkRepository.countByArticleIdAndDifficultyLevel(article.getId(), currentDifficultyLevel); - - if (totalChunksForLevel > 0) { - progressPercentage = (double) currentReadChunkNumber / totalChunksForLevel * 100.0; - } - - // DB에 저장된 완료 여부 사용 - isCompleted = progress.getIsCompleted() != null ? progress.getIsCompleted() : false; - } - } - - ArticleResponse response = new ArticleResponse(); - response.setId(article.getId()); - response.setTitle(article.getTitle()); - response.setAuthor(article.getAuthor()); - response.setCoverImageUrl(article.getCoverImageUrl()); - response.setDifficultyLevel(article.getDifficultyLevel()); - response.setChunkCount((int) articleChunkRepository.countByArticleIdAndDifficultyLevel(article.getId(), currentDifficultyLevel)); - response.setCurrentReadChunkNumber(currentReadChunkNumber); - response.setProgressPercentage(progressPercentage); - response.setCurrentDifficultyLevel(currentDifficultyLevel); - response.setIsCompleted(isCompleted); - response.setReadingTime(article.getReadingTime()); - response.setAverageRating(article.getAverageRating()); - response.setReviewCount(article.getReviewCount()); - response.setViewCount(article.getViewCount()); - response.setCategory(article.getCategory()); - response.setTags(article.getTags()); - - // targetLanguageCode가 null이면 모든 언어 코드로 응답 - List targetLanguageCodes = article.getTargetLanguageCode(); - response.setTargetLanguageCode( - (targetLanguageCodes != null && !targetLanguageCodes.isEmpty()) - ? targetLanguageCodes - : LanguageCode.getAllCodes() - ); - - response.setCreatedAt(article.getCreatedAt()); - return response; - } - - public boolean existsById(String articleId) { - return articleRepository.existsById(articleId); - } - - public Article findById(String articleId) { - return articleRepository.findById(articleId) - .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); - } - - public PageResponse getArticleOrigins(GetArticleOriginsRequest request) { - log.info("Fetching article origins with filters - tags: {}, targetLanguageCode: {}", - request.getTags(), request.getTargetLanguageCode()); - - Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), - Sort.by(Sort.Direction.DESC, "createdAt")); - - Page
articlePage = articleRepository.findArticleOriginsWithFilters(request, pageable); - - List responses = articlePage.getContent().stream() - .map(this::convertToArticleOriginResponse) - .collect(Collectors.toList()); - - return PageResponse.of(articlePage, responses); - } - - private ArticleOriginResponse convertToArticleOriginResponse(Article article) { - ArticleOriginResponse response = new ArticleOriginResponse(); - response.setId(article.getId()); - response.setTitle(article.getTitle()); - response.setOriginUrl(article.getOriginUrl()); - - List targetLanguageCodes = article.getTargetLanguageCode(); - response.setTargetLanguageCode( - (targetLanguageCodes != null && !targetLanguageCodes.isEmpty()) - ? targetLanguageCodes - : LanguageCode.getAllCodes() - ); - - response.setCategory(article.getCategory()); - response.setTags(article.getTags()); - return response; - } - - @Transactional - public long migrateTargetLanguageCode() { - log.info("Starting migration: setting default targetLanguageCode for articles"); - - List
articles = articleRepository.findAll(); - long updatedCount = 0; - - for (Article article : articles) { - if (article.getTargetLanguageCode() == null || article.getTargetLanguageCode().isEmpty()) { - article.setTargetLanguageCode(LanguageCode.getAllCodes()); - articleRepository.save(article); - updatedCount++; - } - } - - log.info("Migration completed: updated {} articles", updatedCount); - return updatedCount; - } + private final ArticleRepository articleRepository; + + private final ArticleProgressRepository articleProgressRepository; + + private final ArticleChunkRepository articleChunkRepository; + + private final ArticleImportService articleImportService; + + private final ArticleReadingTimeService articleReadingTimeService; + + private final ArticleChunkService articleChunkService; + + private final S3AiService s3AiService; + + private final S3TransferService s3TransferService; + + private final S3UrlService s3UrlService; + + private final ImageResizeService imageResizeService; + + private final ArticlePathStrategy articlePathStrategy; + + public PageResponse getArticles(GetArticlesRequest request, String userId) { + validateGetArticlesRequest(request); + + Pageable pageable = createPageable(request); + + // Custom Repository 사용 - 필터링 + 페이지네이션 통합 처리 + Page
articlePage = articleRepository.findArticlesWithFilters(request, userId, pageable); + + List articleResponses = articlePage.getContent() + .stream() + .map(article -> convertToArticleResponse(article, userId)) + .collect(Collectors.toList()); + + return PageResponse.of(articlePage, articleResponses); + } + + public ArticleResponse getArticle(String articleId, String userId) { + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + + return convertToArticleResponse(article, userId); + } + + @Transactional + public ArticleImportResponse importArticle(ArticleImportRequest request) { + log.info("Starting article import for file: {}", request.getId()); + + ArticleImportData importData = s3AiService.downloadJsonFile(request.getId(), ArticleImportData.class, + articlePathStrategy); + + Article article = createArticle(importData, request.getId()); + Article savedArticle = articleRepository.save(article); + + s3TransferService.transferImagesFromAiToStatic(request.getId(), savedArticle.getId(), articlePathStrategy); + + String coverImageUrl = s3UrlService.getCoverImageUrl(savedArticle.getId(), articlePathStrategy); + savedArticle.setCoverImageUrl(coverImageUrl); + + if (StringUtils.hasText(coverImageUrl)) { + try { + log.info("Auto-processing cover image for imported article: {}", savedArticle.getId()); + + String originalCoverS3Key = articlePathStrategy.generateCoverImagePath(savedArticle.getId()); + String smallImageUrl = imageResizeService.createSmallImage(originalCoverS3Key); + + savedArticle.setCoverImageUrl(smallImageUrl); + log.info("Successfully auto-processed cover image: {} → {}", savedArticle.getId(), smallImageUrl); + + } + catch (Exception e) { + log.warn("Failed to auto-process cover image for article: {}, keeping original URL", + savedArticle.getId(), e); + } + } + + articleRepository.save(savedArticle); + + articleImportService.createChunksFromLeveledResults(importData, savedArticle.getId()); + + articleReadingTimeService.updateReadingTime(savedArticle.getId(), importData); + + log.info("Successfully imported article with id: {}", savedArticle.getId()); + + ArticleImportResponse response = new ArticleImportResponse(); + response.setId(savedArticle.getId()); + return response; + } + + private void validateGetArticlesRequest(GetArticlesRequest request) { + if (request.getSortBy() != null) { + if (!isValidSortBy(request.getSortBy())) { + throw new ArticleException(ArticleErrorCode.INVALID_SORT_BY); + } + } + + if (request.getLimit() != null && request.getLimit() > 100) { + request.setLimit(100); + } + } + + private boolean isValidSortBy(String sortBy) { + return "view_count".equals(sortBy) || "average_rating".equals(sortBy) || "created_at".equals(sortBy); + } + + private Pageable createPageable(GetArticlesRequest request) { + Sort sort = createSort(request.getSortBy()); + return PageRequest.of(request.getPage() - 1, request.getLimit(), sort); + } + + private Sort createSort(String sortBy) { + return switch (sortBy) { + case "view_count" -> Sort.by(Sort.Direction.DESC, "viewCount"); + case "average_rating" -> Sort.by(Sort.Direction.DESC, "averageRating"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } + + private Article createArticle(ArticleImportData importData, String requestId) { + Article article = new Article(); + article.setTitle(importData.getTitle()); + article.setAuthor(importData.getAuthor()); + + DifficultyLevel difficultyLevel = DifficultyLevel.valueOf(importData.getOriginalTextLevel().toUpperCase()); + article.setDifficultyLevel(difficultyLevel); + + String coverImageUrl = s3UrlService.getCoverImageUrl(requestId, articlePathStrategy); + article.setCoverImageUrl(coverImageUrl); + + article.setReadingTime(0); + article.setAverageRating(0.0); + article.setReviewCount(0); + article.setViewCount(0); + + // 카테고리와 태그 파싱 + parseCategoryAndTags(article, importData.getTags()); + + // targetLanguageCode 매핑 + if (importData.getTargetLanguageCode() != null && !importData.getTargetLanguageCode().isEmpty()) { + List targetLanguageCodes = importData.getTargetLanguageCode() + .stream() + .map(code -> LanguageCode.valueOf(code.toUpperCase())) + .collect(Collectors.toList()); + article.setTargetLanguageCode(targetLanguageCodes); + } + else { + // null이거나 빈 리스트면 모든 언어 코드로 설정 + article.setTargetLanguageCode(LanguageCode.getAllCodes()); + } + + article.setOriginUrl(importData.getOriginUrl()); + article.setCreatedAt(Instant.now()); + + return article; + } + + /** + * AI가 제공한 태그 리스트에서 카테고리 추출 - 5개 특별 태그(Sports, Science, Tech, Business, Culture) 중 하나가 + * 있으면 category로 설정 - 카테고리는 tags 리스트에도 그대로 유지 (중복 허용) - 유저 선호도 분석 등에 활용 + */ + private void parseCategoryAndTags(Article article, List importedTags) { + if (importedTags == null || importedTags.isEmpty()) { + article.setCategory(null); + article.setTags(List.of()); + return; + } + + ContentCategory foundCategory = null; + + for (String tag : importedTags) { + ContentCategory category = ContentCategory.fromString(tag); + if (category != null && foundCategory == null) { + // 첫 번째로 발견된 카테고리 태그를 사용 + foundCategory = category; + } + } + + article.setCategory(foundCategory); + // 모든 태그를 그대로 유지 + article.setTags(importedTags); + } + + private ArticleResponse convertToArticleResponse(Article article, String userId) { + // 진도 정보 조회 + int currentReadChunkNumber = 0; + double progressPercentage = 0.0; + boolean isCompleted = false; + DifficultyLevel currentDifficultyLevel = article.getDifficultyLevel(); // Fallback: + // Article의 + // 난이도 + + if (userId != null) { + ArticleProgress progress = articleProgressRepository.findByUserIdAndArticleId(userId, article.getId()) + .orElse(null); + + if (progress != null) { + // [DTO_MAPPING] chunk에서 chunkNumber 조회 (안전하게 처리) + try { + ArticleChunk chunk = articleChunkService.findById(progress.getChunkId()); + currentReadChunkNumber = chunk.getChunkNumber() != null ? chunk.getChunkNumber() : 0; + } + catch (Exception e) { + log.warn("Failed to find chunk for progress: {}", progress.getChunkId(), e); + currentReadChunkNumber = 0; + } + + // Progress가 있으면 currentDifficultyLevel 사용 + if (progress.getCurrentDifficultyLevel() != null) { + currentDifficultyLevel = progress.getCurrentDifficultyLevel(); + } + + // V2: 현재 난이도 기준으로 동적으로 청크 수 계산 + long totalChunksForLevel = articleChunkRepository.countByArticleIdAndDifficultyLevel(article.getId(), + currentDifficultyLevel); + + if (totalChunksForLevel > 0) { + progressPercentage = (double) currentReadChunkNumber / totalChunksForLevel * 100.0; + } + + // DB에 저장된 완료 여부 사용 + isCompleted = progress.getIsCompleted() != null ? progress.getIsCompleted() : false; + } + } + + ArticleResponse response = new ArticleResponse(); + response.setId(article.getId()); + response.setTitle(article.getTitle()); + response.setAuthor(article.getAuthor()); + response.setCoverImageUrl(article.getCoverImageUrl()); + response.setDifficultyLevel(article.getDifficultyLevel()); + response.setChunkCount((int) articleChunkRepository.countByArticleIdAndDifficultyLevel(article.getId(), + currentDifficultyLevel)); + response.setCurrentReadChunkNumber(currentReadChunkNumber); + response.setProgressPercentage(progressPercentage); + response.setCurrentDifficultyLevel(currentDifficultyLevel); + response.setIsCompleted(isCompleted); + response.setReadingTime(article.getReadingTime()); + response.setAverageRating(article.getAverageRating()); + response.setReviewCount(article.getReviewCount()); + response.setViewCount(article.getViewCount()); + response.setCategory(article.getCategory()); + response.setTags(article.getTags()); + + // targetLanguageCode가 null이면 모든 언어 코드로 응답 + List targetLanguageCodes = article.getTargetLanguageCode(); + response.setTargetLanguageCode((targetLanguageCodes != null && !targetLanguageCodes.isEmpty()) + ? targetLanguageCodes : LanguageCode.getAllCodes()); + + response.setCreatedAt(article.getCreatedAt()); + return response; + } + + public boolean existsById(String articleId) { + return articleRepository.existsById(articleId); + } + + public Article findById(String articleId) { + return articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + } + + public PageResponse getArticleOrigins(GetArticleOriginsRequest request) { + log.info("Fetching article origins with filters - tags: {}, targetLanguageCode: {}", request.getTags(), + request.getTargetLanguageCode()); + + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), + Sort.by(Sort.Direction.DESC, "createdAt")); + + Page
articlePage = articleRepository.findArticleOriginsWithFilters(request, pageable); + + List responses = articlePage.getContent() + .stream() + .map(this::convertToArticleOriginResponse) + .collect(Collectors.toList()); + + return PageResponse.of(articlePage, responses); + } + + private ArticleOriginResponse convertToArticleOriginResponse(Article article) { + ArticleOriginResponse response = new ArticleOriginResponse(); + response.setId(article.getId()); + response.setTitle(article.getTitle()); + response.setOriginUrl(article.getOriginUrl()); + + List targetLanguageCodes = article.getTargetLanguageCode(); + response.setTargetLanguageCode((targetLanguageCodes != null && !targetLanguageCodes.isEmpty()) + ? targetLanguageCodes : LanguageCode.getAllCodes()); + + response.setCategory(article.getCategory()); + response.setTags(article.getTags()); + return response; + } + + @Transactional + public long migrateTargetLanguageCode() { + log.info("Starting migration: setting default targetLanguageCode for articles"); + + List
articles = articleRepository.findAll(); + long updatedCount = 0; + + for (Article article : articles) { + if (article.getTargetLanguageCode() == null || article.getTargetLanguageCode().isEmpty()) { + article.setTargetLanguageCode(LanguageCode.getAllCodes()); + articleRepository.save(article); + updatedCount++; + } + } + + log.info("Migration completed: updated {} articles", updatedCount); + return updatedCount; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/controller/BooksController.java b/src/main/java/com/linglevel/api/content/book/controller/BooksController.java index 657bffab..f95ffd52 100644 --- a/src/main/java/com/linglevel/api/content/book/controller/BooksController.java +++ b/src/main/java/com/linglevel/api/content/book/controller/BooksController.java @@ -32,8 +32,6 @@ import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; - - @RestController @RequestMapping("/api/v1/books") @RequiredArgsConstructor @@ -41,164 +39,134 @@ @Tag(name = "Books", description = "도서 관련 API") public class BooksController { - private final BookService bookService; - private final ChapterService chapterService; - private final ChunkService chunkService; - private final ReadingSessionService readingSessionService; - - - @Operation(summary = "책 목록 조회", description = "책 목록을 조건에 따라 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity> getBooks( - @ParameterObject @Valid @ModelAttribute GetBooksRequest request, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - PageResponse response = bookService.getBooks(request, userId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "단일 책 조회", description = "특정 책의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}") - public ResponseEntity getBook( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @Parameter(description = "언어 코드", example = "EN") - @RequestParam(defaultValue = "EN") LanguageCode languageCode, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - BookResponse response = bookService.getBook(bookId, userId, languageCode); - return ResponseEntity.ok(response); - } - - @Operation(summary = "챕터 목록 조회", description = "특정 책의 챕터 목록을 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}/chapters") - public ResponseEntity> getChapters( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @ParameterObject @Valid @ModelAttribute GetChaptersRequest request, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - PageResponse response = chapterService.getChapters(bookId, request, userId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "단일 챕터 조회", description = "특정 챕터의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책 또는 챕터를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}/chapters/{chapterId}") - public ResponseEntity getChapter( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - @PathVariable String chapterId, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - ChapterResponse response = chapterService.getChapter(bookId, chapterId, userId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "청크 목록 조회", description = "특정 챕터의 청크 목록을 난이도별로 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "챕터를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 난이도 레벨", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}/chapters/{chapterId}/chunks") - public ResponseEntity> getChunks( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - @PathVariable String chapterId, - @ParameterObject @Valid @ModelAttribute GetChunksRequest request, - @AuthenticationPrincipal JwtClaims claims) { - - if (claims != null) { - readingSessionService.startReadingSession( - claims.getId(), - ContentType.BOOK, - chapterId - ); - } - - String userId = claims != null ? claims.getId() : null; - PageResponse response = chunkService.getChunks(bookId, chapterId, request, userId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "단일 청크 조회", description = "특정 청크의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책, 챕터 또는 청크를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}/chapters/{chapterId}/chunks/{chunkId}") - public ResponseEntity getChunk( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - @PathVariable String chapterId, - @Parameter(description = "청크 ID", example = "60d0fe4f5311236168a109cd") - @PathVariable String chunkId) { - ChunkResponse response = chunkService.getChunk(bookId, chapterId, chunkId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "챕터 네비게이션 조회", description = "특정 챕터의 이전/다음 챕터 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책 또는 챕터를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}/chapters/{chapterId}/navigation") - public ResponseEntity getChapterNavigation( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - @PathVariable String chapterId) { - ChapterNavigationResponse response = chapterService.getChapterNavigation(bookId, chapterId); - return ResponseEntity.ok(response); - } - - @Operation(summary = "책 데이터 import", description = "S3에 저장된 JSON 파일을 읽어서 새로운 책과 관련 챕터, 청크 데이터를 생성합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "500", description = "import 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @SecurityRequirement(name = "adminApiKey") - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/import") - public ResponseEntity importBook( - @RequestBody BookImportRequest request) { - - BookImportResponse response = bookService.importBook(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @ExceptionHandler(BooksException.class) - public ResponseEntity handleBooksException(BooksException e) { - log.info("Books Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } -} \ No newline at end of file + private final BookService bookService; + + private final ChapterService chapterService; + + private final ChunkService chunkService; + + private final ReadingSessionService readingSessionService; + + @Operation(summary = "책 목록 조회", description = "책 목록을 조건에 따라 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity> getBooks( + @ParameterObject @Valid @ModelAttribute GetBooksRequest request, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + PageResponse response = bookService.getBooks(request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "단일 책 조회", description = "특정 책의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}") + public ResponseEntity getBook( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @Parameter(description = "언어 코드", + example = "EN") @RequestParam(defaultValue = "EN") LanguageCode languageCode, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + BookResponse response = bookService.getBook(bookId, userId, languageCode); + return ResponseEntity.ok(response); + } + + @Operation(summary = "챕터 목록 조회", description = "특정 책의 챕터 목록을 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}/chapters") + public ResponseEntity> getChapters( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @ParameterObject @Valid @ModelAttribute GetChaptersRequest request, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + PageResponse response = chapterService.getChapters(bookId, request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "단일 챕터 조회", description = "특정 챕터의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책 또는 챕터를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}/chapters/{chapterId}") + public ResponseEntity getChapter( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") @PathVariable String chapterId, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + ChapterResponse response = chapterService.getChapter(bookId, chapterId, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "청크 목록 조회", description = "특정 챕터의 청크 목록을 난이도별로 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "챕터를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 난이도 레벨", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}/chapters/{chapterId}/chunks") + public ResponseEntity> getChunks( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") @PathVariable String chapterId, + @ParameterObject @Valid @ModelAttribute GetChunksRequest request, + @AuthenticationPrincipal JwtClaims claims) { + + if (claims != null) { + readingSessionService.startReadingSession(claims.getId(), ContentType.BOOK, chapterId); + } + + String userId = claims != null ? claims.getId() : null; + PageResponse response = chunkService.getChunks(bookId, chapterId, request, userId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "단일 청크 조회", description = "특정 청크의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책, 챕터 또는 청크를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}/chapters/{chapterId}/chunks/{chunkId}") + public ResponseEntity getChunk( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") @PathVariable String chapterId, + @Parameter(description = "청크 ID", example = "60d0fe4f5311236168a109cd") @PathVariable String chunkId) { + ChunkResponse response = chunkService.getChunk(bookId, chapterId, chunkId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "챕터 네비게이션 조회", description = "특정 챕터의 이전/다음 챕터 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책 또는 챕터를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}/chapters/{chapterId}/navigation") + public ResponseEntity getChapterNavigation( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @Parameter(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") @PathVariable String chapterId) { + ChapterNavigationResponse response = chapterService.getChapterNavigation(bookId, chapterId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "책 데이터 import", description = "S3에 저장된 JSON 파일을 읽어서 새로운 책과 관련 챕터, 청크 데이터를 생성합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "500", description = "import 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @SecurityRequirement(name = "adminApiKey") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/import") + public ResponseEntity importBook(@RequestBody BookImportRequest request) { + + BookImportResponse response = bookService.importBook(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @ExceptionHandler(BooksException.class) + public ResponseEntity handleBooksException(BooksException e) { + log.info("Books Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/controller/BooksProgressController.java b/src/main/java/com/linglevel/api/content/book/controller/BooksProgressController.java index 1eeedea9..9bb13e67 100644 --- a/src/main/java/com/linglevel/api/content/book/controller/BooksProgressController.java +++ b/src/main/java/com/linglevel/api/content/book/controller/BooksProgressController.java @@ -31,60 +31,50 @@ @Tag(name = "Books Progress", description = "도서 진도 관리 API") public class BooksProgressController { - private final ProgressService progressService; + private final ProgressService progressService; - @Operation(summary = "읽기 진도 업데이트", description = "사용자의 읽기 진도를 업데이트합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책 또는 챕터를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 청크 번호", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PutMapping("/{bookId}/progress") - public ResponseEntity updateProgress( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @Valid @RequestBody ProgressUpdateRequest request, - @AuthenticationPrincipal JwtClaims claims) { - ProgressResponse response = progressService.updateProgress(bookId, request, claims.getId()); - return ResponseEntity.ok(response); - } + @Operation(summary = "읽기 진도 업데이트", description = "사용자의 읽기 진도를 업데이트합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책 또는 챕터를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 청크 번호", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PutMapping("/{bookId}/progress") + public ResponseEntity updateProgress( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @Valid @RequestBody ProgressUpdateRequest request, @AuthenticationPrincipal JwtClaims claims) { + ProgressResponse response = progressService.updateProgress(bookId, request, claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "읽기 진도 조회", description = "특정 책에 대한 사용자의 읽기 진도를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "책을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{bookId}/progress") - public ResponseEntity getProgress( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @AuthenticationPrincipal JwtClaims claims) { - ProgressResponse response = progressService.getProgress(bookId, claims.getId()); - return ResponseEntity.ok(response); - } + @Operation(summary = "읽기 진도 조회", description = "특정 책에 대한 사용자의 읽기 진도를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "책을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{bookId}/progress") + public ResponseEntity getProgress( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @AuthenticationPrincipal JwtClaims claims) { + ProgressResponse response = progressService.getProgress(bookId, claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "읽기 진도 삭제", description = "사용자의 읽기 진도 기록을 완전히 삭제합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "404", description = "책 또는 진도 기록을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/{bookId}/progress") - public ResponseEntity deleteProgress( - @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String bookId, - @AuthenticationPrincipal JwtClaims claims) { - progressService.deleteProgress(bookId, claims.getId()); - return ResponseEntity.noContent().build(); - } + @Operation(summary = "읽기 진도 삭제", description = "사용자의 읽기 진도 기록을 완전히 삭제합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse(responseCode = "404", description = "책 또는 진도 기록을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/{bookId}/progress") + public ResponseEntity deleteProgress( + @Parameter(description = "책 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String bookId, + @AuthenticationPrincipal JwtClaims claims) { + progressService.deleteProgress(bookId, claims.getId()); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(BooksException.class) + public ResponseEntity handleBooksException(BooksException e) { + log.info("Books Progress Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(BooksException.class) - public ResponseEntity handleBooksException(BooksException e) { - log.info("Books Progress Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/BookImportData.java b/src/main/java/com/linglevel/api/content/book/dto/BookImportData.java index baa7c3f7..6865a648 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/BookImportData.java +++ b/src/main/java/com/linglevel/api/content/book/dto/BookImportData.java @@ -9,43 +9,65 @@ @Data public class BookImportData { - @JsonProperty("novel_id") - private String novelId; - private String title; - @JsonProperty("title_translations") - private TitleTranslations titleTranslations; - private String author; - @JsonProperty("original_text_level") - private String originalTextLevel; - @JsonProperty("chapter_metadata") - private List chapterMetadata; - @JsonProperty("leveled_results") - private List leveledResults; - - @Data - public static class ChapterMetadata { - private int chapterNum; - private String title; - private String summary; - } - - @Data - public static class TextLevelData { - private String textLevel; - private List chapters; - } - - @Data - public static class ChapterData { - private int chapterNum; - private List chunks; - } - - @Data - public static class ChunkData { - private int chunkNum; - private String chunkText; - private Boolean isImage; - private String description; - } -} \ No newline at end of file + @JsonProperty("novel_id") + private String novelId; + + private String title; + + @JsonProperty("title_translations") + private TitleTranslations titleTranslations; + + private String author; + + @JsonProperty("original_text_level") + private String originalTextLevel; + + @JsonProperty("chapter_metadata") + private List chapterMetadata; + + @JsonProperty("leveled_results") + private List leveledResults; + + @Data + public static class ChapterMetadata { + + private int chapterNum; + + private String title; + + private String summary; + + } + + @Data + public static class TextLevelData { + + private String textLevel; + + private List chapters; + + } + + @Data + public static class ChapterData { + + private int chapterNum; + + private List chunks; + + } + + @Data + public static class ChunkData { + + private int chunkNum; + + private String chunkText; + + private Boolean isImage; + + private String description; + + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/BookImportRequest.java b/src/main/java/com/linglevel/api/content/book/dto/BookImportRequest.java index 29eab4d4..11ae9c71 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/BookImportRequest.java +++ b/src/main/java/com/linglevel/api/content/book/dto/BookImportRequest.java @@ -6,7 +6,8 @@ @Data @Schema(description = "책 import 요청 DTO") public class BookImportRequest { - - @Schema(description = "S3에 저장된 JSON 파일의 식별자", example = "fdsljfi134") - private String id; -} \ No newline at end of file + + @Schema(description = "S3에 저장된 JSON 파일의 식별자", example = "fdsljfi134") + private String id; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/BookImportResponse.java b/src/main/java/com/linglevel/api/content/book/dto/BookImportResponse.java index c8a7c97f..f34958cb 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/BookImportResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/BookImportResponse.java @@ -8,7 +8,8 @@ @AllArgsConstructor @Schema(description = "책 import 응답 DTO") public class BookImportResponse { - - @Schema(description = "생성된 책의 식별자", example = "60d0fe4f5311236168a109ca") - private String id; -} \ No newline at end of file + + @Schema(description = "생성된 책의 식별자", example = "60d0fe4f5311236168a109ca") + private String id; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/BookResponse.java b/src/main/java/com/linglevel/api/content/book/dto/BookResponse.java index a4658633..0e047eee 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/BookResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/BookResponse.java @@ -16,48 +16,50 @@ @AllArgsConstructor @Schema(description = "책 정보 응답") public class BookResponse { - @Schema(description = "책 ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "책 제목 (언어 코드에 따라 번역된 제목)", example = "The Little Prince") - private String title; - - @Schema(description = "저자", example = "Antoine de Saint-Exupéry") - private String author; - - @Schema(description = "표지 이미지 URL", example = "https://path/to/cover.jpg") - private String coverImageUrl; - - @Schema(description = "기본 난이도", example = "A1") - private DifficultyLevel difficultyLevel; - - @Schema(description = "총 챕터 수", example = "27") - private Integer chapterCount; - - @Schema(description = "현재 읽은 챕터 번호", example = "10") - private Integer currentReadChapterNumber; - - @Schema(description = "진행률", example = "37.0") - private Double progressPercentage; - - @Schema(description = "완료 여부", example = "false") - private Boolean isCompleted; - - @Schema(description = "읽기 시간(분)", example = "120") - private Integer readingTime; - - @Schema(description = "평균 평점", example = "4.8") - private Double averageRating; - - @Schema(description = "리뷰 수", example = "1500") - private Integer reviewCount; - - @Schema(description = "조회수", example = "25000") - private Integer viewCount; - - @Schema(description = "태그 목록", example = "[\"philosophy\", \"children\"]") - private List tags; - - @Schema(description = "생성일자", example = "2024-01-15T00:00:00Z") - private Instant createdAt; -} \ No newline at end of file + + @Schema(description = "책 ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "책 제목 (언어 코드에 따라 번역된 제목)", example = "The Little Prince") + private String title; + + @Schema(description = "저자", example = "Antoine de Saint-Exupéry") + private String author; + + @Schema(description = "표지 이미지 URL", example = "https://path/to/cover.jpg") + private String coverImageUrl; + + @Schema(description = "기본 난이도", example = "A1") + private DifficultyLevel difficultyLevel; + + @Schema(description = "총 챕터 수", example = "27") + private Integer chapterCount; + + @Schema(description = "현재 읽은 챕터 번호", example = "10") + private Integer currentReadChapterNumber; + + @Schema(description = "진행률", example = "37.0") + private Double progressPercentage; + + @Schema(description = "완료 여부", example = "false") + private Boolean isCompleted; + + @Schema(description = "읽기 시간(분)", example = "120") + private Integer readingTime; + + @Schema(description = "평균 평점", example = "4.8") + private Double averageRating; + + @Schema(description = "리뷰 수", example = "1500") + private Integer reviewCount; + + @Schema(description = "조회수", example = "25000") + private Integer viewCount; + + @Schema(description = "태그 목록", example = "[\"philosophy\", \"children\"]") + private List tags; + + @Schema(description = "생성일자", example = "2024-01-15T00:00:00Z") + private Instant createdAt; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/BookSummaryResponse.java b/src/main/java/com/linglevel/api/content/book/dto/BookSummaryResponse.java index 2721cf17..8e6a776c 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/BookSummaryResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/BookSummaryResponse.java @@ -12,18 +12,20 @@ @AllArgsConstructor @Schema(description = "책 요약 정보 응답") public class BookSummaryResponse { - @Schema(description = "책 ID", example = "60d0fe4f5311236168a109cb") - private String id; - - @Schema(description = "책 제목", example = "The Little Prince") - private String title; - - @Schema(description = "저자", example = "Antoine de Saint-Exupéry") - private String author; - - @Schema(description = "표지 이미지 URL", example = "https://path/to/cover.jpg") - private String coverImageUrl; - - @Schema(description = "총 챕터 수", example = "27") - private Integer totalChapters; -} \ No newline at end of file + + @Schema(description = "책 ID", example = "60d0fe4f5311236168a109cb") + private String id; + + @Schema(description = "책 제목", example = "The Little Prince") + private String title; + + @Schema(description = "저자", example = "Antoine de Saint-Exupéry") + private String author; + + @Schema(description = "표지 이미지 URL", example = "https://path/to/cover.jpg") + private String coverImageUrl; + + @Schema(description = "총 챕터 수", example = "27") + private Integer totalChapters; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/BooksProgressResponse.java b/src/main/java/com/linglevel/api/content/book/dto/BooksProgressResponse.java index 56fb9ae2..6b1735d8 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/BooksProgressResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/BooksProgressResponse.java @@ -14,24 +14,26 @@ @AllArgsConstructor @Schema(description = "사용자 읽기 진도 정보 응답") public class BooksProgressResponse { - @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") - private String id; - - @Schema(description = "책 정보") - private BookSummaryResponse book; - - @Schema(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - private String chapterId; - - @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") - private String chunkId; - - @Schema(description = "현재 읽은 챕터 번호", example = "1") - private Integer currentReadChapterNumber; - - @Schema(description = "챕터 진행률", example = "15.5") - private Double progressPercentage; - - @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") - private Instant updatedAt; -} \ No newline at end of file + + @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") + private String id; + + @Schema(description = "책 정보") + private BookSummaryResponse book; + + @Schema(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") + private String chapterId; + + @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") + private String chunkId; + + @Schema(description = "현재 읽은 챕터 번호", example = "1") + private Integer currentReadChapterNumber; + + @Schema(description = "챕터 진행률", example = "15.5") + private Double progressPercentage; + + @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") + private Instant updatedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/ChapterNavigationResponse.java b/src/main/java/com/linglevel/api/content/book/dto/ChapterNavigationResponse.java index e0a38095..295f8c1f 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/ChapterNavigationResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/ChapterNavigationResponse.java @@ -12,21 +12,23 @@ @AllArgsConstructor @Schema(description = "챕터 네비게이션 정보 응답") public class ChapterNavigationResponse { - @Schema(description = "현재 챕터 ID", example = "60d0fe4f5311236168a109cb") - private String currentChapterId; - @Schema(description = "현재 챕터 번호", example = "5") - private Integer currentChapterNumber; + @Schema(description = "현재 챕터 ID", example = "60d0fe4f5311236168a109cb") + private String currentChapterId; - @Schema(description = "이전 챕터 존재 여부", example = "true") - private Boolean hasPreviousChapter; + @Schema(description = "현재 챕터 번호", example = "5") + private Integer currentChapterNumber; - @Schema(description = "이전 챕터 ID", example = "60d0fe4f5311236168a109ca") - private String previousChapterId; + @Schema(description = "이전 챕터 존재 여부", example = "true") + private Boolean hasPreviousChapter; - @Schema(description = "다음 챕터 존재 여부", example = "true") - private Boolean hasNextChapter; + @Schema(description = "이전 챕터 ID", example = "60d0fe4f5311236168a109ca") + private String previousChapterId; + + @Schema(description = "다음 챕터 존재 여부", example = "true") + private Boolean hasNextChapter; + + @Schema(description = "다음 챕터 ID", example = "60d0fe4f5311236168a109cc") + private String nextChapterId; - @Schema(description = "다음 챕터 ID", example = "60d0fe4f5311236168a109cc") - private String nextChapterId; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/ChapterResponse.java b/src/main/java/com/linglevel/api/content/book/dto/ChapterResponse.java index e8f7a28b..90fda356 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/ChapterResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/ChapterResponse.java @@ -13,36 +13,38 @@ @AllArgsConstructor @Schema(description = "챕터 정보 응답") public class ChapterResponse { - @Schema(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - private String id; - - @Schema(description = "챕터 번호", example = "1") - private Integer chapterNumber; - - @Schema(description = "챕터 제목", example = "The Drawing") - private String title; - - @Schema(description = "챕터 이미지 URL", example = "https://path/to/chapter-image.jpg") - private String chapterImageUrl; - - @Schema(description = "챕터 설명", example = "A brief summary of the first chapter.") - private String description; - - @Schema(description = "총 청크 수", example = "10") - private Integer chunkCount; - - @Schema(description = "현재 읽은 청크 번호", example = "8") - private Integer currentReadChunkNumber; - - @Schema(description = "진행률", example = "80.0") - private Double progressPercentage; - - @Schema(description = "챕터 완료 여부", example = "true") - private Boolean isCompleted; - - @Schema(description = "현재 선택한 난이도", example = "EASY") - private DifficultyLevel currentDifficultyLevel; - - @Schema(description = "읽기 시간(분)", example = "15") - private Integer readingTime; -} \ No newline at end of file + + @Schema(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") + private String id; + + @Schema(description = "챕터 번호", example = "1") + private Integer chapterNumber; + + @Schema(description = "챕터 제목", example = "The Drawing") + private String title; + + @Schema(description = "챕터 이미지 URL", example = "https://path/to/chapter-image.jpg") + private String chapterImageUrl; + + @Schema(description = "챕터 설명", example = "A brief summary of the first chapter.") + private String description; + + @Schema(description = "총 청크 수", example = "10") + private Integer chunkCount; + + @Schema(description = "현재 읽은 청크 번호", example = "8") + private Integer currentReadChunkNumber; + + @Schema(description = "진행률", example = "80.0") + private Double progressPercentage; + + @Schema(description = "챕터 완료 여부", example = "true") + private Boolean isCompleted; + + @Schema(description = "현재 선택한 난이도", example = "EASY") + private DifficultyLevel currentDifficultyLevel; + + @Schema(description = "읽기 시간(분)", example = "15") + private Integer readingTime; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/ChunkCountByLevelDto.java b/src/main/java/com/linglevel/api/content/book/dto/ChunkCountByLevelDto.java index ca6352b0..d63aa393 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/ChunkCountByLevelDto.java +++ b/src/main/java/com/linglevel/api/content/book/dto/ChunkCountByLevelDto.java @@ -9,7 +9,11 @@ @NoArgsConstructor @AllArgsConstructor public class ChunkCountByLevelDto { - private String chapterId; - private DifficultyLevel difficultyLevel; - private long count; + + private String chapterId; + + private DifficultyLevel difficultyLevel; + + private long count; + } diff --git a/src/main/java/com/linglevel/api/content/book/dto/ChunkResponse.java b/src/main/java/com/linglevel/api/content/book/dto/ChunkResponse.java index ef115776..3a91ca21 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/ChunkResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/ChunkResponse.java @@ -15,32 +15,34 @@ @AllArgsConstructor @Schema(description = "청크 정보 응답") public class ChunkResponse { - @Schema(description = "청크 ID", example = "60d0fe4f5311236168a109cd") - private String id; - - @Schema(description = "청크 번호", example = "1") - private Integer chunkNumber; - - @Schema(description = "난이도", example = "A1") - private DifficultyLevel difficultyLevel; - - @Schema(description = "청크 타입", example = "TEXT") - private ChunkType type; - - @Schema(description = "내용 (텍스트일 경우 텍스트, 이미지일 경우 URL)", example = "Once when I was six years old...") - private String content; - - @Schema(description = "이미지 설명 (이미지 타입일 경우)", example = "null") - private String description; - - public static ChunkResponse from(Chunk chunk) { - return ChunkResponse.builder() - .id(chunk.getId()) - .chunkNumber(chunk.getChunkNumber()) - .difficultyLevel(chunk.getDifficultyLevel()) - .type(chunk.getType()) - .content(chunk.getContent()) - .description(chunk.getDescription()) - .build(); - } -} \ No newline at end of file + + @Schema(description = "청크 ID", example = "60d0fe4f5311236168a109cd") + private String id; + + @Schema(description = "청크 번호", example = "1") + private Integer chunkNumber; + + @Schema(description = "난이도", example = "A1") + private DifficultyLevel difficultyLevel; + + @Schema(description = "청크 타입", example = "TEXT") + private ChunkType type; + + @Schema(description = "내용 (텍스트일 경우 텍스트, 이미지일 경우 URL)", example = "Once when I was six years old...") + private String content; + + @Schema(description = "이미지 설명 (이미지 타입일 경우)", example = "null") + private String description; + + public static ChunkResponse from(Chunk chunk) { + return ChunkResponse.builder() + .id(chunk.getId()) + .chunkNumber(chunk.getChunkNumber()) + .difficultyLevel(chunk.getDifficultyLevel()) + .type(chunk.getType()) + .content(chunk.getContent()) + .description(chunk.getDescription()) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/GetBooksProgressRequest.java b/src/main/java/com/linglevel/api/content/book/dto/GetBooksProgressRequest.java index eeed9e04..99fa2ccb 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/GetBooksProgressRequest.java +++ b/src/main/java/com/linglevel/api/content/book/dto/GetBooksProgressRequest.java @@ -14,22 +14,16 @@ @AllArgsConstructor @Schema(description = "나의 읽기 진도 조회 요청") public class GetBooksProgressRequest { - - @Schema(description = "페이지 번호", - example = "1", - minimum = "1", - defaultValue = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @Builder.Default - private Integer page = 1; - - @Schema(description = "페이지 크기", - example = "10", - minimum = "1", - maximum = "200", - defaultValue = "10") - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") - @Builder.Default - private Integer limit = 10; -} \ No newline at end of file + + @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @Builder.Default + private Integer page = 1; + + @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") + @Builder.Default + private Integer limit = 10; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/GetBooksRequest.java b/src/main/java/com/linglevel/api/content/book/dto/GetBooksRequest.java index c133552a..b8370292 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/GetBooksRequest.java +++ b/src/main/java/com/linglevel/api/content/book/dto/GetBooksRequest.java @@ -20,49 +20,37 @@ @Schema(description = "책 목록 조회 요청") public class GetBooksRequest { - @Schema(description = "언어 코드 (책 제목 번역)", - example = "EN", - defaultValue = "EN") - @Builder.Default - private LanguageCode languageCode = LanguageCode.EN; + @Schema(description = "언어 코드 (책 제목 번역)", example = "EN", defaultValue = "EN") + @Builder.Default + private LanguageCode languageCode = LanguageCode.EN; - @Schema(description = "정렬 기준", - example = "created_at", - allowableValues = {"view_count", "average_rating", "created_at"}, - defaultValue = "created_at") - @Builder.Default - private String sortBy = "created_at"; + @Schema(description = "정렬 기준", example = "created_at", + allowableValues = { "view_count", "average_rating", "created_at" }, defaultValue = "created_at") + @Builder.Default + private String sortBy = "created_at"; - @Schema(description = "태그 필터 (쉼표로 구분)", - example = "philosophy,children") - private String tags; + @Schema(description = "태그 필터 (쉼표로 구분)", example = "philosophy,children") + private String tags; - @Schema(description = "검색 키워드 (제목 또는 작가명)", - example = "prince") - private String keyword; + @Schema(description = "검색 키워드 (제목 또는 작가명)", example = "prince") + private String keyword; - @Schema(description = "진도별 필터링", example = "IN_PROGRESS") - private ProgressStatus progress; + @Schema(description = "진도별 필터링", example = "IN_PROGRESS") + private ProgressStatus progress; - @Schema(description = "생성 시간 필터 (해당 시간 이후)", example = "2024-01-01T00:00:00") - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - private LocalDateTime createdAfter; + @Schema(description = "생성 시간 필터 (해당 시간 이후)", example = "2024-01-01T00:00:00") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime createdAfter; - @Schema(description = "페이지 번호", - example = "1", - minimum = "1", - defaultValue = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @Builder.Default - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @Builder.Default + private Integer page = 1; - @Schema(description = "페이지 크기", - example = "10", - minimum = "1", - maximum = "200", - defaultValue = "10") - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") - @Builder.Default - private Integer limit = 10; -} \ No newline at end of file + @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") + @Builder.Default + private Integer limit = 10; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/GetChaptersRequest.java b/src/main/java/com/linglevel/api/content/book/dto/GetChaptersRequest.java index 113faa21..c07ae9ca 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/GetChaptersRequest.java +++ b/src/main/java/com/linglevel/api/content/book/dto/GetChaptersRequest.java @@ -16,24 +16,18 @@ @Schema(description = "챕터 목록 조회 요청") public class GetChaptersRequest { - @Schema(description = "진도별 필터링", example = "IN_PROGRESS") - private ProgressStatus progress; - - @Schema(description = "페이지 번호", - example = "1", - minimum = "1", - defaultValue = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @Builder.Default - private Integer page = 1; - - @Schema(description = "페이지 크기", - example = "10", - minimum = "1", - maximum = "200", - defaultValue = "10") - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") - @Builder.Default - private Integer limit = 10; -} \ No newline at end of file + @Schema(description = "진도별 필터링", example = "IN_PROGRESS") + private ProgressStatus progress; + + @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @Builder.Default + private Integer page = 1; + + @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") + @Builder.Default + private Integer limit = 10; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/GetChunksRequest.java b/src/main/java/com/linglevel/api/content/book/dto/GetChunksRequest.java index 903c3ca6..da8b0073 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/GetChunksRequest.java +++ b/src/main/java/com/linglevel/api/content/book/dto/GetChunksRequest.java @@ -17,18 +17,19 @@ @Schema(description = "청크 목록 조회 요청") public class GetChunksRequest { - @Schema(description = "청크의 난이도", example = "A1", required = true) - @NotNull(message = "난이도는 필수입니다.") - private DifficultyLevel difficultyLevel; + @Schema(description = "청크의 난이도", example = "A1", required = true) + @NotNull(message = "난이도는 필수입니다.") + private DifficultyLevel difficultyLevel; - @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @Builder.Default - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", minimum = "1", defaultValue = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + @Builder.Default + private Integer page = 1; - @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") - @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") - @Builder.Default - private Integer limit = 10; -} \ No newline at end of file + @Schema(description = "페이지 크기", example = "10", minimum = "1", maximum = "200", defaultValue = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 크기는 200 이하여야 합니다.") + @Builder.Default + private Integer limit = 10; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/dto/ProgressResponse.java b/src/main/java/com/linglevel/api/content/book/dto/ProgressResponse.java index ea4442de..e05f90df 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/ProgressResponse.java +++ b/src/main/java/com/linglevel/api/content/book/dto/ProgressResponse.java @@ -15,48 +15,50 @@ @AllArgsConstructor @Schema(description = "읽기 진도 정보 응답") public class ProgressResponse { - @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") - private String id; - @Schema(description = "사용자 ID", example = "60d0fe4f5311236168a109ca") - private String userId; + @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") + private String id; - @Schema(description = "책 ID", example = "60d0fe4f5311236168a109cb") - private String bookId; - - @Schema(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") - private String chapterId; - - @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") - private String chunkId; - - @Schema(description = "현재 읽은 챕터 번호", example = "1") - private Integer currentReadChapterNumber; + @Schema(description = "사용자 ID", example = "60d0fe4f5311236168a109ca") + private String userId; - @Schema(description = "현재 읽은 청크 번호", example = "5") - private Integer currentReadChunkNumber; + @Schema(description = "책 ID", example = "60d0fe4f5311236168a109cb") + private String bookId; - @Schema(description = "최대 읽은 챕터 번호", example = "3") - private Integer maxReadChapterNumber; + @Schema(description = "챕터 ID", example = "60d0fe4f5311236168a109cb") + private String chapterId; - @Schema(description = "챕터 우선 정렬 기준의 최대 도달 청크 위치값", example = "65544") - private Integer maxReadChunkNumber; + @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") + private String chunkId; - @Schema(description = "완료 여부", example = "false") - private Boolean isCompleted; + @Schema(description = "현재 읽은 챕터 번호", example = "1") + private Integer currentReadChapterNumber; - @Schema(description = "현재 난이도", example = "EASY") - private DifficultyLevel currentDifficultyLevel; + @Schema(description = "현재 읽은 청크 번호", example = "5") + private Integer currentReadChunkNumber; - @Schema(description = "정규화된 현재 진행률 (%)", example = "75.5") - private Double normalizedProgress; + @Schema(description = "최대 읽은 챕터 번호", example = "3") + private Integer maxReadChapterNumber; - @Schema(description = "정규화된 최대 진행률 (%)", example = "85.2") - private Double maxNormalizedProgress; + @Schema(description = "챕터 우선 정렬 기준의 최대 도달 청크 위치값", example = "65544") + private Integer maxReadChunkNumber; - @Schema(description = "스트릭이 업데이트되었는지 여부 (완료 시 true)", example = "true") - private Boolean streakUpdated; + @Schema(description = "완료 여부", example = "false") + private Boolean isCompleted; - @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") - private Instant updatedAt; -} + @Schema(description = "현재 난이도", example = "EASY") + private DifficultyLevel currentDifficultyLevel; + + @Schema(description = "정규화된 현재 진행률 (%)", example = "75.5") + private Double normalizedProgress; + + @Schema(description = "정규화된 최대 진행률 (%)", example = "85.2") + private Double maxNormalizedProgress; + + @Schema(description = "스트릭이 업데이트되었는지 여부 (완료 시 true)", example = "true") + private Boolean streakUpdated; + + @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") + private Instant updatedAt; + +} diff --git a/src/main/java/com/linglevel/api/content/book/dto/ProgressUpdateRequest.java b/src/main/java/com/linglevel/api/content/book/dto/ProgressUpdateRequest.java index eeca7e2c..45bbb7d8 100644 --- a/src/main/java/com/linglevel/api/content/book/dto/ProgressUpdateRequest.java +++ b/src/main/java/com/linglevel/api/content/book/dto/ProgressUpdateRequest.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "읽기 진도 업데이트 요청") public class ProgressUpdateRequest { - @Schema(description = "청크 ID", example = "60d0fe4f5311236168c172db") - private String chunkId; -} \ No newline at end of file + + @Schema(description = "청크 ID", example = "60d0fe4f5311236168c172db") + private String chunkId; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/entity/Book.java b/src/main/java/com/linglevel/api/content/book/entity/Book.java index 07406cc4..3259f8c3 100644 --- a/src/main/java/com/linglevel/api/content/book/entity/Book.java +++ b/src/main/java/com/linglevel/api/content/book/entity/Book.java @@ -15,30 +15,32 @@ @AllArgsConstructor @Document(collection = "books") public class Book { - @Id - private String id; - - private String title; - - private TitleTranslations titleTranslations; - - private String author; - - private String coverImageUrl; - - private DifficultyLevel difficultyLevel; - - private Integer chapterCount; - - private Integer readingTime; - - private Double averageRating; - - private Integer reviewCount; - - private Integer viewCount; - - private List tags; - - private Instant createdAt; + + @Id + private String id; + + private String title; + + private TitleTranslations titleTranslations; + + private String author; + + private String coverImageUrl; + + private DifficultyLevel difficultyLevel; + + private Integer chapterCount; + + private Integer readingTime; + + private Double averageRating; + + private Integer reviewCount; + + private Integer viewCount; + + private List tags; + + private Instant createdAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/entity/BookProgress.java b/src/main/java/com/linglevel/api/content/book/entity/BookProgress.java index 2b2fdce0..0dbeaa0d 100644 --- a/src/main/java/com/linglevel/api/content/book/entity/BookProgress.java +++ b/src/main/java/com/linglevel/api/content/book/entity/BookProgress.java @@ -22,78 +22,79 @@ @Document(collection = "bookProgress") @CompoundIndex(name = "idx_user_book_progress", def = "{'userId': 1, 'bookId': 1}", unique = true) public class BookProgress { - @Id - private String id; - private String userId; + @Id + private String id; - private String bookId; + private String userId; - private String chapterId; + private String bookId; - private String chunkId; + private String chapterId; - private Integer currentReadChapterNumber; + private String chunkId; - private Integer maxReadChapterNumber; + private Integer currentReadChapterNumber; - /** - * 챕터 우선 정렬 기준의 최대 도달 청크 위치값. - * 비교 순서는 (chapterNumber, chunkNumber)이며 chapter가 우선한다. - */ - private Integer maxReadChunkNumber; + private Integer maxReadChapterNumber; - // V2 Progress Fields - private Double normalizedProgress; + /** + * 챕터 우선 정렬 기준의 최대 도달 청크 위치값. 비교 순서는 (chapterNumber, chunkNumber)이며 chapter가 우선한다. + */ + private Integer maxReadChunkNumber; - private Double maxNormalizedProgress; + // V2 Progress Fields + private Double normalizedProgress; - private DifficultyLevel currentDifficultyLevel; + private Double maxNormalizedProgress; - /** - * 챕터별 진행률 정보 (배열 구조) - * 각 챕터의 진행 상태, 완료 여부, 완료 시점을 저장 - */ - private List chapterProgresses = new ArrayList<>(); + private DifficultyLevel currentDifficultyLevel; - /** - * 책 전체 완료 여부 - * 모든 챕터가 완료되었을 때만 true로 설정되는 특수 조건 - */ - private Boolean isCompleted = false; + /** + * 챕터별 진행률 정보 (배열 구조) 각 챕터의 진행 상태, 완료 여부, 완료 시점을 저장 + */ + private List chapterProgresses = new ArrayList<>(); - private Instant completedAt; + /** + * 책 전체 완료 여부 모든 챕터가 완료되었을 때만 true로 설정되는 특수 조건 + */ + private Boolean isCompleted = false; - @LastModifiedDate - private Instant updatedAt; + private Instant completedAt; - /** - * 챕터 진행률 정보를 담는 내부 클래스 - */ - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class ChapterProgressInfo { - /** - * 챕터 번호 - */ - private Integer chapterNumber; + @LastModifiedDate + private Instant updatedAt; - /** - * 챕터 내 진행률 (0-100%) - */ - private Double progressPercentage; + /** + * 챕터 진행률 정보를 담는 내부 클래스 + */ + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ChapterProgressInfo { - /** - * 챕터 완료 여부 - */ - private Boolean isCompleted; + /** + * 챕터 번호 + */ + private Integer chapterNumber; + + /** + * 챕터 내 진행률 (0-100%) + */ + private Double progressPercentage; + + /** + * 챕터 완료 여부 + */ + private Boolean isCompleted; + + /** + * 챕터 완료 시점 (첫 완료 시점) + */ + private Instant completedAt; + + } - /** - * 챕터 완료 시점 (첫 완료 시점) - */ - private Instant completedAt; - } } diff --git a/src/main/java/com/linglevel/api/content/book/entity/Chapter.java b/src/main/java/com/linglevel/api/content/book/entity/Chapter.java index 270b4998..de544dec 100644 --- a/src/main/java/com/linglevel/api/content/book/entity/Chapter.java +++ b/src/main/java/com/linglevel/api/content/book/entity/Chapter.java @@ -10,18 +10,20 @@ @AllArgsConstructor @Document(collection = "chapters") public class Chapter { - @Id - private String id; - - private String bookId; - - private Integer chapterNumber; - - private String title; - - private String chapterImageUrl; - - private String description; - - private Integer readingTime; -} \ No newline at end of file + + @Id + private String id; + + private String bookId; + + private Integer chapterNumber; + + private String title; + + private String chapterImageUrl; + + private String description; + + private Integer readingTime; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/entity/Chunk.java b/src/main/java/com/linglevel/api/content/book/entity/Chunk.java index 8e95a54b..1b58f90e 100644 --- a/src/main/java/com/linglevel/api/content/book/entity/Chunk.java +++ b/src/main/java/com/linglevel/api/content/book/entity/Chunk.java @@ -17,25 +17,27 @@ @Document(collection = "chunks") @CompoundIndex(name = "chapter_difficulty_chunk_idx", def = "{'chapterId': 1, 'difficultyLevel': 1, 'chunkNumber': 1}") public class Chunk { - @Id - private String id; - - private String chapterId; - - private Integer chunkNumber; - - private DifficultyLevel difficultyLevel; - - private ChunkType type; - - private String content; - - private String description; - - public void updateContent(String content, String description) { - this.content = content; - if (description != null) { - this.description = description; - } - } -} \ No newline at end of file + + @Id + private String id; + + private String chapterId; + + private Integer chunkNumber; + + private DifficultyLevel difficultyLevel; + + private ChunkType type; + + private String content; + + private String description; + + public void updateContent(String content, String description) { + this.content = content; + if (description != null) { + this.description = description; + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/exception/BooksErrorCode.java b/src/main/java/com/linglevel/api/content/book/exception/BooksErrorCode.java index 7d803716..6ecd703e 100644 --- a/src/main/java/com/linglevel/api/content/book/exception/BooksErrorCode.java +++ b/src/main/java/com/linglevel/api/content/book/exception/BooksErrorCode.java @@ -7,20 +7,24 @@ @Getter @AllArgsConstructor public enum BooksErrorCode { - BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "Book not found."), - CHAPTER_NOT_FOUND(HttpStatus.NOT_FOUND, "Chapter not found."), - CHAPTER_NOT_FOUND_IN_BOOK(HttpStatus.NOT_FOUND, "Chapter not found in this book."), - CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "Chunk not found."), - CHUNK_NOT_FOUND_IN_BOOK(HttpStatus.NOT_FOUND, "Chunk not found in this book."), - INVALID_DIFFICULTY_LEVEL(HttpStatus.BAD_REQUEST, "Invalid difficulty level."), - INVALID_SORT_BY(HttpStatus.BAD_REQUEST, "Invalid sort_by parameter. Must be one of: view_count, average_rating, created_at."), - INVALID_TAGS_FORMAT(HttpStatus.BAD_REQUEST, "Invalid tags format. Tags should be comma-separated strings."), - INVALID_CHUNK_NUMBER(HttpStatus.BAD_REQUEST, "Invalid chunkNumber. Must be a positive integer."), - INVALID_PAGINATION(HttpStatus.BAD_REQUEST, "Invalid pagination parameters."), - PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "Progress not found."), - BOOK_IMPORT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to import book from S3."), - BOOK_DELETION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete book and related data."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file + + BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "Book not found."), + CHAPTER_NOT_FOUND(HttpStatus.NOT_FOUND, "Chapter not found."), + CHAPTER_NOT_FOUND_IN_BOOK(HttpStatus.NOT_FOUND, "Chapter not found in this book."), + CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "Chunk not found."), + CHUNK_NOT_FOUND_IN_BOOK(HttpStatus.NOT_FOUND, "Chunk not found in this book."), + INVALID_DIFFICULTY_LEVEL(HttpStatus.BAD_REQUEST, "Invalid difficulty level."), + INVALID_SORT_BY(HttpStatus.BAD_REQUEST, + "Invalid sort_by parameter. Must be one of: view_count, average_rating, created_at."), + INVALID_TAGS_FORMAT(HttpStatus.BAD_REQUEST, "Invalid tags format. Tags should be comma-separated strings."), + INVALID_CHUNK_NUMBER(HttpStatus.BAD_REQUEST, "Invalid chunkNumber. Must be a positive integer."), + INVALID_PAGINATION(HttpStatus.BAD_REQUEST, "Invalid pagination parameters."), + PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "Progress not found."), + BOOK_IMPORT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to import book from S3."), + BOOK_DELETION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete book and related data."); + + private final HttpStatus status; + + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/exception/BooksException.java b/src/main/java/com/linglevel/api/content/book/exception/BooksException.java index 1b757489..ed65cf77 100644 --- a/src/main/java/com/linglevel/api/content/book/exception/BooksException.java +++ b/src/main/java/com/linglevel/api/content/book/exception/BooksException.java @@ -5,10 +5,12 @@ @Getter public class BooksException extends RuntimeException { - private final HttpStatus status; - public BooksException(BooksErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } -} \ No newline at end of file + private final HttpStatus status; + + public BooksException(BooksErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/repository/BookProgressRepository.java b/src/main/java/com/linglevel/api/content/book/repository/BookProgressRepository.java index 076c9551..d8fae247 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/BookProgressRepository.java +++ b/src/main/java/com/linglevel/api/content/book/repository/BookProgressRepository.java @@ -9,9 +9,15 @@ import java.util.Optional; public interface BookProgressRepository extends MongoRepository { - Optional findByUserIdAndBookId(String UserId, String bookId); - List findByUserIdAndBookIdIn(String userId, List bookIds); - Page findAllByUserId(String userId, Pageable pageable); - List findAllByUserId(String userId); - List findByBookId(String bookId); + + Optional findByUserIdAndBookId(String UserId, String bookId); + + List findByUserIdAndBookIdIn(String userId, List bookIds); + + Page findAllByUserId(String userId, Pageable pageable); + + List findAllByUserId(String userId); + + List findByBookId(String bookId); + } diff --git a/src/main/java/com/linglevel/api/content/book/repository/BookRepository.java b/src/main/java/com/linglevel/api/content/book/repository/BookRepository.java index c2ab138e..bf72419c 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/BookRepository.java +++ b/src/main/java/com/linglevel/api/content/book/repository/BookRepository.java @@ -4,4 +4,5 @@ import org.springframework.data.mongodb.repository.MongoRepository; public interface BookRepository extends MongoRepository, BookRepositoryCustom { + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryCustom.java b/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryCustom.java index 642c4409..8891e550 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryCustom.java +++ b/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryCustom.java @@ -6,15 +6,16 @@ import org.springframework.data.domain.Pageable; public interface BookRepositoryCustom { - /** - * 동적 필터링이 적용된 책 목록 조회 - * - * @param request 필터링 조건 (tags, keyword, progress 등) - * @param userId 사용자 ID (진도 필터링에 사용) - * @param pageable 페이지네이션 정보 - * @return 필터링된 책 페이지 - */ - Page findBooksWithFilters(GetBooksRequest request, String userId, Pageable pageable); - - void incrementViewCount(String bookId); + + /** + * 동적 필터링이 적용된 책 목록 조회 + * @param request 필터링 조건 (tags, keyword, progress 등) + * @param userId 사용자 ID (진도 필터링에 사용) + * @param pageable 페이지네이션 정보 + * @return 필터링된 책 페이지 + */ + Page findBooksWithFilters(GetBooksRequest request, String userId, Pageable pageable); + + void incrementViewCount(String bookId); + } diff --git a/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryImpl.java b/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryImpl.java index c0a59d0a..440f54e3 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryImpl.java +++ b/src/main/java/com/linglevel/api/content/book/repository/BookRepositoryImpl.java @@ -19,191 +19,179 @@ import java.util.Set; /** - * Book Repository 커스텀 구현체 - * MongoTemplate + Criteria를 BooleanExpression 스타일로 사용 + * Book Repository 커스텀 구현체 MongoTemplate + Criteria를 BooleanExpression 스타일로 사용 */ @RequiredArgsConstructor public class BookRepositoryImpl implements BookRepositoryCustom { - private final MongoTemplate mongoTemplate; - - @Override - public Page findBooksWithFilters(GetBooksRequest request, String userId, Pageable pageable) { - Query query = buildQuery(request, userId); - - // 총 개수 조회 (필터링 적용 후) - long total = mongoTemplate.count(query, Book.class); - - // 페이지네이션 적용 - query.with(pageable); - - // 데이터 조회 - List books = mongoTemplate.find(query, Book.class); - - return new PageImpl<>(books, pageable, total); - } - - /** - * 동적 쿼리 빌드 (BooleanExpression 스타일) - */ - private Query buildQuery(GetBooksRequest request, String userId) { - Query query = new Query(); - - // 각 필터를 독립적인 메서드로 분리 - applyTagsFilter(query, request.getTags()); - applyKeywordFilter(query, request.getKeyword()); - applyProgressFilter(query, request.getProgress(), userId); - applyCreatedAfterFilter(query, request.getCreatedAfter()); - - return query; - } - - /** - * 태그 필터 적용 - */ - private void applyTagsFilter(Query query, String tags) { - if (!StringUtils.hasText(tags)) { - return; - } - - List tagList = Arrays.asList(tags.split(",")); - query.addCriteria(Criteria.where("tags").in(tagList)); - } - - /** - * 키워드 필터 적용 (제목 또는 작가) - */ - private void applyKeywordFilter(Query query, String keyword) { - if (!StringUtils.hasText(keyword)) { - return; - } - - Criteria keywordCriteria = new Criteria().orOperator( - Criteria.where("title").regex(keyword, "i"), - Criteria.where("author").regex(keyword, "i") - ); - query.addCriteria(keywordCriteria); - } - - /** - * 진도 필터 적용 - */ - private void applyProgressFilter(Query query, ProgressStatus progress, String userId) { - if (progress == null || userId == null) { - return; - } - - List bookIds = getBookIdsByProgress(userId, progress); - if (!bookIds.isEmpty()) { - query.addCriteria(Criteria.where("id").in(bookIds)); - } else { - query.addCriteria(Criteria.where("_id").is(null)); - } - } - - /** - * 생성 시간 필터 적용 (해당 시간 이후) - */ - private void applyCreatedAfterFilter(Query query, java.time.LocalDateTime createdAfter) { - if (createdAfter == null) { - return; - } - - query.addCriteria(Criteria.where("createdAt").gte(createdAfter)); - } - - /** - * 진도 상태별 책 ID 목록 조회 - */ - private List getBookIdsByProgress(String userId, ProgressStatus progressStatus) { - return switch (progressStatus) { - case NOT_STARTED -> getNotStartedBookIds(userId); - case IN_PROGRESS -> getInProgressBookIds(userId); - case COMPLETED -> getCompletedBookIds(userId); - }; - } - - /** - * 시작하지 않은 책 ID 목록 조회 - */ - private List getNotStartedBookIds(String userId) { - // 모든 책 ID 조회 - List allBookIds = mongoTemplate.findAll(Book.class).stream() - .map(Book::getId) - .toList(); - - // 시작한 책(진행 중/완료) ID 조회 - List startedBookIds = findStartedBookIds(userId); - Set startedBookIdSet = new HashSet<>(startedBookIds); - - // 시작하지 않은 책(완료/부분 읽기/완료 챕터 진행률이 없는 책)만 반환 - return allBookIds.stream() - .filter(bookId -> !startedBookIdSet.contains(bookId)) - .toList(); - } - - /** - * 진행 중인 책 ID 목록 조회 - */ - private List getInProgressBookIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(Criteria.where("isCompleted").is(false)); - query.addCriteria(new Criteria().orOperator( - Criteria.where("normalizedProgress").gt(0), - partiallyReadChapterCriteria() - )); - - return findBookIdsFromProgress(query); - } - - /** - * 완료한 책 ID 목록 조회 - */ - private List getCompletedBookIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(Criteria.where("isCompleted").is(true)); - - return findBookIdsFromProgress(query); - } - - /** - * 특정 사용자의 모든 진도 책 ID 조회 - */ - private List findStartedBookIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(new Criteria().orOperator( - Criteria.where("isCompleted").is(true), - Criteria.where("normalizedProgress").gt(0), - partiallyReadChapterCriteria() - )); - - return findBookIdsFromProgress(query); - } - - private Criteria partiallyReadChapterCriteria() { - return Criteria.where("chapterProgresses").elemMatch( - Criteria.where("isCompleted").is(false) - .and("progressPercentage").gt(0) - ); - } - - /** - * Progress 컬렉션에서 bookId 추출 - */ - private List findBookIdsFromProgress(Query query) { - return mongoTemplate.find(query, org.bson.Document.class, "bookProgress") - .stream() - .map(doc -> doc.getString("bookId")) - .toList(); - } - - @Override - public void incrementViewCount(String bookId) { - Query query = new Query(Criteria.where("id").is(bookId)); - Update update = new Update().inc("viewCount", 1); - mongoTemplate.updateFirst(query, update, Book.class); - } + private final MongoTemplate mongoTemplate; + + @Override + public Page findBooksWithFilters(GetBooksRequest request, String userId, Pageable pageable) { + Query query = buildQuery(request, userId); + + // 총 개수 조회 (필터링 적용 후) + long total = mongoTemplate.count(query, Book.class); + + // 페이지네이션 적용 + query.with(pageable); + + // 데이터 조회 + List books = mongoTemplate.find(query, Book.class); + + return new PageImpl<>(books, pageable, total); + } + + /** + * 동적 쿼리 빌드 (BooleanExpression 스타일) + */ + private Query buildQuery(GetBooksRequest request, String userId) { + Query query = new Query(); + + // 각 필터를 독립적인 메서드로 분리 + applyTagsFilter(query, request.getTags()); + applyKeywordFilter(query, request.getKeyword()); + applyProgressFilter(query, request.getProgress(), userId); + applyCreatedAfterFilter(query, request.getCreatedAfter()); + + return query; + } + + /** + * 태그 필터 적용 + */ + private void applyTagsFilter(Query query, String tags) { + if (!StringUtils.hasText(tags)) { + return; + } + + List tagList = Arrays.asList(tags.split(",")); + query.addCriteria(Criteria.where("tags").in(tagList)); + } + + /** + * 키워드 필터 적용 (제목 또는 작가) + */ + private void applyKeywordFilter(Query query, String keyword) { + if (!StringUtils.hasText(keyword)) { + return; + } + + Criteria keywordCriteria = new Criteria().orOperator(Criteria.where("title").regex(keyword, "i"), + Criteria.where("author").regex(keyword, "i")); + query.addCriteria(keywordCriteria); + } + + /** + * 진도 필터 적용 + */ + private void applyProgressFilter(Query query, ProgressStatus progress, String userId) { + if (progress == null || userId == null) { + return; + } + + List bookIds = getBookIdsByProgress(userId, progress); + if (!bookIds.isEmpty()) { + query.addCriteria(Criteria.where("id").in(bookIds)); + } + else { + query.addCriteria(Criteria.where("_id").is(null)); + } + } + + /** + * 생성 시간 필터 적용 (해당 시간 이후) + */ + private void applyCreatedAfterFilter(Query query, java.time.LocalDateTime createdAfter) { + if (createdAfter == null) { + return; + } + + query.addCriteria(Criteria.where("createdAt").gte(createdAfter)); + } + + /** + * 진도 상태별 책 ID 목록 조회 + */ + private List getBookIdsByProgress(String userId, ProgressStatus progressStatus) { + return switch (progressStatus) { + case NOT_STARTED -> getNotStartedBookIds(userId); + case IN_PROGRESS -> getInProgressBookIds(userId); + case COMPLETED -> getCompletedBookIds(userId); + }; + } + + /** + * 시작하지 않은 책 ID 목록 조회 + */ + private List getNotStartedBookIds(String userId) { + // 모든 책 ID 조회 + List allBookIds = mongoTemplate.findAll(Book.class).stream().map(Book::getId).toList(); + + // 시작한 책(진행 중/완료) ID 조회 + List startedBookIds = findStartedBookIds(userId); + Set startedBookIdSet = new HashSet<>(startedBookIds); + + // 시작하지 않은 책(완료/부분 읽기/완료 챕터 진행률이 없는 책)만 반환 + return allBookIds.stream().filter(bookId -> !startedBookIdSet.contains(bookId)).toList(); + } + + /** + * 진행 중인 책 ID 목록 조회 + */ + private List getInProgressBookIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(Criteria.where("isCompleted").is(false)); + query.addCriteria( + new Criteria().orOperator(Criteria.where("normalizedProgress").gt(0), partiallyReadChapterCriteria())); + + return findBookIdsFromProgress(query); + } + + /** + * 완료한 책 ID 목록 조회 + */ + private List getCompletedBookIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(Criteria.where("isCompleted").is(true)); + + return findBookIdsFromProgress(query); + } + + /** + * 특정 사용자의 모든 진도 책 ID 조회 + */ + private List findStartedBookIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(new Criteria().orOperator(Criteria.where("isCompleted").is(true), + Criteria.where("normalizedProgress").gt(0), partiallyReadChapterCriteria())); + + return findBookIdsFromProgress(query); + } + + private Criteria partiallyReadChapterCriteria() { + return Criteria.where("chapterProgresses") + .elemMatch(Criteria.where("isCompleted").is(false).and("progressPercentage").gt(0)); + } + + /** + * Progress 컬렉션에서 bookId 추출 + */ + private List findBookIdsFromProgress(Query query) { + return mongoTemplate.find(query, org.bson.Document.class, "bookProgress") + .stream() + .map(doc -> doc.getString("bookId")) + .toList(); + } + + @Override + public void incrementViewCount(String bookId) { + Query query = new Query(Criteria.where("id").is(bookId)); + Update update = new Update().inc("viewCount", 1); + mongoTemplate.updateFirst(query, update, Book.class); + } + } diff --git a/src/main/java/com/linglevel/api/content/book/repository/ChapterRepository.java b/src/main/java/com/linglevel/api/content/book/repository/ChapterRepository.java index 02aff0a2..366adb9b 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/ChapterRepository.java +++ b/src/main/java/com/linglevel/api/content/book/repository/ChapterRepository.java @@ -9,15 +9,17 @@ import java.util.Optional; public interface ChapterRepository extends MongoRepository, ChapterRepositoryCustom { - Page findByBookId(String chapterId, Pageable pageable); - - List findByBookIdOrderByChapterNumber(String bookId); - Optional findByBookIdAndChapterNumber(String bookId, int chapterNumber); - - Optional findFirstByBookIdOrderByChapterNumberAsc(String chapterId); + Page findByBookId(String chapterId, Pageable pageable); - Integer countByBookId(String bookId); + List findByBookIdOrderByChapterNumber(String bookId); - Optional findById(String chapterId); -} \ No newline at end of file + Optional findByBookIdAndChapterNumber(String bookId, int chapterNumber); + + Optional findFirstByBookIdOrderByChapterNumberAsc(String chapterId); + + Integer countByBookId(String bookId); + + Optional findById(String chapterId); + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryCustom.java b/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryCustom.java index 6b1b557c..c2857d22 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryCustom.java +++ b/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryCustom.java @@ -6,5 +6,7 @@ import org.springframework.data.domain.Pageable; public interface ChapterRepositoryCustom { - Page findChaptersWithFilters(String bookId, GetChaptersRequest request, String userId, Pageable pageable); + + Page findChaptersWithFilters(String bookId, GetChaptersRequest request, String userId, Pageable pageable); + } diff --git a/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryImpl.java b/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryImpl.java index 5c4113fb..51f5de1f 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryImpl.java +++ b/src/main/java/com/linglevel/api/content/book/repository/ChapterRepositoryImpl.java @@ -20,102 +20,99 @@ @RequiredArgsConstructor public class ChapterRepositoryImpl implements ChapterRepositoryCustom { - private final MongoTemplate mongoTemplate; - private final BookProgressRepository bookProgressRepository; - - @Override - public Page findChaptersWithFilters(String bookId, GetChaptersRequest request, String userId, Pageable pageable) { - Query query = buildQuery(bookId, request, userId); - - // 총 개수 조회 (필터링 적용 후) - long total = mongoTemplate.count(query, Chapter.class); - - // 페이지네이션 적용 - query.with(pageable); - - // 데이터 조회 - List chapters = mongoTemplate.find(query, Chapter.class); - - return new PageImpl<>(chapters, pageable, total); - } - - /** - * 동적 쿼리 빌드 - */ - private Query buildQuery(String bookId, GetChaptersRequest request, String userId) { - Query query = new Query(); - - // bookId 필터는 항상 적용 - query.addCriteria(Criteria.where("bookId").is(bookId)); - - // 진도 필터 적용 - applyProgressFilter(query, request.getProgress(), bookId, userId); - - return query; - } - - /** - * 진도 필터 적용 - */ - private void applyProgressFilter(Query query, ProgressStatus progress, String bookId, String userId) { - if (progress == null || userId == null) { - return; - } - - BookProgress bookProgress = bookProgressRepository.findByUserIdAndBookId(userId, bookId) - .orElse(null); - - List chapterNumbers = getChapterNumbersByProgress(bookId, bookProgress, progress); - - if (chapterNumbers == null) { - // null이면 필터링하지 않음 (모든 챕터 반환) - return; - } - - if (!chapterNumbers.isEmpty()) { - query.addCriteria(Criteria.where("chapterNumber").in(chapterNumbers)); - } else { - // 조건에 맞는 챕터가 없으면 빈 결과 반환 - query.addCriteria(Criteria.where("_id").is(null)); - } - } - - /** - * 진도 상태별 챕터 번호 목록 조회 - */ - private List getChapterNumbersByProgress(String bookId, BookProgress bookProgress, ProgressStatus progressStatus) { - // 모든 챕터 번호 조회 - List allChapters = mongoTemplate.find( - Query.query(Criteria.where("bookId").is(bookId)), - Chapter.class - ); - List allChapterNumbers = allChapters.stream().map(Chapter::getChapterNumber).toList(); - - if (bookProgress == null) { - return progressStatus == ProgressStatus.NOT_STARTED ? allChapterNumbers : List.of(); - } - - Map progressInfoMap = - bookProgress.getChapterProgresses() == null - ? Map.of() - : bookProgress.getChapterProgresses().stream() - .collect(Collectors.toMap(BookProgress.ChapterProgressInfo::getChapterNumber, Function.identity())); - - return allChapterNumbers.stream() - .filter(chapterNumber -> { - BookProgress.ChapterProgressInfo info = progressInfoMap.get(chapterNumber); - boolean isCompleted = info != null && Boolean.TRUE.equals(info.getIsCompleted()); - boolean inProgress = info != null - && !isCompleted - && info.getProgressPercentage() != null - && info.getProgressPercentage() > 0; - - return switch (progressStatus) { - case COMPLETED -> isCompleted; - case IN_PROGRESS -> inProgress; - case NOT_STARTED -> !isCompleted && !inProgress; - }; - }) - .toList(); - } + private final MongoTemplate mongoTemplate; + + private final BookProgressRepository bookProgressRepository; + + @Override + public Page findChaptersWithFilters(String bookId, GetChaptersRequest request, String userId, + Pageable pageable) { + Query query = buildQuery(bookId, request, userId); + + // 총 개수 조회 (필터링 적용 후) + long total = mongoTemplate.count(query, Chapter.class); + + // 페이지네이션 적용 + query.with(pageable); + + // 데이터 조회 + List chapters = mongoTemplate.find(query, Chapter.class); + + return new PageImpl<>(chapters, pageable, total); + } + + /** + * 동적 쿼리 빌드 + */ + private Query buildQuery(String bookId, GetChaptersRequest request, String userId) { + Query query = new Query(); + + // bookId 필터는 항상 적용 + query.addCriteria(Criteria.where("bookId").is(bookId)); + + // 진도 필터 적용 + applyProgressFilter(query, request.getProgress(), bookId, userId); + + return query; + } + + /** + * 진도 필터 적용 + */ + private void applyProgressFilter(Query query, ProgressStatus progress, String bookId, String userId) { + if (progress == null || userId == null) { + return; + } + + BookProgress bookProgress = bookProgressRepository.findByUserIdAndBookId(userId, bookId).orElse(null); + + List chapterNumbers = getChapterNumbersByProgress(bookId, bookProgress, progress); + + if (chapterNumbers == null) { + // null이면 필터링하지 않음 (모든 챕터 반환) + return; + } + + if (!chapterNumbers.isEmpty()) { + query.addCriteria(Criteria.where("chapterNumber").in(chapterNumbers)); + } + else { + // 조건에 맞는 챕터가 없으면 빈 결과 반환 + query.addCriteria(Criteria.where("_id").is(null)); + } + } + + /** + * 진도 상태별 챕터 번호 목록 조회 + */ + private List getChapterNumbersByProgress(String bookId, BookProgress bookProgress, + ProgressStatus progressStatus) { + // 모든 챕터 번호 조회 + List allChapters = mongoTemplate.find(Query.query(Criteria.where("bookId").is(bookId)), Chapter.class); + List allChapterNumbers = allChapters.stream().map(Chapter::getChapterNumber).toList(); + + if (bookProgress == null) { + return progressStatus == ProgressStatus.NOT_STARTED ? allChapterNumbers : List.of(); + } + + Map progressInfoMap = bookProgress.getChapterProgresses() == null + ? Map.of() + : bookProgress.getChapterProgresses() + .stream() + .collect(Collectors.toMap(BookProgress.ChapterProgressInfo::getChapterNumber, Function.identity())); + + return allChapterNumbers.stream().filter(chapterNumber -> { + BookProgress.ChapterProgressInfo info = progressInfoMap.get(chapterNumber); + boolean isCompleted = info != null && Boolean.TRUE.equals(info.getIsCompleted()); + boolean inProgress = info != null && !isCompleted && info.getProgressPercentage() != null + && info.getProgressPercentage() > 0; + + return switch (progressStatus) { + case COMPLETED -> isCompleted; + case IN_PROGRESS -> inProgress; + case NOT_STARTED -> !isCompleted && !inProgress; + }; + }).toList(); + } + } diff --git a/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java b/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java index 3ea117bb..454086ab 100644 --- a/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java +++ b/src/main/java/com/linglevel/api/content/book/repository/ChunkRepository.java @@ -13,47 +13,44 @@ import java.util.Optional; public interface ChunkRepository extends MongoRepository { - Page findByChapterIdAndDifficultyLevel(String chapterId, DifficultyLevel difficultyLevel, Pageable pageable); - - Optional findFirstByChapterIdOrderByChunkNumberAsc(String chapterId); - - Optional findById(String chunkId); - - List findByChapterIdOrderByChunkNumber(String chapterId); - - // V2 Progress: Count chunks by difficulty level - long countByChapterIdAndDifficultyLevel(String chapterId, DifficultyLevel difficultyLevel); - - @Aggregation(pipeline = { - """ - { - $match: { - chapterId: { $in: ?0 } - } - } - """, - """ - { - $group: { - _id: { - chapterId: '$chapterId', - difficultyLevel: '$difficultyLevel' - }, - count: { $sum: 1 } - } - } - """, - """ - { - $project: { - chapterId: '$_id.chapterId', - difficultyLevel: '$_id.difficultyLevel', - count: 1, - _id: 0 - } - } - """ - }) - List findChunkCountsByChapterIds(List chapterIds); + + Page findByChapterIdAndDifficultyLevel(String chapterId, DifficultyLevel difficultyLevel, Pageable pageable); + + Optional findFirstByChapterIdOrderByChunkNumberAsc(String chapterId); + + Optional findById(String chunkId); + + List findByChapterIdOrderByChunkNumber(String chapterId); + + // V2 Progress: Count chunks by difficulty level + long countByChapterIdAndDifficultyLevel(String chapterId, DifficultyLevel difficultyLevel); + + @Aggregation(pipeline = { """ + { + $match: { + chapterId: { $in: ?0 } + } + } + """, """ + { + $group: { + _id: { + chapterId: '$chapterId', + difficultyLevel: '$difficultyLevel' + }, + count: { $sum: 1 } + } + } + """, """ + { + $project: { + chapterId: '$_id.chapterId', + difficultyLevel: '$_id.difficultyLevel', + count: 1, + _id: 0 + } + } + """ }) + List findChunkCountsByChapterIds(List chapterIds); } diff --git a/src/main/java/com/linglevel/api/content/book/service/BookImportService.java b/src/main/java/com/linglevel/api/content/book/service/BookImportService.java index 7bf61416..7c45b005 100644 --- a/src/main/java/com/linglevel/api/content/book/service/BookImportService.java +++ b/src/main/java/com/linglevel/api/content/book/service/BookImportService.java @@ -21,68 +21,74 @@ @RequiredArgsConstructor public class BookImportService { - private final ChapterRepository chapterRepository; - private final ChunkRepository chunkRepository; - private final S3UrlService s3UrlService; - private final BookPathStrategy bookPathStrategy; - - public List createChaptersFromMetadata(BookImportData importData, String bookId) { - AtomicInteger chapterCounter = new AtomicInteger(1); - List chapters = importData.getChapterMetadata().stream() - .map(metadata -> { - Chapter chapter = new Chapter(); - chapter.setBookId(bookId); - chapter.setChapterNumber(chapterCounter.getAndIncrement()); - chapter.setTitle(metadata.getTitle()); - chapter.setDescription(metadata.getSummary()); - chapter.setReadingTime(0); - return chapter; - }) - .collect(Collectors.toList()); - - return chapterRepository.saveAll(chapters); - } - - public void createChunksFromLeveledResults(BookImportData importData, List savedChapters, String databaseBookId) { - List allChunks = new ArrayList<>(); - - for (BookImportData.TextLevelData levelData : importData.getLeveledResults()) { - DifficultyLevel difficulty = DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()); - List aiChapters = levelData.getChapters(); - - for (int i = 0; i < savedChapters.size(); i++) { - if (i >= aiChapters.size()) break; // Safety break if lists are not aligned - - Chapter savedChapter = savedChapters.get(i); - BookImportData.ChapterData aiChapterData = aiChapters.get(i); - - int chunkCounter = 1; - for (BookImportData.ChunkData chunkData : aiChapterData.getChunks()) { - Chunk chunk = createChunk(chunkData, savedChapter, difficulty, databaseBookId, chunkCounter++); - allChunks.add(chunk); - } - } - } - chunkRepository.saveAll(allChunks); - } - - private Chunk createChunk(BookImportData.ChunkData chunkData, Chapter chapter, DifficultyLevel difficulty, String bookId, int chunkNumber) { - Chunk chunk = new Chunk(); - chunk.setChapterId(chapter.getId()); - chunk.setChunkNumber(chunkNumber); - chunk.setDifficultyLevel(difficulty); - - if (Boolean.TRUE.equals(chunkData.getIsImage())) { - chunk.setType(ChunkType.IMAGE); - String imageUrl = s3UrlService.buildImageUrl(bookId, chunkData.getChunkText(), bookPathStrategy); - chunk.setContent(imageUrl); - chunk.setDescription(chunkData.getDescription()); - } else { - chunk.setType(ChunkType.TEXT); - chunk.setContent(chunkData.getChunkText()); - chunk.setDescription(null); - } - - return chunk; - } + private final ChapterRepository chapterRepository; + + private final ChunkRepository chunkRepository; + + private final S3UrlService s3UrlService; + + private final BookPathStrategy bookPathStrategy; + + public List createChaptersFromMetadata(BookImportData importData, String bookId) { + AtomicInteger chapterCounter = new AtomicInteger(1); + List chapters = importData.getChapterMetadata().stream().map(metadata -> { + Chapter chapter = new Chapter(); + chapter.setBookId(bookId); + chapter.setChapterNumber(chapterCounter.getAndIncrement()); + chapter.setTitle(metadata.getTitle()); + chapter.setDescription(metadata.getSummary()); + chapter.setReadingTime(0); + return chapter; + }).collect(Collectors.toList()); + + return chapterRepository.saveAll(chapters); + } + + public void createChunksFromLeveledResults(BookImportData importData, List savedChapters, + String databaseBookId) { + List allChunks = new ArrayList<>(); + + for (BookImportData.TextLevelData levelData : importData.getLeveledResults()) { + DifficultyLevel difficulty = DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()); + List aiChapters = levelData.getChapters(); + + for (int i = 0; i < savedChapters.size(); i++) { + if (i >= aiChapters.size()) + break; // Safety break if lists are not aligned + + Chapter savedChapter = savedChapters.get(i); + BookImportData.ChapterData aiChapterData = aiChapters.get(i); + + int chunkCounter = 1; + for (BookImportData.ChunkData chunkData : aiChapterData.getChunks()) { + Chunk chunk = createChunk(chunkData, savedChapter, difficulty, databaseBookId, chunkCounter++); + allChunks.add(chunk); + } + } + } + chunkRepository.saveAll(allChunks); + } + + private Chunk createChunk(BookImportData.ChunkData chunkData, Chapter chapter, DifficultyLevel difficulty, + String bookId, int chunkNumber) { + Chunk chunk = new Chunk(); + chunk.setChapterId(chapter.getId()); + chunk.setChunkNumber(chunkNumber); + chunk.setDifficultyLevel(difficulty); + + if (Boolean.TRUE.equals(chunkData.getIsImage())) { + chunk.setType(ChunkType.IMAGE); + String imageUrl = s3UrlService.buildImageUrl(bookId, chunkData.getChunkText(), bookPathStrategy); + chunk.setContent(imageUrl); + chunk.setDescription(chunkData.getDescription()); + } + else { + chunk.setType(ChunkType.TEXT); + chunk.setContent(chunkData.getChunkText()); + chunk.setDescription(null); + } + + return chunk; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/service/BookReadingTimeService.java b/src/main/java/com/linglevel/api/content/book/service/BookReadingTimeService.java index 4837097f..10aa2868 100644 --- a/src/main/java/com/linglevel/api/content/book/service/BookReadingTimeService.java +++ b/src/main/java/com/linglevel/api/content/book/service/BookReadingTimeService.java @@ -18,43 +18,49 @@ @RequiredArgsConstructor public class BookReadingTimeService { - private final BookRepository bookRepository; - private final ChapterRepository chapterRepository; - private final ReadingTimeService readingTimeService; - - public void updateReadingTimes(String bookId, BookImportData importData) { - Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); - - List chapters = chapterRepository.findByBookIdOrderByChapterNumber(bookId); - - int totalBookReadingTime = 0; - - for (Chapter chapter : chapters) { - int chapterReadingTime = calculateChapterReadingTime(chapter.getChapterNumber(), book.getDifficultyLevel(), importData); - chapter.setReadingTime(chapterReadingTime); - totalBookReadingTime += chapterReadingTime; - } - - book.setReadingTime(totalBookReadingTime); - - chapterRepository.saveAll(chapters); - bookRepository.save(book); - } - - private int calculateChapterReadingTime(int chapterNumber, DifficultyLevel difficultyLevel, BookImportData importData) { - int totalCharacters = importData.getLeveledResults().stream() - .filter(levelData -> DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()) == difficultyLevel) - .flatMap(levelData -> levelData.getChapters().stream()) - .filter(chapterData -> chapterData.getChapterNum() == chapterNumber) - .flatMap(chapterData -> chapterData.getChunks().stream()) - .mapToInt(chunkData -> chunkData.getChunkText().length()) - .sum(); - - return calculateReadingTimeFromCharacters(totalCharacters); - } - - private int calculateReadingTimeFromCharacters(int characterCount) { - return readingTimeService.calculateReadingTimeFromCharacters(characterCount); - } + private final BookRepository bookRepository; + + private final ChapterRepository chapterRepository; + + private final ReadingTimeService readingTimeService; + + public void updateReadingTimes(String bookId, BookImportData importData) { + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); + + List chapters = chapterRepository.findByBookIdOrderByChapterNumber(bookId); + + int totalBookReadingTime = 0; + + for (Chapter chapter : chapters) { + int chapterReadingTime = calculateChapterReadingTime(chapter.getChapterNumber(), book.getDifficultyLevel(), + importData); + chapter.setReadingTime(chapterReadingTime); + totalBookReadingTime += chapterReadingTime; + } + + book.setReadingTime(totalBookReadingTime); + + chapterRepository.saveAll(chapters); + bookRepository.save(book); + } + + private int calculateChapterReadingTime(int chapterNumber, DifficultyLevel difficultyLevel, + BookImportData importData) { + int totalCharacters = importData.getLeveledResults() + .stream() + .filter(levelData -> DifficultyLevel.valueOf(levelData.getTextLevel().toUpperCase()) == difficultyLevel) + .flatMap(levelData -> levelData.getChapters().stream()) + .filter(chapterData -> chapterData.getChapterNum() == chapterNumber) + .flatMap(chapterData -> chapterData.getChunks().stream()) + .mapToInt(chunkData -> chunkData.getChunkText().length()) + .sum(); + + return calculateReadingTimeFromCharacters(totalCharacters); + } + + private int calculateReadingTimeFromCharacters(int characterCount) { + return readingTimeService.calculateReadingTimeFromCharacters(characterCount); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/service/BookService.java b/src/main/java/com/linglevel/api/content/book/service/BookService.java index a09e29d7..27987e0c 100644 --- a/src/main/java/com/linglevel/api/content/book/service/BookService.java +++ b/src/main/java/com/linglevel/api/content/book/service/BookService.java @@ -38,240 +38,234 @@ @Slf4j public class BookService { - private final BookRepository bookRepository; - private final BookProgressRepository bookProgressRepository; - - private final S3AiService s3AiService; - private final S3TransferService s3TransferService; - private final S3UrlService s3UrlService; - private final BookPathStrategy bookPathStrategy; - private final ImageResizeService imageResizeService; - - private final BookReadingTimeService bookReadingTimeService; - private final BookImportService bookImportService; - - @Transactional - public BookImportResponse importBook(BookImportRequest request) { - log.info("Starting book import for file: {}", request.getId()); - BookImportData importData = s3AiService.downloadJsonFile(request.getId(), BookImportData.class, bookPathStrategy); - - Book book = createBook(importData, request.getId()); - Book savedBook = bookRepository.save(book); - - s3TransferService.transferImagesFromAiToStatic(request.getId(), savedBook.getId(), bookPathStrategy); - - String coverImageUrl = s3UrlService.getCoverImageUrl(savedBook.getId(), bookPathStrategy); - savedBook.setCoverImageUrl(coverImageUrl); - - if (StringUtils.hasText(coverImageUrl)) { - try { - log.info("Auto-processing cover image for imported book: {}", savedBook.getId()); - - String originalCoverS3Key = bookPathStrategy.generateCoverImagePath(savedBook.getId()); - String smallImageUrl = imageResizeService.createSmallImage(originalCoverS3Key); - - savedBook.setCoverImageUrl(smallImageUrl); - log.info("Successfully auto-processed cover image: {} → {}", savedBook.getId(), smallImageUrl); - - } catch (Exception e) { - log.warn("Failed to auto-process cover image for book: {}, keeping original URL", savedBook.getId(), e); - } - } - - bookRepository.save(savedBook); - - List savedChapters = bookImportService.createChaptersFromMetadata(importData, savedBook.getId()); - - bookImportService.createChunksFromLeveledResults(importData, savedChapters, savedBook.getId()); - - bookReadingTimeService.updateReadingTimes(savedBook.getId(), importData); - - log.info("Successfully imported book with id: {}", savedBook.getId()); - return new BookImportResponse(savedBook.getId()); - } - - private Book createBook(BookImportData importData, String requestId) { - Book book = new Book(); - book.setTitle(importData.getTitle()); - book.setTitleTranslations(importData.getTitleTranslations()); - book.setAuthor(importData.getAuthor()); - DifficultyLevel difficultyLevel = DifficultyLevel.valueOf(importData - .getOriginalTextLevel().toUpperCase()); - book.setDifficultyLevel(difficultyLevel); - - String coverImageUrl = s3UrlService.getCoverImageUrl(requestId, bookPathStrategy); - book.setCoverImageUrl(coverImageUrl); - - book.setViewCount(0); - book.setAverageRating(0.0); - book.setReviewCount(0); - book.setReadingTime(0); - book.setCreatedAt(Instant.now()); - - int chapterCount = importData.getLeveledResults().isEmpty() ? 0 : - importData.getLeveledResults().get(0).getChapters().size(); - - book.setChapterCount(chapterCount); - - return book; - } - - public PageResponse getBooks(GetBooksRequest request, String userId) { - Sort sort = createSort(request.getSortBy()); - - Pageable pageable = PageRequest.of( - request.getPage() - 1, - request.getLimit(), - sort - ); - - // QueryDSL Custom Repository를 사용하여 필터링 + 페이지네이션 통합 처리 - Page bookPage = bookRepository.findBooksWithFilters(request, userId, pageable); - List books = bookPage.getContent(); - Map progressMap = getProgressMap(userId, books); - - LanguageCode languageCode = request.getLanguageCode(); - List bookResponses = books.stream() - .map(book -> convertToBookResponse(book, progressMap.get(book.getId()), languageCode)) - .collect(Collectors.toList()); - - return new PageResponse<>(bookResponses, bookPage); - } - - public BookResponse getBook(String bookId, String userId, LanguageCode languageCode) { - Book book = bookRepository.findById(bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); - - BookProgress progress = userId == null - ? null - : bookProgressRepository.findByUserIdAndBookId(userId, book.getId()).orElse(null); - - return convertToBookResponse(book, progress, languageCode); - } - - public boolean existsById(String bookId) { - return bookRepository.existsById(bookId); - } - - public Book findById(String bookId) { - return bookRepository.findById(bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); - } - - private Sort createSort(String sortBy) { - if (sortBy == null) { - sortBy = "created_at"; - } - - return switch (sortBy.toLowerCase()) { - case "view_count" -> Sort.by("viewCount").descending(); - case "average_rating" -> Sort.by("averageRating").descending(); - case "created_at" -> Sort.by("createdAt").descending(); - default -> throw new BooksException(BooksErrorCode.INVALID_SORT_BY); - }; - } - - private List filterByProgress(List bookResponses, ProgressStatus progressFilter) { - if (progressFilter == null) { - return bookResponses; // No filter, return all - } - - return bookResponses.stream() - .filter(book -> { - return switch (progressFilter) { - case NOT_STARTED -> book.getProgressPercentage() == 0.0; - case IN_PROGRESS -> book.getProgressPercentage() > 0.0 && !book.getIsCompleted(); - case COMPLETED -> book.getIsCompleted(); - }; - }) - .collect(Collectors.toList()); - } - - private Map getProgressMap(String userId, List books) { - if (userId == null || books.isEmpty()) { - return Map.of(); - } - - List bookIds = books.stream().map(Book::getId).toList(); - List progresses = bookProgressRepository.findByUserIdAndBookIdIn(userId, bookIds); - if (progresses == null || progresses.isEmpty()) { - return Map.of(); - } - - Map progressMap = new HashMap<>(); - for (BookProgress progress : progresses) { - if (progress.getBookId() != null) { - // unique index(userId, bookId) 기준으로 bookId당 1건만 유지 - progressMap.putIfAbsent(progress.getBookId(), progress); - } - } - return progressMap; - } - - private BookResponse convertToBookResponse(Book book, BookProgress progress, LanguageCode languageCode) { - // 진도 정보 조회 - int currentReadChapterNumber = 0; - double progressPercentage = 0.0; - boolean isCompleted = false; - - if (progress != null) { - currentReadChapterNumber = progress.getCurrentReadChapterNumber() != null - ? progress.getCurrentReadChapterNumber() : 0; - - // 진행률은 저장된 normalizedProgress를 단일 소스로 사용한다. - progressPercentage = progress.getNormalizedProgress() != null - ? progress.getNormalizedProgress() - : 0.0; - - // DB에 저장된 완료 여부 사용 - isCompleted = progress.getIsCompleted() != null ? progress.getIsCompleted() : false; - } - - // 언어 코드에 따라 title 선택 - String selectedTitle = selectTitleByLanguage(book, languageCode); - - return BookResponse.builder() - .id(book.getId()) - .title(selectedTitle) - .author(book.getAuthor()) - .coverImageUrl(book.getCoverImageUrl()) - .difficultyLevel(book.getDifficultyLevel()) - .chapterCount(book.getChapterCount()) - .currentReadChapterNumber(currentReadChapterNumber) - .progressPercentage(progressPercentage) - .isCompleted(isCompleted) - .readingTime(book.getReadingTime()) - .averageRating(book.getAverageRating()) - .reviewCount(book.getReviewCount()) - .viewCount(book.getViewCount()) - .tags(book.getTags()) - .createdAt(book.getCreatedAt()) - .build(); - } - - private String selectTitleByLanguage(Book book, LanguageCode languageCode) { - if (languageCode == null) { - return book.getTitle(); - } - - return switch (languageCode) { - case EN -> book.getTitle(); - case KO -> { - if (book.getTitleTranslations() != null && - StringUtils.hasText(book.getTitleTranslations().getKo())) { - yield book.getTitleTranslations().getKo(); - } - yield book.getTitle(); - } - case JA -> { - if (book.getTitleTranslations() != null && - StringUtils.hasText(book.getTitleTranslations().getJa())) { - yield book.getTitleTranslations().getJa(); - } - yield book.getTitle(); - } - }; - } + private final BookRepository bookRepository; + private final BookProgressRepository bookProgressRepository; + + private final S3AiService s3AiService; + + private final S3TransferService s3TransferService; + + private final S3UrlService s3UrlService; + + private final BookPathStrategy bookPathStrategy; + + private final ImageResizeService imageResizeService; + + private final BookReadingTimeService bookReadingTimeService; + + private final BookImportService bookImportService; + + @Transactional + public BookImportResponse importBook(BookImportRequest request) { + log.info("Starting book import for file: {}", request.getId()); + BookImportData importData = s3AiService.downloadJsonFile(request.getId(), BookImportData.class, + bookPathStrategy); + + Book book = createBook(importData, request.getId()); + Book savedBook = bookRepository.save(book); + + s3TransferService.transferImagesFromAiToStatic(request.getId(), savedBook.getId(), bookPathStrategy); + + String coverImageUrl = s3UrlService.getCoverImageUrl(savedBook.getId(), bookPathStrategy); + savedBook.setCoverImageUrl(coverImageUrl); + + if (StringUtils.hasText(coverImageUrl)) { + try { + log.info("Auto-processing cover image for imported book: {}", savedBook.getId()); + + String originalCoverS3Key = bookPathStrategy.generateCoverImagePath(savedBook.getId()); + String smallImageUrl = imageResizeService.createSmallImage(originalCoverS3Key); + + savedBook.setCoverImageUrl(smallImageUrl); + log.info("Successfully auto-processed cover image: {} → {}", savedBook.getId(), smallImageUrl); + + } + catch (Exception e) { + log.warn("Failed to auto-process cover image for book: {}, keeping original URL", savedBook.getId(), e); + } + } + + bookRepository.save(savedBook); + + List savedChapters = bookImportService.createChaptersFromMetadata(importData, savedBook.getId()); + + bookImportService.createChunksFromLeveledResults(importData, savedChapters, savedBook.getId()); + + bookReadingTimeService.updateReadingTimes(savedBook.getId(), importData); + + log.info("Successfully imported book with id: {}", savedBook.getId()); + return new BookImportResponse(savedBook.getId()); + } + + private Book createBook(BookImportData importData, String requestId) { + Book book = new Book(); + book.setTitle(importData.getTitle()); + book.setTitleTranslations(importData.getTitleTranslations()); + book.setAuthor(importData.getAuthor()); + DifficultyLevel difficultyLevel = DifficultyLevel.valueOf(importData.getOriginalTextLevel().toUpperCase()); + book.setDifficultyLevel(difficultyLevel); + + String coverImageUrl = s3UrlService.getCoverImageUrl(requestId, bookPathStrategy); + book.setCoverImageUrl(coverImageUrl); + + book.setViewCount(0); + book.setAverageRating(0.0); + book.setReviewCount(0); + book.setReadingTime(0); + book.setCreatedAt(Instant.now()); + + int chapterCount = importData.getLeveledResults().isEmpty() ? 0 + : importData.getLeveledResults().get(0).getChapters().size(); + + book.setChapterCount(chapterCount); + + return book; + } + + public PageResponse getBooks(GetBooksRequest request, String userId) { + Sort sort = createSort(request.getSortBy()); + + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), sort); + + // QueryDSL Custom Repository를 사용하여 필터링 + 페이지네이션 통합 처리 + Page bookPage = bookRepository.findBooksWithFilters(request, userId, pageable); + List books = bookPage.getContent(); + Map progressMap = getProgressMap(userId, books); + + LanguageCode languageCode = request.getLanguageCode(); + List bookResponses = books.stream() + .map(book -> convertToBookResponse(book, progressMap.get(book.getId()), languageCode)) + .collect(Collectors.toList()); + + return new PageResponse<>(bookResponses, bookPage); + } + + public BookResponse getBook(String bookId, String userId, LanguageCode languageCode) { + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); + + BookProgress progress = userId == null ? null + : bookProgressRepository.findByUserIdAndBookId(userId, book.getId()).orElse(null); + + return convertToBookResponse(book, progress, languageCode); + } + + public boolean existsById(String bookId) { + return bookRepository.existsById(bookId); + } + + public Book findById(String bookId) { + return bookRepository.findById(bookId).orElseThrow(() -> new BooksException(BooksErrorCode.BOOK_NOT_FOUND)); + } + + private Sort createSort(String sortBy) { + if (sortBy == null) { + sortBy = "created_at"; + } + + return switch (sortBy.toLowerCase()) { + case "view_count" -> Sort.by("viewCount").descending(); + case "average_rating" -> Sort.by("averageRating").descending(); + case "created_at" -> Sort.by("createdAt").descending(); + default -> throw new BooksException(BooksErrorCode.INVALID_SORT_BY); + }; + } + + private List filterByProgress(List bookResponses, ProgressStatus progressFilter) { + if (progressFilter == null) { + return bookResponses; // No filter, return all + } + + return bookResponses.stream().filter(book -> { + return switch (progressFilter) { + case NOT_STARTED -> book.getProgressPercentage() == 0.0; + case IN_PROGRESS -> book.getProgressPercentage() > 0.0 && !book.getIsCompleted(); + case COMPLETED -> book.getIsCompleted(); + }; + }).collect(Collectors.toList()); + } + + private Map getProgressMap(String userId, List books) { + if (userId == null || books.isEmpty()) { + return Map.of(); + } + + List bookIds = books.stream().map(Book::getId).toList(); + List progresses = bookProgressRepository.findByUserIdAndBookIdIn(userId, bookIds); + if (progresses == null || progresses.isEmpty()) { + return Map.of(); + } + + Map progressMap = new HashMap<>(); + for (BookProgress progress : progresses) { + if (progress.getBookId() != null) { + // unique index(userId, bookId) 기준으로 bookId당 1건만 유지 + progressMap.putIfAbsent(progress.getBookId(), progress); + } + } + return progressMap; + } + + private BookResponse convertToBookResponse(Book book, BookProgress progress, LanguageCode languageCode) { + // 진도 정보 조회 + int currentReadChapterNumber = 0; + double progressPercentage = 0.0; + boolean isCompleted = false; + + if (progress != null) { + currentReadChapterNumber = progress.getCurrentReadChapterNumber() != null + ? progress.getCurrentReadChapterNumber() : 0; + + // 진행률은 저장된 normalizedProgress를 단일 소스로 사용한다. + progressPercentage = progress.getNormalizedProgress() != null ? progress.getNormalizedProgress() : 0.0; + + // DB에 저장된 완료 여부 사용 + isCompleted = progress.getIsCompleted() != null ? progress.getIsCompleted() : false; + } + + // 언어 코드에 따라 title 선택 + String selectedTitle = selectTitleByLanguage(book, languageCode); + + return BookResponse.builder() + .id(book.getId()) + .title(selectedTitle) + .author(book.getAuthor()) + .coverImageUrl(book.getCoverImageUrl()) + .difficultyLevel(book.getDifficultyLevel()) + .chapterCount(book.getChapterCount()) + .currentReadChapterNumber(currentReadChapterNumber) + .progressPercentage(progressPercentage) + .isCompleted(isCompleted) + .readingTime(book.getReadingTime()) + .averageRating(book.getAverageRating()) + .reviewCount(book.getReviewCount()) + .viewCount(book.getViewCount()) + .tags(book.getTags()) + .createdAt(book.getCreatedAt()) + .build(); + } + + private String selectTitleByLanguage(Book book, LanguageCode languageCode) { + if (languageCode == null) { + return book.getTitle(); + } + + return switch (languageCode) { + case EN -> book.getTitle(); + case KO -> { + if (book.getTitleTranslations() != null && StringUtils.hasText(book.getTitleTranslations().getKo())) { + yield book.getTitleTranslations().getKo(); + } + yield book.getTitle(); + } + case JA -> { + if (book.getTitleTranslations() != null && StringUtils.hasText(book.getTitleTranslations().getJa())) { + yield book.getTitleTranslations().getJa(); + } + yield book.getTitle(); + } + }; + } } diff --git a/src/main/java/com/linglevel/api/content/book/service/ChapterService.java b/src/main/java/com/linglevel/api/content/book/service/ChapterService.java index 200e9a4b..a34defae 100644 --- a/src/main/java/com/linglevel/api/content/book/service/ChapterService.java +++ b/src/main/java/com/linglevel/api/content/book/service/ChapterService.java @@ -34,161 +34,165 @@ @Slf4j public class ChapterService { - private final ChapterRepository chapterRepository; - private final BookProgressRepository bookProgressRepository; - private final ChunkRepository chunkRepository; - private final BookService bookService; - private final BookRepository bookRepository; - - public PageResponse getChapters(String bookId, GetChaptersRequest request, String userId) { - Book book = bookService.findById(bookId); - - bookRepository.incrementViewCount(bookId); - - Pageable pageable = PageRequest.of( - request.getPage() - 1, - request.getLimit(), - Sort.by("chapterNumber").ascending() - ); - - Page chapterPage = chapterRepository.findChaptersWithFilters(bookId, request, userId, pageable); - List chapters = chapterPage.getContent(); - - if (chapters.isEmpty()) { - return new PageResponse<>(Collections.emptyList(), chapterPage); - } - - List chapterIds = chapters.stream().map(Chapter::getId).collect(Collectors.toList()); - - BookProgress bookProgress = Optional.ofNullable(userId) - .flatMap(id -> bookProgressRepository.findByUserIdAndBookId(id, bookId)) - .orElse(null); - - Map> chunkCountsMap = chunkRepository.findChunkCountsByChapterIds(chapterIds) - .stream() - .collect(Collectors.groupingBy( - ChunkCountByLevelDto::getChapterId, - Collectors.toMap(ChunkCountByLevelDto::getDifficultyLevel, ChunkCountByLevelDto::getCount) - )); - - List chapterResponses = chapters.stream() - .map(chapter -> convertToChapterResponse(chapter, book, bookProgress, chunkCountsMap)) - .collect(Collectors.toList()); - - return new PageResponse<>(chapterResponses, chapterPage); - } - - public ChapterResponse getChapter(String bookId, String chapterId, String userId) { - Book book = bookService.findById(bookId); - - Chapter chapter = chapterRepository.findById(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - - if (!bookId.equals(chapter.getBookId())) { - throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); - } - - BookProgress bookProgress = Optional.ofNullable(userId) - .flatMap(id -> bookProgressRepository.findByUserIdAndBookId(id, bookId)) - .orElse(null); - - Map> chunkCountsMap = chunkRepository.findChunkCountsByChapterIds(Collections.singletonList(chapterId)) - .stream() - .collect(Collectors.groupingBy( - ChunkCountByLevelDto::getChapterId, - Collectors.toMap(ChunkCountByLevelDto::getDifficultyLevel, ChunkCountByLevelDto::getCount) - )); - - return convertToChapterResponse(chapter, book, bookProgress, chunkCountsMap); - } - - public boolean existsById(String chapterId) { - return chapterRepository.existsById(chapterId); - } - - public Chapter findById(String chapterId) { - return chapterRepository.findById(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - } - - public Chapter findFirstByBookId(String bookId) { - return chapterRepository.findFirstByBookIdOrderByChapterNumberAsc(bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - } - - public ChapterNavigationResponse getChapterNavigation(String bookId, String chapterId) { - if (!bookService.existsById(bookId)) { - throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); - } - - Chapter currentChapter = chapterRepository.findById(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - - if (!bookId.equals(currentChapter.getBookId())) { - throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); - } - - Optional previousChapter = chapterRepository.findByBookIdAndChapterNumber( - bookId, currentChapter.getChapterNumber() - 1); - - Optional nextChapter = chapterRepository.findByBookIdAndChapterNumber( - bookId, currentChapter.getChapterNumber() + 1); - - return ChapterNavigationResponse.builder() - .currentChapterId(chapterId) - .currentChapterNumber(currentChapter.getChapterNumber()) - .hasPreviousChapter(previousChapter.isPresent()) - .previousChapterId(previousChapter.map(Chapter::getId).orElse(null)) - .hasNextChapter(nextChapter.isPresent()) - .nextChapterId(nextChapter.map(Chapter::getId).orElse(null)) - .build(); - } - - private ChapterResponse convertToChapterResponse(Chapter chapter, Book book, BookProgress bookProgress, Map> chunkCountsMap) { - int currentReadChunkNumber = 0; - double progressPercentage = 0.0; - DifficultyLevel currentDifficultyLevel = book.getDifficultyLevel(); // Fallback: Book's difficulty - boolean isCompleted = false; - - if (bookProgress != null) { - if (bookProgress.getCurrentDifficultyLevel() != null) { - currentDifficultyLevel = bookProgress.getCurrentDifficultyLevel(); - } - - // [V3] chapterProgresses 배열에서 챕터 정보 조회 - BookProgress.ChapterProgressInfo chapterProgressInfo = bookProgress.getChapterProgresses() != null - ? bookProgress.getChapterProgresses().stream() - .filter(cp -> chapter.getChapterNumber().equals(cp.getChapterNumber())) - .findFirst() - .orElse(null) - : null; - - if (chapterProgressInfo != null) { - progressPercentage = chapterProgressInfo.getProgressPercentage() != null - ? chapterProgressInfo.getProgressPercentage() : 0.0; - isCompleted = Boolean.TRUE.equals(chapterProgressInfo.getIsCompleted()); - - // 진행률에 따라 currentReadChunkNumber 계산 - long totalChunksForLevel = chunkCountsMap.getOrDefault(chapter.getId(), Collections.emptyMap()) - .getOrDefault(currentDifficultyLevel, 0L); - currentReadChunkNumber = (int) Math.ceil(progressPercentage * totalChunksForLevel / 100.0); - } - } - - long totalChunkCount = chunkCountsMap.getOrDefault(chapter.getId(), Collections.emptyMap()).getOrDefault(currentDifficultyLevel, 0L); - - return ChapterResponse.builder() - .id(chapter.getId()) - .chapterNumber(chapter.getChapterNumber()) - .title(chapter.getTitle()) - .chapterImageUrl(chapter.getChapterImageUrl()) - .description(chapter.getDescription()) - .chunkCount((int) totalChunkCount) - .currentReadChunkNumber(currentReadChunkNumber) - .progressPercentage(progressPercentage) - .isCompleted(isCompleted) - .currentDifficultyLevel(currentDifficultyLevel) - .readingTime(chapter.getReadingTime()) - .build(); - } + private final ChapterRepository chapterRepository; + + private final BookProgressRepository bookProgressRepository; + + private final ChunkRepository chunkRepository; + + private final BookService bookService; + + private final BookRepository bookRepository; + + public PageResponse getChapters(String bookId, GetChaptersRequest request, String userId) { + Book book = bookService.findById(bookId); + + bookRepository.incrementViewCount(bookId); + + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), + Sort.by("chapterNumber").ascending()); + + Page chapterPage = chapterRepository.findChaptersWithFilters(bookId, request, userId, pageable); + List chapters = chapterPage.getContent(); + + if (chapters.isEmpty()) { + return new PageResponse<>(Collections.emptyList(), chapterPage); + } + + List chapterIds = chapters.stream().map(Chapter::getId).collect(Collectors.toList()); + + BookProgress bookProgress = Optional.ofNullable(userId) + .flatMap(id -> bookProgressRepository.findByUserIdAndBookId(id, bookId)) + .orElse(null); + + Map> chunkCountsMap = chunkRepository.findChunkCountsByChapterIds(chapterIds) + .stream() + .collect(Collectors.groupingBy(ChunkCountByLevelDto::getChapterId, + Collectors.toMap(ChunkCountByLevelDto::getDifficultyLevel, ChunkCountByLevelDto::getCount))); + + List chapterResponses = chapters.stream() + .map(chapter -> convertToChapterResponse(chapter, book, bookProgress, chunkCountsMap)) + .collect(Collectors.toList()); + + return new PageResponse<>(chapterResponses, chapterPage); + } + + public ChapterResponse getChapter(String bookId, String chapterId, String userId) { + Book book = bookService.findById(bookId); + + Chapter chapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + + if (!bookId.equals(chapter.getBookId())) { + throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); + } + + BookProgress bookProgress = Optional.ofNullable(userId) + .flatMap(id -> bookProgressRepository.findByUserIdAndBookId(id, bookId)) + .orElse(null); + + Map> chunkCountsMap = chunkRepository + .findChunkCountsByChapterIds(Collections.singletonList(chapterId)) + .stream() + .collect(Collectors.groupingBy(ChunkCountByLevelDto::getChapterId, + Collectors.toMap(ChunkCountByLevelDto::getDifficultyLevel, ChunkCountByLevelDto::getCount))); + + return convertToChapterResponse(chapter, book, bookProgress, chunkCountsMap); + } + + public boolean existsById(String chapterId) { + return chapterRepository.existsById(chapterId); + } + + public Chapter findById(String chapterId) { + return chapterRepository.findById(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + } + + public Chapter findFirstByBookId(String bookId) { + return chapterRepository.findFirstByBookIdOrderByChapterNumberAsc(bookId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + } + + public ChapterNavigationResponse getChapterNavigation(String bookId, String chapterId) { + if (!bookService.existsById(bookId)) { + throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); + } + + Chapter currentChapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + + if (!bookId.equals(currentChapter.getBookId())) { + throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); + } + + Optional previousChapter = chapterRepository.findByBookIdAndChapterNumber(bookId, + currentChapter.getChapterNumber() - 1); + + Optional nextChapter = chapterRepository.findByBookIdAndChapterNumber(bookId, + currentChapter.getChapterNumber() + 1); + + return ChapterNavigationResponse.builder() + .currentChapterId(chapterId) + .currentChapterNumber(currentChapter.getChapterNumber()) + .hasPreviousChapter(previousChapter.isPresent()) + .previousChapterId(previousChapter.map(Chapter::getId).orElse(null)) + .hasNextChapter(nextChapter.isPresent()) + .nextChapterId(nextChapter.map(Chapter::getId).orElse(null)) + .build(); + } + + private ChapterResponse convertToChapterResponse(Chapter chapter, Book book, BookProgress bookProgress, + Map> chunkCountsMap) { + int currentReadChunkNumber = 0; + double progressPercentage = 0.0; + DifficultyLevel currentDifficultyLevel = book.getDifficultyLevel(); // Fallback: + // Book's + // difficulty + boolean isCompleted = false; + + if (bookProgress != null) { + if (bookProgress.getCurrentDifficultyLevel() != null) { + currentDifficultyLevel = bookProgress.getCurrentDifficultyLevel(); + } + + // [V3] chapterProgresses 배열에서 챕터 정보 조회 + BookProgress.ChapterProgressInfo chapterProgressInfo = bookProgress.getChapterProgresses() != null + ? bookProgress.getChapterProgresses() + .stream() + .filter(cp -> chapter.getChapterNumber().equals(cp.getChapterNumber())) + .findFirst() + .orElse(null) + : null; + + if (chapterProgressInfo != null) { + progressPercentage = chapterProgressInfo.getProgressPercentage() != null + ? chapterProgressInfo.getProgressPercentage() : 0.0; + isCompleted = Boolean.TRUE.equals(chapterProgressInfo.getIsCompleted()); + + // 진행률에 따라 currentReadChunkNumber 계산 + long totalChunksForLevel = chunkCountsMap.getOrDefault(chapter.getId(), Collections.emptyMap()) + .getOrDefault(currentDifficultyLevel, 0L); + currentReadChunkNumber = (int) Math.ceil(progressPercentage * totalChunksForLevel / 100.0); + } + } + + long totalChunkCount = chunkCountsMap.getOrDefault(chapter.getId(), Collections.emptyMap()) + .getOrDefault(currentDifficultyLevel, 0L); + + return ChapterResponse.builder() + .id(chapter.getId()) + .chapterNumber(chapter.getChapterNumber()) + .title(chapter.getTitle()) + .chapterImageUrl(chapter.getChapterImageUrl()) + .description(chapter.getDescription()) + .chunkCount((int) totalChunkCount) + .currentReadChunkNumber(currentReadChunkNumber) + .progressPercentage(progressPercentage) + .isCompleted(isCompleted) + .currentDifficultyLevel(currentDifficultyLevel) + .readingTime(chapter.getReadingTime()) + .build(); + } + } diff --git a/src/main/java/com/linglevel/api/content/book/service/ChunkService.java b/src/main/java/com/linglevel/api/content/book/service/ChunkService.java index 83246de7..73b3ee9b 100644 --- a/src/main/java/com/linglevel/api/content/book/service/ChunkService.java +++ b/src/main/java/com/linglevel/api/content/book/service/ChunkService.java @@ -26,85 +26,89 @@ @Slf4j public class ChunkService { - private final ChunkRepository chunkRepository; - private final ChapterRepository chapterRepository; - private final BookService bookService; - - public PageResponse getChunks(String bookId, String chapterId, GetChunksRequest request, String userId) { - if (!bookService.existsById(bookId)) { - throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); - } - - Chapter chapter = chapterRepository.findById(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - if (!bookId.equals(chapter.getBookId())) { - throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); - } - - DifficultyLevel difficulty; - try { - difficulty = request.getDifficultyLevel(); - } catch (IllegalArgumentException e) { - throw new BooksException(BooksErrorCode.INVALID_DIFFICULTY_LEVEL); - } - - Pageable pageable = PageRequest.of( - request.getPage() - 1, - Math.min(request.getLimit(), 200), - Sort.by("chunkNumber").ascending() - ); - - Page chunkPage = chunkRepository.findByChapterIdAndDifficultyLevel(chapterId, difficulty, pageable); - log.info("Found chunks count: {}", chunkPage.getTotalElements()); - - List chunkResponses = chunkPage.getContent().stream() - .map(this::convertToChunkResponse) - .collect(Collectors.toList()); - - return new PageResponse<>(chunkResponses, chunkPage); - } - - public ChunkResponse getChunk(String bookId, String chapterId, String chunkId) { - if (!bookService.existsById(bookId)) { - throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); - } - - Chapter chapter = chapterRepository.findById(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); - if (!bookId.equals(chapter.getBookId())) { - throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); - } - - Chunk chunk = chunkRepository.findById(chunkId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); - - if (!chapterId.equals(chunk.getChapterId())) { - throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND); - } - - return convertToChunkResponse(chunk); - } - - public boolean existsById(String chunkId) { return chunkRepository.existsById(chunkId); } - - public Chunk findById(String chunkId) { - return chunkRepository.findById(chunkId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); - } - - public Chunk findFirstByChapterId(String chapterId) { - return chunkRepository.findFirstByChapterIdOrderByChunkNumberAsc(chapterId) - .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); - } - - private ChunkResponse convertToChunkResponse(Chunk chunk) { - return ChunkResponse.builder() - .id(chunk.getId()) - .chunkNumber(chunk.getChunkNumber()) - .difficultyLevel(chunk.getDifficultyLevel()) - .type(chunk.getType()) - .content(chunk.getContent()) - .description(chunk.getDescription()) - .build(); - } -} \ No newline at end of file + private final ChunkRepository chunkRepository; + + private final ChapterRepository chapterRepository; + + private final BookService bookService; + + public PageResponse getChunks(String bookId, String chapterId, GetChunksRequest request, + String userId) { + if (!bookService.existsById(bookId)) { + throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); + } + + Chapter chapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + if (!bookId.equals(chapter.getBookId())) { + throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); + } + + DifficultyLevel difficulty; + try { + difficulty = request.getDifficultyLevel(); + } + catch (IllegalArgumentException e) { + throw new BooksException(BooksErrorCode.INVALID_DIFFICULTY_LEVEL); + } + + Pageable pageable = PageRequest.of(request.getPage() - 1, Math.min(request.getLimit(), 200), + Sort.by("chunkNumber").ascending()); + + Page chunkPage = chunkRepository.findByChapterIdAndDifficultyLevel(chapterId, difficulty, pageable); + log.info("Found chunks count: {}", chunkPage.getTotalElements()); + + List chunkResponses = chunkPage.getContent() + .stream() + .map(this::convertToChunkResponse) + .collect(Collectors.toList()); + + return new PageResponse<>(chunkResponses, chunkPage); + } + + public ChunkResponse getChunk(String bookId, String chapterId, String chunkId) { + if (!bookService.existsById(bookId)) { + throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); + } + + Chapter chapter = chapterRepository.findById(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND)); + if (!bookId.equals(chapter.getBookId())) { + throw new BooksException(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK); + } + + Chunk chunk = chunkRepository.findById(chunkId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); + + if (!chapterId.equals(chunk.getChapterId())) { + throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND); + } + + return convertToChunkResponse(chunk); + } + + public boolean existsById(String chunkId) { + return chunkRepository.existsById(chunkId); + } + + public Chunk findById(String chunkId) { + return chunkRepository.findById(chunkId).orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); + } + + public Chunk findFirstByChapterId(String chapterId) { + return chunkRepository.findFirstByChapterIdOrderByChunkNumberAsc(chapterId) + .orElseThrow(() -> new BooksException(BooksErrorCode.CHUNK_NOT_FOUND)); + } + + private ChunkResponse convertToChunkResponse(Chunk chunk) { + return ChunkResponse.builder() + .id(chunk.getId()) + .chunkNumber(chunk.getChunkNumber()) + .difficultyLevel(chunk.getDifficultyLevel()) + .type(chunk.getType()) + .content(chunk.getContent()) + .description(chunk.getDescription()) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/book/service/ProgressService.java b/src/main/java/com/linglevel/api/content/book/service/ProgressService.java index b76552a6..c2ddca81 100644 --- a/src/main/java/com/linglevel/api/content/book/service/ProgressService.java +++ b/src/main/java/com/linglevel/api/content/book/service/ProgressService.java @@ -24,313 +24,307 @@ @RequiredArgsConstructor @Slf4j public class ProgressService { - private static final int CHAPTER_POSITION_SHIFT = 16; - private static final int CHAPTER_NUMBER_MAX = 0x7FFF; // 32767 - private static final int CHUNK_NUMBER_MAX = 0xFFFF; // 65535 - - private final BookService bookService; - private final ChapterService chapterService; - private final ChunkService chunkService; - private final BookProgressRepository bookProgressRepository; - private final ChunkRepository chunkRepository; - private final ReadingCompletionService readingCompletionService; - private final StreakService streakService; - private final ChapterRepository chapterRepository; - - - @Transactional - public ProgressResponse updateProgress(String bookId, ProgressUpdateRequest request, String userId) { - if (!bookService.existsById(bookId)) { - throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); - } - - // chunkId로부터 chunk 정보 조회 - Chunk chunk = chunkService.findById(request.getChunkId()); - - // chunk로부터 chapter 역추산 - if (chunk.getChapterId() == null) { - throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND); - } - Chapter chapter = chapterService.findById(chunk.getChapterId()); - - if (!chapter.getBookId().equals(bookId)) { - throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND_IN_BOOK); - } - - BookProgress bookProgress = bookProgressRepository.findByUserIdAndBookId(userId, bookId) - .orElse(new BookProgress()); - - ensureMigrated(bookProgress); - - // Null 체크 - if (chapter.getChapterNumber() == null || chunk.getChunkNumber() == null) { - throw new BooksException(BooksErrorCode.INVALID_CHUNK_NUMBER); - } - - bookProgress.setUserId(userId); - bookProgress.setBookId(bookId); - bookProgress.setChapterId(chapter.getId()); // 역추산된 chapter ID - bookProgress.setChunkId(request.getChunkId()); - bookProgress.setCurrentReadChapterNumber(chapter.getChapterNumber()); - bookProgress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); - - // [V3_CHAPTER_BASED] 챕터별 진행률 계산 - long totalChunksInChapter = chunkRepository.countByChapterIdAndDifficultyLevel( - chapter.getId(), chunk.getDifficultyLevel() - ); - double chapterProgressPercentage = totalChunksInChapter > 0 - ? (chunk.getChunkNumber() * 100.0 / totalChunksInChapter) - : 0.0; - - // 챕터 진행률 배열 초기화 (null 체크) - if (bookProgress.getChapterProgresses() == null) { - bookProgress.setChapterProgresses(new ArrayList<>()); - } - - // 현재 챕터의 진행률 업데이트 (배열 구조) - updateOrAddChapterProgress(bookProgress, chapter.getChapterNumber(), chapterProgressPercentage, false, null); - - // 책 전체 진행률 = 완료된 챕터 수 / 전체 챕터 수 - Integer totalChapters = chapterRepository.countByBookId(bookId); - long completedCount = getCompletedChapterCount(bookProgress); - double bookProgress_normalizedProgress = totalChapters > 0 - ? (completedCount * 100.0 / totalChapters) - : 0.0; - - bookProgress.setNormalizedProgress(bookProgress_normalizedProgress); - - // max 진도 업데이트 (현재 읽고 있는 챕터 번호 기준) - Integer currentChapterNum = chapter.getChapterNumber(); - Integer maxChapterNum = bookProgress.getMaxReadChapterNumber(); - - if (maxChapterNum == null || currentChapterNum > maxChapterNum) { - bookProgress.setMaxReadChapterNumber(currentChapterNum); - } - - int currentChunkPosition = toChapterFirstPosition(chapter.getChapterNumber(), chunk.getChunkNumber()); - Integer maxChunkPosition = bookProgress.getMaxReadChunkNumber(); - if (maxChunkPosition == null || currentChunkPosition > maxChunkPosition) { - bookProgress.setMaxReadChunkNumber(currentChunkPosition); - } - - // maxNormalizedProgress는 완료된 챕터 기반으로 설정 - bookProgress.setMaxNormalizedProgress(bookProgress_normalizedProgress); - - // 읽기 완료 처리 (30초 이상 읽은 경우 이벤트 발행 + 세션 삭제) - // Book은 category가 없으므로 null 전달 (추천 시스템 집계에서 자동 제외됨) - Long readTimeSeconds = readingCompletionService.processReadingCompletion( - userId, - ContentType.BOOK, - chapter.getId(), - null - ); - - // 스트릭 검사 및 완료 처리 로직 - boolean streakUpdated = false; - if (isLastChunkInChapter(chunk)) { - // 1. 챕터 완료 처리 - BookProgress.ChapterProgressInfo existingProgress = findChapterProgress(bookProgress, chapter.getChapterNumber()); - boolean isFirstCompletion = existingProgress == null || !Boolean.TRUE.equals(existingProgress.getIsCompleted()); - - // 챕터 진행률을 100%로 설정하고 완료 처리 - updateOrAddChapterProgress( - bookProgress, - chapter.getChapterNumber(), - 100.0, - true, - isFirstCompletion ? java.time.Instant.now() : existingProgress.getCompletedAt() - ); - - log.info("Chapter {} completed for book {} (first completion: {})", - chapter.getChapterNumber(), bookId, isFirstCompletion); - - // 스트릭 업데이트 (30초 이상 읽은 경우에만) - if (readTimeSeconds != null && readTimeSeconds >= 30) { - streakService.addStudyTime(userId, readTimeSeconds); - streakUpdated = streakService.updateStreak(userId, ContentType.BOOK, chapter.getId()); - streakService.addCompletedContent(userId, ContentType.BOOK, chapter.getId(), streakUpdated); - } - - // 3. 책 전체 완료 확인 (모든 챕터 완료 시) - boolean allChaptersCompleted = getCompletedChapterCount(bookProgress) >= totalChapters; - if (allChaptersCompleted && bookProgress.getCompletedAt() == null) { - bookProgress.setIsCompleted(true); - bookProgress.setCompletedAt(java.time.Instant.now()); - log.info("Book {} fully completed (all {} chapters) by user {}", bookId, totalChapters, userId); - } - } - - bookProgressRepository.save(bookProgress); - - return convertToProgressResponse(bookProgress, streakUpdated); - } - - /** - * 현재 청크가 챕터의 마지막 청크인지 확인 - */ - private boolean isLastChunkInChapter(Chunk chunk) { - long totalChunks = chunkRepository.countByChapterIdAndDifficultyLevel( - chunk.getChapterId(), chunk.getDifficultyLevel() - ); - return chunk.getChunkNumber() >= totalChunks; - } - - /** - * 챕터 진행률 정보 찾기 - */ - private BookProgress.ChapterProgressInfo findChapterProgress(BookProgress bookProgress, Integer chapterNumber) { - if (bookProgress.getChapterProgresses() == null) { - return null; - } - return bookProgress.getChapterProgresses().stream() - .filter(cp -> chapterNumber.equals(cp.getChapterNumber())) - .findFirst() - .orElse(null); - } - - /** - * 챕터 진행률 업데이트 또는 추가 - */ - private void updateOrAddChapterProgress( - BookProgress bookProgress, - Integer chapterNumber, - Double progressPercentage, - Boolean isCompleted, - java.time.Instant completedAt - ) { - BookProgress.ChapterProgressInfo existing = findChapterProgress(bookProgress, chapterNumber); - - if (existing != null) { - // 기존 항목 업데이트 - existing.setProgressPercentage(progressPercentage); - existing.setIsCompleted(isCompleted); - if (completedAt != null) { - existing.setCompletedAt(completedAt); - } - } else { - // 새 항목 추가 - BookProgress.ChapterProgressInfo newProgress = BookProgress.ChapterProgressInfo.builder() - .chapterNumber(chapterNumber) - .progressPercentage(progressPercentage) - .isCompleted(isCompleted) - .completedAt(completedAt) - .build(); - bookProgress.getChapterProgresses().add(newProgress); - } - } - - /** - * 완료된 챕터 수 계산 - */ - private long getCompletedChapterCount(BookProgress bookProgress) { - if (bookProgress.getChapterProgresses() == null) { - return 0; - } - return bookProgress.getChapterProgresses().stream() - .filter(cp -> Boolean.TRUE.equals(cp.getIsCompleted())) - .count(); - } - - /** - * V3 마이그레이션 보장 - * updateProgress 시점에 한 번만 실행 - */ - private void ensureMigrated(BookProgress progress) { - boolean needsMigration = false; - - // V3 필드 초기화 - if (progress.getChapterProgresses() == null) { - progress.setChapterProgresses(new ArrayList<>()); - needsMigration = true; - } - - if (needsMigration) { - log.info("V3 migration completed for BookProgress id={}, userId={}", - progress.getId(), progress.getUserId()); - } - } - - - @Transactional(readOnly = true) - public ProgressResponse getProgress(String bookId, String userId) { - if (!bookService.existsById(bookId)) { - throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); - } - - return bookProgressRepository.findByUserIdAndBookId(userId, bookId) - .map(progress -> convertToProgressResponse(progress, false)) - .orElseGet(() -> createNotStartedProgressResponse(userId, bookId)); - } - - @Transactional - public void deleteProgress(String bookId, String userId) { - if (!bookService.existsById(bookId)) { - throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); - } - - BookProgress bookProgress = bookProgressRepository.findByUserIdAndBookId(userId, bookId) - .orElseThrow(() -> new BooksException(BooksErrorCode.PROGRESS_NOT_FOUND)); - - bookProgressRepository.delete(bookProgress); - } - - // [V1_COMPAT] Chapter 기반 max 진도만 관리 - private boolean shouldUpdateMaxProgress(BookProgress progress, Integer chapterNum) { - Integer maxChapter = progress.getMaxReadChapterNumber(); - return maxChapter == null || chapterNum > maxChapter; - } - - private ProgressResponse convertToProgressResponse(BookProgress progress, boolean streakUpdated) { - // [DTO_MAPPING] chunk에서 chunkNumber 조회 - Chunk chunk = chunkService.findById(progress.getChunkId()); - - // [SAFETY] 마이그레이션이 안 되어 있는 경우 경고 로그 - if (progress.getChapterProgresses() == null) { - log.warn("BookProgress {} not migrated yet - this should only happen on read-only access", - progress.getId()); - } - - return ProgressResponse.builder() - .id(progress.getId()) - .userId(progress.getUserId()) - .bookId(progress.getBookId()) - .chapterId(progress.getChapterId()) - .chunkId(progress.getChunkId()) - .currentReadChapterNumber(progress.getCurrentReadChapterNumber()) - .currentReadChunkNumber(chunk.getChunkNumber()) - .maxReadChapterNumber(progress.getMaxReadChapterNumber()) - .maxReadChunkNumber(progress.getMaxReadChunkNumber()) - .isCompleted(progress.getIsCompleted()) - .currentDifficultyLevel(progress.getCurrentDifficultyLevel()) - .normalizedProgress(progress.getNormalizedProgress()) - .maxNormalizedProgress(progress.getMaxNormalizedProgress()) - .streakUpdated(streakUpdated) - .updatedAt(progress.getUpdatedAt()) - .build(); - } - - private ProgressResponse createNotStartedProgressResponse(String userId, String bookId) { - return ProgressResponse.builder() - .userId(userId) - .bookId(bookId) - .currentReadChapterNumber(0) - .currentReadChunkNumber(0) - .maxReadChapterNumber(0) - .maxReadChunkNumber(0) - .isCompleted(false) - .normalizedProgress(0.0) - .maxNormalizedProgress(0.0) - .streakUpdated(false) - .build(); - } - - private int toChapterFirstPosition(Integer chapterNumber, Integer chunkNumber) { - if (chapterNumber == null || chapterNumber <= 0 || chunkNumber == null || chunkNumber <= 0) { - throw new BooksException(BooksErrorCode.INVALID_CHUNK_NUMBER); - } - if (chapterNumber > CHAPTER_NUMBER_MAX || chunkNumber > CHUNK_NUMBER_MAX) { - throw new BooksException(BooksErrorCode.INVALID_CHUNK_NUMBER); - } - return (chapterNumber << CHAPTER_POSITION_SHIFT) | chunkNumber; - } + + private static final int CHAPTER_POSITION_SHIFT = 16; + + private static final int CHAPTER_NUMBER_MAX = 0x7FFF; // 32767 + + private static final int CHUNK_NUMBER_MAX = 0xFFFF; // 65535 + + private final BookService bookService; + + private final ChapterService chapterService; + + private final ChunkService chunkService; + + private final BookProgressRepository bookProgressRepository; + + private final ChunkRepository chunkRepository; + + private final ReadingCompletionService readingCompletionService; + + private final StreakService streakService; + + private final ChapterRepository chapterRepository; + + @Transactional + public ProgressResponse updateProgress(String bookId, ProgressUpdateRequest request, String userId) { + if (!bookService.existsById(bookId)) { + throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); + } + + // chunkId로부터 chunk 정보 조회 + Chunk chunk = chunkService.findById(request.getChunkId()); + + // chunk로부터 chapter 역추산 + if (chunk.getChapterId() == null) { + throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND); + } + Chapter chapter = chapterService.findById(chunk.getChapterId()); + + if (!chapter.getBookId().equals(bookId)) { + throw new BooksException(BooksErrorCode.CHUNK_NOT_FOUND_IN_BOOK); + } + + BookProgress bookProgress = bookProgressRepository.findByUserIdAndBookId(userId, bookId) + .orElse(new BookProgress()); + + ensureMigrated(bookProgress); + + // Null 체크 + if (chapter.getChapterNumber() == null || chunk.getChunkNumber() == null) { + throw new BooksException(BooksErrorCode.INVALID_CHUNK_NUMBER); + } + + bookProgress.setUserId(userId); + bookProgress.setBookId(bookId); + bookProgress.setChapterId(chapter.getId()); // 역추산된 chapter ID + bookProgress.setChunkId(request.getChunkId()); + bookProgress.setCurrentReadChapterNumber(chapter.getChapterNumber()); + bookProgress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); + + // [V3_CHAPTER_BASED] 챕터별 진행률 계산 + long totalChunksInChapter = chunkRepository.countByChapterIdAndDifficultyLevel(chapter.getId(), + chunk.getDifficultyLevel()); + double chapterProgressPercentage = totalChunksInChapter > 0 + ? (chunk.getChunkNumber() * 100.0 / totalChunksInChapter) : 0.0; + + // 챕터 진행률 배열 초기화 (null 체크) + if (bookProgress.getChapterProgresses() == null) { + bookProgress.setChapterProgresses(new ArrayList<>()); + } + + // 현재 챕터의 진행률 업데이트 (배열 구조) + updateOrAddChapterProgress(bookProgress, chapter.getChapterNumber(), chapterProgressPercentage, false, null); + + // 책 전체 진행률 = 완료된 챕터 수 / 전체 챕터 수 + Integer totalChapters = chapterRepository.countByBookId(bookId); + long completedCount = getCompletedChapterCount(bookProgress); + double bookProgress_normalizedProgress = totalChapters > 0 ? (completedCount * 100.0 / totalChapters) : 0.0; + + bookProgress.setNormalizedProgress(bookProgress_normalizedProgress); + + // max 진도 업데이트 (현재 읽고 있는 챕터 번호 기준) + Integer currentChapterNum = chapter.getChapterNumber(); + Integer maxChapterNum = bookProgress.getMaxReadChapterNumber(); + + if (maxChapterNum == null || currentChapterNum > maxChapterNum) { + bookProgress.setMaxReadChapterNumber(currentChapterNum); + } + + int currentChunkPosition = toChapterFirstPosition(chapter.getChapterNumber(), chunk.getChunkNumber()); + Integer maxChunkPosition = bookProgress.getMaxReadChunkNumber(); + if (maxChunkPosition == null || currentChunkPosition > maxChunkPosition) { + bookProgress.setMaxReadChunkNumber(currentChunkPosition); + } + + // maxNormalizedProgress는 완료된 챕터 기반으로 설정 + bookProgress.setMaxNormalizedProgress(bookProgress_normalizedProgress); + + // 읽기 완료 처리 (30초 이상 읽은 경우 이벤트 발행 + 세션 삭제) + // Book은 category가 없으므로 null 전달 (추천 시스템 집계에서 자동 제외됨) + Long readTimeSeconds = readingCompletionService.processReadingCompletion(userId, ContentType.BOOK, + chapter.getId(), null); + + // 스트릭 검사 및 완료 처리 로직 + boolean streakUpdated = false; + if (isLastChunkInChapter(chunk)) { + // 1. 챕터 완료 처리 + BookProgress.ChapterProgressInfo existingProgress = findChapterProgress(bookProgress, + chapter.getChapterNumber()); + boolean isFirstCompletion = existingProgress == null + || !Boolean.TRUE.equals(existingProgress.getIsCompleted()); + + // 챕터 진행률을 100%로 설정하고 완료 처리 + updateOrAddChapterProgress(bookProgress, chapter.getChapterNumber(), 100.0, true, + isFirstCompletion ? java.time.Instant.now() : existingProgress.getCompletedAt()); + + log.info("Chapter {} completed for book {} (first completion: {})", chapter.getChapterNumber(), bookId, + isFirstCompletion); + + // 스트릭 업데이트 (30초 이상 읽은 경우에만) + if (readTimeSeconds != null && readTimeSeconds >= 30) { + streakService.addStudyTime(userId, readTimeSeconds); + streakUpdated = streakService.updateStreak(userId, ContentType.BOOK, chapter.getId()); + streakService.addCompletedContent(userId, ContentType.BOOK, chapter.getId(), streakUpdated); + } + + // 3. 책 전체 완료 확인 (모든 챕터 완료 시) + boolean allChaptersCompleted = getCompletedChapterCount(bookProgress) >= totalChapters; + if (allChaptersCompleted && bookProgress.getCompletedAt() == null) { + bookProgress.setIsCompleted(true); + bookProgress.setCompletedAt(java.time.Instant.now()); + log.info("Book {} fully completed (all {} chapters) by user {}", bookId, totalChapters, userId); + } + } + + bookProgressRepository.save(bookProgress); + + return convertToProgressResponse(bookProgress, streakUpdated); + } + + /** + * 현재 청크가 챕터의 마지막 청크인지 확인 + */ + private boolean isLastChunkInChapter(Chunk chunk) { + long totalChunks = chunkRepository.countByChapterIdAndDifficultyLevel(chunk.getChapterId(), + chunk.getDifficultyLevel()); + return chunk.getChunkNumber() >= totalChunks; + } + + /** + * 챕터 진행률 정보 찾기 + */ + private BookProgress.ChapterProgressInfo findChapterProgress(BookProgress bookProgress, Integer chapterNumber) { + if (bookProgress.getChapterProgresses() == null) { + return null; + } + return bookProgress.getChapterProgresses() + .stream() + .filter(cp -> chapterNumber.equals(cp.getChapterNumber())) + .findFirst() + .orElse(null); + } + + /** + * 챕터 진행률 업데이트 또는 추가 + */ + private void updateOrAddChapterProgress(BookProgress bookProgress, Integer chapterNumber, Double progressPercentage, + Boolean isCompleted, java.time.Instant completedAt) { + BookProgress.ChapterProgressInfo existing = findChapterProgress(bookProgress, chapterNumber); + + if (existing != null) { + // 기존 항목 업데이트 + existing.setProgressPercentage(progressPercentage); + existing.setIsCompleted(isCompleted); + if (completedAt != null) { + existing.setCompletedAt(completedAt); + } + } + else { + // 새 항목 추가 + BookProgress.ChapterProgressInfo newProgress = BookProgress.ChapterProgressInfo.builder() + .chapterNumber(chapterNumber) + .progressPercentage(progressPercentage) + .isCompleted(isCompleted) + .completedAt(completedAt) + .build(); + bookProgress.getChapterProgresses().add(newProgress); + } + } + + /** + * 완료된 챕터 수 계산 + */ + private long getCompletedChapterCount(BookProgress bookProgress) { + if (bookProgress.getChapterProgresses() == null) { + return 0; + } + return bookProgress.getChapterProgresses() + .stream() + .filter(cp -> Boolean.TRUE.equals(cp.getIsCompleted())) + .count(); + } + + /** + * V3 마이그레이션 보장 updateProgress 시점에 한 번만 실행 + */ + private void ensureMigrated(BookProgress progress) { + boolean needsMigration = false; + + // V3 필드 초기화 + if (progress.getChapterProgresses() == null) { + progress.setChapterProgresses(new ArrayList<>()); + needsMigration = true; + } + + if (needsMigration) { + log.info("V3 migration completed for BookProgress id={}, userId={}", progress.getId(), + progress.getUserId()); + } + } + + @Transactional(readOnly = true) + public ProgressResponse getProgress(String bookId, String userId) { + if (!bookService.existsById(bookId)) { + throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); + } + + return bookProgressRepository.findByUserIdAndBookId(userId, bookId) + .map(progress -> convertToProgressResponse(progress, false)) + .orElseGet(() -> createNotStartedProgressResponse(userId, bookId)); + } + + @Transactional + public void deleteProgress(String bookId, String userId) { + if (!bookService.existsById(bookId)) { + throw new BooksException(BooksErrorCode.BOOK_NOT_FOUND); + } + + BookProgress bookProgress = bookProgressRepository.findByUserIdAndBookId(userId, bookId) + .orElseThrow(() -> new BooksException(BooksErrorCode.PROGRESS_NOT_FOUND)); + + bookProgressRepository.delete(bookProgress); + } + + // [V1_COMPAT] Chapter 기반 max 진도만 관리 + private boolean shouldUpdateMaxProgress(BookProgress progress, Integer chapterNum) { + Integer maxChapter = progress.getMaxReadChapterNumber(); + return maxChapter == null || chapterNum > maxChapter; + } + + private ProgressResponse convertToProgressResponse(BookProgress progress, boolean streakUpdated) { + // [DTO_MAPPING] chunk에서 chunkNumber 조회 + Chunk chunk = chunkService.findById(progress.getChunkId()); + + // [SAFETY] 마이그레이션이 안 되어 있는 경우 경고 로그 + if (progress.getChapterProgresses() == null) { + log.warn("BookProgress {} not migrated yet - this should only happen on read-only access", + progress.getId()); + } + + return ProgressResponse.builder() + .id(progress.getId()) + .userId(progress.getUserId()) + .bookId(progress.getBookId()) + .chapterId(progress.getChapterId()) + .chunkId(progress.getChunkId()) + .currentReadChapterNumber(progress.getCurrentReadChapterNumber()) + .currentReadChunkNumber(chunk.getChunkNumber()) + .maxReadChapterNumber(progress.getMaxReadChapterNumber()) + .maxReadChunkNumber(progress.getMaxReadChunkNumber()) + .isCompleted(progress.getIsCompleted()) + .currentDifficultyLevel(progress.getCurrentDifficultyLevel()) + .normalizedProgress(progress.getNormalizedProgress()) + .maxNormalizedProgress(progress.getMaxNormalizedProgress()) + .streakUpdated(streakUpdated) + .updatedAt(progress.getUpdatedAt()) + .build(); + } + + private ProgressResponse createNotStartedProgressResponse(String userId, String bookId) { + return ProgressResponse.builder() + .userId(userId) + .bookId(bookId) + .currentReadChapterNumber(0) + .currentReadChunkNumber(0) + .maxReadChapterNumber(0) + .maxReadChunkNumber(0) + .isCompleted(false) + .normalizedProgress(0.0) + .maxNormalizedProgress(0.0) + .streakUpdated(false) + .build(); + } + + private int toChapterFirstPosition(Integer chapterNumber, Integer chunkNumber) { + if (chapterNumber == null || chapterNumber <= 0 || chunkNumber == null || chunkNumber <= 0) { + throw new BooksException(BooksErrorCode.INVALID_CHUNK_NUMBER); + } + if (chapterNumber > CHAPTER_NUMBER_MAX || chunkNumber > CHUNK_NUMBER_MAX) { + throw new BooksException(BooksErrorCode.INVALID_CHUNK_NUMBER); + } + return (chapterNumber << CHAPTER_POSITION_SHIFT) | chunkNumber; + } + } diff --git a/src/main/java/com/linglevel/api/content/common/ChunkType.java b/src/main/java/com/linglevel/api/content/common/ChunkType.java index da8592bf..79649b6b 100644 --- a/src/main/java/com/linglevel/api/content/common/ChunkType.java +++ b/src/main/java/com/linglevel/api/content/common/ChunkType.java @@ -1,6 +1,7 @@ package com.linglevel.api.content.common; public enum ChunkType { - TEXT, - IMAGE + + TEXT, IMAGE + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/ContentCategory.java b/src/main/java/com/linglevel/api/content/common/ContentCategory.java index a81356a7..f8618250 100644 --- a/src/main/java/com/linglevel/api/content/common/ContentCategory.java +++ b/src/main/java/com/linglevel/api/content/common/ContentCategory.java @@ -4,43 +4,42 @@ @Getter public enum ContentCategory { - SPORTS("Sports"), - SCIENCE("Science"), - TECH("Technology"), - BUSINESS("Business"), - EDU("Education"), - CULTURE("Culture"); - - private final String displayName; - - ContentCategory(String displayName) { - this.displayName = displayName; - } - - /** - * displayName 또는 enum name으로부터 ContentCategory로 변환 - * @param value displayName (예: "Technology") 또는 enum name (예: "TECH") - * @return 매칭되는 ContentCategory, 없으면 null - */ - public static ContentCategory fromString(String value) { - if (value == null) { - return null; - } - - String trimmedValue = value.trim(); - - // 먼저 displayName으로 매칭 시도 - for (ContentCategory category : values()) { - if (category.displayName.equalsIgnoreCase(trimmedValue)) { - return category; - } - } - - // displayName 매칭 실패시 enum name으로 매칭 시도 - try { - return ContentCategory.valueOf(trimmedValue.toUpperCase()); - } catch (IllegalArgumentException e) { - return null; - } - } + + SPORTS("Sports"), SCIENCE("Science"), TECH("Technology"), BUSINESS("Business"), EDU("Education"), + CULTURE("Culture"); + + private final String displayName; + + ContentCategory(String displayName) { + this.displayName = displayName; + } + + /** + * displayName 또는 enum name으로부터 ContentCategory로 변환 + * @param value displayName (예: "Technology") 또는 enum name (예: "TECH") + * @return 매칭되는 ContentCategory, 없으면 null + */ + public static ContentCategory fromString(String value) { + if (value == null) { + return null; + } + + String trimmedValue = value.trim(); + + // 먼저 displayName으로 매칭 시도 + for (ContentCategory category : values()) { + if (category.displayName.equalsIgnoreCase(trimmedValue)) { + return category; + } + } + + // displayName 매칭 실패시 enum name으로 매칭 시도 + try { + return ContentCategory.valueOf(trimmedValue.toUpperCase()); + } + catch (IllegalArgumentException e) { + return null; + } + } + } diff --git a/src/main/java/com/linglevel/api/content/common/ContentType.java b/src/main/java/com/linglevel/api/content/common/ContentType.java index 373269e0..a3bd5ac4 100644 --- a/src/main/java/com/linglevel/api/content/common/ContentType.java +++ b/src/main/java/com/linglevel/api/content/common/ContentType.java @@ -6,18 +6,20 @@ @Getter @RequiredArgsConstructor public enum ContentType { - BOOK("BOOK", "책과 소설 등의 긴 형태의 콘텐츠"), - ARTICLE("ARTICLE", "뉴스, 블로그 포스트 등의 짧은 형태의 콘텐츠"), - CUSTOM("CUSTOM", "사용자가 직접 가져온 콘텐츠"); - private final String code; - private final String description; + BOOK("BOOK", "책과 소설 등의 긴 형태의 콘텐츠"), ARTICLE("ARTICLE", "뉴스, 블로그 포스트 등의 짧은 형태의 콘텐츠"), + CUSTOM("CUSTOM", "사용자가 직접 가져온 콘텐츠"); - public boolean isBook() { - return this == BOOK; - } + private final String code; + + private final String description; + + public boolean isBook() { + return this == BOOK; + } + + public boolean isArticle() { + return this == ARTICLE; + } - public boolean isArticle() { - return this == ARTICLE; - } } diff --git a/src/main/java/com/linglevel/api/content/common/DifficultyLevel.java b/src/main/java/com/linglevel/api/content/common/DifficultyLevel.java index f378bf13..58a0a29e 100644 --- a/src/main/java/com/linglevel/api/content/common/DifficultyLevel.java +++ b/src/main/java/com/linglevel/api/content/common/DifficultyLevel.java @@ -4,30 +4,31 @@ @Getter public enum DifficultyLevel { - A0("A0", "Pre-Beginner", "Complete beginner level"), - A1("A1", "Beginner", "Basic user level"), - A2("A2", "Elementary", "Basic user level"), - B1("B1", "Intermediate", "Independent user level"), - B2("B2", "Upper-Intermediate", "Independent user level"), - C1("C1", "Advanced", "Proficient user level"), - C2("C2", "Proficiency", "Proficient user level"); - - private final String code; - private final String name; - private final String description; - - DifficultyLevel(String code, String name, String description) { - this.code = code; - this.name = name; - this.description = description; - } - - public static DifficultyLevel fromCode(String code) { - for (DifficultyLevel level : DifficultyLevel.values()) { - if (level.code.equals(code)) { - return level; - } - } - throw new IllegalArgumentException("Invalid difficulty level code: " + code); - } + + A0("A0", "Pre-Beginner", "Complete beginner level"), A1("A1", "Beginner", "Basic user level"), + A2("A2", "Elementary", "Basic user level"), B1("B1", "Intermediate", "Independent user level"), + B2("B2", "Upper-Intermediate", "Independent user level"), C1("C1", "Advanced", "Proficient user level"), + C2("C2", "Proficiency", "Proficient user level"); + + private final String code; + + private final String name; + + private final String description; + + DifficultyLevel(String code, String name, String description) { + this.code = code; + this.name = name; + this.description = description; + } + + public static DifficultyLevel fromCode(String code) { + for (DifficultyLevel level : DifficultyLevel.values()) { + if (level.code.equals(code)) { + return level; + } + } + throw new IllegalArgumentException("Invalid difficulty level code: " + code); + } + } diff --git a/src/main/java/com/linglevel/api/content/common/ProgressStatus.java b/src/main/java/com/linglevel/api/content/common/ProgressStatus.java index 3ffada17..e20b03c5 100644 --- a/src/main/java/com/linglevel/api/content/common/ProgressStatus.java +++ b/src/main/java/com/linglevel/api/content/common/ProgressStatus.java @@ -4,27 +4,30 @@ @Getter public enum ProgressStatus { - NOT_STARTED("not_started", "Content has not been started yet"), - IN_PROGRESS("in_progress", "Content is currently being read"), - COMPLETED("completed", "Content has been fully completed"); - - private final String code; - private final String description; - - ProgressStatus(String code, String description) { - this.code = code; - this.description = description; - } - - public static ProgressStatus fromCode(String code) { - if (code == null) { - return null; - } - for (ProgressStatus status : ProgressStatus.values()) { - if (status.code.equals(code)) { - return status; - } - } - throw new IllegalArgumentException("Invalid progress status code: " + code); - } + + NOT_STARTED("not_started", "Content has not been started yet"), + IN_PROGRESS("in_progress", "Content is currently being read"), + COMPLETED("completed", "Content has been fully completed"); + + private final String code; + + private final String description; + + ProgressStatus(String code, String description) { + this.code = code; + this.description = description; + } + + public static ProgressStatus fromCode(String code) { + if (code == null) { + return null; + } + for (ProgressStatus status : ProgressStatus.values()) { + if (status.code.equals(code)) { + return status; + } + } + throw new IllegalArgumentException("Invalid progress status code: " + code); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/TitleTranslations.java b/src/main/java/com/linglevel/api/content/common/TitleTranslations.java index a3162f41..37404764 100644 --- a/src/main/java/com/linglevel/api/content/common/TitleTranslations.java +++ b/src/main/java/com/linglevel/api/content/common/TitleTranslations.java @@ -9,6 +9,8 @@ @AllArgsConstructor public class TitleTranslations { - private String ko; - private String ja; + private String ko; + + private String ja; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/controller/ContentController.java b/src/main/java/com/linglevel/api/content/common/controller/ContentController.java index 166a5f57..35193252 100644 --- a/src/main/java/com/linglevel/api/content/common/controller/ContentController.java +++ b/src/main/java/com/linglevel/api/content/common/controller/ContentController.java @@ -27,21 +27,20 @@ @Tag(name = "Contents", description = "통합 콘텐츠 관련 API") public class ContentController { - private final ContentService contentService; + private final ContentService contentService; + + @Operation(summary = "최근 공부 콘텐츠 목록 조회", description = "사용자가 최근에 공부한 모든 타입의 콘텐츠 목록을 최신순으로 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/recent") + public ResponseEntity> getRecentContents( + @AuthenticationPrincipal JwtClaims claims, + @ParameterObject @ModelAttribute GetRecentContentsRequest request) { + PageResponse response = contentService.getRecentContents(claims.getId(), request); + return ResponseEntity.ok(response); + } - @Operation(summary = "최근 공부 콘텐츠 목록 조회", description = "사용자가 최근에 공부한 모든 타입의 콘텐츠 목록을 최신순으로 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/recent") - public ResponseEntity> getRecentContents( - @AuthenticationPrincipal JwtClaims claims, - @ParameterObject @ModelAttribute GetRecentContentsRequest request) { - PageResponse response = contentService.getRecentContents(claims.getId(), request); - return ResponseEntity.ok(response); - } } diff --git a/src/main/java/com/linglevel/api/content/common/dto/GetRecentContentsRequest.java b/src/main/java/com/linglevel/api/content/common/dto/GetRecentContentsRequest.java index 779b1604..ba6533fe 100644 --- a/src/main/java/com/linglevel/api/content/common/dto/GetRecentContentsRequest.java +++ b/src/main/java/com/linglevel/api/content/common/dto/GetRecentContentsRequest.java @@ -8,15 +8,16 @@ @Data public class GetRecentContentsRequest { - @Schema(description = "읽기 진도별 필터링", example = "in_progress", allowableValues = {"in_progress", "completed"}) - private String status; + @Schema(description = "읽기 진도별 필터링", example = "in_progress", allowableValues = { "in_progress", "completed" }) + private String status; - @Schema(description = "조회할 페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private int page = 1; + @Schema(description = "조회할 페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private int page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private int limit = 10; - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private int limit = 10; } diff --git a/src/main/java/com/linglevel/api/content/common/dto/RecentContentResponse.java b/src/main/java/com/linglevel/api/content/common/dto/RecentContentResponse.java index ac43390c..0af019eb 100644 --- a/src/main/java/com/linglevel/api/content/common/dto/RecentContentResponse.java +++ b/src/main/java/com/linglevel/api/content/common/dto/RecentContentResponse.java @@ -12,26 +12,41 @@ @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public class RecentContentResponse { - private String contentId; - private ContentType contentType; - private String title; - private String author; - private String coverImageUrl; - private String difficultyLevel; - private List tags; - private Integer readingTime; - - // Progress fields - private Integer chapterCount; - private Integer currentReadChapterNumber; - private Integer chunkCount; - private Integer currentReadChunkNumber; - private Double progressPercentage; - private Boolean isCompleted; - - // CustomContent specific fields - private String originUrl; - private String originDomain; - - private Instant lastStudiedAt; + + private String contentId; + + private ContentType contentType; + + private String title; + + private String author; + + private String coverImageUrl; + + private String difficultyLevel; + + private List tags; + + private Integer readingTime; + + // Progress fields + private Integer chapterCount; + + private Integer currentReadChapterNumber; + + private Integer chunkCount; + + private Integer currentReadChunkNumber; + + private Double progressPercentage; + + private Boolean isCompleted; + + // CustomContent specific fields + private String originUrl; + + private String originDomain; + + private Instant lastStudiedAt; + } diff --git a/src/main/java/com/linglevel/api/content/common/service/ContentInfo.java b/src/main/java/com/linglevel/api/content/common/service/ContentInfo.java index 096df062..95ff4fb5 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ContentInfo.java +++ b/src/main/java/com/linglevel/api/content/common/service/ContentInfo.java @@ -14,12 +14,16 @@ @AllArgsConstructor public class ContentInfo { - private String title; - private String author; - private String coverImageUrl; - private Integer readingTime; - - public boolean isPresent() { - return title != null; - } + private String title; + + private String author; + + private String coverImageUrl; + + private Integer readingTime; + + public boolean isPresent() { + return title != null; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/ContentInfoProvider.java b/src/main/java/com/linglevel/api/content/common/service/ContentInfoProvider.java index 397b8cd2..7200f7fd 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ContentInfoProvider.java +++ b/src/main/java/com/linglevel/api/content/common/service/ContentInfoProvider.java @@ -4,12 +4,13 @@ public interface ContentInfoProvider { - ContentType getSupportedType(); - - /** - * 콘텐츠 ID로 콘텐츠 정보를 조회 - * @param contentId 콘텐츠 ID - * @return 콘텐츠 정보 (존재하지 않으면 빈 ContentInfo) - */ - ContentInfo getContentInfo(String contentId); + ContentType getSupportedType(); + + /** + * 콘텐츠 ID로 콘텐츠 정보를 조회 + * @param contentId 콘텐츠 ID + * @return 콘텐츠 정보 (존재하지 않으면 빈 ContentInfo) + */ + ContentInfo getContentInfo(String contentId); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/ContentInfoProviderFactory.java b/src/main/java/com/linglevel/api/content/common/service/ContentInfoProviderFactory.java index 87648659..1ea45a06 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ContentInfoProviderFactory.java +++ b/src/main/java/com/linglevel/api/content/common/service/ContentInfoProviderFactory.java @@ -16,40 +16,38 @@ @Component public class ContentInfoProviderFactory { - private final Map providers; - - public ContentInfoProviderFactory(List providerList) { - this.providers = providerList.stream() - .collect(Collectors.toMap( - ContentInfoProvider::getSupportedType, - Function.identity() - )); - - log.info("Registered content info providers: {}", providers.keySet()); - } - - /** - * 콘텐츠 타입에 맞는 정보 제공자를 반환 - */ - public ContentInfoProvider getProvider(ContentType contentType) { - ContentInfoProvider provider = providers.get(contentType); - if (provider == null) { - throw new IllegalArgumentException("Unsupported content type: " + contentType); - } - return provider; - } - - /** - * 콘텐츠 정보를 조회 - */ - public ContentInfo getContentInfo(String contentId, ContentType contentType) { - ContentInfoProvider provider = getProvider(contentType); - ContentInfo contentInfo = provider.getContentInfo(contentId); - - if (!contentInfo.isPresent()) { - throw new IllegalArgumentException("Content not found: " + contentId); - } - - return contentInfo; - } + private final Map providers; + + public ContentInfoProviderFactory(List providerList) { + this.providers = providerList.stream() + .collect(Collectors.toMap(ContentInfoProvider::getSupportedType, Function.identity())); + + log.info("Registered content info providers: {}", providers.keySet()); + } + + /** + * 콘텐츠 타입에 맞는 정보 제공자를 반환 + */ + public ContentInfoProvider getProvider(ContentType contentType) { + ContentInfoProvider provider = providers.get(contentType); + if (provider == null) { + throw new IllegalArgumentException("Unsupported content type: " + contentType); + } + return provider; + } + + /** + * 콘텐츠 정보를 조회 + */ + public ContentInfo getContentInfo(String contentId, ContentType contentType) { + ContentInfoProvider provider = getProvider(contentType); + ContentInfo contentInfo = provider.getContentInfo(contentId); + + if (!contentInfo.isPresent()) { + throw new IllegalArgumentException("Content not found: " + contentId); + } + + return contentInfo; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/ContentService.java b/src/main/java/com/linglevel/api/content/common/service/ContentService.java index 4ebfa910..bd517d7c 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ContentService.java +++ b/src/main/java/com/linglevel/api/content/common/service/ContentService.java @@ -42,167 +42,230 @@ @RequiredArgsConstructor public class ContentService { - private final BookRepository bookRepository; - private final ArticleRepository articleRepository; - private final CustomContentRepository customContentRepository; - private final BookProgressRepository bookProgressRepository; - private final ArticleProgressRepository articleProgressRepository; - private final CustomContentProgressRepository customContentProgressRepository; - private final ArticleChunkService articleChunkService; - private final CustomContentChunkService customContentChunkService; - private final ArticleChunkRepository articleChunkRepository; - private final CustomContentChunkRepository customContentChunkRepository; - - private record GenericProgress(String contentId, ContentType contentType, Instant lastStudiedAt, boolean isCompleted, Object originalProgress) {} - - public PageResponse getRecentContents(String userId, GetRecentContentsRequest request) { - List bookProgresses = bookProgressRepository.findAllByUserId(userId); - List articleProgresses = articleProgressRepository.findAllByUserId(userId); - List customProgresses = customContentProgressRepository.findAllByUserId(userId); - - Stream genericProgressStream = Stream.concat( - bookProgresses.stream().map(p -> new GenericProgress(p.getBookId(), ContentType.BOOK, p.getUpdatedAt(), p.getIsCompleted(), p)), - Stream.concat( - articleProgresses.stream().map(p -> new GenericProgress(p.getArticleId(), ContentType.ARTICLE, p.getUpdatedAt(), p.getIsCompleted(), p)), - customProgresses.stream().map(p -> new GenericProgress(p.getCustomId(), ContentType.CUSTOM, p.getUpdatedAt(), p.getIsCompleted(), p)) - ) - ); - - if (request.getStatus() != null && !request.getStatus().isBlank()) { - boolean requiredStatus = "completed".equalsIgnoreCase(request.getStatus()); - genericProgressStream = genericProgressStream.filter(p -> p.isCompleted() == requiredStatus); - } - - List sortedProgresses = genericProgressStream - .sorted(Comparator.comparing(GenericProgress::lastStudiedAt).reversed()) - .toList(); - - int page = request.getPage() > 0 ? request.getPage() - 1 : 0; - int limit = request.getLimit(); - int totalCount = sortedProgresses.size(); - int fromIndex = page * limit; - int toIndex = Math.min(fromIndex + limit, totalCount); - - if (fromIndex >= totalCount) { - return new PageResponse<>(List.of(), request.getPage(), 0, totalCount, false, false); - } - - List paginatedProgresses = sortedProgresses.subList(fromIndex, toIndex); - - Map> contentIdsByType = paginatedProgresses.stream() - .collect(Collectors.groupingBy(GenericProgress::contentType, - Collectors.mapping(GenericProgress::contentId, Collectors.toList()))); - - Map booksMap = bookRepository.findAllById(contentIdsByType.getOrDefault(ContentType.BOOK, List.of())) - .stream().collect(Collectors.toMap(Book::getId, Function.identity())); - Map articlesMap = articleRepository.findAllById(contentIdsByType.getOrDefault(ContentType.ARTICLE, List.of())) - .stream().collect(Collectors.toMap(Article::getId, Function.identity())); - Map customContentsMap = customContentRepository.findAllById(contentIdsByType.getOrDefault(ContentType.CUSTOM, List.of())) - .stream().collect(Collectors.toMap(CustomContent::getId, Function.identity())); - - List result = paginatedProgresses.stream().map(p -> { - switch (p.contentType()) { - case BOOK: { - Book book = booksMap.get(p.contentId()); - BookProgress progress = (BookProgress) p.originalProgress(); - if (book == null) return null; - - // [FIX] Use normalizedProgress for accurate percentage. Fallback to old calculation if null. - Double progressPercentage = progress.getNormalizedProgress() != null - ? progress.getNormalizedProgress() - : calculatePercentage(progress.getCurrentReadChapterNumber(), book.getChapterCount()); - - return RecentContentResponse.builder() - .contentId(book.getId()).contentType(ContentType.BOOK).title(book.getTitle()).author(book.getAuthor()) - .coverImageUrl(book.getCoverImageUrl()).difficultyLevel(book.getDifficultyLevel().name()).tags(book.getTags()) - .readingTime(book.getReadingTime()).chapterCount(book.getChapterCount()).currentReadChapterNumber(progress.getCurrentReadChapterNumber()) - .progressPercentage(progressPercentage) - .isCompleted(progress.getIsCompleted()).lastStudiedAt(p.lastStudiedAt()).build(); - } - case ARTICLE: { - Article article = articlesMap.get(p.contentId()); - ArticleProgress progress = (ArticleProgress) p.originalProgress(); - if (article == null) return null; - - // [FIX] Use normalizedProgress for accurate percentage. - Double progressPercentage = progress.getNormalizedProgress(); - Integer currentChunkNumber = 0; // Default value - long totalChunks = 0; // Default value - - // For display purposes, we might still need chunk numbers. - // This part is kept for now, but the percentage is taken from the migrated data. - try { - ArticleChunk chunk = articleChunkService.findById(progress.getChunkId()); - currentChunkNumber = chunk.getChunkNumber(); - } catch (Exception e) { - // chunkId might be null for old data, or chunk not found. - } - - DifficultyLevel difficulty = progress.getCurrentDifficultyLevel() != null ? progress.getCurrentDifficultyLevel() : article.getDifficultyLevel(); - totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel(article.getId(), difficulty); - - // If normalizedProgress is somehow null (not migrated), fallback to calculation. - if (progressPercentage == null) { - progressPercentage = calculatePercentage(currentChunkNumber, (int) totalChunks); - } - - return RecentContentResponse.builder() - .contentId(article.getId()).contentType(ContentType.ARTICLE).title(article.getTitle()).author(article.getAuthor()) - .coverImageUrl(article.getCoverImageUrl()).difficultyLevel(article.getDifficultyLevel().name()).tags(article.getTags()) - .readingTime(article.getReadingTime()).chunkCount((int) totalChunks).currentReadChunkNumber(currentChunkNumber) - .progressPercentage(progressPercentage) - .isCompleted(progress.getIsCompleted()).lastStudiedAt(p.lastStudiedAt()).build(); - } - case CUSTOM: { - CustomContent custom = customContentsMap.get(p.contentId()); - CustomContentProgress progress = (CustomContentProgress) p.originalProgress(); - if (custom == null) return null; - - // [FIX] Use normalizedProgress for accurate percentage. - Double progressPercentage = progress.getNormalizedProgress(); - Integer currentChunkNumber = 0; // Default value - long totalChunks = 0; // Default value - - try { - CustomContentChunk chunk = customContentChunkService.findById(progress.getChunkId()); - currentChunkNumber = chunk.getChunkNum(); - } catch (Exception e) { - // chunkId might be null for old data, or chunk not found. - } - - DifficultyLevel difficulty = progress.getCurrentDifficultyLevel() != null ? progress.getCurrentDifficultyLevel() : custom.getDifficultyLevel(); - totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(custom.getId(), difficulty); - - // If normalizedProgress is somehow null (not migrated), fallback to calculation. - if (progressPercentage == null) { - progressPercentage = calculatePercentage(currentChunkNumber, (int) totalChunks); - } - - return RecentContentResponse.builder() - .contentId(custom.getId()).contentType(ContentType.CUSTOM).title(custom.getTitle()).author(custom.getAuthor()) - .coverImageUrl(custom.getCoverImageUrl()).difficultyLevel(custom.getDifficultyLevel().name()).tags(custom.getTags()) - .readingTime(custom.getReadingTime()).chunkCount((int) totalChunks).currentReadChunkNumber(currentChunkNumber) - .progressPercentage(progressPercentage) - .isCompleted(progress.getIsCompleted()).originUrl(custom.getOriginUrl()).originDomain(custom.getOriginDomain()) - .lastStudiedAt(p.lastStudiedAt()).build(); - } - default: return null; - } - }).filter(r -> r != null).collect(Collectors.toList()); - - int totalPages = (int) Math.ceil((double) totalCount / limit); - boolean hasNext = request.getPage() < totalPages; - boolean hasPrevious = request.getPage() > 1; - - return new PageResponse<>(result, request.getPage(), totalPages, totalCount, hasNext, hasPrevious); - } - - private Double calculatePercentage(Integer current, Integer total) { - if (total == null || total == 0 || current == null) { - return 0.0; - } - double percentage = ((double) current / total) * 100.0; - return Math.round(percentage * 10.0) / 10.0; // Round to one decimal place - } + private final BookRepository bookRepository; + + private final ArticleRepository articleRepository; + + private final CustomContentRepository customContentRepository; + + private final BookProgressRepository bookProgressRepository; + + private final ArticleProgressRepository articleProgressRepository; + + private final CustomContentProgressRepository customContentProgressRepository; + + private final ArticleChunkService articleChunkService; + + private final CustomContentChunkService customContentChunkService; + + private final ArticleChunkRepository articleChunkRepository; + + private final CustomContentChunkRepository customContentChunkRepository; + + private record GenericProgress(String contentId, ContentType contentType, Instant lastStudiedAt, + boolean isCompleted, Object originalProgress) { + } + + public PageResponse getRecentContents(String userId, GetRecentContentsRequest request) { + List bookProgresses = bookProgressRepository.findAllByUserId(userId); + List articleProgresses = articleProgressRepository.findAllByUserId(userId); + List customProgresses = customContentProgressRepository.findAllByUserId(userId); + + Stream genericProgressStream = Stream.concat( + bookProgresses.stream() + .map(p -> new GenericProgress(p.getBookId(), ContentType.BOOK, p.getUpdatedAt(), p.getIsCompleted(), + p)), + Stream.concat( + articleProgresses.stream() + .map(p -> new GenericProgress(p.getArticleId(), ContentType.ARTICLE, p.getUpdatedAt(), + p.getIsCompleted(), p)), + customProgresses.stream() + .map(p -> new GenericProgress(p.getCustomId(), ContentType.CUSTOM, p.getUpdatedAt(), + p.getIsCompleted(), p)))); + + if (request.getStatus() != null && !request.getStatus().isBlank()) { + boolean requiredStatus = "completed".equalsIgnoreCase(request.getStatus()); + genericProgressStream = genericProgressStream.filter(p -> p.isCompleted() == requiredStatus); + } + + List sortedProgresses = genericProgressStream + .sorted(Comparator.comparing(GenericProgress::lastStudiedAt).reversed()) + .toList(); + + int page = request.getPage() > 0 ? request.getPage() - 1 : 0; + int limit = request.getLimit(); + int totalCount = sortedProgresses.size(); + int fromIndex = page * limit; + int toIndex = Math.min(fromIndex + limit, totalCount); + + if (fromIndex >= totalCount) { + return new PageResponse<>(List.of(), request.getPage(), 0, totalCount, false, false); + } + + List paginatedProgresses = sortedProgresses.subList(fromIndex, toIndex); + + Map> contentIdsByType = paginatedProgresses.stream() + .collect(Collectors.groupingBy(GenericProgress::contentType, + Collectors.mapping(GenericProgress::contentId, Collectors.toList()))); + + Map booksMap = bookRepository + .findAllById(contentIdsByType.getOrDefault(ContentType.BOOK, List.of())) + .stream() + .collect(Collectors.toMap(Book::getId, Function.identity())); + Map articlesMap = articleRepository + .findAllById(contentIdsByType.getOrDefault(ContentType.ARTICLE, List.of())) + .stream() + .collect(Collectors.toMap(Article::getId, Function.identity())); + Map customContentsMap = customContentRepository + .findAllById(contentIdsByType.getOrDefault(ContentType.CUSTOM, List.of())) + .stream() + .collect(Collectors.toMap(CustomContent::getId, Function.identity())); + + List result = paginatedProgresses.stream().map(p -> { + switch (p.contentType()) { + case BOOK: { + Book book = booksMap.get(p.contentId()); + BookProgress progress = (BookProgress) p.originalProgress(); + if (book == null) + return null; + + // [FIX] Use normalizedProgress for accurate percentage. Fallback to + // old calculation if null. + Double progressPercentage = progress.getNormalizedProgress() != null + ? progress.getNormalizedProgress() + : calculatePercentage(progress.getCurrentReadChapterNumber(), book.getChapterCount()); + + return RecentContentResponse.builder() + .contentId(book.getId()) + .contentType(ContentType.BOOK) + .title(book.getTitle()) + .author(book.getAuthor()) + .coverImageUrl(book.getCoverImageUrl()) + .difficultyLevel(book.getDifficultyLevel().name()) + .tags(book.getTags()) + .readingTime(book.getReadingTime()) + .chapterCount(book.getChapterCount()) + .currentReadChapterNumber(progress.getCurrentReadChapterNumber()) + .progressPercentage(progressPercentage) + .isCompleted(progress.getIsCompleted()) + .lastStudiedAt(p.lastStudiedAt()) + .build(); + } + case ARTICLE: { + Article article = articlesMap.get(p.contentId()); + ArticleProgress progress = (ArticleProgress) p.originalProgress(); + if (article == null) + return null; + + // [FIX] Use normalizedProgress for accurate percentage. + Double progressPercentage = progress.getNormalizedProgress(); + Integer currentChunkNumber = 0; // Default value + long totalChunks = 0; // Default value + + // For display purposes, we might still need chunk numbers. + // This part is kept for now, but the percentage is taken from the + // migrated data. + try { + ArticleChunk chunk = articleChunkService.findById(progress.getChunkId()); + currentChunkNumber = chunk.getChunkNumber(); + } + catch (Exception e) { + // chunkId might be null for old data, or chunk not found. + } + + DifficultyLevel difficulty = progress.getCurrentDifficultyLevel() != null + ? progress.getCurrentDifficultyLevel() : article.getDifficultyLevel(); + totalChunks = articleChunkRepository.countByArticleIdAndDifficultyLevel(article.getId(), + difficulty); + + // If normalizedProgress is somehow null (not migrated), fallback to + // calculation. + if (progressPercentage == null) { + progressPercentage = calculatePercentage(currentChunkNumber, (int) totalChunks); + } + + return RecentContentResponse.builder() + .contentId(article.getId()) + .contentType(ContentType.ARTICLE) + .title(article.getTitle()) + .author(article.getAuthor()) + .coverImageUrl(article.getCoverImageUrl()) + .difficultyLevel(article.getDifficultyLevel().name()) + .tags(article.getTags()) + .readingTime(article.getReadingTime()) + .chunkCount((int) totalChunks) + .currentReadChunkNumber(currentChunkNumber) + .progressPercentage(progressPercentage) + .isCompleted(progress.getIsCompleted()) + .lastStudiedAt(p.lastStudiedAt()) + .build(); + } + case CUSTOM: { + CustomContent custom = customContentsMap.get(p.contentId()); + CustomContentProgress progress = (CustomContentProgress) p.originalProgress(); + if (custom == null) + return null; + + // [FIX] Use normalizedProgress for accurate percentage. + Double progressPercentage = progress.getNormalizedProgress(); + Integer currentChunkNumber = 0; // Default value + long totalChunks = 0; // Default value + + try { + CustomContentChunk chunk = customContentChunkService.findById(progress.getChunkId()); + currentChunkNumber = chunk.getChunkNum(); + } + catch (Exception e) { + // chunkId might be null for old data, or chunk not found. + } + + DifficultyLevel difficulty = progress.getCurrentDifficultyLevel() != null + ? progress.getCurrentDifficultyLevel() : custom.getDifficultyLevel(); + totalChunks = customContentChunkRepository + .countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(custom.getId(), difficulty); + + // If normalizedProgress is somehow null (not migrated), fallback to + // calculation. + if (progressPercentage == null) { + progressPercentage = calculatePercentage(currentChunkNumber, (int) totalChunks); + } + + return RecentContentResponse.builder() + .contentId(custom.getId()) + .contentType(ContentType.CUSTOM) + .title(custom.getTitle()) + .author(custom.getAuthor()) + .coverImageUrl(custom.getCoverImageUrl()) + .difficultyLevel(custom.getDifficultyLevel().name()) + .tags(custom.getTags()) + .readingTime(custom.getReadingTime()) + .chunkCount((int) totalChunks) + .currentReadChunkNumber(currentChunkNumber) + .progressPercentage(progressPercentage) + .isCompleted(progress.getIsCompleted()) + .originUrl(custom.getOriginUrl()) + .originDomain(custom.getOriginDomain()) + .lastStudiedAt(p.lastStudiedAt()) + .build(); + } + default: + return null; + } + }).filter(r -> r != null).collect(Collectors.toList()); + + int totalPages = (int) Math.ceil((double) totalCount / limit); + boolean hasNext = request.getPage() < totalPages; + boolean hasPrevious = request.getPage() > 1; + + return new PageResponse<>(result, request.getPage(), totalPages, totalCount, hasNext, hasPrevious); + } + + private Double calculatePercentage(Integer current, Integer total) { + if (total == null || total == 0 || current == null) { + return 0.0; + } + double percentage = ((double) current / total) * 100.0; + return Math.round(percentage * 10.0) / 10.0; // Round to one decimal place + } } diff --git a/src/main/java/com/linglevel/api/content/common/service/ProgressCalculationService.java b/src/main/java/com/linglevel/api/content/common/service/ProgressCalculationService.java index 40739071..67ed5753 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ProgressCalculationService.java +++ b/src/main/java/com/linglevel/api/content/common/service/ProgressCalculationService.java @@ -3,55 +3,51 @@ import org.springframework.stereotype.Service; /** - * 진행률 계산을 위한 공통 서비스 - * V2 Progress 시스템에서 정규화된 진행률(0-100%) 계산 담당 + * 진행률 계산을 위한 공통 서비스 V2 Progress 시스템에서 정규화된 진행률(0-100%) 계산 담당 */ @Service public class ProgressCalculationService { - /** - * 정규화된 진행률 계산 (0-100%) - * - * @param currentChunkNumber 현재 청크 번호 - * @param totalChunks 해당 난이도의 전체 청크 개수 - * @return 진행률 (0.0 ~ 100.0) - */ - public double calculateNormalizedProgress(int currentChunkNumber, long totalChunks) { - if (totalChunks <= 0) { - return 0.0; - } - return (currentChunkNumber / (double) totalChunks) * 100.0; - } + /** + * 정규화된 진행률 계산 (0-100%) + * @param currentChunkNumber 현재 청크 번호 + * @param totalChunks 해당 난이도의 전체 청크 개수 + * @return 진행률 (0.0 ~ 100.0) + */ + public double calculateNormalizedProgress(int currentChunkNumber, long totalChunks) { + if (totalChunks <= 0) { + return 0.0; + } + return (currentChunkNumber / (double) totalChunks) * 100.0; + } - /** - * max 진행률 업데이트 여부 판단 - * - * @param currentMax 현재 최대 진행률 - * @param newProgress 새로운 진행률 - * @return 업데이트 필요 여부 - */ - public boolean shouldUpdateMaxProgress(Double currentMax, double newProgress) { - return currentMax == null || newProgress > currentMax; - } + /** + * max 진행률 업데이트 여부 판단 + * @param currentMax 현재 최대 진행률 + * @param newProgress 새로운 진행률 + * @return 업데이트 필요 여부 + */ + public boolean shouldUpdateMaxProgress(Double currentMax, double newProgress) { + return currentMax == null || newProgress > currentMax; + } - /** - * 완료 여부 판단 (>= 100%) - * - * @param maxProgress 최대 진행률 - * @return 완료 여부 - */ - public boolean isCompleted(Double maxProgress) { - return maxProgress != null && maxProgress >= 100.0; - } + /** + * 완료 여부 판단 (>= 100%) + * @param maxProgress 최대 진행률 + * @return 완료 여부 + */ + public boolean isCompleted(Double maxProgress) { + return maxProgress != null && maxProgress >= 100.0; + } + + /** + * isCompleted 플래그 업데이트 (한번 true가 되면 계속 유지) + * @param currentCompleted 현재 완료 상태 + * @param newCompleted 새로운 완료 상태 + * @return 업데이트된 완료 상태 + */ + public boolean updateCompletedFlag(Boolean currentCompleted, boolean newCompleted) { + return (currentCompleted != null && currentCompleted) || newCompleted; + } - /** - * isCompleted 플래그 업데이트 (한번 true가 되면 계속 유지) - * - * @param currentCompleted 현재 완료 상태 - * @param newCompleted 새로운 완료 상태 - * @return 업데이트된 완료 상태 - */ - public boolean updateCompletedFlag(Boolean currentCompleted, boolean newCompleted) { - return (currentCompleted != null && currentCompleted) || newCompleted; - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/ReadingCompletionService.java b/src/main/java/com/linglevel/api/content/common/service/ReadingCompletionService.java index 30648d29..d2b96902 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ReadingCompletionService.java +++ b/src/main/java/com/linglevel/api/content/common/service/ReadingCompletionService.java @@ -12,51 +12,42 @@ /** * 콘텐츠 읽기 완료 처리 공통 서비스 * - * 모든 콘텐츠 타입(Article, CustomContent, Book)에서 공통으로 사용하는 - * 읽기 완료 로직을 처리합니다. + * 모든 콘텐츠 타입(Article, CustomContent, Book)에서 공통으로 사용하는 읽기 완료 로직을 처리합니다. */ @Service @RequiredArgsConstructor @Slf4j public class ReadingCompletionService { - private final ReadingSessionService readingSessionService; - private final ApplicationEventPublisher eventPublisher; - - /** - * 읽기 완료 처리 (30초 이상 읽은 경우만) - * - * @param userId 사용자 ID - * @param contentType 콘텐츠 타입 - * @param contentId 콘텐츠 ID - * @param category 카테고리 (nullable, Book은 null) - * @return 읽은 시간(초), 30초 미만이면 null - */ - public Long processReadingCompletion ( - String userId, - ContentType contentType, - String contentId, - ContentCategory category) { - - boolean sessionValid = readingSessionService.isReadingSessionValid(userId, contentType, contentId); - - if (!sessionValid) { - return null; - } - - long readTimeSeconds = readingSessionService.getReadingSessionSeconds(userId, contentType, contentId); - - eventPublisher.publishEvent(new ContentAccessEvent( - this, - userId, - contentId, - contentType, - category, - (int) readTimeSeconds - )); - - readingSessionService.deleteReadingSession(userId); - - return readTimeSeconds; - } + private final ReadingSessionService readingSessionService; + + private final ApplicationEventPublisher eventPublisher; + + /** + * 읽기 완료 처리 (30초 이상 읽은 경우만) + * @param userId 사용자 ID + * @param contentType 콘텐츠 타입 + * @param contentId 콘텐츠 ID + * @param category 카테고리 (nullable, Book은 null) + * @return 읽은 시간(초), 30초 미만이면 null + */ + public Long processReadingCompletion(String userId, ContentType contentType, String contentId, + ContentCategory category) { + + boolean sessionValid = readingSessionService.isReadingSessionValid(userId, contentType, contentId); + + if (!sessionValid) { + return null; + } + + long readTimeSeconds = readingSessionService.getReadingSessionSeconds(userId, contentType, contentId); + + eventPublisher.publishEvent( + new ContentAccessEvent(this, userId, contentId, contentType, category, (int) readTimeSeconds)); + + readingSessionService.deleteReadingSession(userId); + + return readTimeSeconds; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/ReadingTimeService.java b/src/main/java/com/linglevel/api/content/common/service/ReadingTimeService.java index ba465228..f5a4d825 100644 --- a/src/main/java/com/linglevel/api/content/common/service/ReadingTimeService.java +++ b/src/main/java/com/linglevel/api/content/common/service/ReadingTimeService.java @@ -6,16 +6,17 @@ @Service public class ReadingTimeService { - private final int AVERAGE_READING_SPEED_PER_MINUTE = 500; + private final int AVERAGE_READING_SPEED_PER_MINUTE = 500; - public int calculateReadingTimeFromText(String text) { - if (text == null || text.trim().isEmpty()) { - return 0; - } - return calculateReadingTimeFromCharacters(text.length()); - } + public int calculateReadingTimeFromText(String text) { + if (text == null || text.trim().isEmpty()) { + return 0; + } + return calculateReadingTimeFromCharacters(text.length()); + } + + public int calculateReadingTimeFromCharacters(int characterCount) { + return (int) Math.ceil((double) characterCount / AVERAGE_READING_SPEED_PER_MINUTE); + } - public int calculateReadingTimeFromCharacters(int characterCount) { - return (int) Math.ceil((double) characterCount / AVERAGE_READING_SPEED_PER_MINUTE); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/provider/ArticleContentInfoProvider.java b/src/main/java/com/linglevel/api/content/common/service/provider/ArticleContentInfoProvider.java index 812f07d3..a5b614fc 100644 --- a/src/main/java/com/linglevel/api/content/common/service/provider/ArticleContentInfoProvider.java +++ b/src/main/java/com/linglevel/api/content/common/service/provider/ArticleContentInfoProvider.java @@ -17,30 +17,32 @@ @RequiredArgsConstructor public class ArticleContentInfoProvider implements ContentInfoProvider { - private final ArticleRepository articleRepository; + private final ArticleRepository articleRepository; - @Override - public ContentType getSupportedType() { - return ContentType.ARTICLE; - } + @Override + public ContentType getSupportedType() { + return ContentType.ARTICLE; + } - @Override - public ContentInfo getContentInfo(String contentId) { - try { - return articleRepository.findById(contentId) - .map(this::convertToContentInfo) - .orElse(ContentInfo.builder().build()); - } catch (Exception e) { - return ContentInfo.builder().build(); - } - } + @Override + public ContentInfo getContentInfo(String contentId) { + try { + return articleRepository.findById(contentId) + .map(this::convertToContentInfo) + .orElse(ContentInfo.builder().build()); + } + catch (Exception e) { + return ContentInfo.builder().build(); + } + } + + private ContentInfo convertToContentInfo(Article article) { + return ContentInfo.builder() + .title(article.getTitle()) + .author(article.getAuthor()) + .coverImageUrl(article.getCoverImageUrl()) + .readingTime(article.getReadingTime()) + .build(); + } - private ContentInfo convertToContentInfo(Article article) { - return ContentInfo.builder() - .title(article.getTitle()) - .author(article.getAuthor()) - .coverImageUrl(article.getCoverImageUrl()) - .readingTime(article.getReadingTime()) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/common/service/provider/BookContentInfoProvider.java b/src/main/java/com/linglevel/api/content/common/service/provider/BookContentInfoProvider.java index e801be4d..0258ba16 100644 --- a/src/main/java/com/linglevel/api/content/common/service/provider/BookContentInfoProvider.java +++ b/src/main/java/com/linglevel/api/content/common/service/provider/BookContentInfoProvider.java @@ -17,30 +17,32 @@ @RequiredArgsConstructor public class BookContentInfoProvider implements ContentInfoProvider { - private final BookRepository bookRepository; + private final BookRepository bookRepository; - @Override - public ContentType getSupportedType() { - return ContentType.BOOK; - } + @Override + public ContentType getSupportedType() { + return ContentType.BOOK; + } - @Override - public ContentInfo getContentInfo(String contentId) { - try { - return bookRepository.findById(contentId) - .map(this::convertToContentInfo) - .orElse(ContentInfo.builder().build()); - } catch (Exception e) { - return ContentInfo.builder().build(); - } - } + @Override + public ContentInfo getContentInfo(String contentId) { + try { + return bookRepository.findById(contentId) + .map(this::convertToContentInfo) + .orElse(ContentInfo.builder().build()); + } + catch (Exception e) { + return ContentInfo.builder().build(); + } + } + + private ContentInfo convertToContentInfo(Book book) { + return ContentInfo.builder() + .title(book.getTitle()) + .author(book.getAuthor()) + .coverImageUrl(book.getCoverImageUrl()) + .readingTime(book.getReadingTime()) + .build(); + } - private ContentInfo convertToContentInfo(Book book) { - return ContentInfo.builder() - .title(book.getTitle()) - .author(book.getAuthor()) - .coverImageUrl(book.getCoverImageUrl()) - .readingTime(book.getReadingTime()) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentController.java b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentController.java index 4c2e2be5..b0313b06 100644 --- a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentController.java +++ b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentController.java @@ -1,6 +1,5 @@ package com.linglevel.api.content.custom.controller; - import com.linglevel.api.auth.jwt.JwtClaims; import com.linglevel.api.common.dto.ExceptionResponse; import com.linglevel.api.common.dto.MessageResponse; @@ -35,161 +34,129 @@ @Tag(name = "Custom Contents", description = "커스텀 콘텐츠 관련 API") public class CustomContentController { - private final CustomContentService customContentService; - private final CustomContentChunkService customContentChunkService; - private final ReadingSessionService readingSessionService; - - @Operation( - summary = "커스텀 콘텐츠 목록 조회", - description = "완료된 커스텀 콘텐츠 목록을 조회합니다. 기본적으로 최신순으로 정렬되며, 선택적으로 태그나 키워드 필터를 적용할 수 있습니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity> getCustomContents( - @AuthenticationPrincipal JwtClaims claims, - @ParameterObject @ModelAttribute GetCustomContentsRequest request) { - - PageResponse response = customContentService.getCustomContents(claims.getId(), request); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "단일 커스텀 콘텐츠 조회", - description = "특정 커스텀 콘텐츠의 상세 정보를 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "403", description = "권한 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{customContentId}") - public ResponseEntity getCustomContent( - @AuthenticationPrincipal JwtClaims claims, - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customContentId) { - - CustomContentResponse response = customContentService.getCustomContent(claims.getId(), customContentId); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "커스텀 콘텐츠 수정", - description = "커스텀 콘텐츠의 제목이나 태그를 수정합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "403", description = "권한 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PatchMapping("/{customContentId}") - public ResponseEntity updateCustomContent( - @AuthenticationPrincipal JwtClaims claims, - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customContentId, - @RequestBody UpdateCustomContentRequest request) { - - CustomContentResponse response = customContentService.updateCustomContent(claims.getId(), customContentId, request); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "커스텀 콘텐츠 청크 목록 조회", - description = "특정 커스텀 콘텐츠에 속한 텍스트 청크(Chunk)들을 난이도별로 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "403", description = "권한 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 난이도 레벨", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{customContentId}/chunks") - public ResponseEntity> getCustomContentChunks( - @AuthenticationPrincipal JwtClaims claims, - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customContentId, - @ParameterObject @ModelAttribute GetCustomContentChunksRequest request) { - - if (claims != null) { - readingSessionService.startReadingSession( - claims.getId(), - ContentType.CUSTOM, - customContentId - ); - } - - PageResponse response = customContentChunkService.getCustomContentChunks(claims.getId(), customContentId, request); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "단일 커스텀 콘텐츠 청크 조회", - description = "특정 커스텀 콘텐츠 청크의 상세 정보를 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠 또는 청크를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "403", description = "권한 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{customContentId}/chunks/{chunkId}") - public ResponseEntity getCustomContentChunk( - @AuthenticationPrincipal JwtClaims claims, - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customContentId, - @Parameter(description = "청크 ID", example = "60d0fe4f5311236168a109cd") - @PathVariable String chunkId) { - - CustomContentChunkResponse response = customContentChunkService.getCustomContentChunk(claims.getId(), customContentId, chunkId); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "커스텀 콘텐츠 삭제", - description = "사용자가 본인이 생성한 커스텀 콘텐츠를 삭제합니다. 콘텐츠와 관련된 모든 청크 데이터도 함께 soft delete 처리됩니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "403", description = "본인 콘텐츠만 삭제 가능", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/{customContentId}") - public ResponseEntity deleteCustomContent( - @AuthenticationPrincipal JwtClaims claims, - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customContentId) { - - customContentService.deleteCustomContent(claims.getId(), customContentId); - return ResponseEntity.ok(new MessageResponse("Custom content deleted successfully.")); - } - - @ExceptionHandler(CustomContentException.class) - public ResponseEntity handleCustomContentException(CustomContentException e) { - log.info("Custom Content Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } + private final CustomContentService customContentService; + + private final CustomContentChunkService customContentChunkService; + + private final ReadingSessionService readingSessionService; + + @Operation(summary = "커스텀 콘텐츠 목록 조회", + description = "완료된 커스텀 콘텐츠 목록을 조회합니다. 기본적으로 최신순으로 정렬되며, 선택적으로 태그나 키워드 필터를 적용할 수 있습니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity> getCustomContents( + @AuthenticationPrincipal JwtClaims claims, + @ParameterObject @ModelAttribute GetCustomContentsRequest request) { + + PageResponse response = customContentService.getCustomContents(claims.getId(), request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "단일 커스텀 콘텐츠 조회", description = "특정 커스텀 콘텐츠의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{customContentId}") + public ResponseEntity getCustomContent(@AuthenticationPrincipal JwtClaims claims, + @Parameter(description = "커스텀 콘텐츠 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String customContentId) { + + CustomContentResponse response = customContentService.getCustomContent(claims.getId(), customContentId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "커스텀 콘텐츠 수정", description = "커스텀 콘텐츠의 제목이나 태그를 수정합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "수정 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PatchMapping("/{customContentId}") + public ResponseEntity updateCustomContent(@AuthenticationPrincipal JwtClaims claims, + @Parameter(description = "커스텀 콘텐츠 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String customContentId, + @RequestBody UpdateCustomContentRequest request) { + + CustomContentResponse response = customContentService.updateCustomContent(claims.getId(), customContentId, + request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "커스텀 콘텐츠 청크 목록 조회", description = "특정 커스텀 콘텐츠에 속한 텍스트 청크(Chunk)들을 난이도별로 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 난이도 레벨", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{customContentId}/chunks") + public ResponseEntity> getCustomContentChunks( + @AuthenticationPrincipal JwtClaims claims, + @Parameter(description = "커스텀 콘텐츠 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String customContentId, + @ParameterObject @ModelAttribute GetCustomContentChunksRequest request) { + + if (claims != null) { + readingSessionService.startReadingSession(claims.getId(), ContentType.CUSTOM, customContentId); + } + + PageResponse response = customContentChunkService + .getCustomContentChunks(claims.getId(), customContentId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "단일 커스텀 콘텐츠 청크 조회", description = "특정 커스텀 콘텐츠 청크의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠 또는 청크를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{customContentId}/chunks/{chunkId}") + public ResponseEntity getCustomContentChunk(@AuthenticationPrincipal JwtClaims claims, + @Parameter(description = "커스텀 콘텐츠 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String customContentId, + @Parameter(description = "청크 ID", example = "60d0fe4f5311236168a109cd") @PathVariable String chunkId) { + + CustomContentChunkResponse response = customContentChunkService.getCustomContentChunk(claims.getId(), + customContentId, chunkId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "커스텀 콘텐츠 삭제", + description = "사용자가 본인이 생성한 커스텀 콘텐츠를 삭제합니다. 콘텐츠와 관련된 모든 청크 데이터도 함께 soft delete 처리됩니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "삭제 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "403", description = "본인 콘텐츠만 삭제 가능", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/{customContentId}") + public ResponseEntity deleteCustomContent(@AuthenticationPrincipal JwtClaims claims, + @Parameter(description = "커스텀 콘텐츠 ID", + example = "60d0fe4f5311236168a109ca") @PathVariable String customContentId) { + + customContentService.deleteCustomContent(claims.getId(), customContentId); + return ResponseEntity.ok(new MessageResponse("Custom content deleted successfully.")); + } + + @ExceptionHandler(CustomContentException.class) + public ResponseEntity handleCustomContentException(CustomContentException e) { + log.info("Custom Content Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentProgressController.java b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentProgressController.java index 0f3029d0..33f89c4f 100644 --- a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentProgressController.java +++ b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentProgressController.java @@ -27,60 +27,53 @@ @Tag(name = "Custom Contents Progress", description = "커스텀 콘텐츠 진도 관리 API") public class CustomContentProgressController { - private final CustomContentReadingProgressService customContentReadingProgressService; + private final CustomContentReadingProgressService customContentReadingProgressService; - @Operation(summary = "커스텀 콘텐츠 읽기 진도 업데이트", description = "사용자의 커스텀 콘텐츠 읽기 진도를 업데이트합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠 또는 청크를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 청크 ID", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PutMapping("/{customId}/progress") - public ResponseEntity updateProgress( - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customId, - @Valid @RequestBody CustomContentReadingProgressUpdateRequest request, - @AuthenticationPrincipal JwtClaims claims) { - CustomContentReadingProgressResponse response = customContentReadingProgressService.updateProgress(customId, request, claims.getId()); - return ResponseEntity.ok(response); - } + @Operation(summary = "커스텀 콘텐츠 읽기 진도 업데이트", description = "사용자의 커스텀 콘텐츠 읽기 진도를 업데이트합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "업데이트 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠 또는 청크를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 청크 ID", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PutMapping("/{customId}/progress") + public ResponseEntity updateProgress( + @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String customId, + @Valid @RequestBody CustomContentReadingProgressUpdateRequest request, + @AuthenticationPrincipal JwtClaims claims) { + CustomContentReadingProgressResponse response = customContentReadingProgressService.updateProgress(customId, + request, claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "커스텀 콘텐츠 읽기 진도 조회", description = "특정 커스텀 콘텐츠에 대한 사용자의 읽기 진도를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{customId}/progress") - public ResponseEntity getProgress( - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customId, - @AuthenticationPrincipal JwtClaims claims) { - CustomContentReadingProgressResponse response = customContentReadingProgressService.getProgress(customId, claims.getId()); - return ResponseEntity.ok(response); - } + @Operation(summary = "커스텀 콘텐츠 읽기 진도 조회", description = "특정 커스텀 콘텐츠에 대한 사용자의 읽기 진도를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{customId}/progress") + public ResponseEntity getProgress( + @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String customId, + @AuthenticationPrincipal JwtClaims claims) { + CustomContentReadingProgressResponse response = customContentReadingProgressService.getProgress(customId, + claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "커스텀 콘텐츠 읽기 진도 삭제", description = "사용자의 읽기 진도 기록을 완전히 삭제합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠 또는 진도 기록을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @DeleteMapping("/{customId}/progress") - public ResponseEntity deleteProgress( - @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String customId, - @AuthenticationPrincipal JwtClaims claims) { - customContentReadingProgressService.deleteProgress(customId, claims.getId()); - return ResponseEntity.noContent().build(); - } + @Operation(summary = "커스텀 콘텐츠 읽기 진도 삭제", description = "사용자의 읽기 진도 기록을 완전히 삭제합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse(responseCode = "404", description = "커스텀 콘텐츠 또는 진도 기록을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @DeleteMapping("/{customId}/progress") + public ResponseEntity deleteProgress( + @Parameter(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String customId, + @AuthenticationPrincipal JwtClaims claims) { + customContentReadingProgressService.deleteProgress(customId, claims.getId()); + return ResponseEntity.noContent().build(); + } + + @ExceptionHandler(CustomContentException.class) + public ResponseEntity handleCustomContentException(CustomContentException e) { + log.info("Custom Content Progress Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(CustomContentException.class) - public ResponseEntity handleCustomContentException(CustomContentException e) { - log.info("Custom Content Progress Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentRequestController.java b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentRequestController.java index 0166c823..842ce956 100644 --- a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentRequestController.java +++ b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentRequestController.java @@ -1,6 +1,5 @@ package com.linglevel.api.content.custom.controller; - import com.linglevel.api.common.dto.ExceptionResponse; import com.linglevel.api.common.dto.PageResponse; import com.linglevel.api.content.custom.dto.*; @@ -32,74 +31,60 @@ @Tag(name = "Custom Content Requests", description = "커스텀 콘텐츠 처리 요청 관련 API") public class CustomContentRequestController { - private final CustomContentRequestService customContentRequestService; + private final CustomContentRequestService customContentRequestService; + + @Operation(summary = "콘텐츠 처리 요청 생성", description = "사용자가 텍스트를 입력하여 AI 콘텐츠 처리 요청을 생성합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "요청 생성 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping + public ResponseEntity createContentRequest( + @Parameter(description = "콘텐츠 처리 요청 생성", + required = true) @Valid @RequestBody CreateContentRequestRequest request, + @AuthenticationPrincipal JwtClaims claims) { + + CreateContentRequestResponse response = customContentRequestService.createContentRequest(claims.getId(), + request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "콘텐츠 처리 요청 목록 조회", description = "사용자의 콘텐츠 처리 요청 목록을 조회합니다. 진행 중이거나 완료된 요청들을 상태별로 확인할 수 있습니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity> getContentRequests( + @ParameterObject @ModelAttribute GetContentRequestsRequest request, + @AuthenticationPrincipal JwtClaims claims) { + + PageResponse response = customContentRequestService.getContentRequests(claims.getId(), + request); + return ResponseEntity.ok(response); + } - @Operation( - summary = "콘텐츠 처리 요청 생성", - description = "사용자가 텍스트를 입력하여 AI 콘텐츠 처리 요청을 생성합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "요청 생성 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping - public ResponseEntity createContentRequest( - @Parameter(description = "콘텐츠 처리 요청 생성", required = true) - @Valid @RequestBody CreateContentRequestRequest request, - @AuthenticationPrincipal JwtClaims claims) { - - CreateContentRequestResponse response = customContentRequestService.createContentRequest(claims.getId(), request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } + @Operation(summary = "특정 콘텐츠 처리 요청 조회", description = "특정 콘텐츠 처리 요청의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{requestId}") + public ResponseEntity getContentRequest( + @Parameter(description = "요청 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String requestId, + @AuthenticationPrincipal JwtClaims claims) { - @Operation( - summary = "콘텐츠 처리 요청 목록 조회", - description = "사용자의 콘텐츠 처리 요청 목록을 조회합니다. 진행 중이거나 완료된 요청들을 상태별로 확인할 수 있습니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity> getContentRequests( - @ParameterObject @ModelAttribute GetContentRequestsRequest request, - @AuthenticationPrincipal JwtClaims claims) { - - PageResponse response = customContentRequestService.getContentRequests(claims.getId(), request); - return ResponseEntity.ok(response); - } + ContentRequestResponse response = customContentRequestService.getContentRequest(claims.getId(), requestId); + return ResponseEntity.ok(response); + } - @Operation( - summary = "특정 콘텐츠 처리 요청 조회", - description = "특정 콘텐츠 처리 요청의 상세 정보를 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "403", description = "권한 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{requestId}") - public ResponseEntity getContentRequest( - @Parameter(description = "요청 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String requestId, - @AuthenticationPrincipal JwtClaims claims) { - - ContentRequestResponse response = customContentRequestService.getContentRequest(claims.getId(), requestId); - return ResponseEntity.ok(response); - } + @ExceptionHandler(CustomContentException.class) + public ResponseEntity handleCustomContentException(CustomContentException e) { + log.info("Custom Content Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(CustomContentException.class) - public ResponseEntity handleCustomContentException(CustomContentException e) { - log.info("Custom Content Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentWebhookController.java b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentWebhookController.java index 635f227c..8f188d60 100644 --- a/src/main/java/com/linglevel/api/content/custom/controller/CustomContentWebhookController.java +++ b/src/main/java/com/linglevel/api/content/custom/controller/CustomContentWebhookController.java @@ -29,71 +29,57 @@ @SecurityRequirement(name = "adminApiKey") public class CustomContentWebhookController { - private final CustomContentWebhookService customContentWebhookService; + private final CustomContentWebhookService customContentWebhookService; - @Operation( - summary = "AI 콘텐츠 처리 완료 웹훅", - description = "AI가 콘텐츠 처리를 완료했을 때 결과 JSON 파일의 위치를 전달하여 백엔드에서 처리하도록 하는 웹훅 API입니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "처리 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 상태", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/completed") - public ResponseEntity handleContentCompleted( - @Valid @RequestBody CustomContentCompletedRequest request) { + @Operation(summary = "AI 콘텐츠 처리 완료 웹훅", + description = "AI가 콘텐츠 처리를 완료했을 때 결과 JSON 파일의 위치를 전달하여 백엔드에서 처리하도록 하는 웹훅 API입니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "처리 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 상태", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/completed") + public ResponseEntity handleContentCompleted( + @Valid @RequestBody CustomContentCompletedRequest request) { - CustomContentCompletedResponse response = customContentWebhookService.handleContentCompleted(request); - return ResponseEntity.ok(response); - } + CustomContentCompletedResponse response = customContentWebhookService.handleContentCompleted(request); + return ResponseEntity.ok(response); + } - @Operation( - summary = "AI 콘텐츠 처리 실패 웹훅", - description = "AI 콘텐츠 처리가 실패했을 때 요청 상태를 업데이트하고 사용자에게 실패 알림을 발송하는 웹훅 API입니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "처리 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/failed") - public ResponseEntity handleContentFailed( - @Valid @RequestBody CustomContentFailedRequest request) { + @Operation(summary = "AI 콘텐츠 처리 실패 웹훅", + description = "AI 콘텐츠 처리가 실패했을 때 요청 상태를 업데이트하고 사용자에게 실패 알림을 발송하는 웹훅 API입니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "처리 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/failed") + public ResponseEntity handleContentFailed(@Valid @RequestBody CustomContentFailedRequest request) { - customContentWebhookService.handleContentFailed(request); - return ResponseEntity.ok(new MessageResponse("Content request marked as failed successfully")); - } + customContentWebhookService.handleContentFailed(request); + return ResponseEntity.ok(new MessageResponse("Content request marked as failed successfully")); + } - @Operation( - summary = "AI 콘텐츠 처리 진행률 웹훅", - description = "AI 콘텐츠 처리 중 진행률을 업데이트하는 웹훅 API입니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "처리 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping("/progress") - public ResponseEntity handleContentProgress( - @Valid @RequestBody CustomContentProgressRequest request) { + @Operation(summary = "AI 콘텐츠 처리 진행률 웹훅", description = "AI 콘텐츠 처리 중 진행률을 업데이트하는 웹훅 API입니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "처리 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "요청을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 API 키", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping("/progress") + public ResponseEntity handleContentProgress( + @Valid @RequestBody CustomContentProgressRequest request) { - customContentWebhookService.handleContentProgress(request); - return ResponseEntity.ok(new MessageResponse("Progress updated successfully")); - } + customContentWebhookService.handleContentProgress(request); + return ResponseEntity.ok(new MessageResponse("Progress updated successfully")); + } + + @ExceptionHandler(CustomContentException.class) + public ResponseEntity handleCustomContentException(CustomContentException e) { + log.info("Custom Content Webhook Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(CustomContentException.class) - public ResponseEntity handleCustomContentException(CustomContentException e) { - log.info("Custom Content Webhook Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/AiResultDto.java b/src/main/java/com/linglevel/api/content/custom/dto/AiResultDto.java index e6757e86..35191a1e 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/AiResultDto.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/AiResultDto.java @@ -9,69 +9,80 @@ @Getter @Setter public class AiResultDto { - private String id; - - @JsonProperty("content_type") - private String contentType; - - private String title; - private String author; - - @JsonProperty("cover_image_url") - private String coverImageUrl; - - @JsonProperty("original_text_level") - private String originalTextLevel; - - @JsonProperty("leveled_results") - private List leveledResults; - - @JsonProperty("chapter_metadata") - private List chapterMetadata; - - @Getter - @Setter - public static class LeveledResult { - @JsonProperty("textLevel") - private String textLevel; - - @JsonProperty("chapters") - private List chapters; - } - - @Getter - @Setter - public static class Chapter { - @JsonProperty("chapterNum") - private Integer chapterNum; - - @JsonProperty("chunks") - private List chunks; - } - - @Getter - @Setter - public static class Chunk { - @JsonProperty("chunkNum") - private Integer chunkNum; - - @JsonProperty("isImage") - private Boolean isImage; - - @JsonProperty("chunkText") - private String chunkText; - - @JsonProperty("description") - private String description; - } - - @Getter - @Setter - public static class ChapterMetadata { - @JsonProperty("chapterNum") - private Integer chapterNum; - - @JsonProperty("summary") - private String summary; - } + + private String id; + + @JsonProperty("content_type") + private String contentType; + + private String title; + + private String author; + + @JsonProperty("cover_image_url") + private String coverImageUrl; + + @JsonProperty("original_text_level") + private String originalTextLevel; + + @JsonProperty("leveled_results") + private List leveledResults; + + @JsonProperty("chapter_metadata") + private List chapterMetadata; + + @Getter + @Setter + public static class LeveledResult { + + @JsonProperty("textLevel") + private String textLevel; + + @JsonProperty("chapters") + private List chapters; + + } + + @Getter + @Setter + public static class Chapter { + + @JsonProperty("chapterNum") + private Integer chapterNum; + + @JsonProperty("chunks") + private List chunks; + + } + + @Getter + @Setter + public static class Chunk { + + @JsonProperty("chunkNum") + private Integer chunkNum; + + @JsonProperty("isImage") + private Boolean isImage; + + @JsonProperty("chunkText") + private String chunkText; + + @JsonProperty("description") + private String description; + + } + + @Getter + @Setter + public static class ChapterMetadata { + + @JsonProperty("chapterNum") + private Integer chapterNum; + + @JsonProperty("summary") + private String summary; + + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/ContentRequestResponse.java b/src/main/java/com/linglevel/api/content/custom/dto/ContentRequestResponse.java index ce8c7f28..8715b3a2 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/ContentRequestResponse.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/ContentRequestResponse.java @@ -12,49 +12,50 @@ @Setter @Schema(description = "콘텐츠 처리 요청 응답") public class ContentRequestResponse { - - @Schema(description = "요청 ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "제목", example = "My Custom Article") - private String title; - - @Schema(description = "원본 텍스트") - private String originalText; - - @Schema(description = "콘텐츠 타입", example = "CLIPBOARD") - private String contentType; - - @Schema(description = "목표 난이도 목록", example = "[\"A1\", \"B1\"]") - private List targetDifficultyLevels; - - @Schema(description = "원본 URL", example = "https://example.com/article") - private String originUrl; - - @Schema(description = "원본 도메인", example = "example.com") - private String originDomain; - - @Schema(description = "원본 저자", example = "작가명") - private String originAuthor; - - @Schema(description = "커버 이미지 URL", example = "https://example.com/image.jpg") - private String coverImageUrl; - - @Schema(description = "처리 상태", example = "PROCESSING") - private String status; - - @Schema(description = "진행률 (0-100)", example = "45") - private Integer progress; - - @Schema(description = "생성일시", example = "2024-01-15T10:00:00Z") - private Instant createdAt; - - @Schema(description = "완료일시", example = "2024-01-15T10:05:00Z") - private Instant completedAt; - - @Schema(description = "에러 메시지", example = "error_message") - private String errorMessage; - - @Schema(description = "결과 커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109cc") - private String resultCustomContentId; + + @Schema(description = "요청 ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "제목", example = "My Custom Article") + private String title; + + @Schema(description = "원본 텍스트") + private String originalText; + + @Schema(description = "콘텐츠 타입", example = "CLIPBOARD") + private String contentType; + + @Schema(description = "목표 난이도 목록", example = "[\"A1\", \"B1\"]") + private List targetDifficultyLevels; + + @Schema(description = "원본 URL", example = "https://example.com/article") + private String originUrl; + + @Schema(description = "원본 도메인", example = "example.com") + private String originDomain; + + @Schema(description = "원본 저자", example = "작가명") + private String originAuthor; + + @Schema(description = "커버 이미지 URL", example = "https://example.com/image.jpg") + private String coverImageUrl; + + @Schema(description = "처리 상태", example = "PROCESSING") + private String status; + + @Schema(description = "진행률 (0-100)", example = "45") + private Integer progress; + + @Schema(description = "생성일시", example = "2024-01-15T10:00:00Z") + private Instant createdAt; + + @Schema(description = "완료일시", example = "2024-01-15T10:05:00Z") + private Instant completedAt; + + @Schema(description = "에러 메시지", example = "error_message") + private String errorMessage; + + @Schema(description = "결과 커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109cc") + private String resultCustomContentId; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestRequest.java index 126be35d..ebf1c8d7 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestRequest.java @@ -13,29 +13,30 @@ @Setter @Schema(description = "콘텐츠 처리 요청 생성 요청") public class CreateContentRequestRequest { - - @Schema(description = "콘텐츠 제목", example = "My Custom Article") - private String title; - - @Schema(description = "콘텐츠 타입", example = "TEXT") - @NotNull(message = "콘텐츠 타입은 필수입니다.") - private ContentType contentType; - - @Schema(description = "처리할 원본 텍스트 (최대 10,000자)", - example = "Once upon a time, there was a little prince who lived on a small planet...") - @NotBlank(message = "원본 콘텐츠는 필수입니다.") - @Size(min=100, max = 15000, message = "원본 콘텐츠는 최소 100자부터 최대 15,000자까지 입력 가능합니다.") - private String originalContent; - - @Schema(description = "목표 난이도 목록", example = "[\"A1\", \"B1\"]") - private List targetDifficultyLevels; - - @Schema(description = "원본 링크 URL (링크 타입인 경우)", example = "https://example.com/article") - private String originUrl; - - @Schema(description = "원본 저자", example = "작가명") - private String originAuthor; - - @Schema(description = "커버 이미지 URL (옵셔널)", example = "https://example.com/image.jpg") - private String coverImageUrl; + + @Schema(description = "콘텐츠 제목", example = "My Custom Article") + private String title; + + @Schema(description = "콘텐츠 타입", example = "TEXT") + @NotNull(message = "콘텐츠 타입은 필수입니다.") + private ContentType contentType; + + @Schema(description = "처리할 원본 텍스트 (최대 10,000자)", + example = "Once upon a time, there was a little prince who lived on a small planet...") + @NotBlank(message = "원본 콘텐츠는 필수입니다.") + @Size(min = 100, max = 15000, message = "원본 콘텐츠는 최소 100자부터 최대 15,000자까지 입력 가능합니다.") + private String originalContent; + + @Schema(description = "목표 난이도 목록", example = "[\"A1\", \"B1\"]") + private List targetDifficultyLevels; + + @Schema(description = "원본 링크 URL (링크 타입인 경우)", example = "https://example.com/article") + private String originUrl; + + @Schema(description = "원본 저자", example = "작가명") + private String originAuthor; + + @Schema(description = "커버 이미지 URL (옵셔널)", example = "https://example.com/image.jpg") + private String coverImageUrl; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestResponse.java b/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestResponse.java index 1b6c384e..5d4c9c25 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestResponse.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CreateContentRequestResponse.java @@ -16,25 +16,26 @@ @AllArgsConstructor @Schema(description = "콘텐츠 처리 요청 생성 응답") public class CreateContentRequestResponse { - - @Schema(description = "생성된 요청 ID", example = "60d0fe4f5311236168a109ca") - private String requestId; - - @Schema(description = "제목", example = "My Custom Article") - private String title; - - @Schema(description = "요청 상태", example = "PENDING") - private String status; - - @Schema(description = "캐시 히트 여부", example = "true") - private boolean cached; - - @Schema(description = "캐시된 커스텀 콘텐츠 ID", example = "60d0fe4f5311236162332939") - private String customContentId; - - @Schema(description = "캐시된 커스텀 콘텐츠 타이틀", example = "Article Real Title") - private String customContentTitle; - - @Schema(description = "생성일시", example = "2024-01-15T10:00:00Z") - private Instant createdAt; + + @Schema(description = "생성된 요청 ID", example = "60d0fe4f5311236168a109ca") + private String requestId; + + @Schema(description = "제목", example = "My Custom Article") + private String title; + + @Schema(description = "요청 상태", example = "PENDING") + private String status; + + @Schema(description = "캐시 히트 여부", example = "true") + private boolean cached; + + @Schema(description = "캐시된 커스텀 콘텐츠 ID", example = "60d0fe4f5311236162332939") + private String customContentId; + + @Schema(description = "캐시된 커스텀 콘텐츠 타이틀", example = "Article Real Title") + private String customContentTitle; + + @Schema(description = "생성일시", example = "2024-01-15T10:00:00Z") + private Instant createdAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentChunkResponse.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentChunkResponse.java index bca65846..d0f40885 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentChunkResponse.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentChunkResponse.java @@ -10,7 +10,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; - @Getter @Setter @Builder @@ -19,32 +18,33 @@ @Schema(description = "커스텀 콘텐츠 청크 응답") public class CustomContentChunkResponse { - @Schema(description = "청크 ID", example = "60d0fe4f5311236168a109cd") - private String id; + @Schema(description = "청크 ID", example = "60d0fe4f5311236168a109cd") + private String id; + + @Schema(description = "청크 번호", example = "1") + private Integer chunkNumber; - @Schema(description = "청크 번호", example = "1") - private Integer chunkNumber; + @Schema(description = "난이도", example = "A1") + private DifficultyLevel difficultyLevel; - @Schema(description = "난이도", example = "A1") - private DifficultyLevel difficultyLevel; + @Schema(description = "청크 타입", example = "TEXT") + private ChunkType type; - @Schema(description = "청크 타입", example = "TEXT") - private ChunkType type; + @Schema(description = "내용 (텍스트일 경우 텍스트, 이미지일 경우 URL)", example = "Once when I was six years old...") + private String content; - @Schema(description = "내용 (텍스트일 경우 텍스트, 이미지일 경우 URL)", example = "Once when I was six years old...") - private String content; + @Schema(description = "이미지 설명 (이미지 타입일 경우)", example = "null") + private String description; - @Schema(description = "이미지 설명 (이미지 타입일 경우)", example = "null") - private String description; + public static CustomContentChunkResponse from(CustomContentChunk chunk) { + return CustomContentChunkResponse.builder() + .id(chunk.getId()) + .chunkNumber(chunk.getChunkNum()) + .difficultyLevel(chunk.getDifficultyLevel()) + .type(chunk.getType()) + .content(chunk.getChunkText()) + .description(chunk.getDescription()) + .build(); + } - public static CustomContentChunkResponse from(CustomContentChunk chunk) { - return CustomContentChunkResponse.builder() - .id(chunk.getId()) - .chunkNumber(chunk.getChunkNum()) - .difficultyLevel(chunk.getDifficultyLevel()) - .type(chunk.getType()) - .content(chunk.getChunkText()) - .description(chunk.getDescription()) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedRequest.java index 0cf5d979..fbc8138a 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedRequest.java @@ -10,8 +10,9 @@ @Setter @Schema(description = "AI 콘텐츠 처리 완료 웹훅 요청") public class CustomContentCompletedRequest { - - @Schema(description = "처리 요청의 고유 ID", example = "60d0fe4f5311236168a109ca", required = true) - @NotBlank(message = "요청 ID는 필수입니다.") - private String requestId; + + @Schema(description = "처리 요청의 고유 ID", example = "60d0fe4f5311236168a109ca", required = true) + @NotBlank(message = "요청 ID는 필수입니다.") + private String requestId; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedResponse.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedResponse.java index 44959d98..3f9d9bc0 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedResponse.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentCompletedResponse.java @@ -14,10 +14,11 @@ @AllArgsConstructor @Schema(description = "AI 콘텐츠 처리 완료 웹훅 응답") public class CustomContentCompletedResponse { - - @Schema(description = "요청 ID", example = "60d0fe4f5311236168a109ca") - private String requestId; - - @Schema(description = "처리 상태", example = "completed") - private String status; + + @Schema(description = "요청 ID", example = "60d0fe4f5311236168a109ca") + private String requestId; + + @Schema(description = "처리 상태", example = "completed") + private String status; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentFailedRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentFailedRequest.java index 0c049cfb..f3631bba 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentFailedRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentFailedRequest.java @@ -10,13 +10,14 @@ @Setter @Schema(description = "AI 콘텐츠 처리 실패 웹훅 요청") public class CustomContentFailedRequest { - - @Schema(description = "처리 요청의 고유 ID", example = "60d0fe4f5311236168a109ca", required = true) - @NotBlank(message = "요청 ID는 필수입니다.") - private String requestId; - - @Schema(description = "사용자에게 표시할 에러 메시지", - example = "Content processing failed due to unsupported format", required = true) - @NotBlank(message = "에러 메시지는 필수입니다.") - private String errorMessage; + + @Schema(description = "처리 요청의 고유 ID", example = "60d0fe4f5311236168a109ca", required = true) + @NotBlank(message = "요청 ID는 필수입니다.") + private String requestId; + + @Schema(description = "사용자에게 표시할 에러 메시지", example = "Content processing failed due to unsupported format", + required = true) + @NotBlank(message = "에러 메시지는 필수입니다.") + private String errorMessage; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentProgressRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentProgressRequest.java index 49681175..bdc3a976 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentProgressRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentProgressRequest.java @@ -13,14 +13,15 @@ @Setter @Schema(description = "AI 콘텐츠 처리 진행률 웹훅 요청") public class CustomContentProgressRequest { - - @Schema(description = "처리 요청의 고유 ID", example = "60d0fe4f5311236168a109ca", required = true) - @NotBlank(message = "요청 ID는 필수입니다.") - private String requestId; - - @Schema(description = "진행률 0-100", example = "75", required = true) - @NotNull(message = "진행률은 필수입니다.") - @Min(value = 0, message = "진행률은 0 이상이어야 합니다.") - @Max(value = 100, message = "진행률은 100 이하여야 합니다.") - private Integer progress; + + @Schema(description = "처리 요청의 고유 ID", example = "60d0fe4f5311236168a109ca", required = true) + @NotBlank(message = "요청 ID는 필수입니다.") + private String requestId; + + @Schema(description = "진행률 0-100", example = "75", required = true) + @NotNull(message = "진행률은 필수입니다.") + @Min(value = 0, message = "진행률은 0 이상이어야 합니다.") + @Max(value = 100, message = "진행률은 100 이하여야 합니다.") + private Integer progress; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressResponse.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressResponse.java index 8f0cd285..dab067e8 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressResponse.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressResponse.java @@ -15,39 +15,41 @@ @AllArgsConstructor @Schema(description = "커스텀 콘텐츠 읽기 진도 정보 응답") public class CustomContentReadingProgressResponse { - @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") - private String id; - @Schema(description = "사용자 ID", example = "60d0fe4f5311236168a109ca") - private String userId; + @Schema(description = "진도 ID", example = "60d0fe4f5311236168a109d1") + private String id; - @Schema(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109cb") - private String customId; + @Schema(description = "사용자 ID", example = "60d0fe4f5311236168a109ca") + private String userId; - @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") - private String chunkId; + @Schema(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109cb") + private String customId; - @Schema(description = "현재 읽은 청크 번호", example = "3") - private Integer currentReadChunkNumber; + @Schema(description = "청크 ID", example = "60d0fe4f53112389248a182db") + private String chunkId; - @Schema(description = "최대 읽은 청크 번호", example = "5") - private Integer maxReadChunkNumber; + @Schema(description = "현재 읽은 청크 번호", example = "3") + private Integer currentReadChunkNumber; - @Schema(description = "완료 여부", example = "false") - private Boolean isCompleted; + @Schema(description = "최대 읽은 청크 번호", example = "5") + private Integer maxReadChunkNumber; - @Schema(description = "현재 난이도", example = "EASY") - private DifficultyLevel currentDifficultyLevel; + @Schema(description = "완료 여부", example = "false") + private Boolean isCompleted; - @Schema(description = "정규화된 현재 진행률 (%)", example = "75.5") - private Double normalizedProgress; + @Schema(description = "현재 난이도", example = "EASY") + private DifficultyLevel currentDifficultyLevel; - @Schema(description = "정규화된 최대 진행률 (%)", example = "85.2") - private Double maxNormalizedProgress; + @Schema(description = "정규화된 현재 진행률 (%)", example = "75.5") + private Double normalizedProgress; - @Schema(description = "스트릭이 업데이트되었는지 여부 (완료 시 true)", example = "true") - private Boolean streakUpdated; + @Schema(description = "정규화된 최대 진행률 (%)", example = "85.2") + private Double maxNormalizedProgress; + + @Schema(description = "스트릭이 업데이트되었는지 여부 (완료 시 true)", example = "true") + private Boolean streakUpdated; + + @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") + private Instant updatedAt; - @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00Z") - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressUpdateRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressUpdateRequest.java index f2cc3b36..626ef2ae 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressUpdateRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentReadingProgressUpdateRequest.java @@ -12,6 +12,8 @@ @AllArgsConstructor @Schema(description = "커스텀 콘텐츠 읽기 진도 업데이트 요청") public class CustomContentReadingProgressUpdateRequest { - @Schema(description = "청크 ID", example = "60d0fe4f5311236168c172db") - private String chunkId; + + @Schema(description = "청크 ID", example = "60d0fe4f5311236168c172db") + private String chunkId; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentResponse.java b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentResponse.java index e715aae1..d72d7c6c 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/CustomContentResponse.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/CustomContentResponse.java @@ -12,64 +12,65 @@ @Setter @Schema(description = "커스텀 콘텐츠 응답") public class CustomContentResponse { - - @Schema(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "제목", example = "My Custom Article") - private String title; - - @Schema(description = "작가", example = "AI Generated") - private String author; - - @Schema(description = "커버 이미지 URL", example = "https://path/to/cover.jpg") - private String coverImageUrl; - - @Schema(description = "AI가 분석한 최종 난이도", example = "A1") - private DifficultyLevel difficultyLevel; - - @Schema(description = "사용자가 요청한 목표 난이도 목록", example = "[\"A1\", \"B1\"]") - private List targetDifficultyLevels; - - @Schema(description = "청크 개수", example = "12") - private Integer chunkCount; - - @Schema(description = "현재 읽은 청크 번호", example = "7") - private Integer currentReadChunkNumber; - - @Schema(description = "진행률", example = "58.3") - private Double progressPercentage; - - @Schema(description = "현재 선택한 난이도", example = "EASY") - private DifficultyLevel currentDifficultyLevel; - - @Schema(description = "완료 여부", example = "false") - private Boolean isCompleted; - - @Schema(description = "예상 읽기 시간(분)", example = "8") - private Integer readingTime; - - @Schema(description = "평균 평점", example = "4.2") - private Double averageRating; - - @Schema(description = "리뷰 개수", example = "15") - private Integer reviewCount; - - @Schema(description = "조회수", example = "150") - private Integer viewCount; - - @Schema(description = "태그 목록", example = "[\"technology\", \"beginner\"]") - private List tags; - - @Schema(description = "원본 URL", example = "https://example.com/article") - private String originUrl; - - @Schema(description = "출처 도메인", example = "example.com") - private String originDomain; - - @Schema(description = "생성일시", example = "2024-01-15T10:05:00Z") - private Instant createdAt; - - @Schema(description = "수정일시", example = "2024-01-15T10:05:00Z") - private Instant updatedAt; + + @Schema(description = "커스텀 콘텐츠 ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "제목", example = "My Custom Article") + private String title; + + @Schema(description = "작가", example = "AI Generated") + private String author; + + @Schema(description = "커버 이미지 URL", example = "https://path/to/cover.jpg") + private String coverImageUrl; + + @Schema(description = "AI가 분석한 최종 난이도", example = "A1") + private DifficultyLevel difficultyLevel; + + @Schema(description = "사용자가 요청한 목표 난이도 목록", example = "[\"A1\", \"B1\"]") + private List targetDifficultyLevels; + + @Schema(description = "청크 개수", example = "12") + private Integer chunkCount; + + @Schema(description = "현재 읽은 청크 번호", example = "7") + private Integer currentReadChunkNumber; + + @Schema(description = "진행률", example = "58.3") + private Double progressPercentage; + + @Schema(description = "현재 선택한 난이도", example = "EASY") + private DifficultyLevel currentDifficultyLevel; + + @Schema(description = "완료 여부", example = "false") + private Boolean isCompleted; + + @Schema(description = "예상 읽기 시간(분)", example = "8") + private Integer readingTime; + + @Schema(description = "평균 평점", example = "4.2") + private Double averageRating; + + @Schema(description = "리뷰 개수", example = "15") + private Integer reviewCount; + + @Schema(description = "조회수", example = "150") + private Integer viewCount; + + @Schema(description = "태그 목록", example = "[\"technology\", \"beginner\"]") + private List tags; + + @Schema(description = "원본 URL", example = "https://example.com/article") + private String originUrl; + + @Schema(description = "출처 도메인", example = "example.com") + private String originDomain; + + @Schema(description = "생성일시", example = "2024-01-15T10:05:00Z") + private Instant createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T10:05:00Z") + private Instant updatedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/GetContentRequestsRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/GetContentRequestsRequest.java index 410338e9..eba8acfa 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/GetContentRequestsRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/GetContentRequestsRequest.java @@ -11,15 +11,17 @@ @Schema(description = "콘텐츠 처리 요청 목록 조회 요청") public class GetContentRequestsRequest { - @Schema(description = "상태별 필터링", example = "COMPLETED", allowableValues = {"PENDING", "PROCESSING", "COMPLETED", "FAILED"}) - private String status; + @Schema(description = "상태별 필터링", example = "COMPLETED", + allowableValues = { "PENDING", "PROCESSING", "COMPLETED", "FAILED" }) + private String status; - @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private Integer limit = 10; - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private Integer limit = 10; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentChunksRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentChunksRequest.java index 35e22c96..28d12a2f 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentChunksRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentChunksRequest.java @@ -14,16 +14,17 @@ @Schema(description = "커스텀 콘텐츠 청크 목록 조회 요청") public class GetCustomContentChunksRequest { - @Schema(description = "청크의 난이도", example = "A1", required = true) - @NotNull(message = "난이도는 필수입니다.") - private DifficultyLevel difficultyLevel; + @Schema(description = "청크의 난이도", example = "A1", required = true) + @NotNull(message = "난이도는 필수입니다.") + private DifficultyLevel difficultyLevel; - @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private Integer limit = 10; - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private Integer limit = 10; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentsRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentsRequest.java index 24bcc0dd..339ecf7c 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentsRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/GetCustomContentsRequest.java @@ -12,24 +12,26 @@ @Schema(description = "커스텀 콘텐츠 목록 조회 요청") public class GetCustomContentsRequest { - @Schema(description = "정렬 기준", example = "created_at", defaultValue = "created_at", allowableValues = {"view_count", "average_rating", "created_at"}) - private String sortBy = "created_at"; + @Schema(description = "정렬 기준", example = "created_at", defaultValue = "created_at", + allowableValues = { "view_count", "average_rating", "created_at" }) + private String sortBy = "created_at"; - @Schema(description = "검색할 태그들 (쉼표로 구분)", example = "technology,beginner") - private String tags; + @Schema(description = "검색할 태그들 (쉼표로 구분)", example = "technology,beginner") + private String tags; - @Schema(description = "검색할 콘텐츠 제목 또는 작가 이름", example = "prince") - private String keyword; + @Schema(description = "검색할 콘텐츠 제목 또는 작가 이름", example = "prince") + private String keyword; - @Schema(description = "진도별 필터링", example = "IN_PROGRESS") - private ProgressStatus progress; + @Schema(description = "진도별 필터링", example = "IN_PROGRESS") + private ProgressStatus progress; - @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; + + @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private Integer limit = 10; - @Schema(description = "페이지 당 항목 수", example = "10", defaultValue = "10", minimum = "1", maximum = "200") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private Integer limit = 10; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/dto/UpdateCustomContentRequest.java b/src/main/java/com/linglevel/api/content/custom/dto/UpdateCustomContentRequest.java index 8c40e445..75a273f2 100644 --- a/src/main/java/com/linglevel/api/content/custom/dto/UpdateCustomContentRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/dto/UpdateCustomContentRequest.java @@ -8,9 +8,10 @@ @Schema(description = "커스텀 콘텐츠 수정 요청") public class UpdateCustomContentRequest { - @Schema(description = "새로운 콘텐츠 제목", example = "My Updated Content Title") - private String title; + @Schema(description = "새로운 콘텐츠 제목", example = "My Updated Content Title") + private String title; + + @Schema(description = "업데이트할 태그 목록", example = "[\"technology\", \"artificial-intelligence\"]") + private List tags; - @Schema(description = "업데이트할 태그 목록", example = "[\"technology\", \"artificial-intelligence\"]") - private List tags; } diff --git a/src/main/java/com/linglevel/api/content/custom/entity/ContentRequest.java b/src/main/java/com/linglevel/api/content/custom/entity/ContentRequest.java index 0311037b..5f2a41fd 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/ContentRequest.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/ContentRequest.java @@ -19,50 +19,51 @@ @AllArgsConstructor @Document(collection = "contentRequests") public class ContentRequest { - - @Id - private String id; - @NotNull - @Indexed - private String userId; + @Id + private String id; - @NotNull - private String title; + @NotNull + @Indexed + private String userId; - private String originalText; + @NotNull + private String title; - @NotNull - private ContentType contentType; + private String originalText; - private String originAuthor; + @NotNull + private ContentType contentType; - private List targetDifficultyLevels; + private String originAuthor; - private String originUrl; + private List targetDifficultyLevels; - private String originDomain; + private String originUrl; - private String coverImageUrl; + private String originDomain; - @NotNull - @Builder.Default - private ContentRequestStatus status = ContentRequestStatus.PENDING; + private String coverImageUrl; - @Builder.Default - private Integer progress = 0; + @NotNull + @Builder.Default + private ContentRequestStatus status = ContentRequestStatus.PENDING; - @CreatedDate - private Instant createdAt; + @Builder.Default + private Integer progress = 0; - private Instant completedAt; + @CreatedDate + private Instant createdAt; - private Instant deletedAt; + private Instant completedAt; - private String errorMessage; + private Instant deletedAt; - private String resultCustomContentId; + private String errorMessage; + + private String resultCustomContentId; + + @LastModifiedDate + private Instant updatedAt; - @LastModifiedDate - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/entity/ContentRequestStatus.java b/src/main/java/com/linglevel/api/content/custom/entity/ContentRequestStatus.java index 25f509eb..2ac47365 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/ContentRequestStatus.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/ContentRequestStatus.java @@ -6,19 +6,21 @@ @Getter @Schema(description = "콘텐츠 요청 처리 상태") public enum ContentRequestStatus { - PENDING("pending", "대기 중", "AI 처리 대기 중인 상태"), - PROCESSING("processing", "처리 중", "AI가 콘텐츠를 처리하고 있는 상태"), - COMPLETED("completed", "완료", "AI 처리가 성공적으로 완료된 상태"), - FAILED("failed", "실패", "AI 처리가 실패한 상태"), - DELETED("deleted", "삭제됨", "사용자가 삭제한 상태"); - - private final String code; - private final String name; - private final String description; - - ContentRequestStatus(String code, String name, String description) { - this.code = code; - this.name = name; - this.description = description; - } + + PENDING("pending", "대기 중", "AI 처리 대기 중인 상태"), PROCESSING("processing", "처리 중", "AI가 콘텐츠를 처리하고 있는 상태"), + COMPLETED("completed", "완료", "AI 처리가 성공적으로 완료된 상태"), FAILED("failed", "실패", "AI 처리가 실패한 상태"), + DELETED("deleted", "삭제됨", "사용자가 삭제한 상태"); + + private final String code; + + private final String name; + + private final String description; + + ContentRequestStatus(String code, String name, String description) { + this.code = code; + this.name = name; + this.description = description; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/entity/ContentType.java b/src/main/java/com/linglevel/api/content/custom/entity/ContentType.java index 0edcbac3..7360595b 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/ContentType.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/ContentType.java @@ -6,18 +6,20 @@ @Getter @Schema(description = "콘텐츠 타입") public enum ContentType { - TEXT("text", "텍스트", "사용자가 직접 입력한 텍스트"), - LINK("link", "링크", "외부 링크"), - PDF("pdf", "PDF 파일", "PDF 문서"), - YOUTUBE("youtube", "유튜브", "유튜브에서 가져온 자료"); - - private final String code; - private final String name; - private final String description; - - ContentType(String code, String name, String description) { - this.code = code; - this.name = name; - this.description = description; - } + + TEXT("text", "텍스트", "사용자가 직접 입력한 텍스트"), LINK("link", "링크", "외부 링크"), PDF("pdf", "PDF 파일", "PDF 문서"), + YOUTUBE("youtube", "유튜브", "유튜브에서 가져온 자료"); + + private final String code; + + private final String name; + + private final String description; + + ContentType(String code, String name, String description) { + this.code = code; + this.name = name; + this.description = description; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/entity/CustomContent.java b/src/main/java/com/linglevel/api/content/custom/entity/CustomContent.java index c29a9662..903a5da5 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/CustomContent.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/CustomContent.java @@ -19,55 +19,56 @@ @AllArgsConstructor @Document(collection = "customContents") public class CustomContent { - - @Id - private String id; - @NotNull - @Indexed - private String userId; + @Id + private String id; - @NotNull - @Indexed - private String contentRequestId; + @NotNull + @Indexed + private String userId; - @Builder.Default - private Boolean isDeleted = false; + @NotNull + @Indexed + private String contentRequestId; - @NotNull - private String title; + @Builder.Default + private Boolean isDeleted = false; - private String author; + @NotNull + private String title; - private String coverImageUrl; + private String author; - @NotNull - private DifficultyLevel difficultyLevel; + private String coverImageUrl; - private List targetDifficultyLevels; + @NotNull + private DifficultyLevel difficultyLevel; - private Integer readingTime; + private List targetDifficultyLevels; - @Builder.Default - private Double averageRating = 0.0; + private Integer readingTime; - @Builder.Default - private Integer reviewCount = 0; + @Builder.Default + private Double averageRating = 0.0; - @Builder.Default - private Integer viewCount = 0; + @Builder.Default + private Integer reviewCount = 0; - private List tags; + @Builder.Default + private Integer viewCount = 0; - private String originUrl; + private List tags; - private String originDomain; + private String originUrl; - @CreatedDate - private Instant createdAt; + private String originDomain; - @LastModifiedDate - private Instant updatedAt; + @CreatedDate + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; + + private Instant deletedAt; - private Instant deletedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/entity/CustomContentChunk.java b/src/main/java/com/linglevel/api/content/custom/entity/CustomContentChunk.java index cd966bed..c640d84f 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/CustomContentChunk.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/CustomContentChunk.java @@ -21,47 +21,48 @@ @AllArgsConstructor @Document(collection = "customContentChunks") @CompoundIndexes({ - @CompoundIndex(name = "custom_content_difficulty_chapter_chunk_idx", def = "{'customContentId': 1, 'difficultyLevel': 1, 'chapterNum': 1, 'chunkNum': 1}"), - @CompoundIndex(name = "user_deleted_created_idx", def = "{'userId': 1, 'isDeleted': 1, 'createdAt': -1}") -}) + @CompoundIndex(name = "custom_content_difficulty_chapter_chunk_idx", + def = "{'customContentId': 1, 'difficultyLevel': 1, 'chapterNum': 1, 'chunkNum': 1}"), + @CompoundIndex(name = "user_deleted_created_idx", def = "{'userId': 1, 'isDeleted': 1, 'createdAt': -1}") }) public class CustomContentChunk { - - @Id - private String id; - @NotNull - @Indexed - private String customContentId; + @Id + private String id; - @NotNull - @Indexed - private String userId; + @NotNull + @Indexed + private String customContentId; - @NotNull - private DifficultyLevel difficultyLevel; + @NotNull + @Indexed + private String userId; - @NotNull - private Integer chapterNum; + @NotNull + private DifficultyLevel difficultyLevel; - @NotNull - private Integer chunkNum; + @NotNull + private Integer chapterNum; - @NotNull - private ChunkType type; + @NotNull + private Integer chunkNum; - @NotNull - private String chunkText; + @NotNull + private ChunkType type; - private String description; + @NotNull + private String chunkText; - @Builder.Default - private Boolean isDeleted = false; + private String description; - @CreatedDate - private Instant createdAt; + @Builder.Default + private Boolean isDeleted = false; - @LastModifiedDate - private Instant updatedAt; + @CreatedDate + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; + + private Instant deletedAt; - private Instant deletedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/entity/CustomContentProgress.java b/src/main/java/com/linglevel/api/content/custom/entity/CustomContentProgress.java index 3b90d377..5f6fb7f4 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/CustomContentProgress.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/CustomContentProgress.java @@ -19,26 +19,28 @@ @Document(collection = "customProgress") @CompoundIndex(name = "idx_user_custom_progress", def = "{'userId': 1, 'customId': 1}", unique = true) public class CustomContentProgress { - @Id - private String id; - private String userId; + @Id + private String id; - private String customId; + private String userId; - private String chunkId; + private String customId; - // V2 Progress Fields - private Double normalizedProgress; + private String chunkId; - private Double maxNormalizedProgress; + // V2 Progress Fields + private Double normalizedProgress; - private DifficultyLevel currentDifficultyLevel; + private Double maxNormalizedProgress; - private Boolean isCompleted = false; + private DifficultyLevel currentDifficultyLevel; - private Instant completedAt; + private Boolean isCompleted = false; + + private Instant completedAt; + + @LastModifiedDate + private Instant updatedAt; - @LastModifiedDate - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/entity/UserCustomContent.java b/src/main/java/com/linglevel/api/content/custom/entity/UserCustomContent.java index 4cc046ca..22d2026f 100644 --- a/src/main/java/com/linglevel/api/content/custom/entity/UserCustomContent.java +++ b/src/main/java/com/linglevel/api/content/custom/entity/UserCustomContent.java @@ -11,8 +11,7 @@ import java.time.Instant; /** - * 유저와 커스텀 콘텐츠 간의 매핑 엔티티 - * 한 콘텐츠를 여러 유저가 공유할 수 있도록 N:M 관계 구현 + * 유저와 커스텀 콘텐츠 간의 매핑 엔티티 한 콘텐츠를 여러 유저가 공유할 수 있도록 N:M 관계 구현 */ @Getter @Setter @@ -23,21 +22,22 @@ @CompoundIndex(name = "user_content_idx", def = "{'userId': 1, 'customContentId': 1}", unique = true) public class UserCustomContent { - @Id - private String id; + @Id + private String id; - @NotNull - @Indexed - private String userId; + @NotNull + @Indexed + private String userId; - @NotNull - @Indexed - private String customContentId; + @NotNull + @Indexed + private String customContentId; - @NotNull - @Indexed - private String contentRequestId; + @NotNull + @Indexed + private String contentRequestId; + + @CreatedDate + private Instant unlockedAt; - @CreatedDate - private Instant unlockedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/exception/CustomContentErrorCode.java b/src/main/java/com/linglevel/api/content/custom/exception/CustomContentErrorCode.java index df57a2ef..f6423868 100644 --- a/src/main/java/com/linglevel/api/content/custom/exception/CustomContentErrorCode.java +++ b/src/main/java/com/linglevel/api/content/custom/exception/CustomContentErrorCode.java @@ -7,40 +7,43 @@ @Getter @AllArgsConstructor public enum CustomContentErrorCode { - // 요청 관련 (4xx) - CONTENT_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "콘텐츠 처리 요청을 찾을 수 없습니다."), - CONTENT_REQUEST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 콘텐츠 요청에 대한 접근 권한이 없습니다."), - INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 콘텐츠 타입입니다."), - INVALID_DIFFICULTY_LEVEL(HttpStatus.BAD_REQUEST, "유효하지 않은 난이도 레벨입니다."), - INVALID_REQUEST_STATUS(HttpStatus.CONFLICT, "요청이 처리 가능한 상태가 아닙니다."), - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), - URL_REQUIRED(HttpStatus.BAD_REQUEST, "URL is required for LINK content type."), - INVALID_URL_FORMAT(HttpStatus.BAD_REQUEST, "Invalid URL format provided."), - URL_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "URL is not supported for crawling. Please check supported domains."), - - // 커스텀 콘텐츠 관련 (4xx) - CUSTOM_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "커스텀 콘텐츠를 찾을 수 없습니다."), - CUSTOM_CONTENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 커스텀 콘텐츠에 대한 접근 권한이 없습니다."), - CUSTOM_CONTENT_CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "커스텀 콘텐츠 청크를 찾을 수 없습니다."), - CHUNK_NOT_FOUND_IN_CUSTOM_CONTENT(HttpStatus.NOT_FOUND, "Chunk not found in this custom content."), - PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "진도 기록을 찾을 수 없습니다."), - CONTENT_ALREADY_OWNED(HttpStatus.CONFLICT, "이미 소유하고 있는 콘텐츠입니다."), - - // 인증/인가 관련 (4xx) - USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "인증된 사용자 정보를 찾을 수 없습니다."), - INSUFFICIENT_TICKETS(HttpStatus.PAYMENT_REQUIRED, "티켓이 부족합니다."), - - // 외부 서비스 관련 (5xx) - AI_INPUT_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "AI 서비스 입력 전송에 실패했습니다."), - AI_RESULT_PROCESSING_FAILED(HttpStatus.BAD_GATEWAY, "AI 서비스 결과 처리에 실패했습니다."), - - // 내부 서비스 관련 (5xx) - IMPORT_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "콘텐츠 가져오기 처리 중 오류가 발생했습니다."), - WEBHOOK_PROCESSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "웹훅 처리 중 내부 오류가 발생했습니다."), - - // 서비스 상태 관련 (5xx) - SERVICE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 일시적으로 이용할 수 없습니다."); - - private final HttpStatus status; - private final String message; + + // 요청 관련 (4xx) + CONTENT_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "콘텐츠 처리 요청을 찾을 수 없습니다."), + CONTENT_REQUEST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 콘텐츠 요청에 대한 접근 권한이 없습니다."), + INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 콘텐츠 타입입니다."), + INVALID_DIFFICULTY_LEVEL(HttpStatus.BAD_REQUEST, "유효하지 않은 난이도 레벨입니다."), + INVALID_REQUEST_STATUS(HttpStatus.CONFLICT, "요청이 처리 가능한 상태가 아닙니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + URL_REQUIRED(HttpStatus.BAD_REQUEST, "URL is required for LINK content type."), + INVALID_URL_FORMAT(HttpStatus.BAD_REQUEST, "Invalid URL format provided."), + URL_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "URL is not supported for crawling. Please check supported domains."), + + // 커스텀 콘텐츠 관련 (4xx) + CUSTOM_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "커스텀 콘텐츠를 찾을 수 없습니다."), + CUSTOM_CONTENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 커스텀 콘텐츠에 대한 접근 권한이 없습니다."), + CUSTOM_CONTENT_CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "커스텀 콘텐츠 청크를 찾을 수 없습니다."), + CHUNK_NOT_FOUND_IN_CUSTOM_CONTENT(HttpStatus.NOT_FOUND, "Chunk not found in this custom content."), + PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "진도 기록을 찾을 수 없습니다."), + CONTENT_ALREADY_OWNED(HttpStatus.CONFLICT, "이미 소유하고 있는 콘텐츠입니다."), + + // 인증/인가 관련 (4xx) + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "인증된 사용자 정보를 찾을 수 없습니다."), + INSUFFICIENT_TICKETS(HttpStatus.PAYMENT_REQUIRED, "티켓이 부족합니다."), + + // 외부 서비스 관련 (5xx) + AI_INPUT_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "AI 서비스 입력 전송에 실패했습니다."), + AI_RESULT_PROCESSING_FAILED(HttpStatus.BAD_GATEWAY, "AI 서비스 결과 처리에 실패했습니다."), + + // 내부 서비스 관련 (5xx) + IMPORT_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "콘텐츠 가져오기 처리 중 오류가 발생했습니다."), + WEBHOOK_PROCESSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "웹훅 처리 중 내부 오류가 발생했습니다."), + + // 서비스 상태 관련 (5xx) + SERVICE_TEMPORARILY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 일시적으로 이용할 수 없습니다."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/exception/CustomContentException.java b/src/main/java/com/linglevel/api/content/custom/exception/CustomContentException.java index 7b9ea999..980aedc4 100644 --- a/src/main/java/com/linglevel/api/content/custom/exception/CustomContentException.java +++ b/src/main/java/com/linglevel/api/content/custom/exception/CustomContentException.java @@ -5,15 +5,17 @@ @Getter public class CustomContentException extends RuntimeException { - private final HttpStatus status; - public CustomContentException(CustomContentErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public CustomContentException(CustomContentErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + + public CustomContentException(CustomContentErrorCode errorCode, String additionalMessage) { + super(errorCode.getMessage() + " " + additionalMessage); + this.status = errorCode.getStatus(); + } - public CustomContentException(CustomContentErrorCode errorCode, String additionalMessage) { - super(errorCode.getMessage() + " " + additionalMessage); - this.status = errorCode.getStatus(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/repository/ContentRequestRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/ContentRequestRepository.java index a785c2ee..de662832 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/ContentRequestRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/ContentRequestRepository.java @@ -11,10 +11,11 @@ @Repository public interface ContentRequestRepository extends MongoRepository { - - Page findByUserIdAndStatusNot(String userId, ContentRequestStatus status, Pageable pageable); - - Page findByUserIdAndStatus(String userId, ContentRequestStatus status, Pageable pageable); - - Optional findByIdAndUserId(String id, String userId); + + Page findByUserIdAndStatusNot(String userId, ContentRequestStatus status, Pageable pageable); + + Page findByUserIdAndStatus(String userId, ContentRequestStatus status, Pageable pageable); + + Optional findByIdAndUserId(String id, String userId); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java index 846e8542..39102def 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentChunkRepository.java @@ -15,22 +15,28 @@ @Repository public interface CustomContentChunkRepository extends MongoRepository { - List findByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(String customContentId); - - List findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(String customContentId, DifficultyLevel difficultyLevel); - - @Query("{ 'customContentId': ?0, 'difficultyLevel': ?1, 'isDeleted': false }") - Page findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(String customContentId, DifficultyLevel difficultyLevel, Pageable pageable); - - List findByUserIdAndIsDeletedFalse(String userId); - - Page findByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( - String customContentId, Pageable pageable); - - Optional findByIdAndCustomContentIdAndIsDeletedFalse(String id, String customContentId); - - Optional findFirstByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(String customContentId); - - // V2 Progress: Count chunks by difficulty level - long countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(String customContentId, DifficultyLevel difficultyLevel); + List findByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( + String customContentId); + + List findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( + String customContentId, DifficultyLevel difficultyLevel); + + @Query("{ 'customContentId': ?0, 'difficultyLevel': ?1, 'isDeleted': false }") + Page findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( + String customContentId, DifficultyLevel difficultyLevel, Pageable pageable); + + List findByUserIdAndIsDeletedFalse(String userId); + + Page findByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( + String customContentId, Pageable pageable); + + Optional findByIdAndCustomContentIdAndIsDeletedFalse(String id, String customContentId); + + Optional findFirstByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( + String customContentId); + + // V2 Progress: Count chunks by difficulty level + long countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(String customContentId, + DifficultyLevel difficultyLevel); + } diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentProgressRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentProgressRepository.java index ec381be6..5fdde8d5 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentProgressRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentProgressRepository.java @@ -9,6 +9,9 @@ @Repository public interface CustomContentProgressRepository extends MongoRepository { - Optional findByUserIdAndCustomId(String userId, String customId); - List findAllByUserId(String userId); + + Optional findByUserIdAndCustomId(String userId, String customId); + + List findAllByUserId(String userId); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java index 0ff13c1f..c247c4a3 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepository.java @@ -10,10 +10,11 @@ @Repository public interface CustomContentRepository extends MongoRepository, CustomContentRepositoryCustom { - - Page findByUserIdAndIsDeletedFalse(String userId, Pageable pageable); - - Optional findByIdAndIsDeletedFalse(String id); - Optional findByOriginUrlAndIsDeletedFalse(String originUrl); + Page findByUserIdAndIsDeletedFalse(String userId, Pageable pageable); + + Optional findByIdAndIsDeletedFalse(String id); + + Optional findByOriginUrlAndIsDeletedFalse(String originUrl); + } diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryCustom.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryCustom.java index bb75ff64..428dd02d 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryCustom.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryCustom.java @@ -8,9 +8,13 @@ import java.util.List; public interface CustomContentRepositoryCustom { - Page findCustomContentsWithFilters(String userId, GetCustomContentsRequest request, Pageable pageable); - Page findCustomContentsByUserWithFilters(String userId, GetCustomContentsRequest request, Pageable pageable); + Page findCustomContentsWithFilters(String userId, GetCustomContentsRequest request, + Pageable pageable); + + Page findCustomContentsByUserWithFilters(String userId, GetCustomContentsRequest request, + Pageable pageable); + + void incrementViewCount(String customContentId); - void incrementViewCount(String customContentId); } diff --git a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryImpl.java b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryImpl.java index e9a50e8a..a2a45310 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryImpl.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryImpl.java @@ -21,267 +21,264 @@ @RequiredArgsConstructor public class CustomContentRepositoryImpl implements CustomContentRepositoryCustom { - private final MongoTemplate mongoTemplate; - - @Override - public Page findCustomContentsWithFilters(String userId, GetCustomContentsRequest request, Pageable pageable) { - Query query = buildQuery(userId, request); - - // 총 개수 조회 (필터링 적용 후) - long total = mongoTemplate.count(query, CustomContent.class); - - // 페이지네이션 적용 - query.with(pageable); - - // 데이터 조회 - List contents = mongoTemplate.find(query, CustomContent.class); - - return new PageImpl<>(contents, pageable, total); - } - - /** - * 동적 쿼리 빌드 - */ - private Query buildQuery(String userId, GetCustomContentsRequest request) { - Query query = new Query(); - - // UserCustomContent를 통한 유저별 콘텐츠 조회 - List userContentIds = getUserCustomContentIds(userId); - - if (userContentIds.isEmpty()) { - query.addCriteria(Criteria.where("_id").is(null)); - return query; - } - - // 기본 필터 (유저가 해금한 콘텐츠 ID 목록, isDeleted) - query.addCriteria(Criteria.where("id").in(userContentIds)); - query.addCriteria(Criteria.where("isDeleted").is(false)); - - // 각 필터를 독립적인 메서드로 분리 - applyKeywordFilter(query, request.getKeyword()); - applyTagsFilter(query, request.getTags()); - applyProgressFilter(query, request.getProgress(), userId); - - return query; - } - - /** - * 유저가 해금한 콘텐츠 ID 목록 조회 - */ - private List getUserCustomContentIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.fields().include("customContentId"); - - return mongoTemplate.find(query, org.bson.Document.class, "userCustomContents") - .stream() - .map(doc -> doc.getString("customContentId")) - .toList(); - } - - /** - * 키워드 필터 적용 (제목 또는 작가) - */ - private void applyKeywordFilter(Query query, String keyword) { - if (!StringUtils.hasText(keyword)) { - return; - } - - Criteria keywordCriteria = new Criteria().orOperator( - Criteria.where("title").regex(keyword, "i"), - Criteria.where("author").regex(keyword, "i") - ); - query.addCriteria(keywordCriteria); - } - - /** - * 태그 필터 적용 - */ - private void applyTagsFilter(Query query, String tags) { - if (!StringUtils.hasText(tags)) { - return; - } - - String[] tagArray = tags.split(","); - query.addCriteria(Criteria.where("tags").all((Object[]) tagArray)); - } - - /** - * 진도 필터 적용 - */ - private void applyProgressFilter(Query query, ProgressStatus progress, String userId) { - if (progress == null || userId == null) { - return; - } - - List contentIds = getContentIdsByProgress(userId, progress); - if (!contentIds.isEmpty()) { - query.addCriteria(Criteria.where("id").in(contentIds)); - } else { - // 조건에 맞는 콘텐츠가 없으면 빈 결과 반환 - query.addCriteria(Criteria.where("_id").is(null)); - } - } - - /** - * 진도 상태별 콘텐츠 ID 목록 조회 - */ - private List getContentIdsByProgress(String userId, ProgressStatus progressStatus) { - return switch (progressStatus) { - case NOT_STARTED -> getNotStartedContentIds(userId); - case IN_PROGRESS -> getInProgressContentIds(userId); - case COMPLETED -> getCompletedContentIds(userId); - }; - } - - /** - * 시작하지 않은 콘텐츠 ID 목록 조회 - */ - private List getNotStartedContentIds(String userId) { - // 해당 사용자가 해금한 모든 콘텐츠 ID 조회 - List allContentIds = getUserCustomContentIds(userId); - - // 진도가 있는 콘텐츠 ID 조회 - List progressContentIds = findProgressContentIds(userId); - - // 진도가 없는 콘텐츠만 반환 - return allContentIds.stream() - .filter(contentId -> !progressContentIds.contains(contentId)) - .toList(); - } - - /** - * 진행 중인 콘텐츠 ID 목록 조회 - */ - private List getInProgressContentIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(Criteria.where("isCompleted").is(false)); - query.addCriteria(Criteria.where("normalizedProgress").gt(0)); - - return findContentIdsFromProgress(query); - } - - /** - * 완료한 콘텐츠 ID 목록 조회 - */ - private List getCompletedContentIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - query.addCriteria(Criteria.where("isCompleted").is(true)); - - return findContentIdsFromProgress(query); - } - - /** - * 특정 사용자의 모든 진도 콘텐츠 ID 조회 - */ - private List findProgressContentIds(String userId) { - Query query = new Query(); - query.addCriteria(Criteria.where("userId").is(userId)); - - return findContentIdsFromProgress(query); - } - - /** - * CustomContentProgress 컬렉션에서 customId 추출 - */ - private List findContentIdsFromProgress(Query query) { - return mongoTemplate.find(query, org.bson.Document.class, "customProgress") - .stream() - .map(doc -> doc.getString("customId")) - .toList(); - } - - @Override - public Page findCustomContentsByUserWithFilters(String userId, GetCustomContentsRequest request, Pageable pageable) { - List operations = new ArrayList<>(); - - // 1. UserCustomContent에서 userId로 필터링 - operations.add(Aggregation.match(Criteria.where("userId").is(userId))); - - // 2. customContentId를 ObjectId로 변환 - operations.add(Aggregation.addFields() - .addField("customContentIdObj") - .withValueOf(org.springframework.data.mongodb.core.aggregation.ConvertOperators.ToObjectId.toObjectId("$customContentId")) - .build()); - - // 3. CustomContent와 조인 (lookup) - ObjectId로 변환된 필드 사용 - operations.add(Aggregation.lookup( - "customContents", // from collection - "customContentIdObj", // localField (ObjectId로 변환된 필드) - "_id", // foreignField (MongoDB의 _id 필드) - "customContent" // as - )); - - // 4. 배열을 객체로 변환 (lookup 결과는 배열이므로) - operations.add(Aggregation.unwind("customContent")); - - // 5. customContent를 root로 올림 - operations.add(Aggregation.replaceRoot("customContent")); - - // 6. isDeleted = false 필터링 - operations.add(Aggregation.match(Criteria.where("isDeleted").is(false))); - - // 7. 키워드 필터 적용 - if (StringUtils.hasText(request.getKeyword())) { - Criteria keywordCriteria = new Criteria().orOperator( - Criteria.where("title").regex(request.getKeyword(), "i"), - Criteria.where("author").regex(request.getKeyword(), "i") - ); - operations.add(Aggregation.match(keywordCriteria)); - } - - // 8. 태그 필터 적용 - if (StringUtils.hasText(request.getTags())) { - String[] tagArray = request.getTags().split(","); - operations.add(Aggregation.match(Criteria.where("tags").all((Object[]) tagArray))); - } - - // 9. 진도 필터 적용 (별도 처리 필요) - if (request.getProgress() != null) { - List contentIds = getContentIdsByProgress(userId, request.getProgress()); - if (contentIds.isEmpty()) { - return new PageImpl<>(List.of(), pageable, 0); - } - // _id는 ObjectId이므로 String을 ObjectId로 변환해서 비교 - List objectIds = contentIds.stream() - .map(org.bson.types.ObjectId::new) - .toList(); - operations.add(Aggregation.match(Criteria.where("_id").in(objectIds))); - } - - // 총 개수 조회용 aggregation (정렬 및 페이징 전, $count 사용) - List countOps = new ArrayList<>(operations); - countOps.add(Aggregation.count().as("total")); - Aggregation countAggregation = Aggregation.newAggregation(countOps); - - long total = 0; - var countResult = mongoTemplate.aggregate(countAggregation, "userCustomContents", org.bson.Document.class) - .getUniqueMappedResult(); - if (countResult != null && countResult.containsKey("total")) { - total = ((Number) countResult.get("total")).longValue(); - } - - // 10. 정렬 - operations.add(Aggregation.sort(pageable.getSort())); - - // 11. 페이지네이션 - operations.add(Aggregation.skip(pageable.getOffset())); - operations.add(Aggregation.limit(pageable.getPageSize())); - - // 최종 aggregation - Aggregation aggregation = Aggregation.newAggregation(operations); - List contents = mongoTemplate.aggregate(aggregation, "userCustomContents", CustomContent.class) - .getMappedResults(); - - return new PageImpl<>(contents, pageable, total); - } - - @Override - public void incrementViewCount(String customContentId) { - Query query = new Query(Criteria.where("id").is(customContentId)); - Update update = new Update().inc("viewCount", 1); - mongoTemplate.updateFirst(query, update, CustomContent.class); - } + private final MongoTemplate mongoTemplate; + + @Override + public Page findCustomContentsWithFilters(String userId, GetCustomContentsRequest request, + Pageable pageable) { + Query query = buildQuery(userId, request); + + // 총 개수 조회 (필터링 적용 후) + long total = mongoTemplate.count(query, CustomContent.class); + + // 페이지네이션 적용 + query.with(pageable); + + // 데이터 조회 + List contents = mongoTemplate.find(query, CustomContent.class); + + return new PageImpl<>(contents, pageable, total); + } + + /** + * 동적 쿼리 빌드 + */ + private Query buildQuery(String userId, GetCustomContentsRequest request) { + Query query = new Query(); + + // UserCustomContent를 통한 유저별 콘텐츠 조회 + List userContentIds = getUserCustomContentIds(userId); + + if (userContentIds.isEmpty()) { + query.addCriteria(Criteria.where("_id").is(null)); + return query; + } + + // 기본 필터 (유저가 해금한 콘텐츠 ID 목록, isDeleted) + query.addCriteria(Criteria.where("id").in(userContentIds)); + query.addCriteria(Criteria.where("isDeleted").is(false)); + + // 각 필터를 독립적인 메서드로 분리 + applyKeywordFilter(query, request.getKeyword()); + applyTagsFilter(query, request.getTags()); + applyProgressFilter(query, request.getProgress(), userId); + + return query; + } + + /** + * 유저가 해금한 콘텐츠 ID 목록 조회 + */ + private List getUserCustomContentIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.fields().include("customContentId"); + + return mongoTemplate.find(query, org.bson.Document.class, "userCustomContents") + .stream() + .map(doc -> doc.getString("customContentId")) + .toList(); + } + + /** + * 키워드 필터 적용 (제목 또는 작가) + */ + private void applyKeywordFilter(Query query, String keyword) { + if (!StringUtils.hasText(keyword)) { + return; + } + + Criteria keywordCriteria = new Criteria().orOperator(Criteria.where("title").regex(keyword, "i"), + Criteria.where("author").regex(keyword, "i")); + query.addCriteria(keywordCriteria); + } + + /** + * 태그 필터 적용 + */ + private void applyTagsFilter(Query query, String tags) { + if (!StringUtils.hasText(tags)) { + return; + } + + String[] tagArray = tags.split(","); + query.addCriteria(Criteria.where("tags").all((Object[]) tagArray)); + } + + /** + * 진도 필터 적용 + */ + private void applyProgressFilter(Query query, ProgressStatus progress, String userId) { + if (progress == null || userId == null) { + return; + } + + List contentIds = getContentIdsByProgress(userId, progress); + if (!contentIds.isEmpty()) { + query.addCriteria(Criteria.where("id").in(contentIds)); + } + else { + // 조건에 맞는 콘텐츠가 없으면 빈 결과 반환 + query.addCriteria(Criteria.where("_id").is(null)); + } + } + + /** + * 진도 상태별 콘텐츠 ID 목록 조회 + */ + private List getContentIdsByProgress(String userId, ProgressStatus progressStatus) { + return switch (progressStatus) { + case NOT_STARTED -> getNotStartedContentIds(userId); + case IN_PROGRESS -> getInProgressContentIds(userId); + case COMPLETED -> getCompletedContentIds(userId); + }; + } + + /** + * 시작하지 않은 콘텐츠 ID 목록 조회 + */ + private List getNotStartedContentIds(String userId) { + // 해당 사용자가 해금한 모든 콘텐츠 ID 조회 + List allContentIds = getUserCustomContentIds(userId); + + // 진도가 있는 콘텐츠 ID 조회 + List progressContentIds = findProgressContentIds(userId); + + // 진도가 없는 콘텐츠만 반환 + return allContentIds.stream().filter(contentId -> !progressContentIds.contains(contentId)).toList(); + } + + /** + * 진행 중인 콘텐츠 ID 목록 조회 + */ + private List getInProgressContentIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(Criteria.where("isCompleted").is(false)); + query.addCriteria(Criteria.where("normalizedProgress").gt(0)); + + return findContentIdsFromProgress(query); + } + + /** + * 완료한 콘텐츠 ID 목록 조회 + */ + private List getCompletedContentIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + query.addCriteria(Criteria.where("isCompleted").is(true)); + + return findContentIdsFromProgress(query); + } + + /** + * 특정 사용자의 모든 진도 콘텐츠 ID 조회 + */ + private List findProgressContentIds(String userId) { + Query query = new Query(); + query.addCriteria(Criteria.where("userId").is(userId)); + + return findContentIdsFromProgress(query); + } + + /** + * CustomContentProgress 컬렉션에서 customId 추출 + */ + private List findContentIdsFromProgress(Query query) { + return mongoTemplate.find(query, org.bson.Document.class, "customProgress") + .stream() + .map(doc -> doc.getString("customId")) + .toList(); + } + + @Override + public Page findCustomContentsByUserWithFilters(String userId, GetCustomContentsRequest request, + Pageable pageable) { + List operations = new ArrayList<>(); + + // 1. UserCustomContent에서 userId로 필터링 + operations.add(Aggregation.match(Criteria.where("userId").is(userId))); + + // 2. customContentId를 ObjectId로 변환 + operations.add(Aggregation.addFields() + .addField("customContentIdObj") + .withValueOf(org.springframework.data.mongodb.core.aggregation.ConvertOperators.ToObjectId + .toObjectId("$customContentId")) + .build()); + + // 3. CustomContent와 조인 (lookup) - ObjectId로 변환된 필드 사용 + operations.add(Aggregation.lookup("customContents", // from collection + "customContentIdObj", // localField (ObjectId로 변환된 필드) + "_id", // foreignField (MongoDB의 _id 필드) + "customContent" // as + )); + + // 4. 배열을 객체로 변환 (lookup 결과는 배열이므로) + operations.add(Aggregation.unwind("customContent")); + + // 5. customContent를 root로 올림 + operations.add(Aggregation.replaceRoot("customContent")); + + // 6. isDeleted = false 필터링 + operations.add(Aggregation.match(Criteria.where("isDeleted").is(false))); + + // 7. 키워드 필터 적용 + if (StringUtils.hasText(request.getKeyword())) { + Criteria keywordCriteria = new Criteria().orOperator( + Criteria.where("title").regex(request.getKeyword(), "i"), + Criteria.where("author").regex(request.getKeyword(), "i")); + operations.add(Aggregation.match(keywordCriteria)); + } + + // 8. 태그 필터 적용 + if (StringUtils.hasText(request.getTags())) { + String[] tagArray = request.getTags().split(","); + operations.add(Aggregation.match(Criteria.where("tags").all((Object[]) tagArray))); + } + + // 9. 진도 필터 적용 (별도 처리 필요) + if (request.getProgress() != null) { + List contentIds = getContentIdsByProgress(userId, request.getProgress()); + if (contentIds.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + // _id는 ObjectId이므로 String을 ObjectId로 변환해서 비교 + List objectIds = contentIds.stream().map(org.bson.types.ObjectId::new).toList(); + operations.add(Aggregation.match(Criteria.where("_id").in(objectIds))); + } + + // 총 개수 조회용 aggregation (정렬 및 페이징 전, $count 사용) + List countOps = new ArrayList<>(operations); + countOps.add(Aggregation.count().as("total")); + Aggregation countAggregation = Aggregation.newAggregation(countOps); + + long total = 0; + var countResult = mongoTemplate.aggregate(countAggregation, "userCustomContents", org.bson.Document.class) + .getUniqueMappedResult(); + if (countResult != null && countResult.containsKey("total")) { + total = ((Number) countResult.get("total")).longValue(); + } + + // 10. 정렬 + operations.add(Aggregation.sort(pageable.getSort())); + + // 11. 페이지네이션 + operations.add(Aggregation.skip(pageable.getOffset())); + operations.add(Aggregation.limit(pageable.getPageSize())); + + // 최종 aggregation + Aggregation aggregation = Aggregation.newAggregation(operations); + List contents = mongoTemplate.aggregate(aggregation, "userCustomContents", CustomContent.class) + .getMappedResults(); + + return new PageImpl<>(contents, pageable, total); + } + + @Override + public void incrementViewCount(String customContentId) { + Query query = new Query(Criteria.where("id").is(customContentId)); + Update update = new Update().inc("viewCount", 1); + mongoTemplate.updateFirst(query, update, CustomContent.class); + } + } diff --git a/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java b/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java index 4f5ca62a..ce959592 100644 --- a/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java +++ b/src/main/java/com/linglevel/api/content/custom/repository/UserCustomContentRepository.java @@ -12,11 +12,12 @@ @Repository public interface UserCustomContentRepository extends MongoRepository { - Optional findByUserIdAndCustomContentId(String userId, String customContentId); + Optional findByUserIdAndCustomContentId(String userId, String customContentId); - Page findByUserId(String userId, Pageable pageable); + Page findByUserId(String userId, Pageable pageable); - List findByUserId(String userId); + List findByUserId(String userId); + + boolean existsByUserIdAndCustomContentId(String userId, String customContentId); - boolean existsByUserIdAndCustomContentId(String userId, String customContentId); } diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentChunkService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentChunkService.java index ae1dea17..f40076b7 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentChunkService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentChunkService.java @@ -26,83 +26,90 @@ @Slf4j public class CustomContentChunkService { - private final CustomContentChunkRepository customContentChunkRepository; - private final CustomContentRepository customContentRepository; - private final UserCustomContentRepository userCustomContentRepository; - private final FeedRepository feedRepository; + private final CustomContentChunkRepository customContentChunkRepository; - public PageResponse getCustomContentChunks(String userId, String customContentId, GetCustomContentChunksRequest request) { - log.info("Getting custom content chunks for content {} and user: {}", customContentId, userId); + private final CustomContentRepository customContentRepository; - CustomContent customContent = validateCustomContentAccess(customContentId, userId); + private final UserCustomContentRepository userCustomContentRepository; - customContentRepository.incrementViewCount(customContentId); + private final FeedRepository feedRepository; - // Feed 조회수도 함께 증가 (originUrl 기반) - if (customContent.getOriginUrl() != null && !customContent.getOriginUrl().isEmpty()) { - feedRepository.findByUrl(customContent.getOriginUrl()).ifPresent(feed -> { - feed.setViewCount((feed.getViewCount() != null ? feed.getViewCount() : 0) + 1); - feedRepository.save(feed); - log.debug("Incremented Feed viewCount for url: {}", customContent.getOriginUrl()); - }); - } + public PageResponse getCustomContentChunks(String userId, String customContentId, + GetCustomContentChunksRequest request) { + log.info("Getting custom content chunks for content {} and user: {}", customContentId, userId); - DifficultyLevel difficulty = request.getDifficultyLevel(); + CustomContent customContent = validateCustomContentAccess(customContentId, userId); - validatePaginationRequest(request); - Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit()); + customContentRepository.incrementViewCount(customContentId); - Page chunksPage = customContentChunkRepository - .findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( - customContentId, difficulty, pageable); - - List chunkResponses = chunksPage.getContent().stream() - .map(this::convertToCustomContentChunkResponse) - .toList(); - - return PageResponse.of(chunksPage, chunkResponses); - } + // Feed 조회수도 함께 증가 (originUrl 기반) + if (customContent.getOriginUrl() != null && !customContent.getOriginUrl().isEmpty()) { + feedRepository.findByUrl(customContent.getOriginUrl()).ifPresent(feed -> { + feed.setViewCount((feed.getViewCount() != null ? feed.getViewCount() : 0) + 1); + feedRepository.save(feed); + log.debug("Incremented Feed viewCount for url: {}", customContent.getOriginUrl()); + }); + } - public CustomContentChunkResponse getCustomContentChunk(String userId, String customContentId, String chunkId) { - log.info("Getting custom content chunk {} from content {} for user: {}", chunkId, customContentId, userId); + DifficultyLevel difficulty = request.getDifficultyLevel(); - validateCustomContentAccess(customContentId, userId); + validatePaginationRequest(request); + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit()); - CustomContentChunk chunk = customContentChunkRepository.findByIdAndCustomContentIdAndIsDeletedFalse(chunkId, customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND)); - - return convertToCustomContentChunkResponse(chunk); - } + Page chunksPage = customContentChunkRepository + .findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(customContentId, + difficulty, pageable); - private CustomContent validateCustomContentAccess(String customContentId, String userId) { + List chunkResponses = chunksPage.getContent() + .stream() + .map(this::convertToCustomContentChunkResponse) + .toList(); - CustomContent customContent = customContentRepository.findById(customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); + return PageResponse.of(chunksPage, chunkResponses); + } - userCustomContentRepository.findByUserIdAndCustomContentId(userId, customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_ACCESS_DENIED)); + public CustomContentChunkResponse getCustomContentChunk(String userId, String customContentId, String chunkId) { + log.info("Getting custom content chunk {} from content {} for user: {}", chunkId, customContentId, userId); - return customContent; - } + validateCustomContentAccess(customContentId, userId); + CustomContentChunk chunk = customContentChunkRepository + .findByIdAndCustomContentIdAndIsDeletedFalse(chunkId, customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND)); - private void validatePaginationRequest(GetCustomContentChunksRequest request) { - if (request.getLimit() != null && request.getLimit() > 100) { - request.setLimit(100); - } - } + return convertToCustomContentChunkResponse(chunk); + } - public CustomContentChunk findById(String chunkId) { - return customContentChunkRepository.findById(chunkId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND)); - } + private CustomContent validateCustomContentAccess(String customContentId, String userId) { - public CustomContentChunk findFirstByCustomContentId(String customContentId) { - return customContentChunkRepository.findFirstByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND)); - } + CustomContent customContent = customContentRepository.findById(customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); + + userCustomContentRepository.findByUserIdAndCustomContentId(userId, customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_ACCESS_DENIED)); + + return customContent; + } + + private void validatePaginationRequest(GetCustomContentChunksRequest request) { + if (request.getLimit() != null && request.getLimit() > 100) { + request.setLimit(100); + } + } + + public CustomContentChunk findById(String chunkId) { + return customContentChunkRepository.findById(chunkId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND)); + } + + public CustomContentChunk findFirstByCustomContentId(String customContentId) { + return customContentChunkRepository + .findFirstByCustomContentIdAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND)); + } + + private CustomContentChunkResponse convertToCustomContentChunkResponse(CustomContentChunk chunk) { + return CustomContentChunkResponse.from(chunk); + } - private CustomContentChunkResponse convertToCustomContentChunkResponse(CustomContentChunk chunk) { - return CustomContentChunkResponse.from(chunk); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentImportService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentImportService.java index 150755a4..bc15dfe3 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentImportService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentImportService.java @@ -26,118 +26,125 @@ @Slf4j public class CustomContentImportService { - private final CustomContentRepository customContentRepository; - private final CustomContentChunkRepository customContentChunkRepository; - private final S3UrlService s3UrlService; - private final ImageResizeService imageResizeService; - private final CustomContentPathStrategy pathStrategy; - - - public CustomContent createCustomContent(ContentRequest contentRequest, AiResultDto aiResult) { - String title = StringUtils.hasText(aiResult.getTitle()) ? aiResult.getTitle() : "Untitled Content"; - - // ContentRequest의 coverImageUrl이 있으면 우선 사용, 없으면 AI 결과의 coverImageUrl 사용 - String coverImageUrl = StringUtils.hasText(contentRequest.getCoverImageUrl()) - ? contentRequest.getCoverImageUrl() - : aiResult.getCoverImageUrl(); - - String normalizedUrl = null; - if (StringUtils.hasText(contentRequest.getOriginUrl())) { - normalizedUrl = UrlNormalizer.normalize(contentRequest.getOriginUrl()); - } - - CustomContent content = CustomContent.builder() - .userId(contentRequest.getUserId()) - .title(title) - .author(contentRequest.getOriginAuthor()) - .coverImageUrl(coverImageUrl) - .difficultyLevel(DifficultyLevel.fromCode(aiResult.getOriginalTextLevel())) - .targetDifficultyLevels(aiResult.getLeveledResults().stream().map(level -> DifficultyLevel.fromCode(level.getTextLevel())).collect(Collectors.toList())) - .readingTime(0) // Placeholder, can be calculated later - .originUrl(normalizedUrl != null ? normalizedUrl : contentRequest.getOriginUrl()) - .originDomain(contentRequest.getOriginDomain()) - .build(); - - CustomContent savedContent = customContentRepository.save(content); - - if (StringUtils.hasText(aiResult.getCoverImageUrl())) { - try { - log.info("Auto-processing cover image for imported custom content: {}", savedContent.getId()); - String originalCoverS3Key = pathStrategy.generateCoverImagePath(savedContent.getId()); - String smallImageUrl = imageResizeService.createSmallImage(originalCoverS3Key); - savedContent.setCoverImageUrl(smallImageUrl); - customContentRepository.save(savedContent); - log.info("Successfully auto-processed cover image: {} → {}", savedContent.getId(), smallImageUrl); - } catch (Exception e) { - log.warn("Failed to auto-process cover image for custom content: {}, keeping original URL", - savedContent.getId(), e); - } - } - - return savedContent; - } - - public void createCustomContentChunks(CustomContent customContent, AiResultDto aiResult) { - List allChunks = new ArrayList<>(); - - if (aiResult.getLeveledResults() == null) { - log.warn("No leveled results found for custom content: {}", customContent.getId()); - return; - } - - boolean hasCoverImage = StringUtils.hasText(customContent.getCoverImageUrl()); - - for (AiResultDto.LeveledResult leveledResult : aiResult.getLeveledResults()) { - DifficultyLevel difficulty = DifficultyLevel.fromCode(leveledResult.getTextLevel()); - int chapterCounter = 1; - - for (AiResultDto.Chapter chapter : leveledResult.getChapters()) { - int chunkCounter = 1; - - if (hasCoverImage && chapterCounter == 1) { - CustomContentChunk coverImageChunk = CustomContentChunk.builder() - .customContentId(customContent.getId()) - .userId(customContent.getUserId()) - .difficultyLevel(difficulty) - .chapterNum(chapterCounter) - .chunkNum(chunkCounter++) - .type(ChunkType.IMAGE) - .chunkText(customContent.getCoverImageUrl()) - .description("") - .build(); - allChunks.add(coverImageChunk); - log.debug("Added cover image chunk for difficulty {} at chapter {}", difficulty, chapterCounter); - } - - for (AiResultDto.Chunk chunkData : chapter.getChunks()) { - CustomContentChunk newChunk = createCustomContentChunk(chunkData, customContent.getId(), customContent.getUserId(), difficulty, chapterCounter, chunkCounter++); - allChunks.add(newChunk); - } - chapterCounter++; - } - } - customContentChunkRepository.saveAll(allChunks); - log.info("Saved {} chunks for custom content {}", allChunks.size(), customContent.getId()); - } - - private CustomContentChunk createCustomContentChunk(AiResultDto.Chunk chunkData, String customContentId, String userId, DifficultyLevel difficulty, int chapterNum, int chunkNum) { - CustomContentChunk.CustomContentChunkBuilder builder = CustomContentChunk.builder() - .customContentId(customContentId) - .userId(userId) - .difficultyLevel(difficulty) - .chapterNum(chapterNum) - .chunkNum(chunkNum); - - if (Boolean.TRUE.equals(chunkData.getIsImage())) { - String imageUrl = s3UrlService.buildImageUrl(customContentId, chunkData.getChunkText(), pathStrategy); - builder.type(ChunkType.IMAGE) - .chunkText(imageUrl) - .description(chunkData.getDescription()); - } else { - builder.type(ChunkType.TEXT) - .chunkText(chunkData.getChunkText()); - } - - return builder.build(); - } + private final CustomContentRepository customContentRepository; + + private final CustomContentChunkRepository customContentChunkRepository; + + private final S3UrlService s3UrlService; + + private final ImageResizeService imageResizeService; + + private final CustomContentPathStrategy pathStrategy; + + public CustomContent createCustomContent(ContentRequest contentRequest, AiResultDto aiResult) { + String title = StringUtils.hasText(aiResult.getTitle()) ? aiResult.getTitle() : "Untitled Content"; + + // ContentRequest의 coverImageUrl이 있으면 우선 사용, 없으면 AI 결과의 coverImageUrl 사용 + String coverImageUrl = StringUtils.hasText(contentRequest.getCoverImageUrl()) + ? contentRequest.getCoverImageUrl() : aiResult.getCoverImageUrl(); + + String normalizedUrl = null; + if (StringUtils.hasText(contentRequest.getOriginUrl())) { + normalizedUrl = UrlNormalizer.normalize(contentRequest.getOriginUrl()); + } + + CustomContent content = CustomContent.builder() + .userId(contentRequest.getUserId()) + .title(title) + .author(contentRequest.getOriginAuthor()) + .coverImageUrl(coverImageUrl) + .difficultyLevel(DifficultyLevel.fromCode(aiResult.getOriginalTextLevel())) + .targetDifficultyLevels(aiResult.getLeveledResults() + .stream() + .map(level -> DifficultyLevel.fromCode(level.getTextLevel())) + .collect(Collectors.toList())) + .readingTime(0) // Placeholder, can be calculated later + .originUrl(normalizedUrl != null ? normalizedUrl : contentRequest.getOriginUrl()) + .originDomain(contentRequest.getOriginDomain()) + .build(); + + CustomContent savedContent = customContentRepository.save(content); + + if (StringUtils.hasText(aiResult.getCoverImageUrl())) { + try { + log.info("Auto-processing cover image for imported custom content: {}", savedContent.getId()); + String originalCoverS3Key = pathStrategy.generateCoverImagePath(savedContent.getId()); + String smallImageUrl = imageResizeService.createSmallImage(originalCoverS3Key); + savedContent.setCoverImageUrl(smallImageUrl); + customContentRepository.save(savedContent); + log.info("Successfully auto-processed cover image: {} → {}", savedContent.getId(), smallImageUrl); + } + catch (Exception e) { + log.warn("Failed to auto-process cover image for custom content: {}, keeping original URL", + savedContent.getId(), e); + } + } + + return savedContent; + } + + public void createCustomContentChunks(CustomContent customContent, AiResultDto aiResult) { + List allChunks = new ArrayList<>(); + + if (aiResult.getLeveledResults() == null) { + log.warn("No leveled results found for custom content: {}", customContent.getId()); + return; + } + + boolean hasCoverImage = StringUtils.hasText(customContent.getCoverImageUrl()); + + for (AiResultDto.LeveledResult leveledResult : aiResult.getLeveledResults()) { + DifficultyLevel difficulty = DifficultyLevel.fromCode(leveledResult.getTextLevel()); + int chapterCounter = 1; + + for (AiResultDto.Chapter chapter : leveledResult.getChapters()) { + int chunkCounter = 1; + + if (hasCoverImage && chapterCounter == 1) { + CustomContentChunk coverImageChunk = CustomContentChunk.builder() + .customContentId(customContent.getId()) + .userId(customContent.getUserId()) + .difficultyLevel(difficulty) + .chapterNum(chapterCounter) + .chunkNum(chunkCounter++) + .type(ChunkType.IMAGE) + .chunkText(customContent.getCoverImageUrl()) + .description("") + .build(); + allChunks.add(coverImageChunk); + log.debug("Added cover image chunk for difficulty {} at chapter {}", difficulty, chapterCounter); + } + + for (AiResultDto.Chunk chunkData : chapter.getChunks()) { + CustomContentChunk newChunk = createCustomContentChunk(chunkData, customContent.getId(), + customContent.getUserId(), difficulty, chapterCounter, chunkCounter++); + allChunks.add(newChunk); + } + chapterCounter++; + } + } + customContentChunkRepository.saveAll(allChunks); + log.info("Saved {} chunks for custom content {}", allChunks.size(), customContent.getId()); + } + + private CustomContentChunk createCustomContentChunk(AiResultDto.Chunk chunkData, String customContentId, + String userId, DifficultyLevel difficulty, int chapterNum, int chunkNum) { + CustomContentChunk.CustomContentChunkBuilder builder = CustomContentChunk.builder() + .customContentId(customContentId) + .userId(userId) + .difficultyLevel(difficulty) + .chapterNum(chapterNum) + .chunkNum(chunkNum); + + if (Boolean.TRUE.equals(chunkData.getIsImage())) { + String imageUrl = s3UrlService.buildImageUrl(customContentId, chunkData.getChunkText(), pathStrategy); + builder.type(ChunkType.IMAGE).chunkText(imageUrl).description(chunkData.getDescription()); + } + else { + builder.type(ChunkType.TEXT).chunkText(chunkData.getChunkText()); + } + + return builder.build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentNotificationService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentNotificationService.java index 7b758579..2d232375 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentNotificationService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentNotificationService.java @@ -20,162 +20,168 @@ @Slf4j public class CustomContentNotificationService { - private final FcmTokenRepository fcmTokenRepository; - private final FcmMessagingService fcmMessagingService; - private final com.linglevel.api.fcm.service.FcmTokenService fcmTokenService; - - public void sendContentCompletedNotification(String userId, String requestId, String contentTitle, String contentId) { - try { - List userTokens = fcmTokenRepository.findByUserId(userId); - - if (userTokens.isEmpty()) { - log.info("No FCM tokens found for user: {}", userId); - return; - } - - Map additionalData = new HashMap<>(); - additionalData.put("requestId", requestId); - additionalData.put("contentTitle", contentTitle); - if (contentId != null) { - additionalData.put("contentId", contentId); - } - - // 토큰을 국가별로 그룹핑 - Map> tokensByCountry = userTokens.stream() - .collect(Collectors.groupingBy( - token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US - )); - - // 국가별로 다른 메시지 전송 - tokensByCountry.forEach((countryCode, tokens) -> { - String title = NotificationMessage.CONTENT_COMPLETED.getTitle(countryCode); - String body = NotificationMessage.CONTENT_COMPLETED.getBody(countryCode, contentTitle); - - FcmMessageRequest messageRequest = FcmMessageRequest.builder() - .title(title) - .body(body) - .type("custom_content_completed") - .userId(userId) - .action("view_content") - .deepLink(contentId != null ? "linglevel:///customContent/" + contentId : "linglevel:///customContent") - .campaignId("customContent-completed") - .additionalData(additionalData) - .build(); - - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); - log.info("Sent completion notification (country: {}) to user: {}", countryCode, userId); - } else if (!fcmTokens.isEmpty()) { - com.google.firebase.messaging.BatchResponse response = - fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); - - // 개별 응답 처리 - 실패한 토큰 비활성화 - for (int i = 0; i < response.getResponses().size(); i++) { - if (!response.getResponses().get(i).isSuccessful()) { - String failedToken = fcmTokens.get(i); - log.warn("Failed to send completion notification to token for user: {}, error: {}", - userId, response.getResponses().get(i).getException().getMessage()); - fcmTokenService.deactivateToken(failedToken); - } - } - - log.info("Sent multicast completion notification (country: {}) to user: {} - Success: {}, Failed: {}", - countryCode, userId, response.getSuccessCount(), response.getFailureCount()); - } - } catch (Exception e) { - log.error("Failed to send completion notification (country: {}) to user: {}, error: {}", - countryCode, userId, e.getMessage(), e); - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - fcmTokens.forEach(fcmTokenService::deactivateToken); - } - } - }); - - } catch (Exception e) { - log.error("Failed to send content completion notification for user: {}, requestId: {}", - userId, requestId, e); - } - } - - public void sendContentFailedNotification(String userId, String requestId, String contentTitle, String errorMessage) { - try { - List userTokens = fcmTokenRepository.findByUserId(userId); - - if (userTokens.isEmpty()) { - log.info("No FCM tokens found for user: {}", userId); - return; - } - - Map additionalData = new HashMap<>(); - additionalData.put("requestId", requestId); - additionalData.put("contentTitle", contentTitle); - additionalData.put("errorMessage", errorMessage); - - // 토큰을 국가별로 그룹핑 - Map> tokensByCountry = userTokens.stream() - .collect(Collectors.groupingBy( - token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US - )); - - // 국가별로 다른 메시지 전송 - tokensByCountry.forEach((countryCode, tokens) -> { - String title = NotificationMessage.CONTENT_FAILED.getTitle(countryCode); - String body = NotificationMessage.CONTENT_FAILED.getBody(countryCode); - - FcmMessageRequest messageRequest = FcmMessageRequest.builder() - .title(title) - .body(body) - .type("custom_content_failed") - .userId(userId) - .action("view_chat") - .deepLink("linglevel:///import?state=chat") - .campaignId("customContent-failed") - .additionalData(additionalData) - .build(); - - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); - log.info("Sent failure notification (country: {}) to user: {}", countryCode, userId); - } else if (!fcmTokens.isEmpty()) { - com.google.firebase.messaging.BatchResponse response = - fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); - - // 개별 응답 처리 - 실패한 토큰 비활성화 - for (int i = 0; i < response.getResponses().size(); i++) { - if (!response.getResponses().get(i).isSuccessful()) { - String failedToken = fcmTokens.get(i); - log.warn("Failed to send failure notification to token for user: {}, error: {}", - userId, response.getResponses().get(i).getException().getMessage()); - fcmTokenService.deactivateToken(failedToken); - } - } - - log.info("Sent multicast failure notification (country: {}) to user: {} - Success: {}, Failed: {}", - countryCode, userId, response.getSuccessCount(), response.getFailureCount()); - } - } catch (Exception e) { - log.error("Failed to send failure notification (country: {}) to user: {}, error: {}", - countryCode, userId, e.getMessage(), e); - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - fcmTokens.forEach(fcmTokenService::deactivateToken); - } - } - }); - - } catch (Exception e) { - log.error("Failed to send content failure notification for user: {}, requestId: {}", - userId, requestId, e); - } - } + private final FcmTokenRepository fcmTokenRepository; + + private final FcmMessagingService fcmMessagingService; + + private final com.linglevel.api.fcm.service.FcmTokenService fcmTokenService; + + public void sendContentCompletedNotification(String userId, String requestId, String contentTitle, + String contentId) { + try { + List userTokens = fcmTokenRepository.findByUserId(userId); + + if (userTokens.isEmpty()) { + log.info("No FCM tokens found for user: {}", userId); + return; + } + + Map additionalData = new HashMap<>(); + additionalData.put("requestId", requestId); + additionalData.put("contentTitle", contentTitle); + if (contentId != null) { + additionalData.put("contentId", contentId); + } + + // 토큰을 국가별로 그룹핑 + Map> tokensByCountry = userTokens.stream() + .collect(Collectors + .groupingBy(token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US)); + + // 국가별로 다른 메시지 전송 + tokensByCountry.forEach((countryCode, tokens) -> { + String title = NotificationMessage.CONTENT_COMPLETED.getTitle(countryCode); + String body = NotificationMessage.CONTENT_COMPLETED.getBody(countryCode, contentTitle); + + FcmMessageRequest messageRequest = FcmMessageRequest.builder() + .title(title) + .body(body) + .type("custom_content_completed") + .userId(userId) + .action("view_content") + .deepLink(contentId != null ? "linglevel:///customContent/" + contentId + : "linglevel:///customContent") + .campaignId("customContent-completed") + .additionalData(additionalData) + .build(); + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); + log.info("Sent completion notification (country: {}) to user: {}", countryCode, userId); + } + else if (!fcmTokens.isEmpty()) { + com.google.firebase.messaging.BatchResponse response = fcmMessagingService + .sendMulticastMessage(fcmTokens, messageRequest); + + // 개별 응답 처리 - 실패한 토큰 비활성화 + for (int i = 0; i < response.getResponses().size(); i++) { + if (!response.getResponses().get(i).isSuccessful()) { + String failedToken = fcmTokens.get(i); + log.warn("Failed to send completion notification to token for user: {}, error: {}", + userId, response.getResponses().get(i).getException().getMessage()); + fcmTokenService.deactivateToken(failedToken); + } + } + + log.info( + "Sent multicast completion notification (country: {}) to user: {} - Success: {}, Failed: {}", + countryCode, userId, response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + log.error("Failed to send completion notification (country: {}) to user: {}, error: {}", + countryCode, userId, e.getMessage(), e); + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + fcmTokens.forEach(fcmTokenService::deactivateToken); + } + } + }); + + } + catch (Exception e) { + log.error("Failed to send content completion notification for user: {}, requestId: {}", userId, requestId, + e); + } + } + + public void sendContentFailedNotification(String userId, String requestId, String contentTitle, + String errorMessage) { + try { + List userTokens = fcmTokenRepository.findByUserId(userId); + + if (userTokens.isEmpty()) { + log.info("No FCM tokens found for user: {}", userId); + return; + } + + Map additionalData = new HashMap<>(); + additionalData.put("requestId", requestId); + additionalData.put("contentTitle", contentTitle); + additionalData.put("errorMessage", errorMessage); + + // 토큰을 국가별로 그룹핑 + Map> tokensByCountry = userTokens.stream() + .collect(Collectors + .groupingBy(token -> token.getCountryCode() != null ? token.getCountryCode() : CountryCode.US)); + + // 국가별로 다른 메시지 전송 + tokensByCountry.forEach((countryCode, tokens) -> { + String title = NotificationMessage.CONTENT_FAILED.getTitle(countryCode); + String body = NotificationMessage.CONTENT_FAILED.getBody(countryCode); + + FcmMessageRequest messageRequest = FcmMessageRequest.builder() + .title(title) + .body(body) + .type("custom_content_failed") + .userId(userId) + .action("view_chat") + .deepLink("linglevel:///import?state=chat") + .campaignId("customContent-failed") + .additionalData(additionalData) + .build(); + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); + log.info("Sent failure notification (country: {}) to user: {}", countryCode, userId); + } + else if (!fcmTokens.isEmpty()) { + com.google.firebase.messaging.BatchResponse response = fcmMessagingService + .sendMulticastMessage(fcmTokens, messageRequest); + + // 개별 응답 처리 - 실패한 토큰 비활성화 + for (int i = 0; i < response.getResponses().size(); i++) { + if (!response.getResponses().get(i).isSuccessful()) { + String failedToken = fcmTokens.get(i); + log.warn("Failed to send failure notification to token for user: {}, error: {}", userId, + response.getResponses().get(i).getException().getMessage()); + fcmTokenService.deactivateToken(failedToken); + } + } + + log.info( + "Sent multicast failure notification (country: {}) to user: {} - Success: {}, Failed: {}", + countryCode, userId, response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + log.error("Failed to send failure notification (country: {}) to user: {}, error: {}", countryCode, + userId, e.getMessage(), e); + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + fcmTokens.forEach(fcmTokenService::deactivateToken); + } + } + }); + + } + catch (Exception e) { + log.error("Failed to send content failure notification for user: {}, requestId: {}", userId, requestId, e); + } + } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressService.java index c84cc063..8148aea2 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressService.java @@ -28,196 +28,192 @@ @Slf4j public class CustomContentReadingProgressService { - private final CustomContentService customContentService; - private final CustomContentChunkService customContentChunkService; - private final CustomContentProgressRepository customContentProgressRepository; - private final CustomContentChunkRepository customContentChunkRepository; - private final ProgressCalculationService progressCalculationService; - private final ReadingCompletionService readingCompletionService; - private final StreakService streakService; - - - @Transactional - public CustomContentReadingProgressResponse updateProgress(String customId, CustomContentReadingProgressUpdateRequest request, String userId) { - // 커스텀 콘텐츠 존재 여부 확인 - if (!customContentService.existsById(customId)) { - throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); - } - - // chunkId로부터 chunk 정보 조회 - CustomContentChunk chunk = customContentChunkService.findById(request.getChunkId()); - - // chunk가 해당 custom content에 속하는지 검증 - if (chunk.getCustomContentId() == null || !chunk.getCustomContentId().equals(customId)) { - throw new CustomContentException(CustomContentErrorCode.CHUNK_NOT_FOUND_IN_CUSTOM_CONTENT); - } - - CustomContentProgress customProgress = customContentProgressRepository.findByUserIdAndCustomId(userId, customId) - .orElse(new CustomContentProgress()); - - ensureMigrated(customProgress, chunk); - - // Null 체크 - if (chunk.getChunkNum() == null) { - throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND); - } - - customProgress.setUserId(userId); - customProgress.setCustomId(customId); - customProgress.setChunkId(request.getChunkId()); - - // [V2_CORE] V2 필드: 정규화된 진행률 계산 - long totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse( - customId, chunk.getDifficultyLevel() - ); - double normalizedProgress = progressCalculationService.calculateNormalizedProgress( - chunk.getChunkNum(), totalChunks - ); - - customProgress.setNormalizedProgress(normalizedProgress); - customProgress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); - - // maxNormalizedProgress 업데이트 (누적 최대값) - if (progressCalculationService.shouldUpdateMaxProgress( - customProgress.getMaxNormalizedProgress(), normalizedProgress)) { - customProgress.setMaxNormalizedProgress(normalizedProgress); - } - - // 읽기 완료 처리 (30초 이상 읽은 경우 이벤트 발행 + 세션 삭제) - // CustomContent는 Feed에서 생성되므로 category는 null, EventListener에서 Feed 업데이트 처리 - Long readTimeSeconds = readingCompletionService.processReadingCompletion( - userId, - ContentType.CUSTOM, - customId, - null - ); - - // 스트릭 검사 및 완료 처리 로직 - boolean streakUpdated = false; - if (isLastChunk(chunk)) { - // 첫 완료 시에만 isCompleted와 completedAt 설정 - if (customProgress.getCompletedAt() == null) { - customProgress.setIsCompleted(true); - customProgress.setCompletedAt(java.time.Instant.now()); - } - - // 스트릭 업데이트 (30초 이상 읽은 경우에만) - if (readTimeSeconds != null && readTimeSeconds >= 30) { - streakService.addStudyTime(userId, readTimeSeconds); - streakUpdated = streakService.updateStreak(userId, ContentType.CUSTOM, customId); - streakService.addCompletedContent(userId, ContentType.CUSTOM, customId, streakUpdated); - } - } - - customContentProgressRepository.save(customProgress); - - return convertToCustomContentReadingProgressResponse(customProgress, streakUpdated); - } - - private boolean isLastChunk(CustomContentChunk chunk) { - long totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse( - chunk.getCustomContentId(), chunk.getDifficultyLevel() - ); - return chunk.getChunkNum() >= totalChunks; - } - - private void ensureMigrated(CustomContentProgress progress, CustomContentChunk chunk) { - boolean needsMigration = false; - - if (progress.getNormalizedProgress() == null) { - long totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse( - chunk.getCustomContentId(), chunk.getDifficultyLevel() - ); - double normalizedProgress = progressCalculationService.calculateNormalizedProgress( - chunk.getChunkNum(), totalChunks - ); - progress.setNormalizedProgress(normalizedProgress); - progress.setMaxNormalizedProgress(normalizedProgress); - needsMigration = true; - } - - if (progress.getCurrentDifficultyLevel() == null) { - progress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); - needsMigration = true; - } - - if (needsMigration) { - log.info("V2 migration completed for CustomContentProgress id={}, userId={}", - progress.getId(), progress.getUserId()); - } - } - - - @Transactional(readOnly = true) - public CustomContentReadingProgressResponse getProgress(String customId, String userId) { - // 커스텀 콘텐츠 존재 여부 확인 - if (!customContentService.existsById(customId)) { - throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); - } - - CustomContentProgress customProgress = customContentProgressRepository.findByUserIdAndCustomId(userId, customId) - .orElseGet(() -> initializeProgress(userId, customId)); - - return convertToCustomContentReadingProgressResponse(customProgress, false); - } - - private CustomContentProgress initializeProgress(String userId, String customId) { - // 첫 번째 청크로 초기화 - CustomContentChunk firstChunk = customContentChunkService.findFirstByCustomContentId(customId); - - CustomContentProgress newProgress = new CustomContentProgress(); - newProgress.setUserId(userId); - newProgress.setCustomId(customId); - newProgress.setChunkId(firstChunk.getId()); - - // [V2_CORE] V2 필드: 초기 진행률 계산 - long totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse( - customId, firstChunk.getDifficultyLevel() - ); - double initialProgress = progressCalculationService.calculateNormalizedProgress( - firstChunk.getChunkNum(), totalChunks - ); - - newProgress.setNormalizedProgress(initialProgress); - newProgress.setMaxNormalizedProgress(initialProgress); - newProgress.setCurrentDifficultyLevel(firstChunk.getDifficultyLevel()); - - return customContentProgressRepository.save(newProgress); - } - - @Transactional - public void deleteProgress(String customId, String userId) { - if (!customContentService.existsById(customId)) { - throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); - } - - CustomContentProgress customProgress = customContentProgressRepository.findByUserIdAndCustomId(userId, customId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.PROGRESS_NOT_FOUND)); - - customContentProgressRepository.delete(customProgress); - } - - private CustomContentReadingProgressResponse convertToCustomContentReadingProgressResponse(CustomContentProgress progress, boolean streakUpdated) { - // [DTO_MAPPING] chunk에서 chunkNum 조회 - CustomContentChunk chunk = customContentChunkService.findById(progress.getChunkId()); - - if (progress.getNormalizedProgress() == null || progress.getCurrentDifficultyLevel() == null) { - log.warn("CustomContentProgress {} not migrated yet - this should only happen on read-only access", - progress.getId()); - } - - return CustomContentReadingProgressResponse.builder() - .id(progress.getId()) - .userId(progress.getUserId()) - .customId(progress.getCustomId()) - .chunkId(progress.getChunkId()) - .currentReadChunkNumber(chunk.getChunkNum()) - .isCompleted(progress.getIsCompleted()) - .currentDifficultyLevel(progress.getCurrentDifficultyLevel()) - .normalizedProgress(progress.getNormalizedProgress()) - .maxNormalizedProgress(progress.getMaxNormalizedProgress()) - .streakUpdated(streakUpdated) - .updatedAt(progress.getUpdatedAt()) - .build(); - } + private final CustomContentService customContentService; + + private final CustomContentChunkService customContentChunkService; + + private final CustomContentProgressRepository customContentProgressRepository; + + private final CustomContentChunkRepository customContentChunkRepository; + + private final ProgressCalculationService progressCalculationService; + + private final ReadingCompletionService readingCompletionService; + + private final StreakService streakService; + + @Transactional + public CustomContentReadingProgressResponse updateProgress(String customId, + CustomContentReadingProgressUpdateRequest request, String userId) { + // 커스텀 콘텐츠 존재 여부 확인 + if (!customContentService.existsById(customId)) { + throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); + } + + // chunkId로부터 chunk 정보 조회 + CustomContentChunk chunk = customContentChunkService.findById(request.getChunkId()); + + // chunk가 해당 custom content에 속하는지 검증 + if (chunk.getCustomContentId() == null || !chunk.getCustomContentId().equals(customId)) { + throw new CustomContentException(CustomContentErrorCode.CHUNK_NOT_FOUND_IN_CUSTOM_CONTENT); + } + + CustomContentProgress customProgress = customContentProgressRepository.findByUserIdAndCustomId(userId, customId) + .orElse(new CustomContentProgress()); + + ensureMigrated(customProgress, chunk); + + // Null 체크 + if (chunk.getChunkNum() == null) { + throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_CHUNK_NOT_FOUND); + } + + customProgress.setUserId(userId); + customProgress.setCustomId(customId); + customProgress.setChunkId(request.getChunkId()); + + // [V2_CORE] V2 필드: 정규화된 진행률 계산 + long totalChunks = customContentChunkRepository + .countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(customId, chunk.getDifficultyLevel()); + double normalizedProgress = progressCalculationService.calculateNormalizedProgress(chunk.getChunkNum(), + totalChunks); + + customProgress.setNormalizedProgress(normalizedProgress); + customProgress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); + + // maxNormalizedProgress 업데이트 (누적 최대값) + if (progressCalculationService.shouldUpdateMaxProgress(customProgress.getMaxNormalizedProgress(), + normalizedProgress)) { + customProgress.setMaxNormalizedProgress(normalizedProgress); + } + + // 읽기 완료 처리 (30초 이상 읽은 경우 이벤트 발행 + 세션 삭제) + // CustomContent는 Feed에서 생성되므로 category는 null, EventListener에서 Feed 업데이트 처리 + Long readTimeSeconds = readingCompletionService.processReadingCompletion(userId, ContentType.CUSTOM, customId, + null); + + // 스트릭 검사 및 완료 처리 로직 + boolean streakUpdated = false; + if (isLastChunk(chunk)) { + // 첫 완료 시에만 isCompleted와 completedAt 설정 + if (customProgress.getCompletedAt() == null) { + customProgress.setIsCompleted(true); + customProgress.setCompletedAt(java.time.Instant.now()); + } + + // 스트릭 업데이트 (30초 이상 읽은 경우에만) + if (readTimeSeconds != null && readTimeSeconds >= 30) { + streakService.addStudyTime(userId, readTimeSeconds); + streakUpdated = streakService.updateStreak(userId, ContentType.CUSTOM, customId); + streakService.addCompletedContent(userId, ContentType.CUSTOM, customId, streakUpdated); + } + } + + customContentProgressRepository.save(customProgress); + + return convertToCustomContentReadingProgressResponse(customProgress, streakUpdated); + } + + private boolean isLastChunk(CustomContentChunk chunk) { + long totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse( + chunk.getCustomContentId(), chunk.getDifficultyLevel()); + return chunk.getChunkNum() >= totalChunks; + } + + private void ensureMigrated(CustomContentProgress progress, CustomContentChunk chunk) { + boolean needsMigration = false; + + if (progress.getNormalizedProgress() == null) { + long totalChunks = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse( + chunk.getCustomContentId(), chunk.getDifficultyLevel()); + double normalizedProgress = progressCalculationService.calculateNormalizedProgress(chunk.getChunkNum(), + totalChunks); + progress.setNormalizedProgress(normalizedProgress); + progress.setMaxNormalizedProgress(normalizedProgress); + needsMigration = true; + } + + if (progress.getCurrentDifficultyLevel() == null) { + progress.setCurrentDifficultyLevel(chunk.getDifficultyLevel()); + needsMigration = true; + } + + if (needsMigration) { + log.info("V2 migration completed for CustomContentProgress id={}, userId={}", progress.getId(), + progress.getUserId()); + } + } + + @Transactional(readOnly = true) + public CustomContentReadingProgressResponse getProgress(String customId, String userId) { + // 커스텀 콘텐츠 존재 여부 확인 + if (!customContentService.existsById(customId)) { + throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); + } + + CustomContentProgress customProgress = customContentProgressRepository.findByUserIdAndCustomId(userId, customId) + .orElseGet(() -> initializeProgress(userId, customId)); + + return convertToCustomContentReadingProgressResponse(customProgress, false); + } + + private CustomContentProgress initializeProgress(String userId, String customId) { + // 첫 번째 청크로 초기화 + CustomContentChunk firstChunk = customContentChunkService.findFirstByCustomContentId(customId); + + CustomContentProgress newProgress = new CustomContentProgress(); + newProgress.setUserId(userId); + newProgress.setCustomId(customId); + newProgress.setChunkId(firstChunk.getId()); + + // [V2_CORE] V2 필드: 초기 진행률 계산 + long totalChunks = customContentChunkRepository + .countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(customId, firstChunk.getDifficultyLevel()); + double initialProgress = progressCalculationService.calculateNormalizedProgress(firstChunk.getChunkNum(), + totalChunks); + + newProgress.setNormalizedProgress(initialProgress); + newProgress.setMaxNormalizedProgress(initialProgress); + newProgress.setCurrentDifficultyLevel(firstChunk.getDifficultyLevel()); + + return customContentProgressRepository.save(newProgress); + } + + @Transactional + public void deleteProgress(String customId, String userId) { + if (!customContentService.existsById(customId)) { + throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); + } + + CustomContentProgress customProgress = customContentProgressRepository.findByUserIdAndCustomId(userId, customId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.PROGRESS_NOT_FOUND)); + + customContentProgressRepository.delete(customProgress); + } + + private CustomContentReadingProgressResponse convertToCustomContentReadingProgressResponse( + CustomContentProgress progress, boolean streakUpdated) { + // [DTO_MAPPING] chunk에서 chunkNum 조회 + CustomContentChunk chunk = customContentChunkService.findById(progress.getChunkId()); + + if (progress.getNormalizedProgress() == null || progress.getCurrentDifficultyLevel() == null) { + log.warn("CustomContentProgress {} not migrated yet - this should only happen on read-only access", + progress.getId()); + } + + return CustomContentReadingProgressResponse.builder() + .id(progress.getId()) + .userId(progress.getUserId()) + .customId(progress.getCustomId()) + .chunkId(progress.getChunkId()) + .currentReadChunkNumber(chunk.getChunkNum()) + .isCompleted(progress.getIsCompleted()) + .currentDifficultyLevel(progress.getCurrentDifficultyLevel()) + .normalizedProgress(progress.getNormalizedProgress()) + .maxNormalizedProgress(progress.getMaxNormalizedProgress()) + .streakUpdated(streakUpdated) + .updatedAt(progress.getUpdatedAt()) + .build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingTimeService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingTimeService.java index 4ec80722..8b652bb8 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingTimeService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentReadingTimeService.java @@ -18,28 +18,30 @@ @RequiredArgsConstructor public class CustomContentReadingTimeService { - private final CustomContentRepository customContentRepository; - private final CustomContentChunkRepository customContentChunkRepository; - private final ReadingTimeService readingTimeService; - - @Transactional - public void updateReadingTime(String customContentId) { - CustomContent customContent = customContentRepository.findByIdAndIsDeletedFalse(customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); - - List chunks = customContentChunkRepository.findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc( - customContentId, - customContent.getDifficultyLevel() - ); - - int totalCharacters = chunks.stream() - .filter(chunk -> chunk.getType() == ChunkType.TEXT) - .mapToInt(chunk -> chunk.getChunkText().length()) - .sum(); - - int readingTime = readingTimeService.calculateReadingTimeFromCharacters(totalCharacters); - customContent.setReadingTime(readingTime); - - customContentRepository.save(customContent); - } + private final CustomContentRepository customContentRepository; + + private final CustomContentChunkRepository customContentChunkRepository; + + private final ReadingTimeService readingTimeService; + + @Transactional + public void updateReadingTime(String customContentId) { + CustomContent customContent = customContentRepository.findByIdAndIsDeletedFalse(customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); + + List chunks = customContentChunkRepository + .findByCustomContentIdAndDifficultyLevelAndIsDeletedFalseOrderByChapterNumAscChunkNumAsc(customContentId, + customContent.getDifficultyLevel()); + + int totalCharacters = chunks.stream() + .filter(chunk -> chunk.getType() == ChunkType.TEXT) + .mapToInt(chunk -> chunk.getChunkText().length()) + .sum(); + + int readingTime = readingTimeService.calculateReadingTimeFromCharacters(totalCharacters); + customContent.setReadingTime(readingTime); + + customContentRepository.save(customContent); + } + } diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentRequestService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentRequestService.java index fa0d58a5..ad4c9376 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentRequestService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentRequestService.java @@ -38,205 +38,214 @@ @Slf4j public class CustomContentRequestService { - private final ContentRequestRepository contentRequestRepository; - private final CustomContentRepository customContentRepository; - private final UserCustomContentService userCustomContentService; - private final S3AiService s3AiService; - private final CustomContentPathStrategy pathStrategy; - private final CrawlingService crawlingService; - private final TicketService ticketService; - - @Transactional - public CreateContentRequestResponse createContentRequest(String userId, CreateContentRequestRequest request) { - log.info("Creating content request for user: {}", userId); - - String extractedDomain = validateUrlForCrawling(request.getOriginUrl()); - - // URL 기반 캐시 검사 (LINK, YOUTUBE만 해당) - Optional cachedContent = Optional.empty(); - if (isUrlBasedContentType(request.getContentType())) { - cachedContent = checkCachedContent(request.getOriginUrl()); - if (cachedContent.isPresent()) { - if (!userCustomContentService.validateNotOwned(userId, cachedContent.get().getId())) { - throw new CustomContentException(CustomContentErrorCode.CONTENT_ALREADY_OWNED); - } - } - } - - // 티켓 소비 (캐시 히트/미스 무관하게 소비, 단 이미 소유한 경우는 제외) - try { - ticketService.spendTicket(userId, 1, "Custom content creation"); - log.info("Ticket spent for user: {} (Custom content: {})", userId, request.getTitle()); - } catch (Exception e) { - log.error("Failed to spend ticket for user: {}", userId, e); - throw new CustomContentException(CustomContentErrorCode.INSUFFICIENT_TICKETS); - } - - // ContentRequest 생성 - ContentRequest contentRequest = ContentRequest.builder() - .userId(userId) - .title(request.getTitle()) - .contentType(request.getContentType()) - .originalText(request.getOriginalContent()) - .targetDifficultyLevels(request.getTargetDifficultyLevels()) - .originUrl(request.getOriginUrl()) - .originDomain(extractedDomain) - .originAuthor(request.getOriginAuthor()) - .coverImageUrl(request.getCoverImageUrl()) - .status(ContentRequestStatus.PENDING) - .progress(0) - .build(); - - ContentRequest savedRequest = contentRequestRepository.save(contentRequest); - log.info("Content request created with ID: {}", savedRequest.getId()); - - // 캐시 히트 시: 즉시 완료 처리 - if (cachedContent.isPresent()) { - return handleCacheHit(savedRequest, cachedContent.get()); - } - - // 캐시 미스 시: AI 처리 진행 - uploadToAiInput(savedRequest, request); - - return CreateContentRequestResponse.builder() - .requestId(savedRequest.getId()) - .title(savedRequest.getTitle()) - .status(savedRequest.getStatus().getCode()) - .cached(false) - .createdAt(savedRequest.getCreatedAt()) - .build(); - } - - private Optional checkCachedContent(String originUrl) { - if (!StringUtils.hasText(originUrl)) { - return Optional.empty(); - } - - String normalizedUrl = UrlNormalizer.normalize(originUrl); - Optional existingContent = customContentRepository.findByOriginUrlAndIsDeletedFalse(normalizedUrl); - - if (existingContent.isPresent()) { - log.info("Cache HIT: Found existing content for URL: {} -> Content ID: {}", - normalizedUrl, existingContent.get().getId()); - } else { - log.debug("Cache MISS: No existing content for URL: {}", normalizedUrl); - } - - return existingContent; - } - - private CreateContentRequestResponse handleCacheHit(ContentRequest contentRequest, CustomContent cachedContent) { - // 1. UserCustomContent 매핑 생성 - userCustomContentService.createMapping(contentRequest, cachedContent); - - // 2. ContentRequest 즉시 완료 처리 - contentRequest.setResultCustomContentId(cachedContent.getId()); - contentRequest.setStatus(ContentRequestStatus.COMPLETED); - contentRequest.setProgress(100); - contentRequest.setCompletedAt(Instant.now()); - contentRequestRepository.save(contentRequest); - - return CreateContentRequestResponse.builder() - .requestId(contentRequest.getId()) - .title(contentRequest.getTitle()) - .status(ContentRequestStatus.COMPLETED.getCode()) - .cached(true) - .customContentId(cachedContent.getId()) - .customContentTitle(cachedContent.getTitle()) - .createdAt(contentRequest.getCreatedAt()) - .build(); - } - - /** - * URL 기반 캐싱이 가능한 ContentType인지 확인 - * TEXT, PDF는 사용자가 직접 입력한 고유 콘텐츠이므로 캐싱 불가 - */ - private boolean isUrlBasedContentType(ContentType contentType) { - return contentType == ContentType.LINK || contentType == ContentType.YOUTUBE; - } - - private String validateUrlForCrawling(String originUrl) { - return crawlingService.extractDomain(originUrl); - } - - private void uploadToAiInput(ContentRequest contentRequest, CreateContentRequestRequest request) { - try { - Map aiInputData = new HashMap<>(); - aiInputData.put("type", "custom"); - aiInputData.put("content", request.getOriginalContent()); - if (request.getCoverImageUrl() != null) { - aiInputData.put("coverImageUrl", request.getCoverImageUrl()); - } - - s3AiService.uploadJsonToInputBucket(contentRequest.getId(), aiInputData, pathStrategy); - log.info("Successfully uploaded AI input data for request: {}", contentRequest.getId()); - - } catch (Exception e) { - log.error("Failed to upload AI input data for request: {}", contentRequest.getId(), e); - contentRequest.setStatus(ContentRequestStatus.FAILED); - contentRequest.setErrorMessage("AI 입력 데이터 업로드 실패: " + e.getMessage()); - contentRequestRepository.save(contentRequest); - - // AI 입력 업로드 실패 시 티켓 복원 - try { - ticketService.grantTicket(contentRequest.getUserId(), 1, "Content creation failed - refund"); - log.info("Ticket refunded for failed request: {}", contentRequest.getId()); - } catch (Exception ticketE) { - log.error("Failed to refund ticket for request: {}", contentRequest.getId(), ticketE); - } - - throw new CustomContentException(CustomContentErrorCode.AI_INPUT_UPLOAD_FAILED); - } - } - - public PageResponse getContentRequests(String userId, GetContentRequestsRequest request) { - log.info("Getting content requests for user: {}", userId); - - Pageable pageable = PageRequest.of( - request.getPage() - 1, - request.getLimit(), - Sort.by(Sort.Direction.DESC, "createdAt") - ); - - Page contentRequests; - if (request.getStatus() != null) { - ContentRequestStatus status = ContentRequestStatus.valueOf(request.getStatus().toUpperCase()); - contentRequests = contentRequestRepository.findByUserIdAndStatus(userId, status, pageable); - } else { - contentRequests = contentRequestRepository.findByUserIdAndStatusNot( - userId, ContentRequestStatus.DELETED, pageable); - } - - Page responsePage = contentRequests.map(this::mapToResponse); - return new PageResponse<>(responsePage.getContent(), responsePage); - } - - public ContentRequestResponse getContentRequest(String userId, String requestId) { - log.info("Getting content request {} for user: {}", requestId, userId); - - ContentRequest contentRequest = contentRequestRepository.findByIdAndUserId(requestId, userId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); - - return mapToResponse(contentRequest); - } - - private ContentRequestResponse mapToResponse(ContentRequest contentRequest) { - ContentRequestResponse response = new ContentRequestResponse(); - response.setId(contentRequest.getId()); - response.setTitle(contentRequest.getTitle()); - response.setOriginalText(contentRequest.getOriginalText()); - response.setContentType(contentRequest.getContentType().getCode()); - response.setTargetDifficultyLevels(contentRequest.getTargetDifficultyLevels()); - response.setOriginUrl(contentRequest.getOriginUrl()); - response.setOriginDomain(contentRequest.getOriginDomain()); - response.setOriginAuthor(contentRequest.getOriginAuthor()); - response.setCoverImageUrl(contentRequest.getCoverImageUrl()); - response.setStatus(contentRequest.getStatus().getCode()); - response.setProgress(contentRequest.getProgress()); - response.setCreatedAt(contentRequest.getCreatedAt()); - response.setCompletedAt(contentRequest.getCompletedAt()); - response.setErrorMessage(contentRequest.getErrorMessage()); - response.setResultCustomContentId(contentRequest.getResultCustomContentId()); - return response; - } + private final ContentRequestRepository contentRequestRepository; + + private final CustomContentRepository customContentRepository; + + private final UserCustomContentService userCustomContentService; + + private final S3AiService s3AiService; + + private final CustomContentPathStrategy pathStrategy; + + private final CrawlingService crawlingService; + + private final TicketService ticketService; + + @Transactional + public CreateContentRequestResponse createContentRequest(String userId, CreateContentRequestRequest request) { + log.info("Creating content request for user: {}", userId); + + String extractedDomain = validateUrlForCrawling(request.getOriginUrl()); + + // URL 기반 캐시 검사 (LINK, YOUTUBE만 해당) + Optional cachedContent = Optional.empty(); + if (isUrlBasedContentType(request.getContentType())) { + cachedContent = checkCachedContent(request.getOriginUrl()); + if (cachedContent.isPresent()) { + if (!userCustomContentService.validateNotOwned(userId, cachedContent.get().getId())) { + throw new CustomContentException(CustomContentErrorCode.CONTENT_ALREADY_OWNED); + } + } + } + + // 티켓 소비 (캐시 히트/미스 무관하게 소비, 단 이미 소유한 경우는 제외) + try { + ticketService.spendTicket(userId, 1, "Custom content creation"); + log.info("Ticket spent for user: {} (Custom content: {})", userId, request.getTitle()); + } + catch (Exception e) { + log.error("Failed to spend ticket for user: {}", userId, e); + throw new CustomContentException(CustomContentErrorCode.INSUFFICIENT_TICKETS); + } + + // ContentRequest 생성 + ContentRequest contentRequest = ContentRequest.builder() + .userId(userId) + .title(request.getTitle()) + .contentType(request.getContentType()) + .originalText(request.getOriginalContent()) + .targetDifficultyLevels(request.getTargetDifficultyLevels()) + .originUrl(request.getOriginUrl()) + .originDomain(extractedDomain) + .originAuthor(request.getOriginAuthor()) + .coverImageUrl(request.getCoverImageUrl()) + .status(ContentRequestStatus.PENDING) + .progress(0) + .build(); + + ContentRequest savedRequest = contentRequestRepository.save(contentRequest); + log.info("Content request created with ID: {}", savedRequest.getId()); + + // 캐시 히트 시: 즉시 완료 처리 + if (cachedContent.isPresent()) { + return handleCacheHit(savedRequest, cachedContent.get()); + } + + // 캐시 미스 시: AI 처리 진행 + uploadToAiInput(savedRequest, request); + + return CreateContentRequestResponse.builder() + .requestId(savedRequest.getId()) + .title(savedRequest.getTitle()) + .status(savedRequest.getStatus().getCode()) + .cached(false) + .createdAt(savedRequest.getCreatedAt()) + .build(); + } + + private Optional checkCachedContent(String originUrl) { + if (!StringUtils.hasText(originUrl)) { + return Optional.empty(); + } + + String normalizedUrl = UrlNormalizer.normalize(originUrl); + Optional existingContent = customContentRepository + .findByOriginUrlAndIsDeletedFalse(normalizedUrl); + + if (existingContent.isPresent()) { + log.info("Cache HIT: Found existing content for URL: {} -> Content ID: {}", normalizedUrl, + existingContent.get().getId()); + } + else { + log.debug("Cache MISS: No existing content for URL: {}", normalizedUrl); + } + + return existingContent; + } + + private CreateContentRequestResponse handleCacheHit(ContentRequest contentRequest, CustomContent cachedContent) { + // 1. UserCustomContent 매핑 생성 + userCustomContentService.createMapping(contentRequest, cachedContent); + + // 2. ContentRequest 즉시 완료 처리 + contentRequest.setResultCustomContentId(cachedContent.getId()); + contentRequest.setStatus(ContentRequestStatus.COMPLETED); + contentRequest.setProgress(100); + contentRequest.setCompletedAt(Instant.now()); + contentRequestRepository.save(contentRequest); + + return CreateContentRequestResponse.builder() + .requestId(contentRequest.getId()) + .title(contentRequest.getTitle()) + .status(ContentRequestStatus.COMPLETED.getCode()) + .cached(true) + .customContentId(cachedContent.getId()) + .customContentTitle(cachedContent.getTitle()) + .createdAt(contentRequest.getCreatedAt()) + .build(); + } + + /** + * URL 기반 캐싱이 가능한 ContentType인지 확인 TEXT, PDF는 사용자가 직접 입력한 고유 콘텐츠이므로 캐싱 불가 + */ + private boolean isUrlBasedContentType(ContentType contentType) { + return contentType == ContentType.LINK || contentType == ContentType.YOUTUBE; + } + + private String validateUrlForCrawling(String originUrl) { + return crawlingService.extractDomain(originUrl); + } + + private void uploadToAiInput(ContentRequest contentRequest, CreateContentRequestRequest request) { + try { + Map aiInputData = new HashMap<>(); + aiInputData.put("type", "custom"); + aiInputData.put("content", request.getOriginalContent()); + if (request.getCoverImageUrl() != null) { + aiInputData.put("coverImageUrl", request.getCoverImageUrl()); + } + + s3AiService.uploadJsonToInputBucket(contentRequest.getId(), aiInputData, pathStrategy); + log.info("Successfully uploaded AI input data for request: {}", contentRequest.getId()); + + } + catch (Exception e) { + log.error("Failed to upload AI input data for request: {}", contentRequest.getId(), e); + contentRequest.setStatus(ContentRequestStatus.FAILED); + contentRequest.setErrorMessage("AI 입력 데이터 업로드 실패: " + e.getMessage()); + contentRequestRepository.save(contentRequest); + + // AI 입력 업로드 실패 시 티켓 복원 + try { + ticketService.grantTicket(contentRequest.getUserId(), 1, "Content creation failed - refund"); + log.info("Ticket refunded for failed request: {}", contentRequest.getId()); + } + catch (Exception ticketE) { + log.error("Failed to refund ticket for request: {}", contentRequest.getId(), ticketE); + } + + throw new CustomContentException(CustomContentErrorCode.AI_INPUT_UPLOAD_FAILED); + } + } + + public PageResponse getContentRequests(String userId, GetContentRequestsRequest request) { + log.info("Getting content requests for user: {}", userId); + + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), + Sort.by(Sort.Direction.DESC, "createdAt")); + + Page contentRequests; + if (request.getStatus() != null) { + ContentRequestStatus status = ContentRequestStatus.valueOf(request.getStatus().toUpperCase()); + contentRequests = contentRequestRepository.findByUserIdAndStatus(userId, status, pageable); + } + else { + contentRequests = contentRequestRepository.findByUserIdAndStatusNot(userId, ContentRequestStatus.DELETED, + pageable); + } + + Page responsePage = contentRequests.map(this::mapToResponse); + return new PageResponse<>(responsePage.getContent(), responsePage); + } + + public ContentRequestResponse getContentRequest(String userId, String requestId) { + log.info("Getting content request {} for user: {}", requestId, userId); + + ContentRequest contentRequest = contentRequestRepository.findByIdAndUserId(requestId, userId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); + + return mapToResponse(contentRequest); + } + + private ContentRequestResponse mapToResponse(ContentRequest contentRequest) { + ContentRequestResponse response = new ContentRequestResponse(); + response.setId(contentRequest.getId()); + response.setTitle(contentRequest.getTitle()); + response.setOriginalText(contentRequest.getOriginalText()); + response.setContentType(contentRequest.getContentType().getCode()); + response.setTargetDifficultyLevels(contentRequest.getTargetDifficultyLevels()); + response.setOriginUrl(contentRequest.getOriginUrl()); + response.setOriginDomain(contentRequest.getOriginDomain()); + response.setOriginAuthor(contentRequest.getOriginAuthor()); + response.setCoverImageUrl(contentRequest.getCoverImageUrl()); + response.setStatus(contentRequest.getStatus().getCode()); + response.setProgress(contentRequest.getProgress()); + response.setCreatedAt(contentRequest.getCreatedAt()); + response.setCompletedAt(contentRequest.getCompletedAt()); + response.setErrorMessage(contentRequest.getErrorMessage()); + response.setResultCustomContentId(contentRequest.getResultCustomContentId()); + return response; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentService.java index deb786ce..3bccd5b2 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentService.java @@ -36,146 +36,159 @@ @Slf4j public class CustomContentService { - private final CustomContentRepository customContentRepository; - private final CustomContentChunkRepository customContentChunkRepository; - private final CustomContentProgressRepository customContentProgressRepository; - private final UserCustomContentRepository userCustomContentRepository; - private final CustomContentChunkService customContentChunkService; - - public PageResponse getCustomContents(String userId, GetCustomContentsRequest request) { - log.info("Getting custom contents for user: {} with request: {}", userId, request); - - Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); // 기본값: 최신순 - if (StringUtils.hasText(request.getSortBy())) { - sort = switch (request.getSortBy()) { - case "view_count" -> Sort.by(Sort.Direction.DESC, "viewCount"); - case "average_rating" -> Sort.by(Sort.Direction.DESC, "averageRating"); - default -> sort; - }; - } - - Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), sort); - - // UserCustomContent와 CustomContent를 aggregation으로 조인하여 한 번에 조회 - Page page = customContentRepository.findCustomContentsByUserWithFilters(userId, request, pageable); - - List responses = page.getContent().stream() - .map(content -> mapToResponse(content, userId)) - .collect(Collectors.toList()); - - return new PageResponse<>(responses, page); - } - - public CustomContentResponse getCustomContent(String userId, String customContentId) { - log.info("Getting custom content {} for user: {}", customContentId, userId); - - if (!userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId)) { - throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); - } - - CustomContent content = customContentRepository.findByIdAndIsDeletedFalse(customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); - - return mapToResponse(content, userId); - } - - @Transactional - public CustomContentResponse updateCustomContent(String userId, String customContentId, UpdateCustomContentRequest request) { - log.info("Updating custom content {} for user: {}", customContentId, userId); - - if (!userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId)) { - throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); - } - - CustomContent content = customContentRepository.findByIdAndIsDeletedFalse(customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); - - if (request.getTitle() != null) { - content.setTitle(request.getTitle()); - } - if (request.getTags() != null) { - content.setTags(request.getTags()); - } - - CustomContent updatedContent = customContentRepository.save(content); - return mapToResponse(updatedContent, userId); - } - - @Transactional - public void deleteCustomContent(String userId, String customContentId) { - log.info("Deleting custom content {} for user: {}", customContentId, userId); - - UserCustomContent userCustomContent = userCustomContentRepository - .findByUserIdAndCustomContentId(userId, customContentId) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); - - userCustomContentRepository.delete(userCustomContent); - log.info("Deleted UserCustomContent mapping for user: {} and content: {}", userId, customContentId); - } - - private CustomContentResponse mapToResponse(CustomContent content, String userId) { - // 진도 정보 조회 - int currentReadChunkNumber = 0; - double progressPercentage = 0.0; - boolean isCompleted = false; - com.linglevel.api.content.common.DifficultyLevel currentDifficultyLevel = content.getDifficultyLevel(); // Fallback: CustomContent의 난이도 - - if (userId != null) { - CustomContentProgress progress = customContentProgressRepository - .findByUserIdAndCustomId(userId, content.getId()) - .orElse(null); - - if (progress != null) { - // [DTO_MAPPING] chunk에서 chunkNum 조회 (안전하게 처리) - try { - CustomContentChunk chunk = customContentChunkService.findById(progress.getChunkId()); - currentReadChunkNumber = chunk.getChunkNum() != null ? chunk.getChunkNum() : 0; - } catch (Exception e) { - log.warn("Failed to find chunk for progress: {}", progress.getChunkId(), e); - currentReadChunkNumber = 0; - } - - // Progress가 있으면 currentDifficultyLevel 사용 - if (progress.getCurrentDifficultyLevel() != null) { - currentDifficultyLevel = progress.getCurrentDifficultyLevel(); - } - - // V2: 현재 난이도 기준으로 동적으로 청크 수 계산 - long totalChunksForLevel = customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(content.getId(), currentDifficultyLevel); - - if (totalChunksForLevel > 0) { - progressPercentage = (double) currentReadChunkNumber / totalChunksForLevel * 100.0; - } - - isCompleted = progress.getIsCompleted() != null ? progress.getIsCompleted() : false; - - } - } - CustomContentResponse response = new CustomContentResponse(); - response.setId(content.getId()); - response.setTitle(content.getTitle()); - response.setAuthor(content.getAuthor()); - response.setCoverImageUrl(content.getCoverImageUrl()); - response.setDifficultyLevel(content.getDifficultyLevel()); - response.setTargetDifficultyLevels(content.getTargetDifficultyLevels()); - response.setChunkCount((int) customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(content.getId(), currentDifficultyLevel)); - response.setCurrentReadChunkNumber(currentReadChunkNumber); - response.setProgressPercentage(progressPercentage); - response.setCurrentDifficultyLevel(currentDifficultyLevel); - response.setIsCompleted(isCompleted); - response.setReadingTime(content.getReadingTime()); - response.setAverageRating(content.getAverageRating() != null ? content.getAverageRating().floatValue() : 0.0d); - response.setReviewCount(content.getReviewCount()); - response.setViewCount(content.getViewCount()); - response.setTags(content.getTags()); - response.setOriginUrl(content.getOriginUrl()); - response.setOriginDomain(content.getOriginDomain()); - response.setCreatedAt(content.getCreatedAt()); - response.setUpdatedAt(content.getUpdatedAt()); - return response; - } - - public boolean existsById(String customContentId) { - return customContentRepository.existsById(customContentId); - } + private final CustomContentRepository customContentRepository; + + private final CustomContentChunkRepository customContentChunkRepository; + + private final CustomContentProgressRepository customContentProgressRepository; + + private final UserCustomContentRepository userCustomContentRepository; + + private final CustomContentChunkService customContentChunkService; + + public PageResponse getCustomContents(String userId, GetCustomContentsRequest request) { + log.info("Getting custom contents for user: {} with request: {}", userId, request); + + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); // 기본값: 최신순 + if (StringUtils.hasText(request.getSortBy())) { + sort = switch (request.getSortBy()) { + case "view_count" -> Sort.by(Sort.Direction.DESC, "viewCount"); + case "average_rating" -> Sort.by(Sort.Direction.DESC, "averageRating"); + default -> sort; + }; + } + + Pageable pageable = PageRequest.of(request.getPage() - 1, request.getLimit(), sort); + + // UserCustomContent와 CustomContent를 aggregation으로 조인하여 한 번에 조회 + Page page = customContentRepository.findCustomContentsByUserWithFilters(userId, request, + pageable); + + List responses = page.getContent() + .stream() + .map(content -> mapToResponse(content, userId)) + .collect(Collectors.toList()); + + return new PageResponse<>(responses, page); + } + + public CustomContentResponse getCustomContent(String userId, String customContentId) { + log.info("Getting custom content {} for user: {}", customContentId, userId); + + if (!userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId)) { + throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); + } + + CustomContent content = customContentRepository.findByIdAndIsDeletedFalse(customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); + + return mapToResponse(content, userId); + } + + @Transactional + public CustomContentResponse updateCustomContent(String userId, String customContentId, + UpdateCustomContentRequest request) { + log.info("Updating custom content {} for user: {}", customContentId, userId); + + if (!userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId)) { + throw new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND); + } + + CustomContent content = customContentRepository.findByIdAndIsDeletedFalse(customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); + + if (request.getTitle() != null) { + content.setTitle(request.getTitle()); + } + if (request.getTags() != null) { + content.setTags(request.getTags()); + } + + CustomContent updatedContent = customContentRepository.save(content); + return mapToResponse(updatedContent, userId); + } + + @Transactional + public void deleteCustomContent(String userId, String customContentId) { + log.info("Deleting custom content {} for user: {}", customContentId, userId); + + UserCustomContent userCustomContent = userCustomContentRepository + .findByUserIdAndCustomContentId(userId, customContentId) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CUSTOM_CONTENT_NOT_FOUND)); + + userCustomContentRepository.delete(userCustomContent); + log.info("Deleted UserCustomContent mapping for user: {} and content: {}", userId, customContentId); + } + + private CustomContentResponse mapToResponse(CustomContent content, String userId) { + // 진도 정보 조회 + int currentReadChunkNumber = 0; + double progressPercentage = 0.0; + boolean isCompleted = false; + com.linglevel.api.content.common.DifficultyLevel currentDifficultyLevel = content.getDifficultyLevel(); // Fallback: + // CustomContent의 + // 난이도 + + if (userId != null) { + CustomContentProgress progress = customContentProgressRepository + .findByUserIdAndCustomId(userId, content.getId()) + .orElse(null); + + if (progress != null) { + // [DTO_MAPPING] chunk에서 chunkNum 조회 (안전하게 처리) + try { + CustomContentChunk chunk = customContentChunkService.findById(progress.getChunkId()); + currentReadChunkNumber = chunk.getChunkNum() != null ? chunk.getChunkNum() : 0; + } + catch (Exception e) { + log.warn("Failed to find chunk for progress: {}", progress.getChunkId(), e); + currentReadChunkNumber = 0; + } + + // Progress가 있으면 currentDifficultyLevel 사용 + if (progress.getCurrentDifficultyLevel() != null) { + currentDifficultyLevel = progress.getCurrentDifficultyLevel(); + } + + // V2: 현재 난이도 기준으로 동적으로 청크 수 계산 + long totalChunksForLevel = customContentChunkRepository + .countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(content.getId(), currentDifficultyLevel); + + if (totalChunksForLevel > 0) { + progressPercentage = (double) currentReadChunkNumber / totalChunksForLevel * 100.0; + } + + isCompleted = progress.getIsCompleted() != null ? progress.getIsCompleted() : false; + + } + } + CustomContentResponse response = new CustomContentResponse(); + response.setId(content.getId()); + response.setTitle(content.getTitle()); + response.setAuthor(content.getAuthor()); + response.setCoverImageUrl(content.getCoverImageUrl()); + response.setDifficultyLevel(content.getDifficultyLevel()); + response.setTargetDifficultyLevels(content.getTargetDifficultyLevels()); + response.setChunkCount((int) customContentChunkRepository + .countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(content.getId(), currentDifficultyLevel)); + response.setCurrentReadChunkNumber(currentReadChunkNumber); + response.setProgressPercentage(progressPercentage); + response.setCurrentDifficultyLevel(currentDifficultyLevel); + response.setIsCompleted(isCompleted); + response.setReadingTime(content.getReadingTime()); + response.setAverageRating(content.getAverageRating() != null ? content.getAverageRating().floatValue() : 0.0d); + response.setReviewCount(content.getReviewCount()); + response.setViewCount(content.getViewCount()); + response.setTags(content.getTags()); + response.setOriginUrl(content.getOriginUrl()); + response.setOriginDomain(content.getOriginDomain()); + response.setCreatedAt(content.getCreatedAt()); + response.setUpdatedAt(content.getUpdatedAt()); + return response; + } + + public boolean existsById(String customContentId) { + return customContentRepository.existsById(customContentId); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/content/custom/service/CustomContentWebhookService.java b/src/main/java/com/linglevel/api/content/custom/service/CustomContentWebhookService.java index 3d48f197..c90e65e7 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/CustomContentWebhookService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/CustomContentWebhookService.java @@ -26,175 +26,187 @@ @Slf4j public class CustomContentWebhookService { - private final ContentRequestRepository contentRequestRepository; - private final CustomContentRepository customContentRepository; - private final UserCustomContentService userCustomContentService; - private final CustomContentImportService customContentImportService; - private final CustomContentReadingTimeService customContentReadingTimeService; - private final S3AiService s3AiService; - private final S3TransferService s3TransferService; - private final S3UrlService s3UrlService; - private final CustomContentPathStrategy pathStrategy; - private final CustomContentNotificationService notificationService; - private final TicketService ticketService; - - @Transactional - public CustomContentCompletedResponse handleContentCompleted(CustomContentCompletedRequest request) { - log.info("Handling content completion for request: {}", request.getRequestId()); - - try { - // 1. Get ContentRequest and AiResultDto - ContentRequest contentRequest = contentRequestRepository.findById(request.getRequestId()) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); - - AiResultDto aiResult = s3AiService.downloadJsonFile( - request.getRequestId(), - AiResultDto.class, - pathStrategy - ); - - // 2. Create the main CustomContent entity - CustomContent savedContent = customContentImportService.createCustomContent(contentRequest, aiResult); - - // 3. Create UserCustomContent mapping for this user - userCustomContentService.createMapping(contentRequest, savedContent); - - // 4. Transfer S3 images from AI temp location to static location - transferS3ImagesAndUpdateCoverUrl(request.getRequestId(), savedContent, aiResult); - - // Save updated content with permanent cover image URL - savedContent = customContentRepository.save(savedContent); - - // 5. Create all associated chunks with permanent image URLs - customContentImportService.createCustomContentChunks(savedContent, aiResult); - - // 6. Calculate and update reading time - customContentReadingTimeService.updateReadingTime(savedContent.getId()); - - // 7. Update ContentRequest status - contentRequest.setResultCustomContentId(savedContent.getId()); - contentRequest.setStatus(ContentRequestStatus.COMPLETED); - contentRequest.setCompletedAt(Instant.now()); - contentRequestRepository.save(contentRequest); - - // 8. Send notification - notificationService.sendContentCompletedNotification( - contentRequest.getUserId(), - request.getRequestId(), - aiResult.getTitle(), - savedContent.getId() - ); - - log.info("Successfully processed AI result for request: {}", request.getRequestId()); - - return CustomContentCompletedResponse.builder() - .requestId(request.getRequestId()) - .status("completed") - .build(); - - } catch (Exception e) { - log.error("Failed to process content completion for request: {}. Error: {}", request.getRequestId(), e.getMessage()); - - // Handle failure case with proper exception handling - handleContentProcessingFailure(request.getRequestId(), e); - - throw new CustomContentException(CustomContentErrorCode.AI_RESULT_PROCESSING_FAILED, e.getMessage()); - } - } - - @Transactional - public void handleContentFailed(CustomContentFailedRequest request) { - log.info("Handling content failure for request: {}", request.getRequestId()); - - try { - ContentRequest contentRequest = contentRequestRepository.findById(request.getRequestId()) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); - - contentRequest.setStatus(ContentRequestStatus.FAILED); - contentRequest.setErrorMessage(request.getErrorMessage()); - contentRequestRepository.save(contentRequest); - - // 티켓 복원 (1개 환불) - try { - ticketService.grantTicket(contentRequest.getUserId(), 1, "Content creation failed - refund"); - log.info("Ticket refunded for failed request: {}", request.getRequestId()); - } catch (Exception ticketE) { - log.error("Failed to refund ticket for request: {}. Error: {}", request.getRequestId(), ticketE.getMessage()); - } - - String titleForNotification = StringUtils.hasText(contentRequest.getTitle()) - ? contentRequest.getTitle() - : "Untitled Content"; - - notificationService.sendContentFailedNotification( - contentRequest.getUserId(), - request.getRequestId(), - titleForNotification, - request.getErrorMessage() - ); - - log.info("Updated request status to FAILED for request: {}", request.getRequestId()); - - } catch (Exception e) { - log.error("Failed to handle content failure for request: {}. Error: {}", request.getRequestId(), e.getMessage()); - throw new CustomContentException(CustomContentErrorCode.WEBHOOK_PROCESSING_FAILED, e.getMessage()); - } - } - - @Transactional - public void handleContentProgress(CustomContentProgressRequest request) { - log.info("Handling content progress for request: {} - {}%", request.getRequestId(), request.getProgress()); - - try { - ContentRequest contentRequest = contentRequestRepository.findById(request.getRequestId()) - .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); - - contentRequest.setStatus(ContentRequestStatus.PROCESSING); - contentRequest.setProgress(request.getProgress()); - contentRequestRepository.save(contentRequest); - - log.info("Updated progress to {}% for request: {}", request.getProgress(), request.getRequestId()); - - } catch (Exception e) { - log.error("Failed to handle content progress for request: {}. Error: {}", request.getRequestId(), e.getMessage()); - throw new CustomContentException(CustomContentErrorCode.WEBHOOK_PROCESSING_FAILED, e.getMessage()); - } - } - - private void transferS3ImagesAndUpdateCoverUrl(String requestId, CustomContent customContent, AiResultDto aiResult) { - try { - s3TransferService.transferImagesFromAiToStatic(requestId, customContent.getId(), pathStrategy); - - if (StringUtils.hasText(aiResult.getCoverImageUrl())) { - String permanentCoverImageUrl = s3UrlService.getCoverImageUrl(customContent.getId(), pathStrategy); - customContent.setCoverImageUrl(permanentCoverImageUrl); - } - } catch (Exception e) { - log.error("Failed to transfer S3 images for content: {}. Error: {}", customContent.getId(), e.getMessage()); - // Don't fail the entire process for S3 transfer issues - } - } - - private void handleContentProcessingFailure(String requestId, Exception originalException) { - try { - ContentRequest contentRequest = contentRequestRepository.findById(requestId) - .orElse(null); - if (contentRequest != null) { - contentRequest.setStatus(ContentRequestStatus.FAILED); - contentRequest.setErrorMessage("AI 결과 처리 실패: " + originalException.getMessage()); - contentRequestRepository.save(contentRequest); - - // 티켓 복원 (1개 환불) - try { - ticketService.grantTicket(contentRequest.getUserId(), 1, "Content processing failed - refund"); - log.info("Ticket refunded for processing failure: {}", requestId); - } catch (Exception ticketE) { - log.error("Failed to refund ticket for processing failure: {}. Error: {}", requestId, ticketE.getMessage()); - } - } - } catch (Exception e) { - log.error("Failed to update content request status to FAILED for request: {}. Error: {}", requestId, e.getMessage()); - // Don't throw exception here to preserve original exception - } - } + private final ContentRequestRepository contentRequestRepository; + + private final CustomContentRepository customContentRepository; + + private final UserCustomContentService userCustomContentService; + + private final CustomContentImportService customContentImportService; + + private final CustomContentReadingTimeService customContentReadingTimeService; + + private final S3AiService s3AiService; + + private final S3TransferService s3TransferService; + + private final S3UrlService s3UrlService; + + private final CustomContentPathStrategy pathStrategy; + + private final CustomContentNotificationService notificationService; + + private final TicketService ticketService; + + @Transactional + public CustomContentCompletedResponse handleContentCompleted(CustomContentCompletedRequest request) { + log.info("Handling content completion for request: {}", request.getRequestId()); + + try { + // 1. Get ContentRequest and AiResultDto + ContentRequest contentRequest = contentRequestRepository.findById(request.getRequestId()) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); + + AiResultDto aiResult = s3AiService.downloadJsonFile(request.getRequestId(), AiResultDto.class, + pathStrategy); + + // 2. Create the main CustomContent entity + CustomContent savedContent = customContentImportService.createCustomContent(contentRequest, aiResult); + + // 3. Create UserCustomContent mapping for this user + userCustomContentService.createMapping(contentRequest, savedContent); + + // 4. Transfer S3 images from AI temp location to static location + transferS3ImagesAndUpdateCoverUrl(request.getRequestId(), savedContent, aiResult); + + // Save updated content with permanent cover image URL + savedContent = customContentRepository.save(savedContent); + + // 5. Create all associated chunks with permanent image URLs + customContentImportService.createCustomContentChunks(savedContent, aiResult); + + // 6. Calculate and update reading time + customContentReadingTimeService.updateReadingTime(savedContent.getId()); + + // 7. Update ContentRequest status + contentRequest.setResultCustomContentId(savedContent.getId()); + contentRequest.setStatus(ContentRequestStatus.COMPLETED); + contentRequest.setCompletedAt(Instant.now()); + contentRequestRepository.save(contentRequest); + + // 8. Send notification + notificationService.sendContentCompletedNotification(contentRequest.getUserId(), request.getRequestId(), + aiResult.getTitle(), savedContent.getId()); + + log.info("Successfully processed AI result for request: {}", request.getRequestId()); + + return CustomContentCompletedResponse.builder() + .requestId(request.getRequestId()) + .status("completed") + .build(); + + } + catch (Exception e) { + log.error("Failed to process content completion for request: {}. Error: {}", request.getRequestId(), + e.getMessage()); + + // Handle failure case with proper exception handling + handleContentProcessingFailure(request.getRequestId(), e); + + throw new CustomContentException(CustomContentErrorCode.AI_RESULT_PROCESSING_FAILED, e.getMessage()); + } + } + + @Transactional + public void handleContentFailed(CustomContentFailedRequest request) { + log.info("Handling content failure for request: {}", request.getRequestId()); + + try { + ContentRequest contentRequest = contentRequestRepository.findById(request.getRequestId()) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); + + contentRequest.setStatus(ContentRequestStatus.FAILED); + contentRequest.setErrorMessage(request.getErrorMessage()); + contentRequestRepository.save(contentRequest); + + // 티켓 복원 (1개 환불) + try { + ticketService.grantTicket(contentRequest.getUserId(), 1, "Content creation failed - refund"); + log.info("Ticket refunded for failed request: {}", request.getRequestId()); + } + catch (Exception ticketE) { + log.error("Failed to refund ticket for request: {}. Error: {}", request.getRequestId(), + ticketE.getMessage()); + } + + String titleForNotification = StringUtils.hasText(contentRequest.getTitle()) ? contentRequest.getTitle() + : "Untitled Content"; + + notificationService.sendContentFailedNotification(contentRequest.getUserId(), request.getRequestId(), + titleForNotification, request.getErrorMessage()); + + log.info("Updated request status to FAILED for request: {}", request.getRequestId()); + + } + catch (Exception e) { + log.error("Failed to handle content failure for request: {}. Error: {}", request.getRequestId(), + e.getMessage()); + throw new CustomContentException(CustomContentErrorCode.WEBHOOK_PROCESSING_FAILED, e.getMessage()); + } + } + + @Transactional + public void handleContentProgress(CustomContentProgressRequest request) { + log.info("Handling content progress for request: {} - {}%", request.getRequestId(), request.getProgress()); + + try { + ContentRequest contentRequest = contentRequestRepository.findById(request.getRequestId()) + .orElseThrow(() -> new CustomContentException(CustomContentErrorCode.CONTENT_REQUEST_NOT_FOUND)); + + contentRequest.setStatus(ContentRequestStatus.PROCESSING); + contentRequest.setProgress(request.getProgress()); + contentRequestRepository.save(contentRequest); + + log.info("Updated progress to {}% for request: {}", request.getProgress(), request.getRequestId()); + + } + catch (Exception e) { + log.error("Failed to handle content progress for request: {}. Error: {}", request.getRequestId(), + e.getMessage()); + throw new CustomContentException(CustomContentErrorCode.WEBHOOK_PROCESSING_FAILED, e.getMessage()); + } + } + + private void transferS3ImagesAndUpdateCoverUrl(String requestId, CustomContent customContent, + AiResultDto aiResult) { + try { + s3TransferService.transferImagesFromAiToStatic(requestId, customContent.getId(), pathStrategy); + + if (StringUtils.hasText(aiResult.getCoverImageUrl())) { + String permanentCoverImageUrl = s3UrlService.getCoverImageUrl(customContent.getId(), pathStrategy); + customContent.setCoverImageUrl(permanentCoverImageUrl); + } + } + catch (Exception e) { + log.error("Failed to transfer S3 images for content: {}. Error: {}", customContent.getId(), e.getMessage()); + // Don't fail the entire process for S3 transfer issues + } + } + + private void handleContentProcessingFailure(String requestId, Exception originalException) { + try { + ContentRequest contentRequest = contentRequestRepository.findById(requestId).orElse(null); + if (contentRequest != null) { + contentRequest.setStatus(ContentRequestStatus.FAILED); + contentRequest.setErrorMessage("AI 결과 처리 실패: " + originalException.getMessage()); + contentRequestRepository.save(contentRequest); + + // 티켓 복원 (1개 환불) + try { + ticketService.grantTicket(contentRequest.getUserId(), 1, "Content processing failed - refund"); + log.info("Ticket refunded for processing failure: {}", requestId); + } + catch (Exception ticketE) { + log.error("Failed to refund ticket for processing failure: {}. Error: {}", requestId, + ticketE.getMessage()); + } + } + } + catch (Exception e) { + log.error("Failed to update content request status to FAILED for request: {}. Error: {}", requestId, + e.getMessage()); + // Don't throw exception here to preserve original exception + } + } + } diff --git a/src/main/java/com/linglevel/api/content/custom/service/UserCustomContentService.java b/src/main/java/com/linglevel/api/content/custom/service/UserCustomContentService.java index 36b0c007..51ab18e8 100644 --- a/src/main/java/com/linglevel/api/content/custom/service/UserCustomContentService.java +++ b/src/main/java/com/linglevel/api/content/custom/service/UserCustomContentService.java @@ -16,31 +16,27 @@ @Slf4j public class UserCustomContentService { - private final UserCustomContentRepository userCustomContentRepository; - - public boolean validateNotOwned(String userId, String customContentId) { - return !userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId); - } - - @Transactional - public void createMapping(ContentRequest contentRequest, CustomContent customContent) { - createMapping( - contentRequest.getUserId(), - customContent.getId(), - contentRequest.getId() - ); - } - - @Transactional - public void createMapping(String userId, String customContentId, String contentRequestId) { - UserCustomContent userCustomContent = UserCustomContent.builder() - .userId(userId) - .customContentId(customContentId) - .contentRequestId(contentRequestId) - .build(); - - userCustomContentRepository.save(userCustomContent); - log.info("Created UserCustomContent mapping for user: {} and content: {}", - userId, customContentId); - } + private final UserCustomContentRepository userCustomContentRepository; + + public boolean validateNotOwned(String userId, String customContentId) { + return !userCustomContentRepository.existsByUserIdAndCustomContentId(userId, customContentId); + } + + @Transactional + public void createMapping(ContentRequest contentRequest, CustomContent customContent) { + createMapping(contentRequest.getUserId(), customContent.getId(), contentRequest.getId()); + } + + @Transactional + public void createMapping(String userId, String customContentId, String contentRequestId) { + UserCustomContent userCustomContent = UserCustomContent.builder() + .userId(userId) + .customContentId(customContentId) + .contentRequestId(contentRequestId) + .build(); + + userCustomContentRepository.save(userCustomContent); + log.info("Created UserCustomContent mapping for user: {} and content: {}", userId, customContentId); + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/controller/FeedController.java b/src/main/java/com/linglevel/api/content/feed/controller/FeedController.java index be88803f..e6f119ea 100644 --- a/src/main/java/com/linglevel/api/content/feed/controller/FeedController.java +++ b/src/main/java/com/linglevel/api/content/feed/controller/FeedController.java @@ -28,46 +28,37 @@ @Tag(name = "Feeds", description = "피드 추천 관련 API") public class FeedController { - private final FeedService feedService; + private final FeedService feedService; - @Operation( - summary = "피드 목록 조회", - description = "필터링 및 정렬 조건에 따라 피드 목록을 조회합니다." - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity> getFeeds( - @ParameterObject @ModelAttribute GetFeedsRequest request, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - PageResponse response = feedService.getFeeds(request, userId); - return ResponseEntity.ok(response); - } + @Operation(summary = "피드 목록 조회", description = "필터링 및 정렬 조건에 따라 피드 목록을 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity> getFeeds(@ParameterObject @ModelAttribute GetFeedsRequest request, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + PageResponse response = feedService.getFeeds(request, userId); + return ResponseEntity.ok(response); + } - @Operation(summary = "단일 피드 조회", description = "특정 피드의 상세 정보를 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "피드를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/{feedId}") - public ResponseEntity getFeed( - @Parameter(description = "피드 ID", example = "60d0fe4f5311236168a109ca") - @PathVariable String feedId, - @AuthenticationPrincipal JwtClaims claims) { - String userId = claims != null ? claims.getId() : null; - FeedResponse response = feedService.getFeed(feedId, userId); - return ResponseEntity.ok(response); - } + @Operation(summary = "단일 피드 조회", description = "특정 피드의 상세 정보를 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "피드를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/{feedId}") + public ResponseEntity getFeed( + @Parameter(description = "피드 ID", example = "60d0fe4f5311236168a109ca") @PathVariable String feedId, + @AuthenticationPrincipal JwtClaims claims) { + String userId = claims != null ? claims.getId() : null; + FeedResponse response = feedService.getFeed(feedId, userId); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(FeedException.class) + public ResponseEntity handleFeedException(FeedException e) { + log.info("Feed Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(FeedException.class) - public ResponseEntity handleFeedException(FeedException e) { - log.info("Feed Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } diff --git a/src/main/java/com/linglevel/api/content/feed/dto/CreateFeedSourceRequest.java b/src/main/java/com/linglevel/api/content/feed/dto/CreateFeedSourceRequest.java index fad4e1a0..ea6c1e26 100644 --- a/src/main/java/com/linglevel/api/content/feed/dto/CreateFeedSourceRequest.java +++ b/src/main/java/com/linglevel/api/content/feed/dto/CreateFeedSourceRequest.java @@ -19,25 +19,26 @@ @Schema(description = "FeedSource 생성 요청") public class CreateFeedSourceRequest { - @NotBlank(message = "URL is required") - @Schema(description = "RSS Feed URL", example = "https://www.bbc.com/news/technology/rss.xml") - private String url; + @NotBlank(message = "URL is required") + @Schema(description = "RSS Feed URL", example = "https://www.bbc.com/news/technology/rss.xml") + private String url; - @NotBlank(message = "Name is required") - @Schema(description = "FeedSource 이름", example = "BBC Technology News") - private String name; + @NotBlank(message = "Name is required") + @Schema(description = "FeedSource 이름", example = "BBC Technology News") + private String name; - @Schema(description = "커버 이미지 추출 DSL (선택, RSS에 썸네일이 없을 경우)", example = "doc > meta[property=og:image] @ content") - private String coverImageDsl; + @Schema(description = "커버 이미지 추출 DSL (선택, RSS에 썸네일이 없을 경우)", example = "doc > meta[property=og:image] @ content") + private String coverImageDsl; - @NotNull(message = "Content type is required") - @Schema(description = "콘텐츠 타입", example = "NEWS") - private FeedContentType contentType; + @NotNull(message = "Content type is required") + @Schema(description = "콘텐츠 타입", example = "NEWS") + private FeedContentType contentType; - @NotNull(message = "Category is required") - @Schema(description = "카테고리", example = "TECH") - private ContentCategory category; + @NotNull(message = "Category is required") + @Schema(description = "카테고리", example = "TECH") + private ContentCategory category; + + @Schema(description = "태그 목록", example = "[\"Technology\", \"AI\", \"News\"]") + private List tags; - @Schema(description = "태그 목록", example = "[\"Technology\", \"AI\", \"News\"]") - private List tags; } diff --git a/src/main/java/com/linglevel/api/content/feed/dto/FeedResponse.java b/src/main/java/com/linglevel/api/content/feed/dto/FeedResponse.java index ba3ab38c..840913ee 100644 --- a/src/main/java/com/linglevel/api/content/feed/dto/FeedResponse.java +++ b/src/main/java/com/linglevel/api/content/feed/dto/FeedResponse.java @@ -14,45 +14,46 @@ @Schema(description = "피드 응답") public class FeedResponse { - @Schema(description = "피드 ID", example = "60d0fe4f5311236168a109ca") - private String id; + @Schema(description = "피드 ID", example = "60d0fe4f5311236168a109ca") + private String id; - @Schema(description = "콘텐츠 타입", example = "YOUTUBE") - private FeedContentType contentType; + @Schema(description = "콘텐츠 타입", example = "YOUTUBE") + private FeedContentType contentType; - @Schema(description = "제목", example = "AI의 미래: 2024년 트렌드") - private String title; + @Schema(description = "제목", example = "AI의 미래: 2024년 트렌드") + private String title; - @Schema(description = "URL", example = "https://example.com/article/ai-future") - private String url; + @Schema(description = "URL", example = "https://example.com/article/ai-future") + private String url; - @Schema(description = "썸네일 이미지 URL", example = "https://example.com/images/thumbnail.jpg") - private String thumbnailUrl; + @Schema(description = "썸네일 이미지 URL", example = "https://example.com/images/thumbnail.jpg") + private String thumbnailUrl; - @Schema(description = "작성자", example = "John Doe") - private String author; + @Schema(description = "작성자", example = "John Doe") + private String author; - @Schema(description = "설명", example = "AI 기술의 최신 동향과 2024년 전망에 대해 알아봅니다.") - private String description; + @Schema(description = "설명", example = "AI 기술의 최신 동향과 2024년 전망에 대해 알아봅니다.") + private String description; - @Schema(description = "카테고리", example = "TECH") - private ContentCategory category; + @Schema(description = "카테고리", example = "TECH") + private ContentCategory category; - @Schema(description = "태그 목록", example = "[\"AI\", \"Technology\", \"Trends\"]") - private List tags; + @Schema(description = "태그 목록", example = "[\"AI\", \"Technology\", \"Trends\"]") + private List tags; - @Schema(description = "소스 제공자", example = "bbc.com") - private String sourceProvider; + @Schema(description = "소스 제공자", example = "bbc.com") + private String sourceProvider; - @Schema(description = "발행일", example = "2024-01-15T09:30:00Z") - private Instant publishedAt; + @Schema(description = "발행일", example = "2024-01-15T09:30:00Z") + private Instant publishedAt; - @Schema(description = "조회수", example = "1520") - private Integer viewCount; + @Schema(description = "조회수", example = "1520") + private Integer viewCount; - @Schema(description = "평균 읽기 시간 (초)", example = "180.5") - private Double avgReadTimeSeconds; + @Schema(description = "평균 읽기 시간 (초)", example = "180.5") + private Double avgReadTimeSeconds; + + @Schema(description = "생성일", example = "2024-01-15T10:00:00Z") + private Instant createdAt; - @Schema(description = "생성일", example = "2024-01-15T10:00:00Z") - private Instant createdAt; } diff --git a/src/main/java/com/linglevel/api/content/feed/dto/FeedSourceResponse.java b/src/main/java/com/linglevel/api/content/feed/dto/FeedSourceResponse.java index ad91e69b..3152ed52 100644 --- a/src/main/java/com/linglevel/api/content/feed/dto/FeedSourceResponse.java +++ b/src/main/java/com/linglevel/api/content/feed/dto/FeedSourceResponse.java @@ -16,36 +16,37 @@ @Schema(description = "FeedSource 응답") public class FeedSourceResponse { - @Schema(description = "FeedSource ID") - private String id; + @Schema(description = "FeedSource ID") + private String id; - @Schema(description = "RSS Feed URL") - private String url; + @Schema(description = "RSS Feed URL") + private String url; - @Schema(description = "도메인") - private String domain; + @Schema(description = "도메인") + private String domain; - @Schema(description = "FeedSource 이름") - private String name; + @Schema(description = "FeedSource 이름") + private String name; - @Schema(description = "커버 이미지 추출 DSL") - private String coverImageDsl; + @Schema(description = "커버 이미지 추출 DSL") + private String coverImageDsl; - @Schema(description = "콘텐츠 타입") - private FeedContentType contentType; + @Schema(description = "콘텐츠 타입") + private FeedContentType contentType; - @Schema(description = "카테고리") - private ContentCategory category; + @Schema(description = "카테고리") + private ContentCategory category; - @Schema(description = "태그 목록") - private List tags; + @Schema(description = "태그 목록") + private List tags; - @Schema(description = "활성화 여부") - private Boolean isActive; + @Schema(description = "활성화 여부") + private Boolean isActive; - @Schema(description = "생성일") - private Instant createdAt; + @Schema(description = "생성일") + private Instant createdAt; + + @Schema(description = "수정일") + private Instant updatedAt; - @Schema(description = "수정일") - private Instant updatedAt; } diff --git a/src/main/java/com/linglevel/api/content/feed/dto/GetFeedsRequest.java b/src/main/java/com/linglevel/api/content/feed/dto/GetFeedsRequest.java index 3d4c91a9..08c744a0 100644 --- a/src/main/java/com/linglevel/api/content/feed/dto/GetFeedsRequest.java +++ b/src/main/java/com/linglevel/api/content/feed/dto/GetFeedsRequest.java @@ -14,27 +14,30 @@ @Setter public class GetFeedsRequest { - @Schema(description = "콘텐츠 타입 필터 (여러 개 선택 가능, null이면 전체)", example = "[\"YOUTUBE\", \"BLOG\"]") - private List contentTypes; + @Schema(description = "콘텐츠 타입 필터 (여러 개 선택 가능, null이면 전체)", example = "[\"YOUTUBE\", \"BLOG\"]") + private List contentTypes; - @Schema(description = "카테고리 필터 (단일 선택, null이면 전체)", example = "TECH") - private ContentCategory category; + @Schema(description = "카테고리 필터 (단일 선택, null이면 전체)", example = "TECH") + private ContentCategory category; - @Schema(description = "정렬 기준", example = "LATEST", defaultValue = "LATEST") - private SortOrder sortOrder = SortOrder.LATEST; + @Schema(description = "정렬 기준", example = "LATEST", defaultValue = "LATEST") + private SortOrder sortOrder = SortOrder.LATEST; - @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; + @Schema(description = "페이지 번호", example = "1", defaultValue = "1", minimum = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; - @Schema(description = "페이지 당 항목 수", example = "20", defaultValue = "20", minimum = "1", maximum = "100") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 100, message = "페이지 당 항목 수는 100 이하여야 합니다.") - private Integer limit = 20; + @Schema(description = "페이지 당 항목 수", example = "20", defaultValue = "20", minimum = "1", maximum = "100") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 100, message = "페이지 당 항목 수는 100 이하여야 합니다.") + private Integer limit = 20; + + public enum SortOrder { + + RECOMMENDED, // 추천순 (사용자 선호도 기반) + LATEST, // 최신순 + POPULAR // 인기순 (조회수 기반) + + } - public enum SortOrder { - RECOMMENDED, // 추천순 (사용자 선호도 기반) - LATEST, // 최신순 - POPULAR // 인기순 (조회수 기반) - } } diff --git a/src/main/java/com/linglevel/api/content/feed/entity/Feed.java b/src/main/java/com/linglevel/api/content/feed/entity/Feed.java index b3b16d11..de9bb9cd 100644 --- a/src/main/java/com/linglevel/api/content/feed/entity/Feed.java +++ b/src/main/java/com/linglevel/api/content/feed/entity/Feed.java @@ -17,42 +17,43 @@ @Document(collection = "feeds") public class Feed { - @Id - private String id; + @Id + private String id; - private FeedContentType contentType; + private FeedContentType contentType; - private String title; + private String title; - @Indexed(unique = true) - private String url; + @Indexed(unique = true) + private String url; - private String thumbnailUrl; + private String thumbnailUrl; - private String author; + private String author; - private String description; + private String description; - @Indexed - private ContentCategory category; + @Indexed + private ContentCategory category; - private List tags; + private List tags; - private String sourceProvider; + private String sourceProvider; - @Indexed - private Instant publishedAt; + @Indexed + private Instant publishedAt; - private Integer displayOrder; + private Integer displayOrder; - private Integer viewCount; + private Integer viewCount; - private Double avgReadTimeSeconds; + private Double avgReadTimeSeconds; - private Instant createdAt; + private Instant createdAt; - @Builder.Default - private Boolean deleted = false; + @Builder.Default + private Boolean deleted = false; + + private Instant deletedAt; - private Instant deletedAt; } diff --git a/src/main/java/com/linglevel/api/content/feed/entity/FeedContentType.java b/src/main/java/com/linglevel/api/content/feed/entity/FeedContentType.java index 5c950dfd..ddf6f9ad 100644 --- a/src/main/java/com/linglevel/api/content/feed/entity/FeedContentType.java +++ b/src/main/java/com/linglevel/api/content/feed/entity/FeedContentType.java @@ -6,11 +6,13 @@ @Getter @RequiredArgsConstructor public enum FeedContentType { - YOUTUBE("YOUTUBE", "유튜브", "유튜브 비디오 콘텐츠"), - BLOG("BLOG", "블로그", "블로그 포스트"), - NEWS("NEWS", "뉴스", "뉴스 기사"); - private final String code; - private final String displayName; - private final String description; + YOUTUBE("YOUTUBE", "유튜브", "유튜브 비디오 콘텐츠"), BLOG("BLOG", "블로그", "블로그 포스트"), NEWS("NEWS", "뉴스", "뉴스 기사"); + + private final String code; + + private final String displayName; + + private final String description; + } diff --git a/src/main/java/com/linglevel/api/content/feed/entity/FeedSource.java b/src/main/java/com/linglevel/api/content/feed/entity/FeedSource.java index 32e801dc..591d5f31 100644 --- a/src/main/java/com/linglevel/api/content/feed/entity/FeedSource.java +++ b/src/main/java/com/linglevel/api/content/feed/entity/FeedSource.java @@ -17,29 +17,30 @@ @Document(collection = "feedSources") public class FeedSource { - @Id - private String id; + @Id + private String id; - @Indexed(unique = true) - private String url; + @Indexed(unique = true) + private String url; - private String domain; + private String domain; - private String name; + private String name; - private String coverImageDsl; + private String coverImageDsl; - private FeedContentType contentType; + private FeedContentType contentType; - @Indexed - private ContentCategory category; + @Indexed + private ContentCategory category; - private List tags; + private List tags; - @Indexed - private Boolean isActive; + @Indexed + private Boolean isActive; - private Instant createdAt; + private Instant createdAt; + + private Instant updatedAt; - private Instant updatedAt; } diff --git a/src/main/java/com/linglevel/api/content/feed/exception/FeedErrorCode.java b/src/main/java/com/linglevel/api/content/feed/exception/FeedErrorCode.java index cad6d180..8545c3e3 100644 --- a/src/main/java/com/linglevel/api/content/feed/exception/FeedErrorCode.java +++ b/src/main/java/com/linglevel/api/content/feed/exception/FeedErrorCode.java @@ -7,10 +7,14 @@ @Getter @RequiredArgsConstructor public enum FeedErrorCode { - FEED_NOT_FOUND(HttpStatus.NOT_FOUND, "Feed not found."), - INVALID_SORT_ORDER(HttpStatus.BAD_REQUEST, "Invalid sort_order parameter. Must be one of: RECOMMENDED, LATEST, POPULAR."), - INVALID_CONTENT_TYPES(HttpStatus.BAD_REQUEST, "Invalid content_types parameter."); - private final HttpStatus status; - private final String message; + FEED_NOT_FOUND(HttpStatus.NOT_FOUND, "Feed not found."), + INVALID_SORT_ORDER(HttpStatus.BAD_REQUEST, + "Invalid sort_order parameter. Must be one of: RECOMMENDED, LATEST, POPULAR."), + INVALID_CONTENT_TYPES(HttpStatus.BAD_REQUEST, "Invalid content_types parameter."); + + private final HttpStatus status; + + private final String message; + } diff --git a/src/main/java/com/linglevel/api/content/feed/exception/FeedException.java b/src/main/java/com/linglevel/api/content/feed/exception/FeedException.java index c3288339..fdeefabe 100644 --- a/src/main/java/com/linglevel/api/content/feed/exception/FeedException.java +++ b/src/main/java/com/linglevel/api/content/feed/exception/FeedException.java @@ -6,10 +6,11 @@ @Getter public class FeedException extends RuntimeException { - private final HttpStatus status; + private final HttpStatus status; + + public FeedException(FeedErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } - public FeedException(FeedErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } } diff --git a/src/main/java/com/linglevel/api/content/feed/filter/FeedFilter.java b/src/main/java/com/linglevel/api/content/feed/filter/FeedFilter.java index ffbb9955..80848a28 100644 --- a/src/main/java/com/linglevel/api/content/feed/filter/FeedFilter.java +++ b/src/main/java/com/linglevel/api/content/feed/filter/FeedFilter.java @@ -5,11 +5,12 @@ public interface FeedFilter { - FeedFilterResult filter(SyndEntry entry, FeedSource feedSource); + FeedFilterResult filter(SyndEntry entry, FeedSource feedSource); - String getName(); + String getName(); + + default int getOrder() { + return 100; + } - default int getOrder() { - return 100; - } } diff --git a/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterChain.java b/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterChain.java index ad8179da..b2f1450c 100644 --- a/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterChain.java +++ b/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterChain.java @@ -10,52 +10,45 @@ import java.util.stream.Collectors; /** - * 피드 필터 체인 - * 등록된 모든 필터를 순차적으로 실행하여 피드를 검증 + * 피드 필터 체인 등록된 모든 필터를 순차적으로 실행하여 피드를 검증 */ @Component @Slf4j public class FeedFilterChain { - private final List filters; - - /** - * ObjectProvider를 사용하여 @Order에 따라 자동 정렬된 필터 주입 - * Spring이 orderedStream()을 통해 자동으로 정렬해줌 - */ - public FeedFilterChain(ObjectProvider filterProvider) { - this.filters = filterProvider.orderedStream() - .collect(Collectors.toList()); - - log.info("FeedFilterChain initialized with {} filters (auto-sorted by @Order)", filters.size()); - filters.forEach(filter -> - log.info(" - {} (order: {})", filter.getName(), filter.getOrder()) - ); - } - - /** - * 모든 필터를 순차적으로 실행 - * 하나라도 실패하면 즉시 중단하고 실패 결과 반환 (Fail-Fast) - * - * @param entry RSS Entry - * @param feedSource Feed Source - * @return 최종 필터링 결과 - */ - public FeedFilterResult executeFilters(SyndEntry entry, FeedSource feedSource) { - log.debug("Executing {} filters for entry: {}", filters.size(), entry.getLink()); - - // 이미 정렬되어 있음! - for (FeedFilter filter : filters) { - FeedFilterResult result = filter.filter(entry, feedSource); - - if (!result.isPassed()) { - log.info("Feed filtered out by {}: {} - {}", - filter.getName(), entry.getLink(), result.getReason()); - return result; - } - } - - log.debug("Feed passed all filters: {}", entry.getLink()); - return FeedFilterResult.pass(); - } + private final List filters; + + /** + * ObjectProvider를 사용하여 @Order에 따라 자동 정렬된 필터 주입 Spring이 orderedStream()을 통해 자동으로 정렬해줌 + */ + public FeedFilterChain(ObjectProvider filterProvider) { + this.filters = filterProvider.orderedStream().collect(Collectors.toList()); + + log.info("FeedFilterChain initialized with {} filters (auto-sorted by @Order)", filters.size()); + filters.forEach(filter -> log.info(" - {} (order: {})", filter.getName(), filter.getOrder())); + } + + /** + * 모든 필터를 순차적으로 실행 하나라도 실패하면 즉시 중단하고 실패 결과 반환 (Fail-Fast) + * @param entry RSS Entry + * @param feedSource Feed Source + * @return 최종 필터링 결과 + */ + public FeedFilterResult executeFilters(SyndEntry entry, FeedSource feedSource) { + log.debug("Executing {} filters for entry: {}", filters.size(), entry.getLink()); + + // 이미 정렬되어 있음! + for (FeedFilter filter : filters) { + FeedFilterResult result = filter.filter(entry, feedSource); + + if (!result.isPassed()) { + log.info("Feed filtered out by {}: {} - {}", filter.getName(), entry.getLink(), result.getReason()); + return result; + } + } + + log.debug("Feed passed all filters: {}", entry.getLink()); + return FeedFilterResult.pass(); + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterResult.java b/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterResult.java index 7136ca5c..ecd2e664 100644 --- a/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterResult.java +++ b/src/main/java/com/linglevel/api/content/feed/filter/FeedFilterResult.java @@ -12,23 +12,18 @@ @AllArgsConstructor public class FeedFilterResult { - private final boolean passed; + private final boolean passed; - private final String reason; + private final String reason; - private final String filterName; + private final String filterName; - public static FeedFilterResult pass() { - return FeedFilterResult.builder() - .passed(true) - .build(); - } + public static FeedFilterResult pass() { + return FeedFilterResult.builder().passed(true).build(); + } + + public static FeedFilterResult fail(String filterName, String reason) { + return FeedFilterResult.builder().passed(false).filterName(filterName).reason(reason).build(); + } - public static FeedFilterResult fail(String filterName, String reason) { - return FeedFilterResult.builder() - .passed(false) - .filterName(filterName) - .reason(reason) - .build(); - } } diff --git a/src/main/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilter.java b/src/main/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilter.java index d60f1407..16200a50 100644 --- a/src/main/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilter.java +++ b/src/main/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilter.java @@ -15,106 +15,103 @@ import org.springframework.stereotype.Component; /** - * 콘텐츠 크롤링 가능성 필터 - * CrawlingDsl을 사용하여 실제로 텍스트를 추출할 수 있는지 검증 + * 콘텐츠 크롤링 가능성 필터 CrawlingDsl을 사용하여 실제로 텍스트를 추출할 수 있는지 검증 */ @Component @RequiredArgsConstructor @Slf4j public class ContentCrawlabilityFilter implements FeedFilter { - private static final String FILTER_NAME = "ContentCrawlabilityFilter"; - private final CrawlingDslRepository crawlingDslRepository; - private final CrawlingService crawlingService; - - @Override - public FeedFilterResult filter(SyndEntry entry, FeedSource feedSource) { - String url = entry.getLink(); - - if (url == null) { - return FeedFilterResult.fail(FILTER_NAME, "URL is null"); - } - - String domain = extractDomain(url); - if (domain == null) { - log.warn("Failed to extract domain from URL: {}", url); - return FeedFilterResult.pass(); // 도메인 추출 실패는 패스 - } - - // YouTube는 프론트에서 검수 - if (domain.equals("youtube.com")) { - log.debug("YouTube URL detected, skipping crawlability check: {}", url); - return FeedFilterResult.pass(); - } - - if (!isCrawlable(url, domain)) { - return FeedFilterResult.fail(FILTER_NAME, - "Unable to extract content from URL: " + url); - } - - return FeedFilterResult.pass(); - } - - private boolean isCrawlable(String url, String domain) { - CrawlingDsl crawlingDsl = crawlingDslRepository.findByDomain(domain).orElse(null); - - if (crawlingDsl == null) { - return false; - } - - // 2. contentDsl이 없으면 패스 - if (crawlingDsl.getContentDsl() == null || crawlingDsl.getContentDsl().trim().isEmpty()) { - log.debug("No contentDsl defined for domain: {}", domain); - return true; - } - - // 3. 실제 크롤링 시도 - try { - log.debug("Testing crawlability for URL: {} with DSL", url); - - Document doc = Jsoup.connect(url) - .timeout(10000) - .userAgent("Mozilla/5.0") - .get(); - - CrawlerDsl crawler = new CrawlerDsl(doc); - String extractedContent = crawler.executeAsString(crawlingDsl.getContentDsl()); - - // 추출된 콘텐츠가 없거나 너무 짧으면 실패 - if (extractedContent == null || extractedContent.trim().isEmpty()) { - log.warn("No content extracted from URL: {}", url); - return false; - } - - // 콘텐츠가 너무 짧거나 길면 실패 - if (extractedContent.trim().length() < 100 || extractedContent.trim().length() > 15_000) { - log.warn("Extracted content too short ({} chars) from URL: {}", - extractedContent.trim().length(), url); - return false; - } - - log.debug("Successfully extracted {} chars from URL: {}", - extractedContent.trim().length(), url); - return true; - - } catch (Exception e) { - log.warn("Failed to test crawlability for URL: {}", url, e); - // 크롤링 실패는 soft-delete 처리 (콘텐츠를 추출할 수 없음) - return false; - } - } - - private String extractDomain(String urlString) { - return crawlingService.extractDomain(urlString); - } - - @Override - public String getName() { - return FILTER_NAME; - } - - @Override - public int getOrder() { - return 100; // 실제 HTTP 요청이 필요하므로 가장 나중에 실행 - } + private static final String FILTER_NAME = "ContentCrawlabilityFilter"; + + private final CrawlingDslRepository crawlingDslRepository; + + private final CrawlingService crawlingService; + + @Override + public FeedFilterResult filter(SyndEntry entry, FeedSource feedSource) { + String url = entry.getLink(); + + if (url == null) { + return FeedFilterResult.fail(FILTER_NAME, "URL is null"); + } + + String domain = extractDomain(url); + if (domain == null) { + log.warn("Failed to extract domain from URL: {}", url); + return FeedFilterResult.pass(); // 도메인 추출 실패는 패스 + } + + // YouTube는 프론트에서 검수 + if (domain.equals("youtube.com")) { + log.debug("YouTube URL detected, skipping crawlability check: {}", url); + return FeedFilterResult.pass(); + } + + if (!isCrawlable(url, domain)) { + return FeedFilterResult.fail(FILTER_NAME, "Unable to extract content from URL: " + url); + } + + return FeedFilterResult.pass(); + } + + private boolean isCrawlable(String url, String domain) { + CrawlingDsl crawlingDsl = crawlingDslRepository.findByDomain(domain).orElse(null); + + if (crawlingDsl == null) { + return false; + } + + // 2. contentDsl이 없으면 패스 + if (crawlingDsl.getContentDsl() == null || crawlingDsl.getContentDsl().trim().isEmpty()) { + log.debug("No contentDsl defined for domain: {}", domain); + return true; + } + + // 3. 실제 크롤링 시도 + try { + log.debug("Testing crawlability for URL: {} with DSL", url); + + Document doc = Jsoup.connect(url).timeout(10000).userAgent("Mozilla/5.0").get(); + + CrawlerDsl crawler = new CrawlerDsl(doc); + String extractedContent = crawler.executeAsString(crawlingDsl.getContentDsl()); + + // 추출된 콘텐츠가 없거나 너무 짧으면 실패 + if (extractedContent == null || extractedContent.trim().isEmpty()) { + log.warn("No content extracted from URL: {}", url); + return false; + } + + // 콘텐츠가 너무 짧거나 길면 실패 + if (extractedContent.trim().length() < 100 || extractedContent.trim().length() > 15_000) { + log.warn("Extracted content too short ({} chars) from URL: {}", extractedContent.trim().length(), url); + return false; + } + + log.debug("Successfully extracted {} chars from URL: {}", extractedContent.trim().length(), url); + return true; + + } + catch (Exception e) { + log.warn("Failed to test crawlability for URL: {}", url, e); + // 크롤링 실패는 soft-delete 처리 (콘텐츠를 추출할 수 없음) + return false; + } + } + + private String extractDomain(String urlString) { + return crawlingService.extractDomain(urlString); + } + + @Override + public String getName() { + return FILTER_NAME; + } + + @Override + public int getOrder() { + return 100; // 실제 HTTP 요청이 필요하므로 가장 나중에 실행 + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/filter/filters/LanguageFilter.java b/src/main/java/com/linglevel/api/content/feed/filter/filters/LanguageFilter.java index 867a1164..3e3ade11 100644 --- a/src/main/java/com/linglevel/api/content/feed/filter/filters/LanguageFilter.java +++ b/src/main/java/com/linglevel/api/content/feed/filter/filters/LanguageFilter.java @@ -8,87 +8,87 @@ import org.springframework.stereotype.Component; /** - * 언어 필터 - * 영어가 아닌 언어로 작성된 타이틀을 필터링 + * 언어 필터 영어가 아닌 언어로 작성된 타이틀을 필터링 */ @Component @Slf4j public class LanguageFilter implements FeedFilter { - private static final String FILTER_NAME = "LanguageFilter"; - - @Override - public FeedFilterResult filter(SyndEntry entry, FeedSource feedSource) { - String title = entry.getTitle(); - - if (title == null || title.trim().isEmpty()) { - return FeedFilterResult.fail(FILTER_NAME, "Empty title"); - } - - if (isNonEnglishTitle(title)) { - return FeedFilterResult.fail(FILTER_NAME, - "Non-English title detected: " + title.substring(0, Math.min(50, title.length()))); - } - - return FeedFilterResult.pass(); - } - - private boolean isNonEnglishTitle(String title) { - // 비영어권 문자 감지: 한글, 일본어, 중국어, 힌디어, 아랍어 등 - for (char c : title.toCharArray()) { - // 한글: 0xAC00-0xD7AF (완성형), 0x1100-0x11FF (자모) - if ((c >= 0xAC00 && c <= 0xD7AF) || (c >= 0x1100 && c <= 0x11FF)) { - log.debug("Korean character detected: {}", c); - return true; - } - - // 일본어: 히라가나(0x3040-0x309F), 카타카나(0x30A0-0x30FF) - if ((c >= 0x3040 && c <= 0x309F) || (c >= 0x30A0 && c <= 0x30FF)) { - log.debug("Japanese character detected: {}", c); - return true; - } - - // 중국어/한자: CJK Unified Ideographs (0x4E00-0x9FFF) - if (c >= 0x4E00 && c <= 0x9FFF) { - log.debug("Chinese/CJK character detected: {}", c); - return true; - } - - // 힌디어/데바나가리: 0x0900-0x097F - if (c >= 0x0900 && c <= 0x097F) { - log.debug("Hindi/Devanagari character detected: {}", c); - return true; - } - - // 아랍어: 0x0600-0x06FF - if (c >= 0x0600 && c <= 0x06FF) { - log.debug("Arabic character detected: {}", c); - return true; - } - - // 태국어: 0x0E00-0x0E7F - if (c >= 0x0E00 && c <= 0x0E7F) { - log.debug("Thai character detected: {}", c); - return true; - } - - // 키릴 문자 (러시아어 등): 0x0400-0x04FF - if (c >= 0x0400 && c <= 0x04FF) { - log.debug("Cyrillic character detected: {}", c); - return true; - } - } - - return false; - } - - @Override - public String getName() { - return FILTER_NAME; - } - - @Override - public int getOrder() { - return 20; // URL 체크 다음으로 빠름 - } + private static final String FILTER_NAME = "LanguageFilter"; + + @Override + public FeedFilterResult filter(SyndEntry entry, FeedSource feedSource) { + String title = entry.getTitle(); + + if (title == null || title.trim().isEmpty()) { + return FeedFilterResult.fail(FILTER_NAME, "Empty title"); + } + + if (isNonEnglishTitle(title)) { + return FeedFilterResult.fail(FILTER_NAME, + "Non-English title detected: " + title.substring(0, Math.min(50, title.length()))); + } + + return FeedFilterResult.pass(); + } + + private boolean isNonEnglishTitle(String title) { + // 비영어권 문자 감지: 한글, 일본어, 중국어, 힌디어, 아랍어 등 + for (char c : title.toCharArray()) { + // 한글: 0xAC00-0xD7AF (완성형), 0x1100-0x11FF (자모) + if ((c >= 0xAC00 && c <= 0xD7AF) || (c >= 0x1100 && c <= 0x11FF)) { + log.debug("Korean character detected: {}", c); + return true; + } + + // 일본어: 히라가나(0x3040-0x309F), 카타카나(0x30A0-0x30FF) + if ((c >= 0x3040 && c <= 0x309F) || (c >= 0x30A0 && c <= 0x30FF)) { + log.debug("Japanese character detected: {}", c); + return true; + } + + // 중국어/한자: CJK Unified Ideographs (0x4E00-0x9FFF) + if (c >= 0x4E00 && c <= 0x9FFF) { + log.debug("Chinese/CJK character detected: {}", c); + return true; + } + + // 힌디어/데바나가리: 0x0900-0x097F + if (c >= 0x0900 && c <= 0x097F) { + log.debug("Hindi/Devanagari character detected: {}", c); + return true; + } + + // 아랍어: 0x0600-0x06FF + if (c >= 0x0600 && c <= 0x06FF) { + log.debug("Arabic character detected: {}", c); + return true; + } + + // 태국어: 0x0E00-0x0E7F + if (c >= 0x0E00 && c <= 0x0E7F) { + log.debug("Thai character detected: {}", c); + return true; + } + + // 키릴 문자 (러시아어 등): 0x0400-0x04FF + if (c >= 0x0400 && c <= 0x04FF) { + log.debug("Cyrillic character detected: {}", c); + return true; + } + } + + return false; + } + + @Override + public String getName() { + return FILTER_NAME; + } + + @Override + public int getOrder() { + return 20; // URL 체크 다음으로 빠름 + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilter.java b/src/main/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilter.java index 0fc3c49f..4c2d84a7 100644 --- a/src/main/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilter.java +++ b/src/main/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilter.java @@ -8,52 +8,52 @@ import org.springframework.stereotype.Component; /** - * YouTube Shorts 필터 - * YouTube Shorts 영상을 필터링 + * YouTube Shorts 필터 YouTube Shorts 영상을 필터링 */ @Component @Slf4j public class YouTubeShortsFilter implements FeedFilter { - private static final String FILTER_NAME = "YouTubeShortsFilter"; + private static final String FILTER_NAME = "YouTubeShortsFilter"; - @Override - public FeedFilterResult filter(SyndEntry entry, FeedSource feedSource) { - String url = entry.getLink(); + @Override + public FeedFilterResult filter(SyndEntry entry, FeedSource feedSource) { + String url = entry.getLink(); - if (url == null) { - return FeedFilterResult.pass(); - } + if (url == null) { + return FeedFilterResult.pass(); + } - // YouTube URL이 아니면 패스 - if (!url.contains("youtube.com")) { - return FeedFilterResult.pass(); - } + // YouTube URL이 아니면 패스 + if (!url.contains("youtube.com")) { + return FeedFilterResult.pass(); + } - if (isYouTubeShorts(url, entry)) { - return FeedFilterResult.fail(FILTER_NAME, "YouTube Shorts video detected"); - } + if (isYouTubeShorts(url, entry)) { + return FeedFilterResult.fail(FILTER_NAME, "YouTube Shorts video detected"); + } - return FeedFilterResult.pass(); - } + return FeedFilterResult.pass(); + } - private boolean isYouTubeShorts(String url, SyndEntry entry) { - // YouTube Shorts는 /shorts/ 경로 사용 - if (url.contains("/shorts/")) { - log.debug("YouTube Shorts detected by URL pattern: {}", url); - return true; - } + private boolean isYouTubeShorts(String url, SyndEntry entry) { + // YouTube Shorts는 /shorts/ 경로 사용 + if (url.contains("/shorts/")) { + log.debug("YouTube Shorts detected by URL pattern: {}", url); + return true; + } - return false; - } + return false; + } - @Override - public String getName() { - return FILTER_NAME; - } + @Override + public String getName() { + return FILTER_NAME; + } + + @Override + public int getOrder() { + return 10; // 빠른 URL 체크이므로 우선순위 높음 + } - @Override - public int getOrder() { - return 10; // 빠른 URL 체크이므로 우선순위 높음 - } } diff --git a/src/main/java/com/linglevel/api/content/feed/repository/FeedRepository.java b/src/main/java/com/linglevel/api/content/feed/repository/FeedRepository.java index 11f69380..009760bf 100644 --- a/src/main/java/com/linglevel/api/content/feed/repository/FeedRepository.java +++ b/src/main/java/com/linglevel/api/content/feed/repository/FeedRepository.java @@ -9,11 +9,12 @@ public interface FeedRepository extends MongoRepository { - boolean existsByUrl(String url); + boolean existsByUrl(String url); - Optional findByUrl(String url); + Optional findByUrl(String url); - List findByDeletedFalse(); + List findByDeletedFalse(); + + Optional findByIdAndDeletedFalse(String id); - Optional findByIdAndDeletedFalse(String id); } diff --git a/src/main/java/com/linglevel/api/content/feed/repository/FeedSourceRepository.java b/src/main/java/com/linglevel/api/content/feed/repository/FeedSourceRepository.java index 73d699a6..07f21730 100644 --- a/src/main/java/com/linglevel/api/content/feed/repository/FeedSourceRepository.java +++ b/src/main/java/com/linglevel/api/content/feed/repository/FeedSourceRepository.java @@ -8,9 +8,10 @@ public interface FeedSourceRepository extends MongoRepository { - boolean existsByUrl(String url); + boolean existsByUrl(String url); - Optional findByUrl(String url); + Optional findByUrl(String url); + + List findByIsActiveTrue(); - List findByIsActiveTrue(); } diff --git a/src/main/java/com/linglevel/api/content/feed/scheduler/FeedCrawlingScheduler.java b/src/main/java/com/linglevel/api/content/feed/scheduler/FeedCrawlingScheduler.java index 531f4cd9..75e80cb9 100644 --- a/src/main/java/com/linglevel/api/content/feed/scheduler/FeedCrawlingScheduler.java +++ b/src/main/java/com/linglevel/api/content/feed/scheduler/FeedCrawlingScheduler.java @@ -15,54 +15,56 @@ @Slf4j public class FeedCrawlingScheduler { - private final FeedSourceRepository feedSourceRepository; - private final FeedCrawlingService feedCrawlingService; - - /** - * 매일 새벽 3시에 활성화된 모든 FeedSource 크롤링 (한국 시간 기준) - */ - @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") - public void scheduledCrawling() { - log.info("Scheduled crawling started at 3 AM"); - - List sources = feedSourceRepository.findByIsActiveTrue(); - - log.info("Found {} active FeedSources to crawl", sources.size()); - - int totalCrawled = 0; - for (FeedSource source : sources) { - int count = feedCrawlingService.crawlFeedSource(source); - totalCrawled += count; - } - - log.info("Scheduled crawling completed: {} feeds collected", totalCrawled); - } - - /** - * 수동 트리거: 모든 활성화된 FeedSource 즉시 크롤링 - */ - public int crawlAllSources() { - log.info("Manual crawling triggered for all sources"); - - List sources = feedSourceRepository.findByIsActiveTrue(); - int totalCrawled = 0; - - for (FeedSource source : sources) { - int count = feedCrawlingService.crawlFeedSource(source); - totalCrawled += count; - } - - log.info("Manual crawling completed: {} feeds collected", totalCrawled); - return totalCrawled; - } - - /** - * 수동 트리거: 특정 FeedSource만 크롤링 - */ - public int crawlSingleSource(String feedSourceId) { - FeedSource source = feedSourceRepository.findById(feedSourceId) - .orElseThrow(() -> new IllegalArgumentException("FeedSource not found: " + feedSourceId)); - - return feedCrawlingService.crawlFeedSource(source); - } + private final FeedSourceRepository feedSourceRepository; + + private final FeedCrawlingService feedCrawlingService; + + /** + * 매일 새벽 3시에 활성화된 모든 FeedSource 크롤링 (한국 시간 기준) + */ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void scheduledCrawling() { + log.info("Scheduled crawling started at 3 AM"); + + List sources = feedSourceRepository.findByIsActiveTrue(); + + log.info("Found {} active FeedSources to crawl", sources.size()); + + int totalCrawled = 0; + for (FeedSource source : sources) { + int count = feedCrawlingService.crawlFeedSource(source); + totalCrawled += count; + } + + log.info("Scheduled crawling completed: {} feeds collected", totalCrawled); + } + + /** + * 수동 트리거: 모든 활성화된 FeedSource 즉시 크롤링 + */ + public int crawlAllSources() { + log.info("Manual crawling triggered for all sources"); + + List sources = feedSourceRepository.findByIsActiveTrue(); + int totalCrawled = 0; + + for (FeedSource source : sources) { + int count = feedCrawlingService.crawlFeedSource(source); + totalCrawled += count; + } + + log.info("Manual crawling completed: {} feeds collected", totalCrawled); + return totalCrawled; + } + + /** + * 수동 트리거: 특정 FeedSource만 크롤링 + */ + public int crawlSingleSource(String feedSourceId) { + FeedSource source = feedSourceRepository.findById(feedSourceId) + .orElseThrow(() -> new IllegalArgumentException("FeedSource not found: " + feedSourceId)); + + return feedCrawlingService.crawlFeedSource(source); + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/service/FeedCrawlingService.java b/src/main/java/com/linglevel/api/content/feed/service/FeedCrawlingService.java index dc0ac751..b3f8fa62 100644 --- a/src/main/java/com/linglevel/api/content/feed/service/FeedCrawlingService.java +++ b/src/main/java/com/linglevel/api/content/feed/service/FeedCrawlingService.java @@ -28,351 +28,359 @@ @Slf4j public class FeedCrawlingService { - private final FeedRepository feedRepository; - private final FeedSourceRepository feedSourceRepository; - private final FeedFilterChain feedFilterChain; - - /** - * RSS FeedSource를 파싱하여 Feed 생성/업데이트 - * - * @param feedSource RSS Feed 소스 - * @return 수집된 Feed 개수 - */ - public int crawlFeedSource(FeedSource feedSource) { - try { - log.info("Crawling RSS FeedSource: {} ({})", feedSource.getName(), feedSource.getUrl()); - - - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed rssFeed = input.build(new XmlReader(new URL(feedSource.getUrl()))); - - List entries = rssFeed.getEntries(); - log.info("Found {} entries in RSS feed: {}", entries.size(), feedSource.getName()); - - int crawledCount = 0; - int filteredCount = 0; - - for (SyndEntry entry : entries) { - try { - String feedUrl = entry.getLink(); - - if (feedRepository.existsByUrl(feedUrl)) { - log.debug("Feed already exists: {}", feedUrl); - continue; - } - - // 필터링 체크 - FeedFilterResult filterResult = feedFilterChain.executeFilters(entry, feedSource); - - Feed feed = convertEntryToFeed(entry, feedSource); - if (feed != null) { - if (!filterResult.isPassed()) { - feed.setDeleted(true); - feed.setDeletedAt(Instant.now()); - filteredCount++; - } - - feedRepository.save(feed); - crawledCount++; - } - } catch (Exception e) { - log.error("Failed to convert RSS entry to Feed: {}", entry.getLink(), e); - } - } - - feedSource.setUpdatedAt(Instant.now()); - feedSourceRepository.save(feedSource); - - log.info("RSS crawling completed: {} feeds collected, {} filtered (soft-deleted) from {}", - crawledCount, filteredCount, feedSource.getName()); - return crawledCount; - - } catch (Exception e) { - log.error("Failed to crawl RSS FeedSource: {}", feedSource.getName(), e); - return 0; - } - } - - /** - * RSS Entry를 Feed 엔티티로 변환 - */ - private Feed convertEntryToFeed(SyndEntry entry, FeedSource feedSource) { - try { - String title = entry.getTitle(); - String url = entry.getLink(); - - if (title == null || title.trim().isEmpty() || url == null) { - log.warn("RSS entry missing title or link"); - return null; - } - - // 썸네일 URL 추출 (RSS -> DSL fallback) - String thumbnailUrl = extractThumbnailUrl(entry, url, feedSource); - - // 발행일 추출 - Instant publishedAt = extractPublishedDate(entry); - - // 필수 필드 검증: thumbnailUrl이 null이면 null 반환 (소프트 딜리트 처리됨) - if (thumbnailUrl == null) { - log.warn("RSS entry missing thumbnailUrl: {}", url); - return null; - } - - // 작성자 추출 - String author = entry.getAuthor(); - - // 설명 추출 - String description = extractDescription(entry); - - return Feed.builder() - .contentType(feedSource.getContentType()) - .title(title.trim()) - .url(url) - .thumbnailUrl(thumbnailUrl) - .author(author != null && !author.trim().isEmpty() ? author.trim() : null) - .description(description) - .category(feedSource.getCategory()) - .tags(feedSource.getTags()) - .sourceProvider(extractDomainFromUrl(url)) - .publishedAt(publishedAt != null ? publishedAt : Instant.now()) - .displayOrder(0) - .viewCount(0) - .avgReadTimeSeconds(0.0) - .createdAt(Instant.now()) - .build(); - - } catch (Exception e) { - log.error("Failed to convert entry: {}", entry.getLink(), e); - return null; - } - } - - /** - * RSS Entry에서 썸네일 URL 추출 (RSS -> DSL fallback) - * 1. RSS enclosures에서 이미지 찾기 - * 2. Media 모듈에서 썸네일 찾기 (YouTube 등) - * 3. 실패하면 coverImageDsl을 사용하여 article 페이지에서 크롤링 - */ - private String extractThumbnailUrl(SyndEntry entry, String articleUrl, FeedSource feedSource) { - // 1. Enclosures에서 이미지 찾기 - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - String rssThumbnail = entry.getEnclosures().stream() - .filter(enc -> enc.getType() != null && enc.getType().startsWith("image/")) - .map(enc -> enc.getUrl()) - .findFirst() - .orElse(null); - - if (rssThumbnail != null) { - log.debug("Thumbnail found in RSS enclosures: {}", rssThumbnail); - return rssThumbnail; - } - } - - // 2. Media 모듈에서 썸네일 찾기 (YouTube의 media:thumbnail) - try { - if (entry.getForeignMarkup() != null) { - log.debug("ForeignMarkup size: {}", entry.getForeignMarkup().size()); - for (Object element : entry.getForeignMarkup()) { - if (element instanceof org.jdom2.Element) { - org.jdom2.Element elem = (org.jdom2.Element) element; - log.debug("Element name: {}, namespace: {}, namespaceURI: {}", - elem.getName(), elem.getNamespace(), elem.getNamespaceURI()); - - // media:group > media:thumbnail 태그 찾기 - if ("group".equals(elem.getName()) && elem.getNamespaceURI() != null && - (elem.getNamespaceURI().contains("media") || elem.getNamespaceURI().contains("mrss"))) { - - log.debug("Found media:group, searching for thumbnail..."); - - // 방법 1: 같은 namespace로 찾기 - org.jdom2.Element thumbnail = elem.getChild("thumbnail", elem.getNamespace()); - if (thumbnail != null && thumbnail.getAttributeValue("url") != null) { - String thumbnailUrl = thumbnail.getAttributeValue("url"); - log.info("Thumbnail found in media module: {}", thumbnailUrl); - return thumbnailUrl; - } - - // 방법 2: 모든 자식 요소 탐색 - for (Object child : elem.getChildren()) { - if (child instanceof org.jdom2.Element) { - org.jdom2.Element childElem = (org.jdom2.Element) child; - if ("thumbnail".equals(childElem.getName())) { - String thumbnailUrl = childElem.getAttributeValue("url"); - if (thumbnailUrl != null) { - log.info("Thumbnail found via children search: {}", thumbnailUrl); - return thumbnailUrl; - } - } - } - } - - log.debug("media:group found but no thumbnail child"); - } - } - } - } - } catch (Exception e) { - log.warn("Failed to extract thumbnail from media module", e); - } - - // 2. RSS에 썸네일이 없고, coverImageDsl이 설정되어 있으면 크롤링 - if (feedSource.getCoverImageDsl() != null && !feedSource.getCoverImageDsl().trim().isEmpty()) { - try { - log.debug("RSS thumbnail not found, crawling article: {}", articleUrl); - Document doc = Jsoup.connect(articleUrl) - .timeout(10000) - .userAgent("Mozilla/5.0") - .get(); - - CrawlerDsl crawler = new CrawlerDsl(doc); - String crawledThumbnail = crawler.executeAsString(feedSource.getCoverImageDsl()); - - if (crawledThumbnail != null && !crawledThumbnail.trim().isEmpty()) { - log.debug("Thumbnail found via DSL: {}", crawledThumbnail); - return crawledThumbnail.trim(); - } - } catch (Exception e) { - log.warn("Failed to crawl thumbnail from article: {}", articleUrl, e); - } - } - - return null; - } - - /** - * RSS Entry에서 설명 추출 - * 1. RSS description 필드 (일반 RSS) - * 2. media:description (YouTube 등) - * 3. contents 필드 - */ - private String extractDescription(SyndEntry entry) { - // 1. description 필드에서 추출 (일반 RSS: BBC, Medium 등) - if (entry.getDescription() != null && entry.getDescription().getValue() != null) { - String description = entry.getDescription().getValue(); - - // Medium의 경우 medium-feed-snippet 클래스의 내용만 추출 - if (description.contains("medium-feed-snippet")) { - int snippetStart = description.indexOf("

"); - if (snippetStart != -1) { - snippetStart += "

".length(); - int snippetEnd = description.indexOf("

", snippetStart); - if (snippetEnd != -1) { - description = description.substring(snippetStart, snippetEnd); - } - } - } - - // HTML 태그 제거 - description = description.replaceAll("<[^>]*>", "").trim(); - // CDATA, 엔티티 정리 - description = description.replaceAll("", "").trim(); - // HTML 엔티티 디코딩 (… -> …) - description = decodeHtmlEntities(description); - // 개행문자 띄어쓰기로 변경 - description = description.replaceAll("[\\n\\r]", " ").trim(); - - if (!description.isEmpty()) { - // 너무 긴 경우 일부만 추출 (500자 제한) - return description.length() > 500 ? description.substring(0, 500) + "..." : description; - } - } - - // 2. media:description에서 추출 (YouTube 등) - try { - if (entry.getForeignMarkup() != null) { - for (Object element : entry.getForeignMarkup()) { - if (element instanceof org.jdom2.Element) { - org.jdom2.Element elem = (org.jdom2.Element) element; - - // media:group > media:description 태그 찾기 - if ("group".equals(elem.getName()) && elem.getNamespaceURI() != null && - (elem.getNamespaceURI().contains("media") || elem.getNamespaceURI().contains("mrss"))) { - - // 같은 namespace로 찾기 - org.jdom2.Element descriptionElem = elem.getChild("description", elem.getNamespace()); - if (descriptionElem != null && descriptionElem.getText() != null) { - String description = descriptionElem.getText().trim(); - if (!description.isEmpty()) { - log.debug("Description found in media module: {}", description.substring(0, Math.min(50, description.length()))); - description = description.replaceAll("[\\n\\r]", " ").trim(); - // 너무 긴 경우 일부만 추출 (500자 제한) - return description.length() > 500 ? description.substring(0, 500) + "..." : description; - } - } - - // 모든 자식 요소 탐색 - for (Object child : elem.getChildren()) { - if (child instanceof org.jdom2.Element) { - org.jdom2.Element childElem = (org.jdom2.Element) child; - if ("description".equals(childElem.getName())) { - String description = childElem.getText(); - if (description != null && !description.trim().isEmpty()) { - description = description.trim(); - log.debug("Description found via children search: {}", description.substring(0, Math.min(50, description.length()))); - description = description.replaceAll("[\\n\\r]", " ").trim(); - // 너무 긴 경우 일부만 추출 (500자 제한) - return description.length() > 500 ? description.substring(0, 500) + "..." : description; - } - } - } - } - } - } - } - } - } catch (Exception e) { - log.warn("Failed to extract description from media module", e); - } - - // 3. contents에서 추출 - if (entry.getContents() != null && !entry.getContents().isEmpty()) { - String content = entry.getContents().get(0).getValue(); - if (content != null) { - // HTML 태그 제거 - content = content.replaceAll("<[^>]*>", "").trim(); - if (!content.isEmpty()) { - content = content.replaceAll("[\\n\\r]", " ").trim(); - // 너무 긴 경우 일부만 추출 (500자 제한) - return content.length() > 500 ? content.substring(0, 500) + "..." : content; - } - } - } - - return null; - } - - /** - * RSS Entry에서 발행일 추출 - */ - private Instant extractPublishedDate(SyndEntry entry) { - Date publishedDate = entry.getPublishedDate(); - if (publishedDate != null) { - return publishedDate.toInstant(); - } - - Date updatedDate = entry.getUpdatedDate(); - if (updatedDate != null) { - return updatedDate.toInstant(); - } - - return null; - } - - /** - * URL에서 도메인 추출 - */ - private String extractDomainFromUrl(String urlString) { - try { - URL url = new URL(urlString); - return url.getHost(); - } catch (Exception e) { - log.warn("Failed to extract domain from URL: {}", urlString, e); - return null; - } - } - - private String decodeHtmlEntities(String text) { - return HtmlUtils.htmlUnescape(text); - } + private final FeedRepository feedRepository; + + private final FeedSourceRepository feedSourceRepository; + + private final FeedFilterChain feedFilterChain; + + /** + * RSS FeedSource를 파싱하여 Feed 생성/업데이트 + * @param feedSource RSS Feed 소스 + * @return 수집된 Feed 개수 + */ + public int crawlFeedSource(FeedSource feedSource) { + try { + log.info("Crawling RSS FeedSource: {} ({})", feedSource.getName(), feedSource.getUrl()); + + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed rssFeed = input.build(new XmlReader(new URL(feedSource.getUrl()))); + + List entries = rssFeed.getEntries(); + log.info("Found {} entries in RSS feed: {}", entries.size(), feedSource.getName()); + + int crawledCount = 0; + int filteredCount = 0; + + for (SyndEntry entry : entries) { + try { + String feedUrl = entry.getLink(); + + if (feedRepository.existsByUrl(feedUrl)) { + log.debug("Feed already exists: {}", feedUrl); + continue; + } + + // 필터링 체크 + FeedFilterResult filterResult = feedFilterChain.executeFilters(entry, feedSource); + + Feed feed = convertEntryToFeed(entry, feedSource); + if (feed != null) { + if (!filterResult.isPassed()) { + feed.setDeleted(true); + feed.setDeletedAt(Instant.now()); + filteredCount++; + } + + feedRepository.save(feed); + crawledCount++; + } + } + catch (Exception e) { + log.error("Failed to convert RSS entry to Feed: {}", entry.getLink(), e); + } + } + + feedSource.setUpdatedAt(Instant.now()); + feedSourceRepository.save(feedSource); + + log.info("RSS crawling completed: {} feeds collected, {} filtered (soft-deleted) from {}", crawledCount, + filteredCount, feedSource.getName()); + return crawledCount; + + } + catch (Exception e) { + log.error("Failed to crawl RSS FeedSource: {}", feedSource.getName(), e); + return 0; + } + } + + /** + * RSS Entry를 Feed 엔티티로 변환 + */ + private Feed convertEntryToFeed(SyndEntry entry, FeedSource feedSource) { + try { + String title = entry.getTitle(); + String url = entry.getLink(); + + if (title == null || title.trim().isEmpty() || url == null) { + log.warn("RSS entry missing title or link"); + return null; + } + + // 썸네일 URL 추출 (RSS -> DSL fallback) + String thumbnailUrl = extractThumbnailUrl(entry, url, feedSource); + + // 발행일 추출 + Instant publishedAt = extractPublishedDate(entry); + + // 필수 필드 검증: thumbnailUrl이 null이면 null 반환 (소프트 딜리트 처리됨) + if (thumbnailUrl == null) { + log.warn("RSS entry missing thumbnailUrl: {}", url); + return null; + } + + // 작성자 추출 + String author = entry.getAuthor(); + + // 설명 추출 + String description = extractDescription(entry); + + return Feed.builder() + .contentType(feedSource.getContentType()) + .title(title.trim()) + .url(url) + .thumbnailUrl(thumbnailUrl) + .author(author != null && !author.trim().isEmpty() ? author.trim() : null) + .description(description) + .category(feedSource.getCategory()) + .tags(feedSource.getTags()) + .sourceProvider(extractDomainFromUrl(url)) + .publishedAt(publishedAt != null ? publishedAt : Instant.now()) + .displayOrder(0) + .viewCount(0) + .avgReadTimeSeconds(0.0) + .createdAt(Instant.now()) + .build(); + + } + catch (Exception e) { + log.error("Failed to convert entry: {}", entry.getLink(), e); + return null; + } + } + + /** + * RSS Entry에서 썸네일 URL 추출 (RSS -> DSL fallback) 1. RSS enclosures에서 이미지 찾기 2. Media + * 모듈에서 썸네일 찾기 (YouTube 등) 3. 실패하면 coverImageDsl을 사용하여 article 페이지에서 크롤링 + */ + private String extractThumbnailUrl(SyndEntry entry, String articleUrl, FeedSource feedSource) { + // 1. Enclosures에서 이미지 찾기 + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + String rssThumbnail = entry.getEnclosures() + .stream() + .filter(enc -> enc.getType() != null && enc.getType().startsWith("image/")) + .map(enc -> enc.getUrl()) + .findFirst() + .orElse(null); + + if (rssThumbnail != null) { + log.debug("Thumbnail found in RSS enclosures: {}", rssThumbnail); + return rssThumbnail; + } + } + + // 2. Media 모듈에서 썸네일 찾기 (YouTube의 media:thumbnail) + try { + if (entry.getForeignMarkup() != null) { + log.debug("ForeignMarkup size: {}", entry.getForeignMarkup().size()); + for (Object element : entry.getForeignMarkup()) { + if (element instanceof org.jdom2.Element) { + org.jdom2.Element elem = (org.jdom2.Element) element; + log.debug("Element name: {}, namespace: {}, namespaceURI: {}", elem.getName(), + elem.getNamespace(), elem.getNamespaceURI()); + + // media:group > media:thumbnail 태그 찾기 + if ("group".equals(elem.getName()) && elem.getNamespaceURI() != null + && (elem.getNamespaceURI().contains("media") + || elem.getNamespaceURI().contains("mrss"))) { + + log.debug("Found media:group, searching for thumbnail..."); + + // 방법 1: 같은 namespace로 찾기 + org.jdom2.Element thumbnail = elem.getChild("thumbnail", elem.getNamespace()); + if (thumbnail != null && thumbnail.getAttributeValue("url") != null) { + String thumbnailUrl = thumbnail.getAttributeValue("url"); + log.info("Thumbnail found in media module: {}", thumbnailUrl); + return thumbnailUrl; + } + + // 방법 2: 모든 자식 요소 탐색 + for (Object child : elem.getChildren()) { + if (child instanceof org.jdom2.Element) { + org.jdom2.Element childElem = (org.jdom2.Element) child; + if ("thumbnail".equals(childElem.getName())) { + String thumbnailUrl = childElem.getAttributeValue("url"); + if (thumbnailUrl != null) { + log.info("Thumbnail found via children search: {}", thumbnailUrl); + return thumbnailUrl; + } + } + } + } + + log.debug("media:group found but no thumbnail child"); + } + } + } + } + } + catch (Exception e) { + log.warn("Failed to extract thumbnail from media module", e); + } + + // 2. RSS에 썸네일이 없고, coverImageDsl이 설정되어 있으면 크롤링 + if (feedSource.getCoverImageDsl() != null && !feedSource.getCoverImageDsl().trim().isEmpty()) { + try { + log.debug("RSS thumbnail not found, crawling article: {}", articleUrl); + Document doc = Jsoup.connect(articleUrl).timeout(10000).userAgent("Mozilla/5.0").get(); + + CrawlerDsl crawler = new CrawlerDsl(doc); + String crawledThumbnail = crawler.executeAsString(feedSource.getCoverImageDsl()); + + if (crawledThumbnail != null && !crawledThumbnail.trim().isEmpty()) { + log.debug("Thumbnail found via DSL: {}", crawledThumbnail); + return crawledThumbnail.trim(); + } + } + catch (Exception e) { + log.warn("Failed to crawl thumbnail from article: {}", articleUrl, e); + } + } + + return null; + } + + /** + * RSS Entry에서 설명 추출 1. RSS description 필드 (일반 RSS) 2. media:description (YouTube 등) + * 3. contents 필드 + */ + private String extractDescription(SyndEntry entry) { + // 1. description 필드에서 추출 (일반 RSS: BBC, Medium 등) + if (entry.getDescription() != null && entry.getDescription().getValue() != null) { + String description = entry.getDescription().getValue(); + + // Medium의 경우 medium-feed-snippet 클래스의 내용만 추출 + if (description.contains("medium-feed-snippet")) { + int snippetStart = description.indexOf("

"); + if (snippetStart != -1) { + snippetStart += "

".length(); + int snippetEnd = description.indexOf("

", snippetStart); + if (snippetEnd != -1) { + description = description.substring(snippetStart, snippetEnd); + } + } + } + + // HTML 태그 제거 + description = description.replaceAll("<[^>]*>", "").trim(); + // CDATA, 엔티티 정리 + description = description.replaceAll("", "").trim(); + // HTML 엔티티 디코딩 (… -> …) + description = decodeHtmlEntities(description); + // 개행문자 띄어쓰기로 변경 + description = description.replaceAll("[\\n\\r]", " ").trim(); + + if (!description.isEmpty()) { + // 너무 긴 경우 일부만 추출 (500자 제한) + return description.length() > 500 ? description.substring(0, 500) + "..." : description; + } + } + + // 2. media:description에서 추출 (YouTube 등) + try { + if (entry.getForeignMarkup() != null) { + for (Object element : entry.getForeignMarkup()) { + if (element instanceof org.jdom2.Element) { + org.jdom2.Element elem = (org.jdom2.Element) element; + + // media:group > media:description 태그 찾기 + if ("group".equals(elem.getName()) && elem.getNamespaceURI() != null + && (elem.getNamespaceURI().contains("media") + || elem.getNamespaceURI().contains("mrss"))) { + + // 같은 namespace로 찾기 + org.jdom2.Element descriptionElem = elem.getChild("description", elem.getNamespace()); + if (descriptionElem != null && descriptionElem.getText() != null) { + String description = descriptionElem.getText().trim(); + if (!description.isEmpty()) { + log.debug("Description found in media module: {}", + description.substring(0, Math.min(50, description.length()))); + description = description.replaceAll("[\\n\\r]", " ").trim(); + // 너무 긴 경우 일부만 추출 (500자 제한) + return description.length() > 500 ? description.substring(0, 500) + "..." + : description; + } + } + + // 모든 자식 요소 탐색 + for (Object child : elem.getChildren()) { + if (child instanceof org.jdom2.Element) { + org.jdom2.Element childElem = (org.jdom2.Element) child; + if ("description".equals(childElem.getName())) { + String description = childElem.getText(); + if (description != null && !description.trim().isEmpty()) { + description = description.trim(); + log.debug("Description found via children search: {}", + description.substring(0, Math.min(50, description.length()))); + description = description.replaceAll("[\\n\\r]", " ").trim(); + // 너무 긴 경우 일부만 추출 (500자 제한) + return description.length() > 500 ? description.substring(0, 500) + "..." + : description; + } + } + } + } + } + } + } + } + } + catch (Exception e) { + log.warn("Failed to extract description from media module", e); + } + + // 3. contents에서 추출 + if (entry.getContents() != null && !entry.getContents().isEmpty()) { + String content = entry.getContents().get(0).getValue(); + if (content != null) { + // HTML 태그 제거 + content = content.replaceAll("<[^>]*>", "").trim(); + if (!content.isEmpty()) { + content = content.replaceAll("[\\n\\r]", " ").trim(); + // 너무 긴 경우 일부만 추출 (500자 제한) + return content.length() > 500 ? content.substring(0, 500) + "..." : content; + } + } + } + + return null; + } + + /** + * RSS Entry에서 발행일 추출 + */ + private Instant extractPublishedDate(SyndEntry entry) { + Date publishedDate = entry.getPublishedDate(); + if (publishedDate != null) { + return publishedDate.toInstant(); + } + + Date updatedDate = entry.getUpdatedDate(); + if (updatedDate != null) { + return updatedDate.toInstant(); + } + + return null; + } + + /** + * URL에서 도메인 추출 + */ + private String extractDomainFromUrl(String urlString) { + try { + URL url = new URL(urlString); + return url.getHost(); + } + catch (Exception e) { + log.warn("Failed to extract domain from URL: {}", urlString, e); + return null; + } + } + + private String decodeHtmlEntities(String text) { + return HtmlUtils.htmlUnescape(text); + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/service/FeedRecommendationService.java b/src/main/java/com/linglevel/api/content/feed/service/FeedRecommendationService.java index 7b90ebe4..ca99e4bb 100644 --- a/src/main/java/com/linglevel/api/content/feed/service/FeedRecommendationService.java +++ b/src/main/java/com/linglevel/api/content/feed/service/FeedRecommendationService.java @@ -15,144 +15,149 @@ /** * Feed 추천 서비스 * - * 사용자의 카테고리 선호도를 기반으로 Feed 추천 점수를 계산합니다. - * - 카테고리 매칭: 80% - * - 품질 (평균 읽기 시간): 10% - * - 신선도 (발행 시간): 10% + * 사용자의 카테고리 선호도를 기반으로 Feed 추천 점수를 계산합니다. - 카테고리 매칭: 80% - 품질 (평균 읽기 시간): 10% - 신선도 (발행 + * 시간): 10% */ @Service @RequiredArgsConstructor @Slf4j public class FeedRecommendationService { - private final UserCategoryPreferenceRepository userCategoryPreferenceRepository; - - // 가중치 상수 - private static final double CATEGORY_WEIGHT = 0.8; // 80% - private static final double QUALITY_WEIGHT = 0.1; // 10% - private static final double FRESHNESS_WEIGHT = 0.1; // 10% - - /** - * Feed 목록을 추천 점수순으로 정렬 - */ - public List sortByRecommendation(List feeds, String userId) { - // 사용자 선호도 조회 - Optional preferenceOpt = userCategoryPreferenceRepository.findByUserId(userId); - - if (preferenceOpt.isEmpty() || preferenceOpt.get().getCategoryScores() == null) { - // 선호도 없으면 최신순 정렬 - return feeds.stream() - .sorted(Comparator.comparing( - Feed::getCreatedAt, - Comparator.nullsLast(Comparator.reverseOrder()) - )) - .collect(Collectors.toList()); - } - - UserCategoryPreference preference = preferenceOpt.get(); - Map categoryScores = preference.getCategoryScores(); - - // Feed별 추천 점수 계산 및 정렬 - return feeds.stream() - .map(feed -> new FeedWithScore(feed, calculateScore(feed, categoryScores))) - .sorted(Comparator.comparingDouble(FeedWithScore::getScore).reversed()) - .map(FeedWithScore::getFeed) - .collect(Collectors.toList()); - } - - /** - * Feed의 추천 점수 계산 - */ - private double calculateScore(Feed feed, Map categoryScores) { - double categoryScore = calculateCategoryScore(feed, categoryScores); - double qualityScore = calculateQualityScore(feed); - double freshnessScore = calculateFreshnessScore(feed); - - return (categoryScore * CATEGORY_WEIGHT) - + (qualityScore * QUALITY_WEIGHT) - + (freshnessScore * FRESHNESS_WEIGHT); - } - - /** - * 카테고리 매칭 점수 계산 (0.0 ~ 1.0) - */ - private double calculateCategoryScore(Feed feed, Map categoryScores) { - if (feed.getCategory() == null) { - return 0.0; // 카테고리 없으면 0점 - } - - Double score = categoryScores.get(feed.getCategory()); - return score != null ? score : 0.0; - } - - /** - * 품질 점수 계산 (0.0 ~ 1.0) - * avgReadTimeSeconds를 기반으로 계산 - */ - private double calculateQualityScore(Feed feed) { - if (feed.getAvgReadTimeSeconds() == null || feed.getAvgReadTimeSeconds() <= 0) { - return 0.5; // 중립적 점수 - } - - double avgReadTime = feed.getAvgReadTimeSeconds(); - - // 읽기 시간 구간별 점수 - if (avgReadTime < 15) { - return 0.2; // 15초 미만: 낮은 품질 - } else if (avgReadTime < 30) { // 30초 - return 0.5; // 짧게 읽힘 - } else if (avgReadTime < 60) { // 1분 - return 0.8; // 적절한 길이 - } else { - return 1.0; // 1분 이상: 높은 품질 (깊이 있는 콘텐츠) - } - } - - /** - * 신선도 점수 계산 (0.0 ~ 1.0) - * 최근 발행일수록 높은 점수 - */ - private double calculateFreshnessScore(Feed feed) { - if (feed.getPublishedAt() == null) { - return 0.5; // 중립적 점수 - } - - Instant now = Instant.now(); - Instant publishedAt = feed.getPublishedAt(); - long daysSincePublished = java.time.Duration.between(publishedAt, now).toDays(); - - // 발행 기간별 점수 - if (daysSincePublished < 1) { - return 1.0; // 1일 이내: 매우 신선 - } else if (daysSincePublished < 7) { - return 0.8; // 1주일 이내: 신선 - } else if (daysSincePublished < 30) { - return 0.5; // 1개월 이내: 보통 - } else if (daysSincePublished < 90) { - return 0.3; // 3개월 이내: 오래됨 - } else { - return 0.1; // 3개월 이상: 매우 오래됨 - } - } - - /** - * Feed와 점수를 함께 저장하는 내부 클래스 - */ - private static class FeedWithScore { - private final Feed feed; - private final double score; - - public FeedWithScore(Feed feed, double score) { - this.feed = feed; - this.score = score; - } - - public Feed getFeed() { - return feed; - } - - public double getScore() { - return score; - } - } + private final UserCategoryPreferenceRepository userCategoryPreferenceRepository; + + // 가중치 상수 + private static final double CATEGORY_WEIGHT = 0.8; // 80% + + private static final double QUALITY_WEIGHT = 0.1; // 10% + + private static final double FRESHNESS_WEIGHT = 0.1; // 10% + + /** + * Feed 목록을 추천 점수순으로 정렬 + */ + public List sortByRecommendation(List feeds, String userId) { + // 사용자 선호도 조회 + Optional preferenceOpt = userCategoryPreferenceRepository.findByUserId(userId); + + if (preferenceOpt.isEmpty() || preferenceOpt.get().getCategoryScores() == null) { + // 선호도 없으면 최신순 정렬 + return feeds.stream() + .sorted(Comparator.comparing(Feed::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .collect(Collectors.toList()); + } + + UserCategoryPreference preference = preferenceOpt.get(); + Map categoryScores = preference.getCategoryScores(); + + // Feed별 추천 점수 계산 및 정렬 + return feeds.stream() + .map(feed -> new FeedWithScore(feed, calculateScore(feed, categoryScores))) + .sorted(Comparator.comparingDouble(FeedWithScore::getScore).reversed()) + .map(FeedWithScore::getFeed) + .collect(Collectors.toList()); + } + + /** + * Feed의 추천 점수 계산 + */ + private double calculateScore(Feed feed, Map categoryScores) { + double categoryScore = calculateCategoryScore(feed, categoryScores); + double qualityScore = calculateQualityScore(feed); + double freshnessScore = calculateFreshnessScore(feed); + + return (categoryScore * CATEGORY_WEIGHT) + (qualityScore * QUALITY_WEIGHT) + + (freshnessScore * FRESHNESS_WEIGHT); + } + + /** + * 카테고리 매칭 점수 계산 (0.0 ~ 1.0) + */ + private double calculateCategoryScore(Feed feed, Map categoryScores) { + if (feed.getCategory() == null) { + return 0.0; // 카테고리 없으면 0점 + } + + Double score = categoryScores.get(feed.getCategory()); + return score != null ? score : 0.0; + } + + /** + * 품질 점수 계산 (0.0 ~ 1.0) avgReadTimeSeconds를 기반으로 계산 + */ + private double calculateQualityScore(Feed feed) { + if (feed.getAvgReadTimeSeconds() == null || feed.getAvgReadTimeSeconds() <= 0) { + return 0.5; // 중립적 점수 + } + + double avgReadTime = feed.getAvgReadTimeSeconds(); + + // 읽기 시간 구간별 점수 + if (avgReadTime < 15) { + return 0.2; // 15초 미만: 낮은 품질 + } + else if (avgReadTime < 30) { // 30초 + return 0.5; // 짧게 읽힘 + } + else if (avgReadTime < 60) { // 1분 + return 0.8; // 적절한 길이 + } + else { + return 1.0; // 1분 이상: 높은 품질 (깊이 있는 콘텐츠) + } + } + + /** + * 신선도 점수 계산 (0.0 ~ 1.0) 최근 발행일수록 높은 점수 + */ + private double calculateFreshnessScore(Feed feed) { + if (feed.getPublishedAt() == null) { + return 0.5; // 중립적 점수 + } + + Instant now = Instant.now(); + Instant publishedAt = feed.getPublishedAt(); + long daysSincePublished = java.time.Duration.between(publishedAt, now).toDays(); + + // 발행 기간별 점수 + if (daysSincePublished < 1) { + return 1.0; // 1일 이내: 매우 신선 + } + else if (daysSincePublished < 7) { + return 0.8; // 1주일 이내: 신선 + } + else if (daysSincePublished < 30) { + return 0.5; // 1개월 이내: 보통 + } + else if (daysSincePublished < 90) { + return 0.3; // 3개월 이내: 오래됨 + } + else { + return 0.1; // 3개월 이상: 매우 오래됨 + } + } + + /** + * Feed와 점수를 함께 저장하는 내부 클래스 + */ + private static class FeedWithScore { + + private final Feed feed; + + private final double score; + + public FeedWithScore(Feed feed, double score) { + this.feed = feed; + this.score = score; + } + + public Feed getFeed() { + return feed; + } + + public double getScore() { + return score; + } + + } + } diff --git a/src/main/java/com/linglevel/api/content/feed/service/FeedService.java b/src/main/java/com/linglevel/api/content/feed/service/FeedService.java index 25961f8f..4da601b6 100644 --- a/src/main/java/com/linglevel/api/content/feed/service/FeedService.java +++ b/src/main/java/com/linglevel/api/content/feed/service/FeedService.java @@ -18,103 +18,103 @@ @Slf4j public class FeedService { - private final FeedRepository feedRepository; - private final FeedRecommendationService feedRecommendationService; - - public PageResponse getFeeds(GetFeedsRequest request, String userId) { - List allFeeds = feedRepository.findByDeletedFalse(); - - if (request.getContentTypes() != null && !request.getContentTypes().isEmpty()) { - allFeeds = allFeeds.stream() - .filter(feed -> request.getContentTypes().contains(feed.getContentType())) - .collect(java.util.stream.Collectors.toList()); - } - - if (request.getCategory() != null) { - allFeeds = allFeeds.stream() - .filter(feed -> request.getCategory().equals(feed.getCategory())) - .collect(java.util.stream.Collectors.toList()); - } - - List sortedFeeds = sortFeeds(allFeeds, request.getSortOrder(), userId); - - int totalCount = sortedFeeds.size(); - int totalPages = (int) Math.ceil((double) totalCount / request.getLimit()); - int offset = (request.getPage() - 1) * request.getLimit(); - - List pagedFeeds = sortedFeeds.stream() - .skip(offset) - .limit(request.getLimit()) - .collect(java.util.stream.Collectors.toList()); - - List feedResponses = pagedFeeds.stream() - .map(this::mapToResponse) - .collect(java.util.stream.Collectors.toList()); - - return PageResponse.builder() - .data(feedResponses) - .totalCount(totalCount) - .totalPages(totalPages) - .currentPage(request.getPage()) - .hasNext(request.getPage() < totalPages) - .hasPrevious(request.getPage() > 1) - .build(); - } - - /** - * Feed 정렬 - */ - private List sortFeeds(List feeds, GetFeedsRequest.SortOrder sortOrder, String userId) { - switch (sortOrder) { - case POPULAR: - return feeds.stream() - .sorted((f1, f2) -> { - int v1 = f1.getViewCount() != null ? f1.getViewCount() : 0; - int v2 = f2.getViewCount() != null ? f2.getViewCount() : 0; - return Integer.compare(v2, v1); - }) - .collect(java.util.stream.Collectors.toList()); - - case RECOMMENDED: - return feedRecommendationService.sortByRecommendation(feeds, userId); - - case LATEST: - default: - // 최신순: publishedAt 내림차순 - return feeds.stream() - .sorted((f1, f2) -> { - if (f1.getPublishedAt() == null) return 1; - if (f2.getPublishedAt() == null) return -1; - return f2.getPublishedAt().compareTo(f1.getPublishedAt()); - }) - .collect(java.util.stream.Collectors.toList()); - } - } - - public FeedResponse getFeed(String feedId, String userId) { - Feed feed = feedRepository.findByIdAndDeletedFalse(feedId) - .orElseThrow(() -> new FeedException(FeedErrorCode.FEED_NOT_FOUND)); - feed.setViewCount((feed.getViewCount() != null ? feed.getViewCount() : 0) + 1); - feedRepository.save(feed); - return mapToResponse(feed); - } - - private FeedResponse mapToResponse(Feed feed) { - FeedResponse response = new FeedResponse(); - response.setId(feed.getId()); - response.setContentType(feed.getContentType()); - response.setTitle(feed.getTitle()); - response.setUrl(feed.getUrl()); - response.setThumbnailUrl(feed.getThumbnailUrl()); - response.setAuthor(feed.getAuthor()); - response.setDescription(feed.getDescription()); - response.setCategory(feed.getCategory()); - response.setTags(feed.getTags()); - response.setSourceProvider(feed.getSourceProvider()); - response.setPublishedAt(feed.getPublishedAt()); - response.setViewCount(feed.getViewCount()); - response.setAvgReadTimeSeconds(feed.getAvgReadTimeSeconds()); - response.setCreatedAt(feed.getCreatedAt()); - return response; - } + private final FeedRepository feedRepository; + + private final FeedRecommendationService feedRecommendationService; + + public PageResponse getFeeds(GetFeedsRequest request, String userId) { + List allFeeds = feedRepository.findByDeletedFalse(); + + if (request.getContentTypes() != null && !request.getContentTypes().isEmpty()) { + allFeeds = allFeeds.stream() + .filter(feed -> request.getContentTypes().contains(feed.getContentType())) + .collect(java.util.stream.Collectors.toList()); + } + + if (request.getCategory() != null) { + allFeeds = allFeeds.stream() + .filter(feed -> request.getCategory().equals(feed.getCategory())) + .collect(java.util.stream.Collectors.toList()); + } + + List sortedFeeds = sortFeeds(allFeeds, request.getSortOrder(), userId); + + int totalCount = sortedFeeds.size(); + int totalPages = (int) Math.ceil((double) totalCount / request.getLimit()); + int offset = (request.getPage() - 1) * request.getLimit(); + + List pagedFeeds = sortedFeeds.stream() + .skip(offset) + .limit(request.getLimit()) + .collect(java.util.stream.Collectors.toList()); + + List feedResponses = pagedFeeds.stream() + .map(this::mapToResponse) + .collect(java.util.stream.Collectors.toList()); + + return PageResponse.builder() + .data(feedResponses) + .totalCount(totalCount) + .totalPages(totalPages) + .currentPage(request.getPage()) + .hasNext(request.getPage() < totalPages) + .hasPrevious(request.getPage() > 1) + .build(); + } + + /** + * Feed 정렬 + */ + private List sortFeeds(List feeds, GetFeedsRequest.SortOrder sortOrder, String userId) { + switch (sortOrder) { + case POPULAR: + return feeds.stream().sorted((f1, f2) -> { + int v1 = f1.getViewCount() != null ? f1.getViewCount() : 0; + int v2 = f2.getViewCount() != null ? f2.getViewCount() : 0; + return Integer.compare(v2, v1); + }).collect(java.util.stream.Collectors.toList()); + + case RECOMMENDED: + return feedRecommendationService.sortByRecommendation(feeds, userId); + + case LATEST: + default: + // 최신순: publishedAt 내림차순 + return feeds.stream().sorted((f1, f2) -> { + if (f1.getPublishedAt() == null) + return 1; + if (f2.getPublishedAt() == null) + return -1; + return f2.getPublishedAt().compareTo(f1.getPublishedAt()); + }).collect(java.util.stream.Collectors.toList()); + } + } + + public FeedResponse getFeed(String feedId, String userId) { + Feed feed = feedRepository.findByIdAndDeletedFalse(feedId) + .orElseThrow(() -> new FeedException(FeedErrorCode.FEED_NOT_FOUND)); + feed.setViewCount((feed.getViewCount() != null ? feed.getViewCount() : 0) + 1); + feedRepository.save(feed); + return mapToResponse(feed); + } + + private FeedResponse mapToResponse(Feed feed) { + FeedResponse response = new FeedResponse(); + response.setId(feed.getId()); + response.setContentType(feed.getContentType()); + response.setTitle(feed.getTitle()); + response.setUrl(feed.getUrl()); + response.setThumbnailUrl(feed.getThumbnailUrl()); + response.setAuthor(feed.getAuthor()); + response.setDescription(feed.getDescription()); + response.setCategory(feed.getCategory()); + response.setTags(feed.getTags()); + response.setSourceProvider(feed.getSourceProvider()); + response.setPublishedAt(feed.getPublishedAt()); + response.setViewCount(feed.getViewCount()); + response.setAvgReadTimeSeconds(feed.getAvgReadTimeSeconds()); + response.setCreatedAt(feed.getCreatedAt()); + return response; + } + } diff --git a/src/main/java/com/linglevel/api/content/recommendation/entity/ContentAccessLog.java b/src/main/java/com/linglevel/api/content/recommendation/entity/ContentAccessLog.java index 841cfea8..738f5301 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/entity/ContentAccessLog.java +++ b/src/main/java/com/linglevel/api/content/recommendation/entity/ContentAccessLog.java @@ -16,25 +16,24 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "contentAccessLogs") -@CompoundIndexes({ - @CompoundIndex(name = "user_accessed_idx", def = "{'userId': 1, 'accessedAt': -1}"), - @CompoundIndex(name = "user_category_idx", def = "{'userId': 1, 'category': 1}"), - @CompoundIndex(name = "user_content_type_idx", def = "{'userId': 1, 'contentType': 1}") -}) +@CompoundIndexes({ @CompoundIndex(name = "user_accessed_idx", def = "{'userId': 1, 'accessedAt': -1}"), + @CompoundIndex(name = "user_category_idx", def = "{'userId': 1, 'category': 1}"), + @CompoundIndex(name = "user_content_type_idx", def = "{'userId': 1, 'contentType': 1}") }) public class ContentAccessLog { - @Id - private String id; + @Id + private String id; - private String userId; + private String userId; - private String contentId; + private String contentId; - private ContentType contentType; + private ContentType contentType; - private ContentCategory category; + private ContentCategory category; - private Integer readTimeSeconds; + private Integer readTimeSeconds; + + private Instant accessedAt; - private Instant accessedAt; } diff --git a/src/main/java/com/linglevel/api/content/recommendation/entity/UserCategoryPreference.java b/src/main/java/com/linglevel/api/content/recommendation/entity/UserCategoryPreference.java index 7ea65d4b..988a362d 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/entity/UserCategoryPreference.java +++ b/src/main/java/com/linglevel/api/content/recommendation/entity/UserCategoryPreference.java @@ -17,22 +17,23 @@ @Document(collection = "userCategoryPreferences") public class UserCategoryPreference { - @Id - private String id; + @Id + private String id; - @Indexed(unique = true) - private String userId; + @Indexed(unique = true) + private String userId; - @Indexed - private ContentCategory primaryCategory; + @Indexed + private ContentCategory primaryCategory; - private Map categoryScores; + private Map categoryScores; - private Map rawAccessCounts; + private Map rawAccessCounts; - private Map tagScores; + private Map tagScores; - private Integer totalAccessCount; + private Integer totalAccessCount; + + private Instant lastUpdatedAt; - private Instant lastUpdatedAt; } diff --git a/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEvent.java b/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEvent.java index 426da41a..ce007536 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEvent.java +++ b/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEvent.java @@ -8,18 +8,24 @@ @Getter public class ContentAccessEvent extends ApplicationEvent { - private final String userId; - private final String contentId; - private final ContentType contentType; - private final ContentCategory category; // nullable - private final Integer readTimeSeconds; // nullable - - public ContentAccessEvent(Object source, String userId, String contentId, ContentType contentType, ContentCategory category, Integer readTimeSeconds) { - super(source); - this.userId = userId; - this.contentId = contentId; - this.contentType = contentType; - this.category = category; - this.readTimeSeconds = readTimeSeconds; - } + private final String userId; + + private final String contentId; + + private final ContentType contentType; + + private final ContentCategory category; // nullable + + private final Integer readTimeSeconds; // nullable + + public ContentAccessEvent(Object source, String userId, String contentId, ContentType contentType, + ContentCategory category, Integer readTimeSeconds) { + super(source); + this.userId = userId; + this.contentId = contentId; + this.contentType = contentType; + this.category = category; + this.readTimeSeconds = readTimeSeconds; + } + } diff --git a/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEventListener.java b/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEventListener.java index 795a49ae..4cad1c46 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEventListener.java +++ b/src/main/java/com/linglevel/api/content/recommendation/event/ContentAccessEventListener.java @@ -21,96 +21,107 @@ @Slf4j public class ContentAccessEventListener { - private final ContentAccessLogRepository contentAccessLogRepository; - private final FeedRepository feedRepository; - private final CustomContentRepository customContentRepository; - - @Async - @EventListener - public void handleContentAccessEvent(ContentAccessEvent event) { - try { - // CustomContent인 경우 Feed에서 category 가져오기 - var category = event.getCategory(); - if (event.getContentType() == ContentType.CUSTOM && category == null) { - category = getCategoryFromFeed(event.getContentId()); - } - - // 1. ContentAccessLog 저장 (readTimeSeconds 포함) - ContentAccessLog accessLog = ContentAccessLog.builder() - .userId(event.getUserId()) - .contentId(event.getContentId()) - .contentType(event.getContentType()) - .category(category) - .readTimeSeconds(event.getReadTimeSeconds()) - .accessedAt(Instant.now()) - .build(); - - contentAccessLogRepository.save(accessLog); - log.debug("Content access logged: userId={}, contentId={}, contentType={}, category={}, readTimeSeconds={}", - event.getUserId(), event.getContentId(), event.getContentType(), - category, event.getReadTimeSeconds()); - - // 2. CustomContent인 경우 Feed의 avgReadTimeSeconds 업데이트 - if (event.getContentType() == ContentType.CUSTOM && event.getReadTimeSeconds() != null) { - updateFeedAvgReadTime(event.getContentId(), event.getReadTimeSeconds()); - } - } catch (Exception e) { - log.error("Failed to log content access", e); - } - } - - /** - * CustomContent의 originUrl로 Feed의 category를 가져옴 - */ - private ContentCategory getCategoryFromFeed(String customContentId) { - try { - CustomContent customContent = customContentRepository.findById(customContentId).orElse(null); - if (customContent == null || customContent.getOriginUrl() == null || customContent.getOriginUrl().isEmpty()) { - return null; - } - - Feed feed = feedRepository.findByUrl(customContent.getOriginUrl()).orElse(null); - if (feed == null) { - return null; - } - - return feed.getCategory(); - } catch (Exception e) { - log.error("Failed to get category from Feed for customContentId: {}", customContentId, e); - return null; - } - } - - private void updateFeedAvgReadTime(String customContentId, Integer readTimeSeconds) { - try { - // CustomContent 조회하여 originUrl 가져오기 - CustomContent customContent = customContentRepository.findById(customContentId).orElse(null); - if (customContent == null || customContent.getOriginUrl() == null || customContent.getOriginUrl().isEmpty()) { - return; - } - - // Feed 조회 (originUrl 기반) - Feed feed = feedRepository.findByUrl(customContent.getOriginUrl()).orElse(null); - if (feed == null) { - return; - } - - // 평균 읽기 시간 갱신 (이동 평균) - Integer currentViewCount = feed.getViewCount() != null ? feed.getViewCount() : 0; - Double currentAvg = feed.getAvgReadTimeSeconds() != null ? feed.getAvgReadTimeSeconds() : 0.0; - - if (currentViewCount <= 1) { - feed.setAvgReadTimeSeconds(readTimeSeconds.doubleValue()); - } else { - // 이동 평균 계산: (기존평균 × (현재횟수-1) + 새값) / 현재횟수 - Double newAvg = ((currentAvg * (currentViewCount - 1)) + readTimeSeconds) / currentViewCount.doubleValue(); - feed.setAvgReadTimeSeconds(newAvg); - } - - feedRepository.save(feed); - log.debug("Updated Feed avgReadTimeSeconds: feedId={}, newAvg={}", feed.getId(), feed.getAvgReadTimeSeconds()); - } catch (Exception e) { - log.error("Failed to update Feed avgReadTimeSeconds for customContentId: {}", customContentId, e); - } - } + private final ContentAccessLogRepository contentAccessLogRepository; + + private final FeedRepository feedRepository; + + private final CustomContentRepository customContentRepository; + + @Async + @EventListener + public void handleContentAccessEvent(ContentAccessEvent event) { + try { + // CustomContent인 경우 Feed에서 category 가져오기 + var category = event.getCategory(); + if (event.getContentType() == ContentType.CUSTOM && category == null) { + category = getCategoryFromFeed(event.getContentId()); + } + + // 1. ContentAccessLog 저장 (readTimeSeconds 포함) + ContentAccessLog accessLog = ContentAccessLog.builder() + .userId(event.getUserId()) + .contentId(event.getContentId()) + .contentType(event.getContentType()) + .category(category) + .readTimeSeconds(event.getReadTimeSeconds()) + .accessedAt(Instant.now()) + .build(); + + contentAccessLogRepository.save(accessLog); + log.debug("Content access logged: userId={}, contentId={}, contentType={}, category={}, readTimeSeconds={}", + event.getUserId(), event.getContentId(), event.getContentType(), category, + event.getReadTimeSeconds()); + + // 2. CustomContent인 경우 Feed의 avgReadTimeSeconds 업데이트 + if (event.getContentType() == ContentType.CUSTOM && event.getReadTimeSeconds() != null) { + updateFeedAvgReadTime(event.getContentId(), event.getReadTimeSeconds()); + } + } + catch (Exception e) { + log.error("Failed to log content access", e); + } + } + + /** + * CustomContent의 originUrl로 Feed의 category를 가져옴 + */ + private ContentCategory getCategoryFromFeed(String customContentId) { + try { + CustomContent customContent = customContentRepository.findById(customContentId).orElse(null); + if (customContent == null || customContent.getOriginUrl() == null + || customContent.getOriginUrl().isEmpty()) { + return null; + } + + Feed feed = feedRepository.findByUrl(customContent.getOriginUrl()).orElse(null); + if (feed == null) { + return null; + } + + return feed.getCategory(); + } + catch (Exception e) { + log.error("Failed to get category from Feed for customContentId: {}", customContentId, e); + return null; + } + } + + private void updateFeedAvgReadTime(String customContentId, Integer readTimeSeconds) { + try { + // CustomContent 조회하여 originUrl 가져오기 + CustomContent customContent = customContentRepository.findById(customContentId).orElse(null); + if (customContent == null || customContent.getOriginUrl() == null + || customContent.getOriginUrl().isEmpty()) { + return; + } + + // Feed 조회 (originUrl 기반) + Feed feed = feedRepository.findByUrl(customContent.getOriginUrl()).orElse(null); + if (feed == null) { + return; + } + + // 평균 읽기 시간 갱신 (이동 평균) + Integer currentViewCount = feed.getViewCount() != null ? feed.getViewCount() : 0; + Double currentAvg = feed.getAvgReadTimeSeconds() != null ? feed.getAvgReadTimeSeconds() : 0.0; + + if (currentViewCount <= 1) { + feed.setAvgReadTimeSeconds(readTimeSeconds.doubleValue()); + } + else { + // 이동 평균 계산: (기존평균 × (현재횟수-1) + 새값) / 현재횟수 + Double newAvg = ((currentAvg * (currentViewCount - 1)) + readTimeSeconds) + / currentViewCount.doubleValue(); + feed.setAvgReadTimeSeconds(newAvg); + } + + feedRepository.save(feed); + log.debug("Updated Feed avgReadTimeSeconds: feedId={}, newAvg={}", feed.getId(), + feed.getAvgReadTimeSeconds()); + } + catch (Exception e) { + log.error("Failed to update Feed avgReadTimeSeconds for customContentId: {}", customContentId, e); + } + } + } diff --git a/src/main/java/com/linglevel/api/content/recommendation/repository/ContentAccessLogRepository.java b/src/main/java/com/linglevel/api/content/recommendation/repository/ContentAccessLogRepository.java index a3b5ba2b..4259583c 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/repository/ContentAccessLogRepository.java +++ b/src/main/java/com/linglevel/api/content/recommendation/repository/ContentAccessLogRepository.java @@ -8,6 +8,8 @@ public interface ContentAccessLogRepository extends MongoRepository { - List findByAccessedAtAfter(Instant after); - void deleteByAccessedAtBefore(Instant before); + List findByAccessedAtAfter(Instant after); + + void deleteByAccessedAtBefore(Instant before); + } diff --git a/src/main/java/com/linglevel/api/content/recommendation/repository/UserCategoryPreferenceRepository.java b/src/main/java/com/linglevel/api/content/recommendation/repository/UserCategoryPreferenceRepository.java index 40a10c39..05475bb3 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/repository/UserCategoryPreferenceRepository.java +++ b/src/main/java/com/linglevel/api/content/recommendation/repository/UserCategoryPreferenceRepository.java @@ -7,5 +7,6 @@ public interface UserCategoryPreferenceRepository extends MongoRepository { - Optional findByUserId(String userId); + Optional findByUserId(String userId); + } diff --git a/src/main/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationScheduler.java b/src/main/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationScheduler.java index 56bd9f6c..52c705aa 100644 --- a/src/main/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationScheduler.java +++ b/src/main/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationScheduler.java @@ -21,160 +21,171 @@ @Slf4j public class UserPreferenceAggregationScheduler { - private final ContentAccessLogRepository contentAccessLogRepository; - private final UserCategoryPreferenceRepository userCategoryPreferenceRepository; - - @Scheduled(cron = "0 0 3 * * *") - public void aggregateUserPreferences() { - Instant startTime = Instant.now(); - log.info("Starting user preference aggregation batch job at {}", startTime); - - int successCount = 0; - int failureCount = 0; - - try { - Instant cutoffDate = Instant.now().minus(90, java.time.temporal.ChronoUnit.DAYS); - List recentLogs = contentAccessLogRepository.findByAccessedAtAfter(cutoffDate); - - if (recentLogs.isEmpty()) { - log.info("No recent access logs found. Skipping aggregation."); - return; - } - - log.info("Processing {} access logs from {} users", - recentLogs.size(), - recentLogs.stream().map(ContentAccessLog::getUserId).distinct().count()); - - Map> logsByUser = recentLogs.stream() - .collect(Collectors.groupingBy(ContentAccessLog::getUserId)); - - for (Entry> entry : logsByUser.entrySet()) { - String userId = entry.getKey(); - List userLogs = entry.getValue(); - - try { - updateUserPreference(userId, userLogs); - successCount++; - } catch (Exception e) { - failureCount++; - log.error("Failed to update preference for user: {}, logs count: {}", - userId, userLogs.size(), e); - } - } - - Instant endTime = Instant.now(); - long durationMillis = java.time.Duration.between(startTime, endTime).toMillis(); - - log.info("User preference aggregation completed. Success: {}, Failure: {}, Duration: {}ms", - successCount, failureCount, durationMillis); - - // 90일 이전 로그 삭제 - deleteOldLogs(cutoffDate); - - } catch (Exception e) { - log.error("Critical error during user preference aggregation. Success: {}, Failure: {}", - successCount, failureCount, e); - } - } - - private void updateUserPreference(String userId, List logs) { - // 시간 감쇠 가중치 계산 - Instant now = Instant.now(); - Instant sevenDaysAgo = now.minus(7, java.time.temporal.ChronoUnit.DAYS); - Instant thirtyDaysAgo = now.minus(30, java.time.temporal.ChronoUnit.DAYS); - - Map categoryScores = new HashMap<>(); - Map rawCounts = new HashMap<>(); - - for (ContentAccessLog log : logs) { - ContentCategory category = log.getCategory(); - if (category == null) continue; - - // 원본 카운트 증가 - rawCounts.merge(category, 1, Integer::sum); - - // 시간 감쇠 가중치 적용 - double timeDecayWeight = calculateTimeDecayWeight(log.getAccessedAt(), sevenDaysAgo, thirtyDaysAgo); - - // 읽기 시간 가중치 적용 - double readTimeWeight = calculateReadTimeWeight(log.getReadTimeSeconds()); - - // 최종 가중치 = 시간 감쇠 × 읽기 시간 - double finalWeight = timeDecayWeight * readTimeWeight; - - categoryScores.merge(category, finalWeight, Double::sum); - } - - double totalScore = categoryScores.values().stream().mapToDouble(Double::doubleValue).sum(); - if (totalScore > 0) { - categoryScores.replaceAll((category, score) -> score / totalScore); - } - - // 1순위 카테고리 계산 (점수가 가장 높은 카테고리) - ContentCategory primaryCategory = null; - if (!categoryScores.isEmpty()) { - primaryCategory = categoryScores.entrySet().stream() - .max(Entry.comparingByValue()) - .map(Entry::getKey) - .orElse(null); - } - - UserCategoryPreference preference = userCategoryPreferenceRepository.findByUserId(userId) - .orElse(UserCategoryPreference.builder() - .userId(userId) - .build()); - - preference.setPrimaryCategory(primaryCategory); - preference.setCategoryScores(categoryScores); - preference.setRawAccessCounts(rawCounts); - preference.setTotalAccessCount(logs.size()); - preference.setLastUpdatedAt(now); - - userCategoryPreferenceRepository.save(preference); - - // 로그: category 없는 콘텐츠만 본 경우 명시 - if (primaryCategory != null) { - log.debug("Updated preference for user {}: primaryCategory={}, totalAccess={}", - userId, primaryCategory, logs.size()); - } else { - log.debug("Updated preference for user {} without category (Book/CustomContent only), totalAccess={}", - userId, logs.size()); - } - } - - private double calculateTimeDecayWeight(Instant accessedAt, Instant sevenDaysAgo, Instant thirtyDaysAgo) { - if (accessedAt.isAfter(sevenDaysAgo)) { - return 1.0; // 최근 7일: 최대 가중치 - } else if (accessedAt.isAfter(thirtyDaysAgo)) { - return 0.5; // 7~30일: 중간 가중치 - } else { - return 0.2; // 30일 이전: 낮은 가중치 - } - } - - /** - * 읽기 시간 기반 가중치 계산 - * 읽기 시간이 길수록 콘텐츠에 대한 진정한 관심도가 높다고 판단 - */ - private double calculateReadTimeWeight(Integer readTimeSeconds) { - if (readTimeSeconds == null || readTimeSeconds < 30) { - return 0.1; - } else if (readTimeSeconds < 60) { // 1분 - return 0.5; - } else if (readTimeSeconds < 90) { // 1.5분 - return 0.8; - } else { - return 1.0; - } - } - - private void deleteOldLogs(Instant cutoffDate) { - try { - Instant deleteBeforeDate = cutoffDate.minus(30, java.time.temporal.ChronoUnit.DAYS); // 120일 이전 로그 삭제 - contentAccessLogRepository.deleteByAccessedAtBefore(deleteBeforeDate); - log.info("Deleted old access logs before {}", deleteBeforeDate); - } catch (Exception e) { - log.error("Error deleting old logs", e); - } - } + private final ContentAccessLogRepository contentAccessLogRepository; + + private final UserCategoryPreferenceRepository userCategoryPreferenceRepository; + + @Scheduled(cron = "0 0 3 * * *") + public void aggregateUserPreferences() { + Instant startTime = Instant.now(); + log.info("Starting user preference aggregation batch job at {}", startTime); + + int successCount = 0; + int failureCount = 0; + + try { + Instant cutoffDate = Instant.now().minus(90, java.time.temporal.ChronoUnit.DAYS); + List recentLogs = contentAccessLogRepository.findByAccessedAtAfter(cutoffDate); + + if (recentLogs.isEmpty()) { + log.info("No recent access logs found. Skipping aggregation."); + return; + } + + log.info("Processing {} access logs from {} users", recentLogs.size(), + recentLogs.stream().map(ContentAccessLog::getUserId).distinct().count()); + + Map> logsByUser = recentLogs.stream() + .collect(Collectors.groupingBy(ContentAccessLog::getUserId)); + + for (Entry> entry : logsByUser.entrySet()) { + String userId = entry.getKey(); + List userLogs = entry.getValue(); + + try { + updateUserPreference(userId, userLogs); + successCount++; + } + catch (Exception e) { + failureCount++; + log.error("Failed to update preference for user: {}, logs count: {}", userId, userLogs.size(), e); + } + } + + Instant endTime = Instant.now(); + long durationMillis = java.time.Duration.between(startTime, endTime).toMillis(); + + log.info("User preference aggregation completed. Success: {}, Failure: {}, Duration: {}ms", successCount, + failureCount, durationMillis); + + // 90일 이전 로그 삭제 + deleteOldLogs(cutoffDate); + + } + catch (Exception e) { + log.error("Critical error during user preference aggregation. Success: {}, Failure: {}", successCount, + failureCount, e); + } + } + + private void updateUserPreference(String userId, List logs) { + // 시간 감쇠 가중치 계산 + Instant now = Instant.now(); + Instant sevenDaysAgo = now.minus(7, java.time.temporal.ChronoUnit.DAYS); + Instant thirtyDaysAgo = now.minus(30, java.time.temporal.ChronoUnit.DAYS); + + Map categoryScores = new HashMap<>(); + Map rawCounts = new HashMap<>(); + + for (ContentAccessLog log : logs) { + ContentCategory category = log.getCategory(); + if (category == null) + continue; + + // 원본 카운트 증가 + rawCounts.merge(category, 1, Integer::sum); + + // 시간 감쇠 가중치 적용 + double timeDecayWeight = calculateTimeDecayWeight(log.getAccessedAt(), sevenDaysAgo, thirtyDaysAgo); + + // 읽기 시간 가중치 적용 + double readTimeWeight = calculateReadTimeWeight(log.getReadTimeSeconds()); + + // 최종 가중치 = 시간 감쇠 × 읽기 시간 + double finalWeight = timeDecayWeight * readTimeWeight; + + categoryScores.merge(category, finalWeight, Double::sum); + } + + double totalScore = categoryScores.values().stream().mapToDouble(Double::doubleValue).sum(); + if (totalScore > 0) { + categoryScores.replaceAll((category, score) -> score / totalScore); + } + + // 1순위 카테고리 계산 (점수가 가장 높은 카테고리) + ContentCategory primaryCategory = null; + if (!categoryScores.isEmpty()) { + primaryCategory = categoryScores.entrySet() + .stream() + .max(Entry.comparingByValue()) + .map(Entry::getKey) + .orElse(null); + } + + UserCategoryPreference preference = userCategoryPreferenceRepository.findByUserId(userId) + .orElse(UserCategoryPreference.builder().userId(userId).build()); + + preference.setPrimaryCategory(primaryCategory); + preference.setCategoryScores(categoryScores); + preference.setRawAccessCounts(rawCounts); + preference.setTotalAccessCount(logs.size()); + preference.setLastUpdatedAt(now); + + userCategoryPreferenceRepository.save(preference); + + // 로그: category 없는 콘텐츠만 본 경우 명시 + if (primaryCategory != null) { + log.debug("Updated preference for user {}: primaryCategory={}, totalAccess={}", userId, primaryCategory, + logs.size()); + } + else { + log.debug("Updated preference for user {} without category (Book/CustomContent only), totalAccess={}", + userId, logs.size()); + } + } + + private double calculateTimeDecayWeight(Instant accessedAt, Instant sevenDaysAgo, Instant thirtyDaysAgo) { + if (accessedAt.isAfter(sevenDaysAgo)) { + return 1.0; // 최근 7일: 최대 가중치 + } + else if (accessedAt.isAfter(thirtyDaysAgo)) { + return 0.5; // 7~30일: 중간 가중치 + } + else { + return 0.2; // 30일 이전: 낮은 가중치 + } + } + + /** + * 읽기 시간 기반 가중치 계산 읽기 시간이 길수록 콘텐츠에 대한 진정한 관심도가 높다고 판단 + */ + private double calculateReadTimeWeight(Integer readTimeSeconds) { + if (readTimeSeconds == null || readTimeSeconds < 30) { + return 0.1; + } + else if (readTimeSeconds < 60) { // 1분 + return 0.5; + } + else if (readTimeSeconds < 90) { // 1.5분 + return 0.8; + } + else { + return 1.0; + } + } + + private void deleteOldLogs(Instant cutoffDate) { + try { + Instant deleteBeforeDate = cutoffDate.minus(30, java.time.temporal.ChronoUnit.DAYS); // 120일 + // 이전 + // 로그 + // 삭제 + contentAccessLogRepository.deleteByAccessedAtBefore(deleteBeforeDate); + log.info("Deleted old access logs before {}", deleteBeforeDate); + } + catch (Exception e) { + log.error("Error deleting old logs", e); + } + } + } diff --git a/src/main/java/com/linglevel/api/crawling/controller/CrawlingController.java b/src/main/java/com/linglevel/api/crawling/controller/CrawlingController.java index e76da752..0e716841 100644 --- a/src/main/java/com/linglevel/api/crawling/controller/CrawlingController.java +++ b/src/main/java/com/linglevel/api/crawling/controller/CrawlingController.java @@ -31,52 +31,45 @@ @Tag(name = "Crawling DSL", description = "크롤링 DSL 관리 관련 API") public class CrawlingController { - private final CrawlingService crawlingService; + private final CrawlingService crawlingService; - @Operation(summary = "DSL 조회 및 URL 유효성 검증", - description = "클라이언트가 현재 접속 중인 URL을 전달하면, 해당 URL의 도메인이 존재하는 경우 제목/본문 추출 DSL을 반환합니다. 또한 URL이 크롤링 가능한지 유효성 검증도 수행할 수 있습니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (URL 누락 또는 형식 오류)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/crawling-dsl/lookup") - public ResponseEntity lookupDsl( - @Parameter(description = "크롤링할 전체 URL", required = true, example = "https://www.coupang.com/vp/products/123456") - @RequestParam String url, - @Parameter(description = "DSL 반환 없이 유효성만 검증", example = "false") - @RequestParam(defaultValue = "false") boolean validate_only) { - - DslLookupResponse response = crawlingService.lookupDsl(url, validate_only); - return ResponseEntity.ok(response); - } + @Operation(summary = "DSL 조회 및 URL 유효성 검증", + description = "클라이언트가 현재 접속 중인 URL을 전달하면, 해당 URL의 도메인이 존재하는 경우 제목/본문 추출 DSL을 반환합니다. 또한 URL이 크롤링 가능한지 유효성 검증도 수행할 수 있습니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (URL 누락 또는 형식 오류)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/crawling-dsl/lookup") + public ResponseEntity lookupDsl( + @Parameter(description = "크롤링할 전체 URL", required = true, + example = "https://www.coupang.com/vp/products/123456") @RequestParam String url, + @Parameter(description = "DSL 반환 없이 유효성만 검증", + example = "false") @RequestParam(defaultValue = "false") boolean validate_only) { - @Operation(summary = "등록된 도메인 목록 조회", description = "시스템에 등록된 모든 도메인 목록을 조회합니다. contentTypes 파라미터로 특정 콘텐츠 타입들만 필터링할 수 있습니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) - }) - @GetMapping("/crawling-dsl/domains") - public ResponseEntity> getDomains( - @Parameter(description = "조회할 페이지 번호", example = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - @RequestParam(defaultValue = "1") int page, - @Parameter(description = "페이지 당 항목 수 (최대 200)", example = "10") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - @RequestParam(defaultValue = "10") int limit, - @Parameter(description = "필터링할 콘텐츠 타입 목록 (선택 사항)", example = "[\"BLOG\", \"NEWS\"]") - @RequestParam(required = false) List contentTypes) { + DslLookupResponse response = crawlingService.lookupDsl(url, validate_only); + return ResponseEntity.ok(response); + } + @Operation(summary = "등록된 도메인 목록 조회", + description = "시스템에 등록된 모든 도메인 목록을 조회합니다. contentTypes 파라미터로 특정 콘텐츠 타입들만 필터링할 수 있습니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) }) + @GetMapping("/crawling-dsl/domains") + public ResponseEntity> getDomains( + @Parameter(description = "조회할 페이지 번호", example = "1") @Min(value = 1, + message = "페이지 번호는 1 이상이어야 합니다.") @RequestParam(defaultValue = "1") int page, + @Parameter(description = "페이지 당 항목 수 (최대 200)", example = "10") @Min(value = 1, + message = "페이지 당 항목 수는 1 이상이어야 합니다.") @Max(value = 200, + message = "페이지 당 항목 수는 200 이하여야 합니다.") @RequestParam(defaultValue = "10") int limit, + @Parameter(description = "필터링할 콘텐츠 타입 목록 (선택 사항)", example = "[\"BLOG\", \"NEWS\"]") @RequestParam( + required = false) List contentTypes) { - Page domains = crawlingService.getDomains(page, limit, contentTypes); - return ResponseEntity.ok(new PageResponse<>(domains.getContent(), domains)); - } + Page domains = crawlingService.getDomains(page, limit, contentTypes); + return ResponseEntity.ok(new PageResponse<>(domains.getContent(), domains)); + } + @ExceptionHandler(CrawlingException.class) + public ResponseEntity handleCrawlingException(CrawlingException e) { + log.info("Crawling Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e.getMessage())); + } - @ExceptionHandler(CrawlingException.class) - public ResponseEntity handleCrawlingException(CrawlingException e) { - log.info("Crawling Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e.getMessage())); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/dsl/CrawlerDsl.java b/src/main/java/com/linglevel/api/crawling/dsl/CrawlerDsl.java index aa3cb3c0..983cf6ef 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/CrawlerDsl.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/CrawlerDsl.java @@ -10,90 +10,88 @@ /** * Crawler DSL 인터프리터 * - * 사용 예시: - * - D'h1'# : h1 요소의 텍스트 - * - D'img'@'src' : img 요소의 src 속성 - * - D'''p'''># : 모든 p 요소의 텍스트 (map each) - * - D'h1'#?D'title'# : h1 텍스트, 없으면 title 텍스트 (null coalescing) - * - D'article h1'# ? D'meta[property="og:title"]'@'content' + * 사용 예시: - D'h1'# : h1 요소의 텍스트 - D'img'@'src' : img 요소의 src 속성 - D'''p'''># : 모든 p 요소의 + * 텍스트 (map each) - D'h1'#?D'title'# : h1 텍스트, 없으면 title 텍스트 (null coalescing) - D'article + * h1'# ? D'meta[property="og:title"]'@'content' */ @Slf4j public class CrawlerDsl { - private final Document document; - - public CrawlerDsl(String html) { - this(Jsoup.parse(html)); - } - - public CrawlerDsl(Document document) { - this.document = document; - } - - /** - * DSL 표현식을 실행하여 결과 반환 - * - * @param dsl DSL 표현식 - * @return 추출된 값 (String, List, Element 등) - */ - public Object execute(String dsl) { - if (dsl == null || dsl.trim().isEmpty()) { - return null; - } - - try { - // Tokenize - DslTokenizer tokenizer = new DslTokenizer(dsl); - List tokens = tokenizer.tokenize(); - - // Parse - DslParser parser = new DslParser(tokens); - ASTNode ast = parser.parse(); - - // Interpret - DslInterpreter interpreter = new DslInterpreter(document); - return interpreter.evaluate(ast); - - } catch (Exception e) { - log.error("Error executing DSL: {} - {}: {}", dsl, e.getClass().getSimpleName(), e.getMessage()); - log.error("Stack trace:", e); - return null; - } - } - - /** - * DSL 표현식을 실행하여 문자열 결과 반환 - * - * @param dsl DSL 표현식 - * @return 추출된 문자열 (null 가능) - */ - public String executeAsString(String dsl) { - Object result = execute(dsl); - if (result == null) { - return null; - } - - if (result instanceof String) { - return (String) result; - } - - if (result instanceof List) { - List list = (List) result; - if (list.isEmpty()) { - return null; - } - // Join list elements with newline - StringBuilder sb = new StringBuilder(); - for (Object item : list) { - if (item != null) { - if (sb.length() > 0) { - sb.append("\n\n"); - } - sb.append(item.toString()); - } - } - return sb.length() > 0 ? sb.toString() : null; - } - - return result.toString(); - } + + private final Document document; + + public CrawlerDsl(String html) { + this(Jsoup.parse(html)); + } + + public CrawlerDsl(Document document) { + this.document = document; + } + + /** + * DSL 표현식을 실행하여 결과 반환 + * @param dsl DSL 표현식 + * @return 추출된 값 (String, List, Element 등) + */ + public Object execute(String dsl) { + if (dsl == null || dsl.trim().isEmpty()) { + return null; + } + + try { + // Tokenize + DslTokenizer tokenizer = new DslTokenizer(dsl); + List tokens = tokenizer.tokenize(); + + // Parse + DslParser parser = new DslParser(tokens); + ASTNode ast = parser.parse(); + + // Interpret + DslInterpreter interpreter = new DslInterpreter(document); + return interpreter.evaluate(ast); + + } + catch (Exception e) { + log.error("Error executing DSL: {} - {}: {}", dsl, e.getClass().getSimpleName(), e.getMessage()); + log.error("Stack trace:", e); + return null; + } + } + + /** + * DSL 표현식을 실행하여 문자열 결과 반환 + * @param dsl DSL 표현식 + * @return 추출된 문자열 (null 가능) + */ + public String executeAsString(String dsl) { + Object result = execute(dsl); + if (result == null) { + return null; + } + + if (result instanceof String) { + return (String) result; + } + + if (result instanceof List) { + List list = (List) result; + if (list.isEmpty()) { + return null; + } + // Join list elements with newline + StringBuilder sb = new StringBuilder(); + for (Object item : list) { + if (item != null) { + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append(item.toString()); + } + } + return sb.length() > 0 ? sb.toString() : null; + } + + return result.toString(); + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/DslInterpreter.java b/src/main/java/com/linglevel/api/crawling/dsl/DslInterpreter.java index 69236e83..314fa049 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/DslInterpreter.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/DslInterpreter.java @@ -8,15 +8,18 @@ @Getter @Setter public class DslInterpreter { - private final Document document; - private Object currentContext; - public DslInterpreter(Document document) { - this.document = document; - this.currentContext = document; - } + private final Document document; + + private Object currentContext; + + public DslInterpreter(Document document) { + this.document = document; + this.currentContext = document; + } + + public Object evaluate(ASTNode node) { + return node.evaluate(this); + } - public Object evaluate(ASTNode node) { - return node.evaluate(this); - } } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/DslParser.java b/src/main/java/com/linglevel/api/crawling/dsl/DslParser.java index 6f5a3350..83e51c4c 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/DslParser.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/DslParser.java @@ -6,185 +6,202 @@ import java.util.List; public class DslParser { - private final List tokens; - private int position; - - public DslParser(List tokens) { - this.tokens = tokens; - this.position = 0; - } - - private Token current() { - return tokens.get(position); - } - - public ASTNode parse() { - ASTNode result = parseExpr(); - if (current().getType() != TokenType.EOF) { - throw new RuntimeException("Unexpected token at position " + current().getPosition() + ": " + current().getValue()); - } - return result; - } - - private ASTNode parseExpr() { - return parseOr(); - } - - private ASTNode parseOr() { - ASTNode left = parseAdd(); - - while (current().getType() == TokenType.QUESTION) { - position++; - ASTNode right = parseAdd(); - left = new QuestionNode(left, right); - } - - return left; - } - - private ASTNode parseAdd() { - ASTNode left = parseChain(); - - while (current().getType() == TokenType.PLUS) { - position++; - ASTNode right = parseChain(); - left = new PlusNode(left, right); - } - - return left; - } - - private ASTNode parseChain() { - ASTNode primary = parsePrimary(); - List items = new ArrayList<>(); - items.add(primary); - - while (true) { - if (current().getType() == TokenType.GT) { - position++; - List actionChain = parseActionChain(); - ASTNode source = items.size() == 1 ? items.get(0) : new ChainNode(items.get(0), new ArrayList<>(items.subList(1, items.size()))); - items.clear(); - items.add(new MapEachNode(source, actionChain)); - } else if (isPostfix()) { - items.add(parsePostfix()); - } else { - break; - } - } - - if (items.size() == 1) { - return items.get(0); - } - return new ChainNode(items.get(0), new ArrayList<>(items.subList(1, items.size()))); - } - - private List parseActionChain() { - List actions = new ArrayList<>(); - - if (current().getType() == TokenType.LPAREN) { - position++; - while (current().getType() != TokenType.RPAREN && current().getType() != TokenType.EOF) { - if (!isPostfix()) { - throw new RuntimeException("Only postfix operations allowed in > action chain at position " + current().getPosition()); - } - actions.add(parsePostfix()); - } - if (current().getType() != TokenType.RPAREN) { - throw new RuntimeException("Expected ) at position " + current().getPosition()); - } - position++; - } else { - while (isPostfix()) { - actions.add(parsePostfix()); - } - } - - if (actions.isEmpty()) { - throw new RuntimeException("Empty action chain after > at position " + current().getPosition()); - } - - return actions; - } - - private boolean isPostfix() { - return current().getType() == TokenType.SINGLE_QUOTE || - current().getType() == TokenType.TRIPLE_QUOTE || - current().getType() == TokenType.AT || - current().getType() == TokenType.HASH; - } - - private ASTNode parsePostfix() { - if (current().getType() == TokenType.SINGLE_QUOTE) { - String selector = current().getValue(); - position++; - return new Selector1Node(selector); - } else if (current().getType() == TokenType.TRIPLE_QUOTE) { - String selector = current().getValue(); - position++; - return new SelectorAllNode(selector); - } else if (current().getType() == TokenType.AT) { - position++; - if (current().getType() != TokenType.SINGLE_QUOTE) { - throw new RuntimeException("Expected attribute name after @ at position " + current().getPosition()); - } - String attr = current().getValue(); - position++; - return new AttrNode(attr); - } else if (current().getType() == TokenType.HASH) { - position++; - return new TextNode(); - } else { - throw new RuntimeException("Expected postfix operation at position " + current().getPosition()); - } - } - - private ASTNode parsePrimary() { - if (current().getType() == TokenType.D) { - position++; - return new DocumentNode(); - } else if (current().getType() == TokenType.LBRACKET) { - return parseCollect(); - } else if (current().getType() == TokenType.LPAREN) { - position++; - ASTNode expr = parseExpr(); - if (current().getType() != TokenType.RPAREN) { - throw new RuntimeException("Expected ) at position " + current().getPosition()); - } - position++; - return new GroupNode(expr); - } else { - throw new RuntimeException("Unexpected token at position " + current().getPosition() + ": " + current().getType()); - } - } - - private ASTNode parseCollect() { - if (current().getType() != TokenType.LBRACKET) { - throw new RuntimeException("Expected [ at position " + current().getPosition()); - } - position++; - - List statements = new ArrayList<>(); - - while (current().getType() != TokenType.RBRACKET && current().getType() != TokenType.EOF) { - ASTNode expr = parseExpr(); - - if (current().getType() == TokenType.CARET) { - position++; - statements.add(new CollectStatement(expr)); - } else if (current().getType() == TokenType.RBRACKET) { - // Last expression without ^ - break; - } else { - throw new RuntimeException("Expected ^ or ] at position " + current().getPosition()); - } - } - - if (current().getType() != TokenType.RBRACKET) { - throw new RuntimeException("Expected ] at position " + current().getPosition()); - } - position++; - - return new CollectNode(statements); - } + + private final List tokens; + + private int position; + + public DslParser(List tokens) { + this.tokens = tokens; + this.position = 0; + } + + private Token current() { + return tokens.get(position); + } + + public ASTNode parse() { + ASTNode result = parseExpr(); + if (current().getType() != TokenType.EOF) { + throw new RuntimeException( + "Unexpected token at position " + current().getPosition() + ": " + current().getValue()); + } + return result; + } + + private ASTNode parseExpr() { + return parseOr(); + } + + private ASTNode parseOr() { + ASTNode left = parseAdd(); + + while (current().getType() == TokenType.QUESTION) { + position++; + ASTNode right = parseAdd(); + left = new QuestionNode(left, right); + } + + return left; + } + + private ASTNode parseAdd() { + ASTNode left = parseChain(); + + while (current().getType() == TokenType.PLUS) { + position++; + ASTNode right = parseChain(); + left = new PlusNode(left, right); + } + + return left; + } + + private ASTNode parseChain() { + ASTNode primary = parsePrimary(); + List items = new ArrayList<>(); + items.add(primary); + + while (true) { + if (current().getType() == TokenType.GT) { + position++; + List actionChain = parseActionChain(); + ASTNode source = items.size() == 1 ? items.get(0) + : new ChainNode(items.get(0), new ArrayList<>(items.subList(1, items.size()))); + items.clear(); + items.add(new MapEachNode(source, actionChain)); + } + else if (isPostfix()) { + items.add(parsePostfix()); + } + else { + break; + } + } + + if (items.size() == 1) { + return items.get(0); + } + return new ChainNode(items.get(0), new ArrayList<>(items.subList(1, items.size()))); + } + + private List parseActionChain() { + List actions = new ArrayList<>(); + + if (current().getType() == TokenType.LPAREN) { + position++; + while (current().getType() != TokenType.RPAREN && current().getType() != TokenType.EOF) { + if (!isPostfix()) { + throw new RuntimeException( + "Only postfix operations allowed in > action chain at position " + current().getPosition()); + } + actions.add(parsePostfix()); + } + if (current().getType() != TokenType.RPAREN) { + throw new RuntimeException("Expected ) at position " + current().getPosition()); + } + position++; + } + else { + while (isPostfix()) { + actions.add(parsePostfix()); + } + } + + if (actions.isEmpty()) { + throw new RuntimeException("Empty action chain after > at position " + current().getPosition()); + } + + return actions; + } + + private boolean isPostfix() { + return current().getType() == TokenType.SINGLE_QUOTE || current().getType() == TokenType.TRIPLE_QUOTE + || current().getType() == TokenType.AT || current().getType() == TokenType.HASH; + } + + private ASTNode parsePostfix() { + if (current().getType() == TokenType.SINGLE_QUOTE) { + String selector = current().getValue(); + position++; + return new Selector1Node(selector); + } + else if (current().getType() == TokenType.TRIPLE_QUOTE) { + String selector = current().getValue(); + position++; + return new SelectorAllNode(selector); + } + else if (current().getType() == TokenType.AT) { + position++; + if (current().getType() != TokenType.SINGLE_QUOTE) { + throw new RuntimeException("Expected attribute name after @ at position " + current().getPosition()); + } + String attr = current().getValue(); + position++; + return new AttrNode(attr); + } + else if (current().getType() == TokenType.HASH) { + position++; + return new TextNode(); + } + else { + throw new RuntimeException("Expected postfix operation at position " + current().getPosition()); + } + } + + private ASTNode parsePrimary() { + if (current().getType() == TokenType.D) { + position++; + return new DocumentNode(); + } + else if (current().getType() == TokenType.LBRACKET) { + return parseCollect(); + } + else if (current().getType() == TokenType.LPAREN) { + position++; + ASTNode expr = parseExpr(); + if (current().getType() != TokenType.RPAREN) { + throw new RuntimeException("Expected ) at position " + current().getPosition()); + } + position++; + return new GroupNode(expr); + } + else { + throw new RuntimeException( + "Unexpected token at position " + current().getPosition() + ": " + current().getType()); + } + } + + private ASTNode parseCollect() { + if (current().getType() != TokenType.LBRACKET) { + throw new RuntimeException("Expected [ at position " + current().getPosition()); + } + position++; + + List statements = new ArrayList<>(); + + while (current().getType() != TokenType.RBRACKET && current().getType() != TokenType.EOF) { + ASTNode expr = parseExpr(); + + if (current().getType() == TokenType.CARET) { + position++; + statements.add(new CollectStatement(expr)); + } + else if (current().getType() == TokenType.RBRACKET) { + // Last expression without ^ + break; + } + else { + throw new RuntimeException("Expected ^ or ] at position " + current().getPosition()); + } + } + + if (current().getType() != TokenType.RBRACKET) { + throw new RuntimeException("Expected ] at position " + current().getPosition()); + } + position++; + + return new CollectNode(statements); + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/DslTokenizer.java b/src/main/java/com/linglevel/api/crawling/dsl/DslTokenizer.java index acd027c7..c708a4a8 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/DslTokenizer.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/DslTokenizer.java @@ -4,122 +4,130 @@ import java.util.List; public class DslTokenizer { - private final String input; - private int position; - private final List tokens; - - public DslTokenizer(String input) { - this.input = input; - this.position = 0; - this.tokens = new ArrayList<>(); - } - - public List tokenize() { - while (position < input.length()) { - skipWhitespace(); - if (position >= input.length()) { - break; - } - - int startPos = position; - char ch = input.charAt(position); - - switch (ch) { - case 'D': - tokens.add(new Token(TokenType.D, "D", startPos)); - position++; - break; - case '@': - tokens.add(new Token(TokenType.AT, "@", startPos)); - position++; - break; - case '#': - tokens.add(new Token(TokenType.HASH, "#", startPos)); - position++; - break; - case '?': - tokens.add(new Token(TokenType.QUESTION, "?", startPos)); - position++; - break; - case '+': - tokens.add(new Token(TokenType.PLUS, "+", startPos)); - position++; - break; - case '>': - tokens.add(new Token(TokenType.GT, ">", startPos)); - position++; - break; - case '^': - tokens.add(new Token(TokenType.CARET, "^", startPos)); - position++; - break; - case '(': - tokens.add(new Token(TokenType.LPAREN, "(", startPos)); - position++; - break; - case ')': - tokens.add(new Token(TokenType.RPAREN, ")", startPos)); - position++; - break; - case '[': - tokens.add(new Token(TokenType.LBRACKET, "[", startPos)); - position++; - break; - case ']': - tokens.add(new Token(TokenType.RBRACKET, "]", startPos)); - position++; - break; - case '\'': - parseQuote(startPos); - break; - default: - throw new RuntimeException("Unexpected character at position " + position + ": " + ch); - } - } - - tokens.add(new Token(TokenType.EOF, "", position)); - return tokens; - } - - private void skipWhitespace() { - while (position < input.length() && Character.isWhitespace(input.charAt(position))) { - position++; - } - } - - private void parseQuote(int startPos) { - if (position + 2 < input.length() && input.substring(position, position + 3).equals("'''")) { - position += 3; - String content = extractQuotedContent("'''"); - tokens.add(new Token(TokenType.TRIPLE_QUOTE, content, startPos)); - } else if (input.charAt(position) == '\'') { - position++; - String content = extractQuotedContent("'"); - tokens.add(new Token(TokenType.SINGLE_QUOTE, content, startPos)); - } - } - - private String extractQuotedContent(String quote) { - StringBuilder buffer = new StringBuilder(); - - while (position < input.length()) { - if (quote.equals("'''") && position + 2 < input.length() && - input.substring(position, position + 3).equals("'''")) { - position += 3; - return buffer.toString(); - } else if (quote.equals("'") && input.charAt(position) == '\'') { - position++; - return buffer.toString(); - } else if (input.charAt(position) == '\\' && position + 1 < input.length()) { - position++; - buffer.append(input.charAt(position)); - position++; - } else { - buffer.append(input.charAt(position)); - position++; - } - } - - throw new RuntimeException("Unterminated quote at position " + (position - buffer.length())); - } + + private final String input; + + private int position; + + private final List tokens; + + public DslTokenizer(String input) { + this.input = input; + this.position = 0; + this.tokens = new ArrayList<>(); + } + + public List tokenize() { + while (position < input.length()) { + skipWhitespace(); + if (position >= input.length()) { + break; + } + + int startPos = position; + char ch = input.charAt(position); + + switch (ch) { + case 'D': + tokens.add(new Token(TokenType.D, "D", startPos)); + position++; + break; + case '@': + tokens.add(new Token(TokenType.AT, "@", startPos)); + position++; + break; + case '#': + tokens.add(new Token(TokenType.HASH, "#", startPos)); + position++; + break; + case '?': + tokens.add(new Token(TokenType.QUESTION, "?", startPos)); + position++; + break; + case '+': + tokens.add(new Token(TokenType.PLUS, "+", startPos)); + position++; + break; + case '>': + tokens.add(new Token(TokenType.GT, ">", startPos)); + position++; + break; + case '^': + tokens.add(new Token(TokenType.CARET, "^", startPos)); + position++; + break; + case '(': + tokens.add(new Token(TokenType.LPAREN, "(", startPos)); + position++; + break; + case ')': + tokens.add(new Token(TokenType.RPAREN, ")", startPos)); + position++; + break; + case '[': + tokens.add(new Token(TokenType.LBRACKET, "[", startPos)); + position++; + break; + case ']': + tokens.add(new Token(TokenType.RBRACKET, "]", startPos)); + position++; + break; + case '\'': + parseQuote(startPos); + break; + default: + throw new RuntimeException("Unexpected character at position " + position + ": " + ch); + } + } + + tokens.add(new Token(TokenType.EOF, "", position)); + return tokens; + } + + private void skipWhitespace() { + while (position < input.length() && Character.isWhitespace(input.charAt(position))) { + position++; + } + } + + private void parseQuote(int startPos) { + if (position + 2 < input.length() && input.substring(position, position + 3).equals("'''")) { + position += 3; + String content = extractQuotedContent("'''"); + tokens.add(new Token(TokenType.TRIPLE_QUOTE, content, startPos)); + } + else if (input.charAt(position) == '\'') { + position++; + String content = extractQuotedContent("'"); + tokens.add(new Token(TokenType.SINGLE_QUOTE, content, startPos)); + } + } + + private String extractQuotedContent(String quote) { + StringBuilder buffer = new StringBuilder(); + + while (position < input.length()) { + if (quote.equals("'''") && position + 2 < input.length() + && input.substring(position, position + 3).equals("'''")) { + position += 3; + return buffer.toString(); + } + else if (quote.equals("'") && input.charAt(position) == '\'') { + position++; + return buffer.toString(); + } + else if (input.charAt(position) == '\\' && position + 1 < input.length()) { + position++; + buffer.append(input.charAt(position)); + position++; + } + else { + buffer.append(input.charAt(position)); + position++; + } + } + + throw new RuntimeException("Unterminated quote at position " + (position - buffer.length())); + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/Token.java b/src/main/java/com/linglevel/api/crawling/dsl/Token.java index c6038446..402759e5 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/Token.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/Token.java @@ -6,12 +6,16 @@ @Getter @AllArgsConstructor public class Token { - private final TokenType type; - private final String value; - private final int position; - @Override - public String toString() { - return type + "('" + value + "')@" + position; - } + private final TokenType type; + + private final String value; + + private final int position; + + @Override + public String toString() { + return type + "('" + value + "')@" + position; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/TokenType.java b/src/main/java/com/linglevel/api/crawling/dsl/TokenType.java index 5f06e4c8..265de271 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/TokenType.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/TokenType.java @@ -1,18 +1,20 @@ package com.linglevel.api.crawling.dsl; public enum TokenType { - D, // Document - SINGLE_QUOTE, // 'selector' - TRIPLE_QUOTE, // '''selector''' - AT, // @ - HASH, // # - QUESTION, // ? - PLUS, // + - GT, // > - CARET, // ^ - LPAREN, // ( - RPAREN, // ) - LBRACKET, // [ - RBRACKET, // ] - EOF + + D, // Document + SINGLE_QUOTE, // 'selector' + TRIPLE_QUOTE, // '''selector''' + AT, // @ + HASH, // # + QUESTION, // ? + PLUS, // + + GT, // > + CARET, // ^ + LPAREN, // ( + RPAREN, // ) + LBRACKET, // [ + RBRACKET, // ] + EOF + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/ASTNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/ASTNode.java index c7c89618..da7350d3 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/ASTNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/ASTNode.java @@ -3,5 +3,7 @@ import com.linglevel.api.crawling.dsl.DslInterpreter; public interface ASTNode { - Object evaluate(DslInterpreter interpreter); + + Object evaluate(DslInterpreter interpreter); + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/AttrNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/AttrNode.java index edc939d5..89f809e2 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/AttrNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/AttrNode.java @@ -6,18 +6,20 @@ @RequiredArgsConstructor public class AttrNode implements ASTNode { - private final String attribute; - @Override - public Object evaluate(DslInterpreter interpreter) { - Object context = interpreter.getCurrentContext(); - if (context instanceof Element) { - Element element = (Element) context; - String value = element.attr(attribute); - if (value != null && !value.trim().isEmpty()) { - return value; - } - } - return null; - } + private final String attribute; + + @Override + public Object evaluate(DslInterpreter interpreter) { + Object context = interpreter.getCurrentContext(); + if (context instanceof Element) { + Element element = (Element) context; + String value = element.attr(attribute); + if (value != null && !value.trim().isEmpty()) { + return value; + } + } + return null; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/ChainNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/ChainNode.java index 0d405c7a..873eae61 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/ChainNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/ChainNode.java @@ -7,24 +7,27 @@ @RequiredArgsConstructor public class ChainNode implements ASTNode { - private final ASTNode base; - private final List postfixes; - - @Override - public Object evaluate(DslInterpreter interpreter) { - Object result = base.evaluate(interpreter); - - for (ASTNode postfix : postfixes) { - Object saved = interpreter.getCurrentContext(); - interpreter.setCurrentContext(result); - result = postfix.evaluate(interpreter); - interpreter.setCurrentContext(saved); - - if (result == null) { - break; - } - } - - return result; - } + + private final ASTNode base; + + private final List postfixes; + + @Override + public Object evaluate(DslInterpreter interpreter) { + Object result = base.evaluate(interpreter); + + for (ASTNode postfix : postfixes) { + Object saved = interpreter.getCurrentContext(); + interpreter.setCurrentContext(result); + result = postfix.evaluate(interpreter); + interpreter.setCurrentContext(saved); + + if (result == null) { + break; + } + } + + return result; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectNode.java index 02fa998b..0c34f4de 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectNode.java @@ -8,46 +8,51 @@ @RequiredArgsConstructor public class CollectNode implements ASTNode { - private final List statements; - - @Override - @SuppressWarnings("unchecked") - public Object evaluate(DslInterpreter interpreter) { - List collected = new ArrayList<>(); - - for (CollectStatement stmt : statements) { - Object value = stmt.getExpr().evaluate(interpreter); - collect(value, collected); - } - - return collected; - } - - private void collect(Object value, List collected) { - if (value == null) { - return; - } - - if (value instanceof String) { - String trimmed = ((String) value).trim(); - if (!trimmed.isEmpty()) { - collected.add(trimmed); - } - } else if (value instanceof List) { - for (Object item : (List) value) { - if (item != null) { - if (item instanceof String) { - String trimmed = ((String) item).trim(); - if (!trimmed.isEmpty()) { - collected.add(trimmed); - } - } else { - collected.add(item); - } - } - } - } else { - collected.add(value); - } - } + + private final List statements; + + @Override + @SuppressWarnings("unchecked") + public Object evaluate(DslInterpreter interpreter) { + List collected = new ArrayList<>(); + + for (CollectStatement stmt : statements) { + Object value = stmt.getExpr().evaluate(interpreter); + collect(value, collected); + } + + return collected; + } + + private void collect(Object value, List collected) { + if (value == null) { + return; + } + + if (value instanceof String) { + String trimmed = ((String) value).trim(); + if (!trimmed.isEmpty()) { + collected.add(trimmed); + } + } + else if (value instanceof List) { + for (Object item : (List) value) { + if (item != null) { + if (item instanceof String) { + String trimmed = ((String) item).trim(); + if (!trimmed.isEmpty()) { + collected.add(trimmed); + } + } + else { + collected.add(item); + } + } + } + } + else { + collected.add(value); + } + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectStatement.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectStatement.java index 9d1e7703..c55473a7 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectStatement.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/CollectStatement.java @@ -6,5 +6,7 @@ @Getter @AllArgsConstructor public class CollectStatement { - private final ASTNode expr; + + private final ASTNode expr; + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/DocumentNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/DocumentNode.java index 82ec5c7e..5c8578ac 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/DocumentNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/DocumentNode.java @@ -3,8 +3,10 @@ import com.linglevel.api.crawling.dsl.DslInterpreter; public class DocumentNode implements ASTNode { - @Override - public Object evaluate(DslInterpreter interpreter) { - return interpreter.getDocument(); - } + + @Override + public Object evaluate(DslInterpreter interpreter) { + return interpreter.getDocument(); + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/GroupNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/GroupNode.java index e62bc3b2..79cb5a4e 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/GroupNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/GroupNode.java @@ -5,10 +5,12 @@ @RequiredArgsConstructor public class GroupNode implements ASTNode { - private final ASTNode expr; - @Override - public Object evaluate(DslInterpreter interpreter) { - return expr.evaluate(interpreter); - } + private final ASTNode expr; + + @Override + public Object evaluate(DslInterpreter interpreter) { + return expr.evaluate(interpreter); + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/MapEachNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/MapEachNode.java index 8144c608..badfbacb 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/MapEachNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/MapEachNode.java @@ -9,75 +9,79 @@ @RequiredArgsConstructor public class MapEachNode implements ASTNode { - private final ASTNode source; - private final List actionChain; - - @Override - @SuppressWarnings("unchecked") - public Object evaluate(DslInterpreter interpreter) { - Object sourceValue = source.evaluate(interpreter); - List elementList = listify(sourceValue); - - if (elementList == null) { - return new ArrayList<>(); - } - - List results = new ArrayList<>(); - - for (Element element : elementList) { - Object saved = interpreter.getCurrentContext(); - - Object current = element; - - // Apply action chain sequentially - for (ASTNode action : actionChain) { - interpreter.setCurrentContext(current); - current = action.evaluate(interpreter); - if (current == null) { - break; - } - } - - // Add result if not null - if (current != null) { - if (current instanceof List) { - // Flatten list - for (Object item : (List) current) { - if (item != null) { - results.add(item); - } - } - } else { - results.add(current); - } - } - - interpreter.setCurrentContext(saved); - } - - return results; - } - - @SuppressWarnings("unchecked") - private List listify(Object value) { - if (value == null) { - return null; - } - if (value instanceof Element) { - List list = new ArrayList<>(); - list.add((Element) value); - return list; - } - if (value instanceof List) { - List list = (List) value; - if (list.isEmpty()) { - return new ArrayList<>(); - } - if (list.get(0) instanceof Element) { - // Always create a copy to avoid ConcurrentModificationException - return new ArrayList<>((List) list); - } - } - return null; - } + + private final ASTNode source; + + private final List actionChain; + + @Override + @SuppressWarnings("unchecked") + public Object evaluate(DslInterpreter interpreter) { + Object sourceValue = source.evaluate(interpreter); + List elementList = listify(sourceValue); + + if (elementList == null) { + return new ArrayList<>(); + } + + List results = new ArrayList<>(); + + for (Element element : elementList) { + Object saved = interpreter.getCurrentContext(); + + Object current = element; + + // Apply action chain sequentially + for (ASTNode action : actionChain) { + interpreter.setCurrentContext(current); + current = action.evaluate(interpreter); + if (current == null) { + break; + } + } + + // Add result if not null + if (current != null) { + if (current instanceof List) { + // Flatten list + for (Object item : (List) current) { + if (item != null) { + results.add(item); + } + } + } + else { + results.add(current); + } + } + + interpreter.setCurrentContext(saved); + } + + return results; + } + + @SuppressWarnings("unchecked") + private List listify(Object value) { + if (value == null) { + return null; + } + if (value instanceof Element) { + List list = new ArrayList<>(); + list.add((Element) value); + return list; + } + if (value instanceof List) { + List list = (List) value; + if (list.isEmpty()) { + return new ArrayList<>(); + } + if (list.get(0) instanceof Element) { + // Always create a copy to avoid ConcurrentModificationException + return new ArrayList<>((List) list); + } + } + return null; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/PlusNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/PlusNode.java index 7ce501be..393b2e1b 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/PlusNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/PlusNode.java @@ -9,42 +9,45 @@ @RequiredArgsConstructor public class PlusNode implements ASTNode { - private final ASTNode left; - private final ASTNode right; - - @Override - public Object evaluate(DslInterpreter interpreter) { - Object leftValue = left.evaluate(interpreter); - Object rightValue = right.evaluate(interpreter); - - List leftList = listify(leftValue); - List rightList = listify(rightValue); - - if (leftList != null && rightList != null) { - List result = new ArrayList<>(leftList); - result.addAll(rightList); - return result; - } - - throw new RuntimeException("Type error in + operator: both operands must be Element or List"); - } - - @SuppressWarnings("unchecked") - private List listify(Object value) { - if (value == null) { - return null; - } - if (value instanceof Element) { - List list = new ArrayList<>(); - list.add((Element) value); - return list; - } - if (value instanceof List) { - List list = (List) value; - if (list.isEmpty() || list.get(0) instanceof Element) { - return (List) list; - } - } - return null; - } + + private final ASTNode left; + + private final ASTNode right; + + @Override + public Object evaluate(DslInterpreter interpreter) { + Object leftValue = left.evaluate(interpreter); + Object rightValue = right.evaluate(interpreter); + + List leftList = listify(leftValue); + List rightList = listify(rightValue); + + if (leftList != null && rightList != null) { + List result = new ArrayList<>(leftList); + result.addAll(rightList); + return result; + } + + throw new RuntimeException("Type error in + operator: both operands must be Element or List"); + } + + @SuppressWarnings("unchecked") + private List listify(Object value) { + if (value == null) { + return null; + } + if (value instanceof Element) { + List list = new ArrayList<>(); + list.add((Element) value); + return list; + } + if (value instanceof List) { + List list = (List) value; + if (list.isEmpty() || list.get(0) instanceof Element) { + return (List) list; + } + } + return null; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/QuestionNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/QuestionNode.java index 43f501da..7c9e0c9f 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/QuestionNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/QuestionNode.java @@ -7,28 +7,31 @@ @RequiredArgsConstructor public class QuestionNode implements ASTNode { - private final ASTNode left; - private final ASTNode right; - - @Override - public Object evaluate(DslInterpreter interpreter) { - Object leftValue = left.evaluate(interpreter); - if (isNullOrEmpty(leftValue)) { - return right.evaluate(interpreter); - } - return leftValue; - } - - private boolean isNullOrEmpty(Object value) { - if (value == null) { - return true; - } - if (value instanceof String && ((String) value).trim().isEmpty()) { - return true; - } - if (value instanceof List && ((List) value).isEmpty()) { - return true; - } - return false; - } + + private final ASTNode left; + + private final ASTNode right; + + @Override + public Object evaluate(DslInterpreter interpreter) { + Object leftValue = left.evaluate(interpreter); + if (isNullOrEmpty(leftValue)) { + return right.evaluate(interpreter); + } + return leftValue; + } + + private boolean isNullOrEmpty(Object value) { + if (value == null) { + return true; + } + if (value instanceof String && ((String) value).trim().isEmpty()) { + return true; + } + if (value instanceof List && ((List) value).isEmpty()) { + return true; + } + return false; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/Selector1Node.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/Selector1Node.java index 3afb42ca..f4a7aa85 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/Selector1Node.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/Selector1Node.java @@ -7,16 +7,19 @@ @RequiredArgsConstructor public class Selector1Node implements ASTNode { - private final String selector; - @Override - public Object evaluate(DslInterpreter interpreter) { - Object context = interpreter.getCurrentContext(); - if (context instanceof Document) { - return ((Document) context).selectFirst(selector); - } else if (context instanceof Element) { - return ((Element) context).selectFirst(selector); - } - return null; - } + private final String selector; + + @Override + public Object evaluate(DslInterpreter interpreter) { + Object context = interpreter.getCurrentContext(); + if (context instanceof Document) { + return ((Document) context).selectFirst(selector); + } + else if (context instanceof Element) { + return ((Element) context).selectFirst(selector); + } + return null; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/SelectorAllNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/SelectorAllNode.java index 88edc200..5cf5e279 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/SelectorAllNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/SelectorAllNode.java @@ -11,23 +11,26 @@ @RequiredArgsConstructor public class SelectorAllNode implements ASTNode { - private final String selector; - @Override - public Object evaluate(DslInterpreter interpreter) { - Object context = interpreter.getCurrentContext(); - Elements elements = null; + private final String selector; - if (context instanceof Document) { - elements = ((Document) context).select(selector); - } else if (context instanceof Element) { - elements = ((Element) context).select(selector); - } + @Override + public Object evaluate(DslInterpreter interpreter) { + Object context = interpreter.getCurrentContext(); + Elements elements = null; - if (elements == null || elements.isEmpty()) { - return new ArrayList<>(); - } + if (context instanceof Document) { + elements = ((Document) context).select(selector); + } + else if (context instanceof Element) { + elements = ((Element) context).select(selector); + } + + if (elements == null || elements.isEmpty()) { + return new ArrayList<>(); + } + + return new ArrayList<>(elements); + } - return new ArrayList<>(elements); - } } diff --git a/src/main/java/com/linglevel/api/crawling/dsl/ast/TextNode.java b/src/main/java/com/linglevel/api/crawling/dsl/ast/TextNode.java index 39bd8f83..dd1a1ae4 100644 --- a/src/main/java/com/linglevel/api/crawling/dsl/ast/TextNode.java +++ b/src/main/java/com/linglevel/api/crawling/dsl/ast/TextNode.java @@ -4,13 +4,15 @@ import org.jsoup.nodes.Element; public class TextNode implements ASTNode { - @Override - public Object evaluate(DslInterpreter interpreter) { - Object context = interpreter.getCurrentContext(); - if (context instanceof Element) { - String text = ((Element) context).text().trim(); - return text.isEmpty() ? null : text; - } - return null; - } + + @Override + public Object evaluate(DslInterpreter interpreter) { + Object context = interpreter.getCurrentContext(); + if (context instanceof Element) { + String text = ((Element) context).text().trim(); + return text.isEmpty() ? null : text; + } + return null; + } + } diff --git a/src/main/java/com/linglevel/api/crawling/dto/CreateDslRequest.java b/src/main/java/com/linglevel/api/crawling/dto/CreateDslRequest.java index 88fe3075..7790c178 100644 --- a/src/main/java/com/linglevel/api/crawling/dto/CreateDslRequest.java +++ b/src/main/java/com/linglevel/api/crawling/dto/CreateDslRequest.java @@ -11,29 +11,30 @@ @NoArgsConstructor @AllArgsConstructor public class CreateDslRequest { - - @NotBlank(message = "Domain is required") - @Schema(description = "도메인명", example = "coupang.com", required = true) - private String domain; - - @NotBlank(message = "Name is required") - @Schema(description = "사이트명", example = "쿠팡", required = true) - private String name; - - @Schema(description = "Feed 콘텐츠 타입", example = "BLOG", required = false) - private FeedContentType contentType; - - @NotBlank(message = "Title DSL is required") - @Schema(description = "제목 추출 DSL 규칙", example = "h1.product-title", required = true) - private String titleDsl; - - @NotBlank(message = "Content DSL is required") - @Schema(description = "본문 추출 DSL 규칙", example = ".product-description", required = true) - private String contentDsl; - - @Schema(description = "커버 이미지 추출 DSL 규칙", example = "meta[property='og:image']", required = false) - private String coverImageDsl; - - @Schema(description = "접근 조회용 URL", example = "https://www.coupang.com", required = false) - private String accessUrl; + + @NotBlank(message = "Domain is required") + @Schema(description = "도메인명", example = "coupang.com", required = true) + private String domain; + + @NotBlank(message = "Name is required") + @Schema(description = "사이트명", example = "쿠팡", required = true) + private String name; + + @Schema(description = "Feed 콘텐츠 타입", example = "BLOG", required = false) + private FeedContentType contentType; + + @NotBlank(message = "Title DSL is required") + @Schema(description = "제목 추출 DSL 규칙", example = "h1.product-title", required = true) + private String titleDsl; + + @NotBlank(message = "Content DSL is required") + @Schema(description = "본문 추출 DSL 규칙", example = ".product-description", required = true) + private String contentDsl; + + @Schema(description = "커버 이미지 추출 DSL 규칙", example = "meta[property='og:image']", required = false) + private String coverImageDsl; + + @Schema(description = "접근 조회용 URL", example = "https://www.coupang.com", required = false) + private String accessUrl; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/dto/CreateDslResponse.java b/src/main/java/com/linglevel/api/crawling/dto/CreateDslResponse.java index a2a464bd..ee8ed9eb 100644 --- a/src/main/java/com/linglevel/api/crawling/dto/CreateDslResponse.java +++ b/src/main/java/com/linglevel/api/crawling/dto/CreateDslResponse.java @@ -12,13 +12,14 @@ @NoArgsConstructor @AllArgsConstructor public class CreateDslResponse { - - @Schema(description = "생성된 DSL ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "도메인명", example = "coupang.com") - private String domain; - - @Schema(description = "응답 메시지", example = "DSL created successfully.") - private String message; + + @Schema(description = "생성된 DSL ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "도메인명", example = "coupang.com") + private String domain; + + @Schema(description = "응답 메시지", example = "DSL created successfully.") + private String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/dto/DomainsResponse.java b/src/main/java/com/linglevel/api/crawling/dto/DomainsResponse.java index 658860d5..1c38907a 100644 --- a/src/main/java/com/linglevel/api/crawling/dto/DomainsResponse.java +++ b/src/main/java/com/linglevel/api/crawling/dto/DomainsResponse.java @@ -13,19 +13,20 @@ @NoArgsConstructor @AllArgsConstructor public class DomainsResponse { - - @Schema(description = "MongoDB ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "도메인명", example = "coupang.com") - private String domain; - @Schema(description = "사이트명", example = "쿠팡") - private String name; + @Schema(description = "MongoDB ID", example = "60d0fe4f5311236168a109ca") + private String id; - @Schema(description = "Feed 콘텐츠 타입", example = "BLOG") - private FeedContentType contentType; + @Schema(description = "도메인명", example = "coupang.com") + private String domain; + + @Schema(description = "사이트명", example = "쿠팡") + private String name; + + @Schema(description = "Feed 콘텐츠 타입", example = "BLOG") + private FeedContentType contentType; + + @Schema(description = "접근 조회용 URL") + private String accessUrl; - @Schema(description = "접근 조회용 URL") - private String accessUrl; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/dto/DslLookupResponse.java b/src/main/java/com/linglevel/api/crawling/dto/DslLookupResponse.java index d282d0fa..c3a9f038 100644 --- a/src/main/java/com/linglevel/api/crawling/dto/DslLookupResponse.java +++ b/src/main/java/com/linglevel/api/crawling/dto/DslLookupResponse.java @@ -13,25 +13,26 @@ @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class DslLookupResponse { - - @Schema(description = "도메인명", example = "coupang.com") - private String domain; - - @Schema(description = "제목 추출 DSL 규칙 (validate_only=true인 경우 포함되지 않음)") - private String titleDsl; - - @Schema(description = "본문 추출 DSL 규칙 (validate_only=true인 경우 포함되지 않음)") - private String contentDsl; - - @Schema(description = "커버 이미지 추출 DSL 규칙 (validate_only=true인 경우 포함되지 않음)") - private String coverImageDsl; - - @Schema(description = "접근 조회용 URL (validate_only=true인 경우 포함되지 않음)") - private String accessUrl; - - @Schema(description = "유효성 여부", example = "true") - private boolean valid; - - @Schema(description = "응답 메시지") - private String message; + + @Schema(description = "도메인명", example = "coupang.com") + private String domain; + + @Schema(description = "제목 추출 DSL 규칙 (validate_only=true인 경우 포함되지 않음)") + private String titleDsl; + + @Schema(description = "본문 추출 DSL 규칙 (validate_only=true인 경우 포함되지 않음)") + private String contentDsl; + + @Schema(description = "커버 이미지 추출 DSL 규칙 (validate_only=true인 경우 포함되지 않음)") + private String coverImageDsl; + + @Schema(description = "접근 조회용 URL (validate_only=true인 경우 포함되지 않음)") + private String accessUrl; + + @Schema(description = "유효성 여부", example = "true") + private boolean valid; + + @Schema(description = "응답 메시지") + private String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/dto/UpdateDslRequest.java b/src/main/java/com/linglevel/api/crawling/dto/UpdateDslRequest.java index 0d7f2458..f80c9cd2 100644 --- a/src/main/java/com/linglevel/api/crawling/dto/UpdateDslRequest.java +++ b/src/main/java/com/linglevel/api/crawling/dto/UpdateDslRequest.java @@ -12,24 +12,25 @@ @AllArgsConstructor public class UpdateDslRequest { - @NotBlank(message = "Name is required") - @Schema(description = "업데이트할 사이트명", example = "쿠팡", required = true) - private String name; - - @Schema(description = "업데이트할 Feed 콘텐츠 타입", example = "BLOG", required = false) - private FeedContentType contentType; - - @NotBlank(message = "Title DSL is required") - @Schema(description = "업데이트할 제목 추출 DSL 규칙", example = "h1.new-product-title", required = true) - private String titleDsl; - - @NotBlank(message = "Content DSL is required") - @Schema(description = "업데이트할 본문 추출 DSL 규칙", example = ".new-product-description", required = true) - private String contentDsl; - - @Schema(description = "업데이트할 커버 이미지 추출 DSL 규칙", example = "meta[property='og:image']", required = false) - private String coverImageDsl; - - @Schema(description = "업데이트할 접근 조회용 URL", example = "https://www.coupang.com", required = false) - private String accessUrl; + @NotBlank(message = "Name is required") + @Schema(description = "업데이트할 사이트명", example = "쿠팡", required = true) + private String name; + + @Schema(description = "업데이트할 Feed 콘텐츠 타입", example = "BLOG", required = false) + private FeedContentType contentType; + + @NotBlank(message = "Title DSL is required") + @Schema(description = "업데이트할 제목 추출 DSL 규칙", example = "h1.new-product-title", required = true) + private String titleDsl; + + @NotBlank(message = "Content DSL is required") + @Schema(description = "업데이트할 본문 추출 DSL 규칙", example = ".new-product-description", required = true) + private String contentDsl; + + @Schema(description = "업데이트할 커버 이미지 추출 DSL 규칙", example = "meta[property='og:image']", required = false) + private String coverImageDsl; + + @Schema(description = "업데이트할 접근 조회용 URL", example = "https://www.coupang.com", required = false) + private String accessUrl; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/dto/UpdateDslResponse.java b/src/main/java/com/linglevel/api/crawling/dto/UpdateDslResponse.java index e8b4c80d..bec28e1e 100644 --- a/src/main/java/com/linglevel/api/crawling/dto/UpdateDslResponse.java +++ b/src/main/java/com/linglevel/api/crawling/dto/UpdateDslResponse.java @@ -12,28 +12,29 @@ @NoArgsConstructor @AllArgsConstructor public class UpdateDslResponse { - - @Schema(description = "업데이트된 DSL ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "도메인명", example = "coupang.com") - private String domain; - - @Schema(description = "사이트명", example = "쿠팡") - private String name; - - @Schema(description = "업데이트된 제목 추출 DSL 규칙") - private String titleDsl; - - @Schema(description = "업데이트된 본문 추출 DSL 규칙") - private String contentDsl; - - @Schema(description = "업데이트된 커버 이미지 추출 DSL 규칙") - private String coverImageDsl; - - @Schema(description = "접근 조회용 URL") - private String accessUrl; - - @Schema(description = "응답 메시지", example = "DSL updated successfully.") - private String message; + + @Schema(description = "업데이트된 DSL ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "도메인명", example = "coupang.com") + private String domain; + + @Schema(description = "사이트명", example = "쿠팡") + private String name; + + @Schema(description = "업데이트된 제목 추출 DSL 규칙") + private String titleDsl; + + @Schema(description = "업데이트된 본문 추출 DSL 규칙") + private String contentDsl; + + @Schema(description = "업데이트된 커버 이미지 추출 DSL 규칙") + private String coverImageDsl; + + @Schema(description = "접근 조회용 URL") + private String accessUrl; + + @Schema(description = "응답 메시지", example = "DSL updated successfully.") + private String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/entity/CrawlingDsl.java b/src/main/java/com/linglevel/api/crawling/entity/CrawlingDsl.java index 93a2ef9c..d4c92035 100644 --- a/src/main/java/com/linglevel/api/crawling/entity/CrawlingDsl.java +++ b/src/main/java/com/linglevel/api/crawling/entity/CrawlingDsl.java @@ -15,25 +15,27 @@ @AllArgsConstructor @Document(collection = "crawlingDsl") public class CrawlingDsl { - @Id - private String id; - - @Indexed(unique = true) - private String domain; - private String name; + @Id + private String id; - private FeedContentType contentType; + @Indexed(unique = true) + private String domain; - private String titleDsl; - - private String contentDsl; + private String name; - private String coverImageDsl; + private FeedContentType contentType; - private String accessUrl; + private String titleDsl; - private Instant createdAt; + private String contentDsl; + + private String coverImageDsl; + + private String accessUrl; + + private Instant createdAt; + + private Instant updatedAt; - private Instant updatedAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/exception/CrawlingErrorCode.java b/src/main/java/com/linglevel/api/crawling/exception/CrawlingErrorCode.java index 7434b56a..7c267fbc 100644 --- a/src/main/java/com/linglevel/api/crawling/exception/CrawlingErrorCode.java +++ b/src/main/java/com/linglevel/api/crawling/exception/CrawlingErrorCode.java @@ -7,14 +7,17 @@ @Getter @RequiredArgsConstructor public enum CrawlingErrorCode { - DOMAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "Domain not found."), - DOMAIN_ALREADY_EXISTS(HttpStatus.CONFLICT, "Domain already exists."), - INVALID_URL_FORMAT(HttpStatus.BAD_REQUEST, "Invalid URL format."), - URL_PARAMETER_REQUIRED(HttpStatus.BAD_REQUEST, "URL parameter is required."), - DOMAIN_AND_DSL_REQUIRED(HttpStatus.BAD_REQUEST, "Domain and dsl are required."), - DSL_REQUIRED(HttpStatus.BAD_REQUEST, "DSL is required."), - INVALID_API_KEY(HttpStatus.UNAUTHORIZED, "Invalid API key."); - private final HttpStatus status; - private final String message; + DOMAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "Domain not found."), + DOMAIN_ALREADY_EXISTS(HttpStatus.CONFLICT, "Domain already exists."), + INVALID_URL_FORMAT(HttpStatus.BAD_REQUEST, "Invalid URL format."), + URL_PARAMETER_REQUIRED(HttpStatus.BAD_REQUEST, "URL parameter is required."), + DOMAIN_AND_DSL_REQUIRED(HttpStatus.BAD_REQUEST, "Domain and dsl are required."), + DSL_REQUIRED(HttpStatus.BAD_REQUEST, "DSL is required."), + INVALID_API_KEY(HttpStatus.UNAUTHORIZED, "Invalid API key."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/exception/CrawlingException.java b/src/main/java/com/linglevel/api/crawling/exception/CrawlingException.java index b8908500..61f3fc45 100644 --- a/src/main/java/com/linglevel/api/crawling/exception/CrawlingException.java +++ b/src/main/java/com/linglevel/api/crawling/exception/CrawlingException.java @@ -5,18 +5,21 @@ @Getter public class CrawlingException extends RuntimeException { - private final HttpStatus status; - private final String errorCode; - public CrawlingException(CrawlingErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - this.errorCode = errorCode.name(); - } + private final HttpStatus status; + + private final String errorCode; + + public CrawlingException(CrawlingErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + this.errorCode = errorCode.name(); + } + + public CrawlingException(CrawlingErrorCode errorCode, String customMessage) { + super(customMessage); + this.status = errorCode.getStatus(); + this.errorCode = errorCode.name(); + } - public CrawlingException(CrawlingErrorCode errorCode, String customMessage) { - super(customMessage); - this.status = errorCode.getStatus(); - this.errorCode = errorCode.name(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/repository/CrawlingDslRepository.java b/src/main/java/com/linglevel/api/crawling/repository/CrawlingDslRepository.java index cea8bb9e..7ec3c306 100644 --- a/src/main/java/com/linglevel/api/crawling/repository/CrawlingDslRepository.java +++ b/src/main/java/com/linglevel/api/crawling/repository/CrawlingDslRepository.java @@ -12,13 +12,15 @@ @Repository public interface CrawlingDslRepository extends MongoRepository { - Optional findByDomain(String domain); - Page findAll(Pageable pageable); + Optional findByDomain(String domain); - Page findByContentTypeIn(List contentTypes, Pageable pageable); + Page findAll(Pageable pageable); - boolean existsByDomain(String domain); + Page findByContentTypeIn(List contentTypes, Pageable pageable); + + boolean existsByDomain(String domain); + + void deleteByDomain(String domain); - void deleteByDomain(String domain); } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/crawling/service/CrawlingService.java b/src/main/java/com/linglevel/api/crawling/service/CrawlingService.java index 3d37f5bb..e081326c 100644 --- a/src/main/java/com/linglevel/api/crawling/service/CrawlingService.java +++ b/src/main/java/com/linglevel/api/crawling/service/CrawlingService.java @@ -25,146 +25,151 @@ @Service @RequiredArgsConstructor @Slf4j -public class CrawlingService { - - private final CrawlingDslRepository crawlingDslRepository; - - public DslLookupResponse lookupDsl(String url, boolean validateOnly) { - if (url == null || url.trim().isEmpty()) { - throw new CrawlingException(CrawlingErrorCode.URL_PARAMETER_REQUIRED); - } - - String domain = extractDomain(url); - Optional crawlingDsl = crawlingDslRepository.findByDomain(domain); - - if (crawlingDsl.isPresent()) { - if (validateOnly) { - return DslLookupResponse.builder() - .domain(domain) - .valid(true) - .message("URL is valid for crawling.") - .build(); - } else { - return DslLookupResponse.builder() - .domain(domain) - .titleDsl(crawlingDsl.get().getTitleDsl()) - .contentDsl(crawlingDsl.get().getContentDsl()) - .coverImageDsl(crawlingDsl.get().getCoverImageDsl()) - .accessUrl(crawlingDsl.get().getAccessUrl()) - .valid(true) - .build(); - } - } else { - return DslLookupResponse.builder() - .domain(domain) - .valid(false) - .message("DSL not available for this domain.") - .build(); - } - } - - public Page getDomains(int page, int limit, List contentTypes) { - Pageable pageable = PageRequest.of(page - 1, limit); - Page domains; - - if (contentTypes == null || contentTypes.isEmpty()) { - domains = crawlingDslRepository.findAll(pageable); - } else { - domains = crawlingDslRepository.findByContentTypeIn(contentTypes, pageable); - } - - return domains.map(dsl -> DomainsResponse.builder() - .id(dsl.getId()) - .domain(dsl.getDomain()) - .name(dsl.getName()) - .contentType(dsl.getContentType()) - .accessUrl(dsl.getAccessUrl()) - .build()); - } - - public CreateDslResponse createDsl(CreateDslRequest request) { - if (crawlingDslRepository.existsByDomain(request.getDomain())) { - throw new CrawlingException(CrawlingErrorCode.DOMAIN_ALREADY_EXISTS); - } - - CrawlingDsl crawlingDsl = CrawlingDsl.builder() - .domain(request.getDomain()) - .name(request.getName()) - .contentType(request.getContentType()) - .titleDsl(request.getTitleDsl()) - .contentDsl(request.getContentDsl()) - .coverImageDsl(request.getCoverImageDsl()) - .accessUrl(request.getAccessUrl()) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) - .build(); - - CrawlingDsl saved = crawlingDslRepository.save(crawlingDsl); - - return CreateDslResponse.builder() - .id(saved.getId()) - .domain(saved.getDomain()) - .message("DSL created successfully.") - .build(); - } - - public UpdateDslResponse updateDsl(String domain, UpdateDslRequest request) { - CrawlingDsl crawlingDsl = crawlingDslRepository.findByDomain(domain) - .orElseThrow(() -> new CrawlingException(CrawlingErrorCode.DOMAIN_NOT_FOUND)); - - crawlingDsl.setName(request.getName()); - crawlingDsl.setTitleDsl(request.getTitleDsl()); - crawlingDsl.setContentDsl(request.getContentDsl()); - crawlingDsl.setCoverImageDsl(request.getCoverImageDsl()); - - // contentType은 옵셔널 - null이 아닐 경우에만 업데이트 - if (request.getContentType() != null) { - crawlingDsl.setContentType(request.getContentType()); - } - - // accessUrl은 옵셔널 - null이 아닐 경우에만 업데이트 - if (request.getAccessUrl() != null) { - crawlingDsl.setAccessUrl(request.getAccessUrl()); - } - - crawlingDsl.setUpdatedAt(Instant.now()); - - CrawlingDsl updated = crawlingDslRepository.save(crawlingDsl); - - return UpdateDslResponse.builder() - .id(updated.getId()) - .domain(updated.getDomain()) - .name(updated.getName()) - .titleDsl(updated.getTitleDsl()) - .contentDsl(updated.getContentDsl()) - .coverImageDsl(updated.getCoverImageDsl()) - .accessUrl(updated.getAccessUrl()) - .message("DSL updated successfully.") - .build(); - } - - public void deleteDsl(String domain) { - if (!crawlingDslRepository.existsByDomain(domain)) { - throw new CrawlingException(CrawlingErrorCode.DOMAIN_NOT_FOUND); - } - crawlingDslRepository.deleteByDomain(domain); - } - - public String extractDomain(String url) { - try { - URL parsedUrl = new URL(url); - String host = parsedUrl.getHost().toLowerCase(); - - Pattern pattern = Pattern.compile("([^.]+\\.[^.]+)$"); - Matcher matcher = pattern.matcher(host); - - if (matcher.find()) { - return matcher.group(1); - } - - return host; - } catch (MalformedURLException e) { - return null; - } - } +public class CrawlingService { + + private final CrawlingDslRepository crawlingDslRepository; + + public DslLookupResponse lookupDsl(String url, boolean validateOnly) { + if (url == null || url.trim().isEmpty()) { + throw new CrawlingException(CrawlingErrorCode.URL_PARAMETER_REQUIRED); + } + + String domain = extractDomain(url); + Optional crawlingDsl = crawlingDslRepository.findByDomain(domain); + + if (crawlingDsl.isPresent()) { + if (validateOnly) { + return DslLookupResponse.builder() + .domain(domain) + .valid(true) + .message("URL is valid for crawling.") + .build(); + } + else { + return DslLookupResponse.builder() + .domain(domain) + .titleDsl(crawlingDsl.get().getTitleDsl()) + .contentDsl(crawlingDsl.get().getContentDsl()) + .coverImageDsl(crawlingDsl.get().getCoverImageDsl()) + .accessUrl(crawlingDsl.get().getAccessUrl()) + .valid(true) + .build(); + } + } + else { + return DslLookupResponse.builder() + .domain(domain) + .valid(false) + .message("DSL not available for this domain.") + .build(); + } + } + + public Page getDomains(int page, int limit, List contentTypes) { + Pageable pageable = PageRequest.of(page - 1, limit); + Page domains; + + if (contentTypes == null || contentTypes.isEmpty()) { + domains = crawlingDslRepository.findAll(pageable); + } + else { + domains = crawlingDslRepository.findByContentTypeIn(contentTypes, pageable); + } + + return domains.map(dsl -> DomainsResponse.builder() + .id(dsl.getId()) + .domain(dsl.getDomain()) + .name(dsl.getName()) + .contentType(dsl.getContentType()) + .accessUrl(dsl.getAccessUrl()) + .build()); + } + + public CreateDslResponse createDsl(CreateDslRequest request) { + if (crawlingDslRepository.existsByDomain(request.getDomain())) { + throw new CrawlingException(CrawlingErrorCode.DOMAIN_ALREADY_EXISTS); + } + + CrawlingDsl crawlingDsl = CrawlingDsl.builder() + .domain(request.getDomain()) + .name(request.getName()) + .contentType(request.getContentType()) + .titleDsl(request.getTitleDsl()) + .contentDsl(request.getContentDsl()) + .coverImageDsl(request.getCoverImageDsl()) + .accessUrl(request.getAccessUrl()) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + + CrawlingDsl saved = crawlingDslRepository.save(crawlingDsl); + + return CreateDslResponse.builder() + .id(saved.getId()) + .domain(saved.getDomain()) + .message("DSL created successfully.") + .build(); + } + + public UpdateDslResponse updateDsl(String domain, UpdateDslRequest request) { + CrawlingDsl crawlingDsl = crawlingDslRepository.findByDomain(domain) + .orElseThrow(() -> new CrawlingException(CrawlingErrorCode.DOMAIN_NOT_FOUND)); + + crawlingDsl.setName(request.getName()); + crawlingDsl.setTitleDsl(request.getTitleDsl()); + crawlingDsl.setContentDsl(request.getContentDsl()); + crawlingDsl.setCoverImageDsl(request.getCoverImageDsl()); + + // contentType은 옵셔널 - null이 아닐 경우에만 업데이트 + if (request.getContentType() != null) { + crawlingDsl.setContentType(request.getContentType()); + } + + // accessUrl은 옵셔널 - null이 아닐 경우에만 업데이트 + if (request.getAccessUrl() != null) { + crawlingDsl.setAccessUrl(request.getAccessUrl()); + } + + crawlingDsl.setUpdatedAt(Instant.now()); + + CrawlingDsl updated = crawlingDslRepository.save(crawlingDsl); + + return UpdateDslResponse.builder() + .id(updated.getId()) + .domain(updated.getDomain()) + .name(updated.getName()) + .titleDsl(updated.getTitleDsl()) + .contentDsl(updated.getContentDsl()) + .coverImageDsl(updated.getCoverImageDsl()) + .accessUrl(updated.getAccessUrl()) + .message("DSL updated successfully.") + .build(); + } + + public void deleteDsl(String domain) { + if (!crawlingDslRepository.existsByDomain(domain)) { + throw new CrawlingException(CrawlingErrorCode.DOMAIN_NOT_FOUND); + } + crawlingDslRepository.deleteByDomain(domain); + } + + public String extractDomain(String url) { + try { + URL parsedUrl = new URL(url); + String host = parsedUrl.getHost().toLowerCase(); + + Pattern pattern = Pattern.compile("([^.]+\\.[^.]+)$"); + Matcher matcher = pattern.matcher(host); + + if (matcher.find()) { + return matcher.group(1); + } + + return host; + } + catch (MalformedURLException e) { + return null; + } + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/controller/FcmController.java b/src/main/java/com/linglevel/api/fcm/controller/FcmController.java index 6dfed745..049d7c45 100644 --- a/src/main/java/com/linglevel/api/fcm/controller/FcmController.java +++ b/src/main/java/com/linglevel/api/fcm/controller/FcmController.java @@ -27,45 +27,45 @@ @Tag(name = "FCM Token Management", description = "Firebase Cloud Messaging 토큰 관리 API") public class FcmController { - private final FcmTokenService fcmTokenService; + private final FcmTokenService fcmTokenService; - @Operation(summary = "FCM 토큰 등록/업데이트", - description = "사용자의 FCM 토큰을 등록하거나 업데이트합니다. 동일한 사용자+디바이스 조합이 이미 존재하는 경우 토큰을 업데이트하고, 존재하지 않는 경우 새로 생성합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "FCM 토큰 업데이트 성공", - content = @Content(schema = @Schema(implementation = FcmTokenUpdateResponse.class))), - @ApiResponse(responseCode = "201", description = "FCM 토큰 생성 성공", - content = @Content(schema = @Schema(implementation = FcmTokenCreateResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PutMapping("/token") - public ResponseEntity upsertFcmToken(@Valid @RequestBody FcmTokenUpsertRequest request, - @AuthenticationPrincipal JwtClaims claims) { + @Operation(summary = "FCM 토큰 등록/업데이트", + description = "사용자의 FCM 토큰을 등록하거나 업데이트합니다. 동일한 사용자+디바이스 조합이 이미 존재하는 경우 토큰을 업데이트하고, 존재하지 않는 경우 새로 생성합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "FCM 토큰 업데이트 성공", + content = @Content(schema = @Schema(implementation = FcmTokenUpdateResponse.class))), + @ApiResponse(responseCode = "201", description = "FCM 토큰 생성 성공", + content = @Content(schema = @Schema(implementation = FcmTokenCreateResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PutMapping("/token") + public ResponseEntity upsertFcmToken(@Valid @RequestBody FcmTokenUpsertRequest request, + @AuthenticationPrincipal JwtClaims claims) { - FcmTokenUpsertResult result = fcmTokenService.upsertFcmToken(claims.getId(), request); + FcmTokenUpsertResult result = fcmTokenService.upsertFcmToken(claims.getId(), request); - if (result.isCreated()) { - FcmTokenCreateResponse response = FcmTokenCreateResponse.builder() - .message("FCM token created successfully.") - .tokenId(result.getTokenId()) - .build(); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } else { - FcmTokenUpdateResponse response = FcmTokenUpdateResponse.builder() - .message("FCM token updated successfully.") - .tokenId(result.getTokenId()) - .build(); - return ResponseEntity.ok(response); - } - } + if (result.isCreated()) { + FcmTokenCreateResponse response = FcmTokenCreateResponse.builder() + .message("FCM token created successfully.") + .tokenId(result.getTokenId()) + .build(); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + else { + FcmTokenUpdateResponse response = FcmTokenUpdateResponse.builder() + .message("FCM token updated successfully.") + .tokenId(result.getTokenId()) + .build(); + return ResponseEntity.ok(response); + } + } + + @ExceptionHandler(FcmException.class) + public ResponseEntity handleFcmException(FcmException e) { + log.error("FCM Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(FcmException.class) - public ResponseEntity handleFcmException(FcmException e) { - log.error("FCM Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/controller/PushLogController.java b/src/main/java/com/linglevel/api/fcm/controller/PushLogController.java index bd972597..47a5bdf5 100644 --- a/src/main/java/com/linglevel/api/fcm/controller/PushLogController.java +++ b/src/main/java/com/linglevel/api/fcm/controller/PushLogController.java @@ -17,15 +17,14 @@ @Tag(name = "Push Logs", description = "푸시 알림 로그 API") public class PushLogController { - private final PushLogService pushLogService; + private final PushLogService pushLogService; + + @PostMapping("/opened") + @Operation(summary = "푸시 알림 오픈 리포트", + description = "클라이언트에서 사용자가 푸시 알림을 탭하여 열었을 때 서버에 리포트합니다. 인증 불필요 (userId는 요청 본문에 포함).") + public ResponseEntity logOpened(@Valid @RequestBody PushOpenedRequest request) { + pushLogService.logOpened(request.getUserId(), request.getCampaignId(), request.getOpenedAt()); + return ResponseEntity.ok().build(); + } - @PostMapping("/opened") - @Operation( - summary = "푸시 알림 오픈 리포트", - description = "클라이언트에서 사용자가 푸시 알림을 탭하여 열었을 때 서버에 리포트합니다. 인증 불필요 (userId는 요청 본문에 포함)." - ) - public ResponseEntity logOpened(@Valid @RequestBody PushOpenedRequest request) { - pushLogService.logOpened(request.getUserId(), request.getCampaignId(), request.getOpenedAt()); - return ResponseEntity.ok().build(); - } } diff --git a/src/main/java/com/linglevel/api/fcm/dto/FcmMessageRequest.java b/src/main/java/com/linglevel/api/fcm/dto/FcmMessageRequest.java index 11a5baa0..11e2851a 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/FcmMessageRequest.java +++ b/src/main/java/com/linglevel/api/fcm/dto/FcmMessageRequest.java @@ -12,53 +12,59 @@ @Schema(description = "FCM 메시지 요청") public class FcmMessageRequest { - @Schema(description = "알림 제목", example = "Content Ready", required = true) - private String title; + @Schema(description = "알림 제목", example = "Content Ready", required = true) + private String title; - @Schema(description = "알림 내용", example = "Your content has been successfully processed", required = true) - private String body; + @Schema(description = "알림 내용", example = "Your content has been successfully processed", required = true) + private String body; - @Schema(description = "알림 타입", example = "custom_content_completed") - private String type; + @Schema(description = "알림 타입", example = "custom_content_completed") + private String type; - @Schema(description = "사용자 ID", example = "user123") - private String userId; + @Schema(description = "사용자 ID", example = "user123") + private String userId; - @Schema(description = "탭했을 때 수행할 액션", example = "view_content") - private String action; + @Schema(description = "탭했을 때 수행할 액션", example = "view_content") + private String action; - @Schema(description = "딥링크 URL", example = "/custom-content/123") - private String deepLink; + @Schema(description = "딥링크 URL", example = "/custom-content/123") + private String deepLink; - @Schema(description = "캠페인 ID", example = "newArticle-tech") - private String campaignId; + @Schema(description = "캠페인 ID", example = "newArticle-tech") + private String campaignId; - @Schema(description = "추가 데이터", example = "{\"requestId\": \"req123\", \"contentTitle\": \"My Content\"}") - private Map additionalData; + @Schema(description = "추가 데이터", example = "{\"requestId\": \"req123\", \"contentTitle\": \"My Content\"}") + private Map additionalData; - @Schema(description = "최종 FCM data (자동 생성)", hidden = true) - private Map data; + @Schema(description = "최종 FCM data (자동 생성)", hidden = true) + private Map data; - public Map buildFcmData() { - Map fcmData = new HashMap<>(); + public Map buildFcmData() { + Map fcmData = new HashMap<>(); - if (type != null) fcmData.put("type", type); - if (userId != null) fcmData.put("userId", userId); - if (action != null) fcmData.put("action", action); - if (deepLink != null) fcmData.put("deepLink", deepLink); - if (campaignId != null) fcmData.put("campaignId", campaignId); + if (type != null) + fcmData.put("type", type); + if (userId != null) + fcmData.put("userId", userId); + if (action != null) + fcmData.put("action", action); + if (deepLink != null) + fcmData.put("deepLink", deepLink); + if (campaignId != null) + fcmData.put("campaignId", campaignId); - if (additionalData != null) { - fcmData.putAll(additionalData); - } + if (additionalData != null) { + fcmData.putAll(additionalData); + } - return fcmData; - } + return fcmData; + } + + public Map getData() { + if (data == null) { + data = buildFcmData(); + } + return data; + } - public Map getData() { - if (data == null) { - data = buildFcmData(); - } - return data; - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenCreateResponse.java b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenCreateResponse.java index 68834606..c98a6739 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenCreateResponse.java +++ b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenCreateResponse.java @@ -9,9 +9,10 @@ @Schema(description = "FCM 토큰 생성 응답") public class FcmTokenCreateResponse { - @Schema(description = "응답 메시지", example = "FCM token created successfully.") - private String message; + @Schema(description = "응답 메시지", example = "FCM token created successfully.") + private String message; + + @Schema(description = "생성된 토큰 ID", example = "60d0fe4f5311236168a109ca") + private String tokenId; - @Schema(description = "생성된 토큰 ID", example = "60d0fe4f5311236168a109ca") - private String tokenId; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpdateResponse.java b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpdateResponse.java index 4403caea..63f30822 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpdateResponse.java +++ b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpdateResponse.java @@ -9,9 +9,10 @@ @Schema(description = "FCM 토큰 업데이트 응답") public class FcmTokenUpdateResponse { - @Schema(description = "응답 메시지", example = "FCM token updated successfully.") - private String message; + @Schema(description = "응답 메시지", example = "FCM token updated successfully.") + private String message; + + @Schema(description = "업데이트된 토큰 ID", example = "60d0fe4f5311236168a109ca") + private String tokenId; - @Schema(description = "업데이트된 토큰 ID", example = "60d0fe4f5311236168a109ca") - private String tokenId; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertRequest.java b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertRequest.java index 382e72f9..860cbb04 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertRequest.java +++ b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertRequest.java @@ -11,24 +11,25 @@ @Schema(description = "FCM 토큰 등록/업데이트 요청") public class FcmTokenUpsertRequest { - @NotBlank(message = "FCM token is required") - @Schema(description = "Firebase Cloud Messaging 토큰", example = "fK7JxVxGTkOZ9h_0XYzGjI:APA91bHxyz...") - private String fcmToken; + @NotBlank(message = "FCM token is required") + @Schema(description = "Firebase Cloud Messaging 토큰", example = "fK7JxVxGTkOZ9h_0XYzGjI:APA91bHxyz...") + private String fcmToken; - @NotBlank(message = "Device ID is required") - @Schema(description = "디바이스 고유 식별자", example = "device-uuid-123") - private String deviceId; + @NotBlank(message = "Device ID is required") + @Schema(description = "디바이스 고유 식별자", example = "device-uuid-123") + private String deviceId; - @NotNull(message = "Platform is required") - @Schema(description = "플랫폼 종류", example = "ANDROID", allowableValues = {"ANDROID", "IOS", "WEB"}) - private FcmPlatform platform; + @NotNull(message = "Platform is required") + @Schema(description = "플랫폼 종류", example = "ANDROID", allowableValues = { "ANDROID", "IOS", "WEB" }) + private FcmPlatform platform; - @Schema(description = "디바이스 국가 코드 (기본값: US)", example = "KR", defaultValue = "US") - private CountryCode countryCode; + @Schema(description = "디바이스 국가 코드 (기본값: US)", example = "KR", defaultValue = "US") + private CountryCode countryCode; - @Schema(description = "앱 버전", example = "1.2.3") - private String appVersion; + @Schema(description = "앱 버전", example = "1.2.3") + private String appVersion; + + @Schema(description = "OS 버전", example = "iOS 17.1") + private String osVersion; - @Schema(description = "OS 버전", example = "iOS 17.1") - private String osVersion; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertResult.java b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertResult.java index f81ea931..2ab93910 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertResult.java +++ b/src/main/java/com/linglevel/api/fcm/dto/FcmTokenUpsertResult.java @@ -6,6 +6,9 @@ @Data @Builder public class FcmTokenUpsertResult { - private String tokenId; - private boolean created; + + private String tokenId; + + private boolean created; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/dto/NotificationMessage.java b/src/main/java/com/linglevel/api/fcm/dto/NotificationMessage.java index 7f0dcfda..f9772bae 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/NotificationMessage.java +++ b/src/main/java/com/linglevel/api/fcm/dto/NotificationMessage.java @@ -10,48 +10,42 @@ @Getter @RequiredArgsConstructor public enum NotificationMessage { - // Custom Content 메시지 - CONTENT_COMPLETED( - Map.of( - CountryCode.KR, new Message("콘텐츠 준비 완료", "'%s' 처리가 완료되었습니다."), - CountryCode.US, new Message("Content Ready", "'%s' has been successfully processed."), - CountryCode.JP, new Message("コンテンツ準備完了", "'%s'の処理が完了しました。") - ) - ), - CONTENT_FAILED( - Map.of( - CountryCode.KR, new Message("콘텐츠 처리 실패", "처리 중 오류가 발생했습니다."), - CountryCode.US, new Message("Content Processing Failed", "An error occurred while processing."), - CountryCode.JP, new Message("コンテンツ処理失敗", "処理中にエラーが発生しました。") - ) - ); - - private final Map translations; - - /** - * 국가 코드에 해당하는 제목을 반환합니다. - * 해당 국가의 번역이 없으면 기본값(US)을 반환합니다. - */ - public String getTitle(CountryCode countryCode) { - CountryCode code = countryCode != null ? countryCode : CountryCode.US; - return translations.getOrDefault(code, translations.get(CountryCode.US)).title; - } - - /** - * 국가 코드에 해당하는 본문을 반환합니다. - * 파라미터를 사용하여 메시지 템플릿을 포맷팅합니다. - * 해당 국가의 번역이 없으면 기본값(US)을 반환합니다. - */ - public String getBody(CountryCode countryCode, Object... params) { - CountryCode code = countryCode != null ? countryCode : CountryCode.US; - String template = translations.getOrDefault(code, translations.get(CountryCode.US)).body; - return params.length > 0 ? String.format(template, params) : template; - } - - @Getter - @AllArgsConstructor - public static class Message { - private final String title; - private final String body; - } + + // Custom Content 메시지 + CONTENT_COMPLETED(Map.of(CountryCode.KR, new Message("콘텐츠 준비 완료", "'%s' 처리가 완료되었습니다."), CountryCode.US, + new Message("Content Ready", "'%s' has been successfully processed."), CountryCode.JP, + new Message("コンテンツ準備完了", "'%s'の処理が完了しました。"))), + CONTENT_FAILED(Map.of(CountryCode.KR, new Message("콘텐츠 처리 실패", "처리 중 오류가 발생했습니다."), CountryCode.US, + new Message("Content Processing Failed", "An error occurred while processing."), CountryCode.JP, + new Message("コンテンツ処理失敗", "処理中にエラーが発生しました。"))); + + private final Map translations; + + /** + * 국가 코드에 해당하는 제목을 반환합니다. 해당 국가의 번역이 없으면 기본값(US)을 반환합니다. + */ + public String getTitle(CountryCode countryCode) { + CountryCode code = countryCode != null ? countryCode : CountryCode.US; + return translations.getOrDefault(code, translations.get(CountryCode.US)).title; + } + + /** + * 국가 코드에 해당하는 본문을 반환합니다. 파라미터를 사용하여 메시지 템플릿을 포맷팅합니다. 해당 국가의 번역이 없으면 기본값(US)을 반환합니다. + */ + public String getBody(CountryCode countryCode, Object... params) { + CountryCode code = countryCode != null ? countryCode : CountryCode.US; + String template = translations.getOrDefault(code, translations.get(CountryCode.US)).body; + return params.length > 0 ? String.format(template, params) : template; + } + + @Getter + @AllArgsConstructor + public static class Message { + + private final String title; + + private final String body; + + } + } diff --git a/src/main/java/com/linglevel/api/fcm/dto/PushCampaignStats.java b/src/main/java/com/linglevel/api/fcm/dto/PushCampaignStats.java index bfc40fe2..57ddf3f2 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/PushCampaignStats.java +++ b/src/main/java/com/linglevel/api/fcm/dto/PushCampaignStats.java @@ -11,24 +11,25 @@ @Schema(description = "푸시 캠페인 통계") public class PushCampaignStats { - @Schema(description = "캠페인 ID", example = "article-tech-1234567890") - private String campaignId; + @Schema(description = "캠페인 ID", example = "article-tech-1234567890") + private String campaignId; - @Schema(description = "캠페인 시작 시간 (첫 전송 시간)", example = "2024-01-15T10:00:00") - private LocalDateTime firstSentAt; + @Schema(description = "캠페인 시작 시간 (첫 전송 시간)", example = "2024-01-15T10:00:00") + private LocalDateTime firstSentAt; - @Schema(description = "총 전송 시도 수", example = "1000") - private int totalSent; + @Schema(description = "총 전송 시도 수", example = "1000") + private int totalSent; - @Schema(description = "전송 성공 수", example = "950") - private int sentSuccess; + @Schema(description = "전송 성공 수", example = "950") + private int sentSuccess; - @Schema(description = "알림 오픈 수", example = "380") - private int totalOpened; + @Schema(description = "알림 오픈 수", example = "380") + private int totalOpened; - @Schema(description = "전송 성공률 (sentSuccess / totalSent)", example = "0.95") - private double deliveryRate; + @Schema(description = "전송 성공률 (sentSuccess / totalSent)", example = "0.95") + private double deliveryRate; + + @Schema(description = "오픈율 (totalOpened / sentSuccess)", example = "0.40") + private double openRate; - @Schema(description = "오픈율 (totalOpened / sentSuccess)", example = "0.40") - private double openRate; } diff --git a/src/main/java/com/linglevel/api/fcm/dto/PushCampaignSummary.java b/src/main/java/com/linglevel/api/fcm/dto/PushCampaignSummary.java index 77b84976..42fc68be 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/PushCampaignSummary.java +++ b/src/main/java/com/linglevel/api/fcm/dto/PushCampaignSummary.java @@ -11,24 +11,25 @@ @Schema(description = "푸시 캠페인 요약") public class PushCampaignSummary { - @Schema(description = "캠페인 ID", example = "article-tech-1234567890") - private String campaignId; + @Schema(description = "캠페인 ID", example = "article-tech-1234567890") + private String campaignId; - @Schema(description = "캠페인 타입 (campaignId에서 추출)", example = "article") - private String campaignType; + @Schema(description = "캠페인 타입 (campaignId에서 추출)", example = "article") + private String campaignType; - @Schema(description = "캠페인 시작 시간", example = "2024-01-15T10:00:00") - private LocalDateTime firstSentAt; + @Schema(description = "캠페인 시작 시간", example = "2024-01-15T10:00:00") + private LocalDateTime firstSentAt; - @Schema(description = "총 전송 수", example = "1000") - private int totalSent; + @Schema(description = "총 전송 수", example = "1000") + private int totalSent; - @Schema(description = "전송 성공 수", example = "950") - private int sentSuccess; + @Schema(description = "전송 성공 수", example = "950") + private int sentSuccess; - @Schema(description = "오픈 수", example = "380") - private int totalOpened; + @Schema(description = "오픈 수", example = "380") + private int totalOpened; + + @Schema(description = "오픈율", example = "0.40") + private double openRate; - @Schema(description = "오픈율", example = "0.40") - private double openRate; } diff --git a/src/main/java/com/linglevel/api/fcm/dto/PushOpenedRequest.java b/src/main/java/com/linglevel/api/fcm/dto/PushOpenedRequest.java index cb1aad8a..a7607cfa 100644 --- a/src/main/java/com/linglevel/api/fcm/dto/PushOpenedRequest.java +++ b/src/main/java/com/linglevel/api/fcm/dto/PushOpenedRequest.java @@ -11,15 +11,16 @@ @Schema(description = "푸시 알림 오픈 리포트 요청") public class PushOpenedRequest { - @NotBlank(message = "Campaign ID is required") - @Schema(description = "캠페인 ID", example = "article-tech-1234567890", required = true) - private String campaignId; + @NotBlank(message = "Campaign ID is required") + @Schema(description = "캠페인 ID", example = "article-tech-1234567890", required = true) + private String campaignId; - @NotBlank(message = "User ID is required") - @Schema(description = "사용자 ID", example = "user123", required = true) - private String userId; + @NotBlank(message = "User ID is required") + @Schema(description = "사용자 ID", example = "user123", required = true) + private String userId; + + @NotNull(message = "Opened time is required") + @Schema(description = "알림을 오픈한 시간", example = "2024-01-15T10:30:00", required = true) + private LocalDateTime openedAt; - @NotNull(message = "Opened time is required") - @Schema(description = "알림을 오픈한 시간", example = "2024-01-15T10:30:00", required = true) - private LocalDateTime openedAt; } diff --git a/src/main/java/com/linglevel/api/fcm/entity/FcmPlatform.java b/src/main/java/com/linglevel/api/fcm/entity/FcmPlatform.java index eb26929c..d4487612 100644 --- a/src/main/java/com/linglevel/api/fcm/entity/FcmPlatform.java +++ b/src/main/java/com/linglevel/api/fcm/entity/FcmPlatform.java @@ -6,22 +6,26 @@ @Getter @Schema(description = "FCM 플랫폼 타입") public enum FcmPlatform { - @Schema(description = "안드로이드 플랫폼") - ANDROID("android", "Android", "안드로이드 플랫폼"), - - @Schema(description = "iOS 플랫폼") - IOS("ios", "iOS", "iOS 플랫폼"), - - @Schema(description = "웹 플랫폼") - WEB("web", "Web", "웹 플랫폼"); - - private final String code; - private final String name; - private final String description; - - FcmPlatform(String code, String name, String description) { - this.code = code; - this.name = name; - this.description = description; - } + + @Schema(description = "안드로이드 플랫폼") + ANDROID("android", "Android", "안드로이드 플랫폼"), + + @Schema(description = "iOS 플랫폼") + IOS("ios", "iOS", "iOS 플랫폼"), + + @Schema(description = "웹 플랫폼") + WEB("web", "Web", "웹 플랫폼"); + + private final String code; + + private final String name; + + private final String description; + + FcmPlatform(String code, String name, String description) { + this.code = code; + this.name = name; + this.description = description; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/entity/FcmToken.java b/src/main/java/com/linglevel/api/fcm/entity/FcmToken.java index 25860e0e..ff961f49 100644 --- a/src/main/java/com/linglevel/api/fcm/entity/FcmToken.java +++ b/src/main/java/com/linglevel/api/fcm/entity/FcmToken.java @@ -18,37 +18,38 @@ @AllArgsConstructor @Document(collection = "fcmTokens") public class FcmToken { - - @Id - private String id; - - @NotNull - @Indexed - private String userId; - - @NotNull - private String deviceId; - - @NotNull - @Indexed(unique = true) - private String fcmToken; - - @NotNull - private FcmPlatform platform; - - private CountryCode countryCode; - - private String appVersion; - - private String osVersion; - - @CreatedDate - private LocalDateTime createdAt; - - @LastModifiedDate - @Indexed(expireAfter = "7776000s") // 90일 자동 삭제 - private LocalDateTime updatedAt; - - @Builder.Default - private Boolean isActive = true; + + @Id + private String id; + + @NotNull + @Indexed + private String userId; + + @NotNull + private String deviceId; + + @NotNull + @Indexed(unique = true) + private String fcmToken; + + @NotNull + private FcmPlatform platform; + + private CountryCode countryCode; + + private String appVersion; + + private String osVersion; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + @Indexed(expireAfter = "7776000s") // 90일 자동 삭제 + private LocalDateTime updatedAt; + + @Builder.Default + private Boolean isActive = true; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/entity/PushLog.java b/src/main/java/com/linglevel/api/fcm/entity/PushLog.java index ce4d22ab..06976e75 100644 --- a/src/main/java/com/linglevel/api/fcm/entity/PushLog.java +++ b/src/main/java/com/linglevel/api/fcm/entity/PushLog.java @@ -17,29 +17,32 @@ @Document(collection = "pushLogs") public class PushLog { - @Id - private String id; + @Id + private String id; - @Indexed(unique = true) - private String campaignId; // 각 메시지의 고유 ID (자체 UUID) + @Indexed(unique = true) + private String campaignId; // 각 메시지의 고유 ID (자체 UUID) - private String fcmMessageId; // FCM messageId (선택적, FCM 추적용) + private String fcmMessageId; // FCM messageId (선택적, FCM 추적용) - @Indexed - private String campaignGroup; // 내부 그룹화용 (선택적) + @Indexed + private String campaignGroup; // 내부 그룹화용 (선택적) - @Indexed - private String userId; + @Indexed + private String userId; - @Indexed - private LocalDateTime sentAt; - private Boolean sentSuccess; - private LocalDateTime openedAt; + @Indexed + private LocalDateTime sentAt; - @CreatedDate - @Indexed(expireAfter = "15552000s") - private LocalDateTime createdAt; + private Boolean sentSuccess; + + private LocalDateTime openedAt; + + @CreatedDate + @Indexed(expireAfter = "15552000s") + private LocalDateTime createdAt; + + @Version + private Long version; - @Version - private Long version; } diff --git a/src/main/java/com/linglevel/api/fcm/exception/FcmErrorCode.java b/src/main/java/com/linglevel/api/fcm/exception/FcmErrorCode.java index 801d809b..a4a345ea 100644 --- a/src/main/java/com/linglevel/api/fcm/exception/FcmErrorCode.java +++ b/src/main/java/com/linglevel/api/fcm/exception/FcmErrorCode.java @@ -7,24 +7,27 @@ @Getter @AllArgsConstructor public enum FcmErrorCode { - FCM_TOKEN_REQUIRED(HttpStatus.BAD_REQUEST, "fcmToken, deviceId, and platform are required."), - INVALID_FCM_TOKEN(HttpStatus.BAD_REQUEST, "Invalid FCM token format."), - INVALID_DEVICE_ID(HttpStatus.BAD_REQUEST, "Invalid device ID format."), - INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "Invalid platform. Must be one of: ANDROID, IOS, WEB."), - - TOKEN_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create FCM token."), - TOKEN_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to update FCM token."), - TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "FCM token not found."), - INVALID_FCM_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "FCM token validation failed."), - MESSAGE_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to send FCM message."), - - PUSH_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "Push log not found for the given campaign and user."), - PUSH_LOG_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to save push log."), - - UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "Invalid or expired token."), - - NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "not implemented yet."); - - private final HttpStatus status; - private final String message; + + FCM_TOKEN_REQUIRED(HttpStatus.BAD_REQUEST, "fcmToken, deviceId, and platform are required."), + INVALID_FCM_TOKEN(HttpStatus.BAD_REQUEST, "Invalid FCM token format."), + INVALID_DEVICE_ID(HttpStatus.BAD_REQUEST, "Invalid device ID format."), + INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "Invalid platform. Must be one of: ANDROID, IOS, WEB."), + + TOKEN_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create FCM token."), + TOKEN_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to update FCM token."), + TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "FCM token not found."), + INVALID_FCM_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "FCM token validation failed."), + MESSAGE_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to send FCM message."), + + PUSH_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "Push log not found for the given campaign and user."), + PUSH_LOG_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to save push log."), + + UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "Invalid or expired token."), + + NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "not implemented yet."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/exception/FcmException.java b/src/main/java/com/linglevel/api/fcm/exception/FcmException.java index 12d3ed06..6d0786f1 100644 --- a/src/main/java/com/linglevel/api/fcm/exception/FcmException.java +++ b/src/main/java/com/linglevel/api/fcm/exception/FcmException.java @@ -5,10 +5,12 @@ @Getter public class FcmException extends RuntimeException { - private final HttpStatus status; - public FcmException(FcmErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public FcmException(FcmErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/repository/FcmTokenRepository.java b/src/main/java/com/linglevel/api/fcm/repository/FcmTokenRepository.java index 2f5a80f2..1580bbb4 100644 --- a/src/main/java/com/linglevel/api/fcm/repository/FcmTokenRepository.java +++ b/src/main/java/com/linglevel/api/fcm/repository/FcmTokenRepository.java @@ -8,17 +8,18 @@ public interface FcmTokenRepository extends MongoRepository { - Optional findByUserIdAndDeviceId(String userId, String deviceId); + Optional findByUserIdAndDeviceId(String userId, String deviceId); - List findByUserId(String userId); + List findByUserId(String userId); - Optional findByFcmToken(String fcmToken); + Optional findByFcmToken(String fcmToken); - Optional findFirstByFcmToken(String fcmToken); + Optional findFirstByFcmToken(String fcmToken); - List findByUserIdAndIsActive(String userId, Boolean isActive); + List findByUserIdAndIsActive(String userId, Boolean isActive); - List findByIsActive(Boolean isActive); + List findByIsActive(Boolean isActive); + + List findAllByFcmTokenIn(List fcmTokens); - List findAllByFcmTokenIn(List fcmTokens); } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/repository/PushLogRepository.java b/src/main/java/com/linglevel/api/fcm/repository/PushLogRepository.java index 08b4997f..04fb3900 100644 --- a/src/main/java/com/linglevel/api/fcm/repository/PushLogRepository.java +++ b/src/main/java/com/linglevel/api/fcm/repository/PushLogRepository.java @@ -11,11 +11,12 @@ @Repository public interface PushLogRepository extends MongoRepository { - Optional findByCampaignId(String campaignId); + Optional findByCampaignId(String campaignId); - List findByCampaignGroup(String campaignGroup); + List findByCampaignGroup(String campaignGroup); - List findByUserId(String userId); + List findByUserId(String userId); + + List findBySentAtBetween(LocalDateTime startDate, LocalDateTime endDate); - List findBySentAtBetween(LocalDateTime startDate, LocalDateTime endDate); } diff --git a/src/main/java/com/linglevel/api/fcm/service/FcmMessagingService.java b/src/main/java/com/linglevel/api/fcm/service/FcmMessagingService.java index 01158cc3..b31c9bc2 100644 --- a/src/main/java/com/linglevel/api/fcm/service/FcmMessagingService.java +++ b/src/main/java/com/linglevel/api/fcm/service/FcmMessagingService.java @@ -25,237 +25,242 @@ @Slf4j public class FcmMessagingService { - private final FirebaseMessaging firebaseMessaging; - private final FcmTokenRepository fcmTokenRepository; - private final PushLogRepository pushLogRepository; - private final PushLogService pushLogService; - - private static final String ANALYTICS_LABEL_PREFIX = "notification_sent_"; - - /** - * 단일 사용자에게 알림 전송 - */ - public String sendMessage(String fcmToken, FcmMessageRequest messageRequest) { - String userId = getUserIdFromToken(fcmToken); - String campaignGroup = messageRequest.getCampaignId(); // 원래의 campaignId를 그룹으로 사용 - String pushId = UUID.randomUUID().toString(); - - try { - Map data = buildDataWithUserId(messageRequest, userId); - data.put("campaignId", pushId); - - Message.Builder messageBuilder = Message.builder() - .setToken(fcmToken) - .setNotification(Notification.builder() - .setTitle(messageRequest.getTitle()) - .setBody(messageRequest.getBody()) - .build()) - .putAllData(data); - - // Google Analytics 추적을 위한 FcmOptions 설정 - if (campaignGroup != null) { - String analyticsLabel = ANALYTICS_LABEL_PREFIX + campaignGroup; - messageBuilder.setFcmOptions(FcmOptions.withAnalyticsLabel(analyticsLabel)); - log.debug("Analytics label set: {}", analyticsLabel); - } - - Message message = messageBuilder.build(); - String fcmMessageId = firebaseMessaging.send(message); - log.debug("FCM message sent successfully - pushId: {}, fcmMessageId: {}", pushId, fcmMessageId); - - if (userId != null) { - pushLogService.logSent(pushId, userId, true, campaignGroup, fcmMessageId); - } - - return pushId; // 자체 UUID 반환 - - } catch (FirebaseMessagingException e) { - log.error("Failed to send FCM message - pushId: {}", pushId, e); - - if (userId != null) { - pushLogService.logSent(pushId, userId, false, campaignGroup, null); - } - - throw new FcmException(FcmErrorCode.MESSAGE_SEND_FAILED); - } - } - - /** - * 여러 사용자에게 동시 알림 전송 (각 토큰마다 userId 포함) - */ - public BatchResponse sendMulticastMessage(List fcmTokens, FcmMessageRequest messageRequest) { - String campaignGroup = messageRequest.getCampaignId(); // 원래의 campaignId를 그룹으로 사용 - - try { - if (fcmTokens == null || fcmTokens.isEmpty()) { - throw new FcmException(FcmErrorCode.MESSAGE_SEND_FAILED); - } - - // 각 토큰마다 userId를 포함한 개별 메시지 생성 - Map tokenToUserId = getTokenToUserIdMap(fcmTokens); - List messages = new ArrayList<>(); - Map indexToPushId = new java.util.HashMap<>(); // 인덱스별 pushId 매핑 - - // Google Analytics 추적을 위한 FcmOptions 설정 - String analyticsLabel = null; - FcmOptions fcmOptions = null; - if (campaignGroup != null) { - analyticsLabel = ANALYTICS_LABEL_PREFIX + campaignGroup; - fcmOptions = FcmOptions.withAnalyticsLabel(analyticsLabel); - } - - int index = 0; - for (String fcmToken : fcmTokens) { - String userId = tokenToUserId.get(fcmToken); - String pushId = UUID.randomUUID().toString(); - indexToPushId.put(index, pushId); - - Map data = buildDataWithUserId(messageRequest, userId); - data.put("campaignId", pushId); - - Message.Builder messageBuilder = Message.builder() - .setToken(fcmToken) - .setNotification(Notification.builder() - .setTitle(messageRequest.getTitle()) - .setBody(messageRequest.getBody()) - .build()) - .putAllData(data); - - if (fcmOptions != null) { - messageBuilder.setFcmOptions(fcmOptions); - } - - messages.add(messageBuilder.build()); - index++; - } - - BatchResponse response = firebaseMessaging.sendEach(messages); - log.info("Batch messages sent to {} tokens - Success: {}, Failed: {} (Analytics: {})", - fcmTokens.size(), response.getSuccessCount(), response.getFailureCount(), - analyticsLabel != null ? analyticsLabel : "N/A"); - - // 배치로 로그 저장 (자체 UUID와 FCM messageId 함께 저장) - savePushLogsBatch(fcmTokens, campaignGroup, response, indexToPushId); - - return response; - - } catch (FirebaseMessagingException e) { - log.error("Failed to send multicast FCM message: {}", e.getMessage()); - - savePushLogsAllFailed(fcmTokens, campaignGroup); - - throw new FcmException(FcmErrorCode.MESSAGE_SEND_FAILED); - } - } - - /** - * 푸시 로그를 배치로 저장 (성공/실패 혼합) - */ - private void savePushLogsBatch(List fcmTokens, String campaignGroup, BatchResponse response, Map indexToPushId) { - try { - Map tokenToUserId = getTokenToUserIdMap(fcmTokens); - List logsToSave = new ArrayList<>(); - LocalDateTime now = LocalDateTime.now(); - - for (int i = 0; i < response.getResponses().size(); i++) { - String fcmToken = fcmTokens.get(i); - String userId = tokenToUserId.get(fcmToken); - SendResponse sendResponse = response.getResponses().get(i); - boolean success = sendResponse.isSuccessful(); - String pushId = indexToPushId.get(i); // 미리 생성된 UUID - - if (userId != null && pushId != null) { - String fcmMessageId = success ? sendResponse.getMessageId() : null; - logsToSave.add(createPushLog(pushId, userId, success, campaignGroup, fcmMessageId, now)); - } - } - - savePushLogsIfNotEmpty(logsToSave, campaignGroup); - } catch (Exception e) { - log.error("Failed to batch save push logs for campaignGroup: {}", campaignGroup, e); - } - } - - /** - * 전체 실패 시 푸시 로그를 배치로 저장 - */ - private void savePushLogsAllFailed(List fcmTokens, String campaignGroup) { - try { - Map tokenToUserId = getTokenToUserIdMap(fcmTokens); - List logsToSave = new ArrayList<>(); - LocalDateTime now = LocalDateTime.now(); - - for (String fcmToken : fcmTokens) { - String userId = tokenToUserId.get(fcmToken); - if (userId != null) { - String pushId = UUID.randomUUID().toString(); - logsToSave.add(createPushLog(pushId, userId, false, campaignGroup, null, now)); - } - } - - savePushLogsIfNotEmpty(logsToSave, campaignGroup); - } catch (Exception e) { - log.error("Failed to batch save failed push logs for campaignGroup: {}", campaignGroup, e); - } - } - - /** - * FCM 토큰 목록으로 토큰-사용자ID 맵 생성 - */ - private Map getTokenToUserIdMap(List fcmTokens) { - List tokens = fcmTokenRepository.findAllByFcmTokenIn(fcmTokens); - return tokens.stream() - .collect(Collectors.toMap( - FcmToken::getFcmToken, - FcmToken::getUserId, - (existing, replacement) -> existing - )); - } - - /** - * PushLog 객체 생성 - */ - private PushLog createPushLog(String pushId, String userId, boolean success, String campaignGroup, String fcmMessageId, LocalDateTime now) { - return PushLog.builder() - .campaignId(pushId) // 자체 UUID를 campaignId로 사용 - .fcmMessageId(fcmMessageId) // FCM messageId (선택적) - .campaignGroup(campaignGroup) // 캠페인 그룹 - .userId(userId) - .sentAt(now) - .sentSuccess(success) - .createdAt(now) - .build(); - } - - /** - * PushLog 목록이 비어있지 않으면 배치 저장 - */ - private void savePushLogsIfNotEmpty(List logsToSave, String campaignGroup) { - if (!logsToSave.isEmpty()) { - pushLogRepository.saveAll(logsToSave); - log.debug("Batch saved {} push logs for campaignGroup: {}", logsToSave.size(), campaignGroup); - } - } - - /** - * FCM 토큰으로 사용자 ID 조회 - */ - private String getUserIdFromToken(String fcmToken) { - Optional tokenOpt = fcmTokenRepository.findFirstByFcmToken(fcmToken); - return tokenOpt.map(FcmToken::getUserId).orElse(null); - } - - /** - * 메시지 요청의 data에 userId 추가 - */ - private Map buildDataWithUserId(FcmMessageRequest messageRequest, String userId) { - Map data = messageRequest.getData() != null - ? new java.util.HashMap<>(messageRequest.getData()) - : new java.util.HashMap<>(); - if (userId != null) { - data.put("userId", userId); - } - return data; - } + private final FirebaseMessaging firebaseMessaging; + + private final FcmTokenRepository fcmTokenRepository; + + private final PushLogRepository pushLogRepository; + + private final PushLogService pushLogService; + + private static final String ANALYTICS_LABEL_PREFIX = "notification_sent_"; + + /** + * 단일 사용자에게 알림 전송 + */ + public String sendMessage(String fcmToken, FcmMessageRequest messageRequest) { + String userId = getUserIdFromToken(fcmToken); + String campaignGroup = messageRequest.getCampaignId(); // 원래의 campaignId를 그룹으로 사용 + String pushId = UUID.randomUUID().toString(); + + try { + Map data = buildDataWithUserId(messageRequest, userId); + data.put("campaignId", pushId); + + Message.Builder messageBuilder = Message.builder() + .setToken(fcmToken) + .setNotification(Notification.builder() + .setTitle(messageRequest.getTitle()) + .setBody(messageRequest.getBody()) + .build()) + .putAllData(data); + + // Google Analytics 추적을 위한 FcmOptions 설정 + if (campaignGroup != null) { + String analyticsLabel = ANALYTICS_LABEL_PREFIX + campaignGroup; + messageBuilder.setFcmOptions(FcmOptions.withAnalyticsLabel(analyticsLabel)); + log.debug("Analytics label set: {}", analyticsLabel); + } + + Message message = messageBuilder.build(); + String fcmMessageId = firebaseMessaging.send(message); + log.debug("FCM message sent successfully - pushId: {}, fcmMessageId: {}", pushId, fcmMessageId); + + if (userId != null) { + pushLogService.logSent(pushId, userId, true, campaignGroup, fcmMessageId); + } + + return pushId; // 자체 UUID 반환 + + } + catch (FirebaseMessagingException e) { + log.error("Failed to send FCM message - pushId: {}", pushId, e); + + if (userId != null) { + pushLogService.logSent(pushId, userId, false, campaignGroup, null); + } + + throw new FcmException(FcmErrorCode.MESSAGE_SEND_FAILED); + } + } + + /** + * 여러 사용자에게 동시 알림 전송 (각 토큰마다 userId 포함) + */ + public BatchResponse sendMulticastMessage(List fcmTokens, FcmMessageRequest messageRequest) { + String campaignGroup = messageRequest.getCampaignId(); // 원래의 campaignId를 그룹으로 사용 + + try { + if (fcmTokens == null || fcmTokens.isEmpty()) { + throw new FcmException(FcmErrorCode.MESSAGE_SEND_FAILED); + } + + // 각 토큰마다 userId를 포함한 개별 메시지 생성 + Map tokenToUserId = getTokenToUserIdMap(fcmTokens); + List messages = new ArrayList<>(); + Map indexToPushId = new java.util.HashMap<>(); // 인덱스별 pushId + // 매핑 + + // Google Analytics 추적을 위한 FcmOptions 설정 + String analyticsLabel = null; + FcmOptions fcmOptions = null; + if (campaignGroup != null) { + analyticsLabel = ANALYTICS_LABEL_PREFIX + campaignGroup; + fcmOptions = FcmOptions.withAnalyticsLabel(analyticsLabel); + } + + int index = 0; + for (String fcmToken : fcmTokens) { + String userId = tokenToUserId.get(fcmToken); + String pushId = UUID.randomUUID().toString(); + indexToPushId.put(index, pushId); + + Map data = buildDataWithUserId(messageRequest, userId); + data.put("campaignId", pushId); + + Message.Builder messageBuilder = Message.builder() + .setToken(fcmToken) + .setNotification(Notification.builder() + .setTitle(messageRequest.getTitle()) + .setBody(messageRequest.getBody()) + .build()) + .putAllData(data); + + if (fcmOptions != null) { + messageBuilder.setFcmOptions(fcmOptions); + } + + messages.add(messageBuilder.build()); + index++; + } + + BatchResponse response = firebaseMessaging.sendEach(messages); + log.info("Batch messages sent to {} tokens - Success: {}, Failed: {} (Analytics: {})", fcmTokens.size(), + response.getSuccessCount(), response.getFailureCount(), + analyticsLabel != null ? analyticsLabel : "N/A"); + + // 배치로 로그 저장 (자체 UUID와 FCM messageId 함께 저장) + savePushLogsBatch(fcmTokens, campaignGroup, response, indexToPushId); + + return response; + + } + catch (FirebaseMessagingException e) { + log.error("Failed to send multicast FCM message: {}", e.getMessage()); + + savePushLogsAllFailed(fcmTokens, campaignGroup); + + throw new FcmException(FcmErrorCode.MESSAGE_SEND_FAILED); + } + } + + /** + * 푸시 로그를 배치로 저장 (성공/실패 혼합) + */ + private void savePushLogsBatch(List fcmTokens, String campaignGroup, BatchResponse response, + Map indexToPushId) { + try { + Map tokenToUserId = getTokenToUserIdMap(fcmTokens); + List logsToSave = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + for (int i = 0; i < response.getResponses().size(); i++) { + String fcmToken = fcmTokens.get(i); + String userId = tokenToUserId.get(fcmToken); + SendResponse sendResponse = response.getResponses().get(i); + boolean success = sendResponse.isSuccessful(); + String pushId = indexToPushId.get(i); // 미리 생성된 UUID + + if (userId != null && pushId != null) { + String fcmMessageId = success ? sendResponse.getMessageId() : null; + logsToSave.add(createPushLog(pushId, userId, success, campaignGroup, fcmMessageId, now)); + } + } + + savePushLogsIfNotEmpty(logsToSave, campaignGroup); + } + catch (Exception e) { + log.error("Failed to batch save push logs for campaignGroup: {}", campaignGroup, e); + } + } + + /** + * 전체 실패 시 푸시 로그를 배치로 저장 + */ + private void savePushLogsAllFailed(List fcmTokens, String campaignGroup) { + try { + Map tokenToUserId = getTokenToUserIdMap(fcmTokens); + List logsToSave = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + + for (String fcmToken : fcmTokens) { + String userId = tokenToUserId.get(fcmToken); + if (userId != null) { + String pushId = UUID.randomUUID().toString(); + logsToSave.add(createPushLog(pushId, userId, false, campaignGroup, null, now)); + } + } + + savePushLogsIfNotEmpty(logsToSave, campaignGroup); + } + catch (Exception e) { + log.error("Failed to batch save failed push logs for campaignGroup: {}", campaignGroup, e); + } + } + + /** + * FCM 토큰 목록으로 토큰-사용자ID 맵 생성 + */ + private Map getTokenToUserIdMap(List fcmTokens) { + List tokens = fcmTokenRepository.findAllByFcmTokenIn(fcmTokens); + return tokens.stream() + .collect(Collectors.toMap(FcmToken::getFcmToken, FcmToken::getUserId, (existing, replacement) -> existing)); + } + + /** + * PushLog 객체 생성 + */ + private PushLog createPushLog(String pushId, String userId, boolean success, String campaignGroup, + String fcmMessageId, LocalDateTime now) { + return PushLog.builder() + .campaignId(pushId) // 자체 UUID를 campaignId로 사용 + .fcmMessageId(fcmMessageId) // FCM messageId (선택적) + .campaignGroup(campaignGroup) // 캠페인 그룹 + .userId(userId) + .sentAt(now) + .sentSuccess(success) + .createdAt(now) + .build(); + } + + /** + * PushLog 목록이 비어있지 않으면 배치 저장 + */ + private void savePushLogsIfNotEmpty(List logsToSave, String campaignGroup) { + if (!logsToSave.isEmpty()) { + pushLogRepository.saveAll(logsToSave); + log.debug("Batch saved {} push logs for campaignGroup: {}", logsToSave.size(), campaignGroup); + } + } + + /** + * FCM 토큰으로 사용자 ID 조회 + */ + private String getUserIdFromToken(String fcmToken) { + Optional tokenOpt = fcmTokenRepository.findFirstByFcmToken(fcmToken); + return tokenOpt.map(FcmToken::getUserId).orElse(null); + } + + /** + * 메시지 요청의 data에 userId 추가 + */ + private Map buildDataWithUserId(FcmMessageRequest messageRequest, String userId) { + Map data = messageRequest.getData() != null ? new java.util.HashMap<>(messageRequest.getData()) + : new java.util.HashMap<>(); + if (userId != null) { + data.put("userId", userId); + } + return data; + } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/service/FcmTokenService.java b/src/main/java/com/linglevel/api/fcm/service/FcmTokenService.java index 0346a6e6..df9dfa75 100644 --- a/src/main/java/com/linglevel/api/fcm/service/FcmTokenService.java +++ b/src/main/java/com/linglevel/api/fcm/service/FcmTokenService.java @@ -24,165 +24,165 @@ @Slf4j public class FcmTokenService { - private final FcmTokenRepository fcmTokenRepository; - private final FirebaseMessaging firebaseMessaging; - - /** - * FCM 토큰 유효성 검증 - */ - private boolean validateFcmToken(String fcmToken) { - try { - Message testMessage = Message.builder() - .setToken(fcmToken) - .setNotification(Notification.builder() - .setTitle("Token Validation") - .setBody("This is a test message") - .build()) - .build(); - - // dry-run으로 토큰 검증 (실제 전송 안함) - firebaseMessaging.send(testMessage, true); - return true; - } catch (FirebaseMessagingException e) { - log.warn("Invalid FCM token: {}, error: {}", fcmToken, e.getMessage()); - return false; - } - } - - /** - * FCM 토큰을 비활성화합니다. - * 전송 실패한 토큰을 비활성화하여 더 이상 사용하지 않도록 합니다. - */ - public void deactivateToken(String fcmToken) { - try { - Optional tokenOpt = fcmTokenRepository.findByFcmToken(fcmToken); - if (tokenOpt.isPresent()) { - FcmToken token = tokenOpt.get(); - token.setIsActive(false); - token.setUpdatedAt(LocalDateTime.now()); - fcmTokenRepository.save(token); - log.info("Deactivated invalid FCM token for user: {}, device: {}", - token.getUserId(), token.getDeviceId()); - } else { - log.warn("FCM token not found in database for deactivation"); - } - } catch (Exception e) { - log.error("Failed to deactivate FCM token", e); - } - } - - /** - * 특정 사용자의 특정 디바이스 FCM 토큰을 비활성화합니다. - * 로그아웃 시 사용됩니다. - */ - public void deactivateTokenByDevice(String userId, String deviceId) { - try { - Optional tokenOpt = fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId); - if (tokenOpt.isPresent()) { - FcmToken token = tokenOpt.get(); - token.setIsActive(false); - token.setUpdatedAt(LocalDateTime.now()); - fcmTokenRepository.save(token); - log.info("Deactivated FCM token on logout - user: {}, device: {}", userId, deviceId); - } else { - log.debug("No FCM token found for user: {}, device: {}", userId, deviceId); - } - } catch (Exception e) { - log.error("Failed to deactivate FCM token for user: {}, device: {}", userId, deviceId, e); - } - } - - /** - * 특정 사용자의 모든 FCM 토큰을 비활성화합니다. - * 전체 로그아웃 또는 계정 삭제 시 사용됩니다. - */ - public void deactivateAllTokens(String userId) { - try { - List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); - if (tokens.isEmpty()) { - log.debug("No active FCM tokens found for user: {}", userId); - return; - } - - int deactivatedCount = 0; - for (FcmToken token : tokens) { - token.setIsActive(false); - token.setUpdatedAt(LocalDateTime.now()); - fcmTokenRepository.save(token); - deactivatedCount++; - } - - log.info("Deactivated {} FCM token(s) for user: {}", deactivatedCount, userId); - } catch (Exception e) { - log.error("Failed to deactivate all FCM tokens for user: {}", userId, e); - } - } - - public FcmTokenUpsertResult upsertFcmToken(String userId, FcmTokenUpsertRequest request) { - try { - // FCM 토큰 유효성 검증 - if (!validateFcmToken(request.getFcmToken())) { - throw new FcmException(FcmErrorCode.INVALID_FCM_TOKEN_FORMAT); - } - - // countryCode가 제공되지 않으면 기본값 US 사용 - CountryCode countryCode = request.getCountryCode() != null ? request.getCountryCode() : CountryCode.US; - - // 1. 같은 fcmToken이 다른 유저/기기에 할당되어 있는지 확인 - Optional existingFcmToken = fcmTokenRepository.findFirstByFcmToken(request.getFcmToken()); - if (existingFcmToken.isPresent()) { - FcmToken oldToken = existingFcmToken.get(); - if (!oldToken.getUserId().equals(userId) || !oldToken.getDeviceId().equals(request.getDeviceId())) { - fcmTokenRepository.delete(oldToken); - } - } - - Optional existingToken = fcmTokenRepository.findByUserIdAndDeviceId(userId, request.getDeviceId()); - - if (existingToken.isPresent()) { - // 기존 토큰 업데이트 - FcmToken token = existingToken.get(); - token.setFcmToken(request.getFcmToken()); - token.setPlatform(request.getPlatform()); - token.setCountryCode(countryCode); - token.setAppVersion(request.getAppVersion()); - token.setOsVersion(request.getOsVersion()); - token.setUpdatedAt(LocalDateTime.now()); - token.setIsActive(true); - - FcmToken savedToken = fcmTokenRepository.save(token); - log.info("FCM token updated for user: {}, device: {}", userId, request.getDeviceId()); - - return FcmTokenUpsertResult.builder() - .tokenId(savedToken.getId()) - .created(false) - .build(); - } else { - // 새 토큰 생성 - FcmToken newToken = FcmToken.builder() - .userId(userId) - .deviceId(request.getDeviceId()) - .fcmToken(request.getFcmToken()) - .platform(request.getPlatform()) - .countryCode(countryCode) - .appVersion(request.getAppVersion()) - .osVersion(request.getOsVersion()) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .isActive(true) - .build(); - - FcmToken savedToken = fcmTokenRepository.save(newToken); - log.info("FCM token created for user: {}, device: {}", userId, request.getDeviceId()); - - return FcmTokenUpsertResult.builder() - .tokenId(savedToken.getId()) - .created(true) - .build(); - } - } catch (Exception e) { - log.error("Failed to upsert FCM token for user: {}, device: {}", userId, request.getDeviceId(), e); - throw new FcmException(FcmErrorCode.TOKEN_CREATION_FAILED); - } - } + private final FcmTokenRepository fcmTokenRepository; + + private final FirebaseMessaging firebaseMessaging; + + /** + * FCM 토큰 유효성 검증 + */ + private boolean validateFcmToken(String fcmToken) { + try { + Message testMessage = Message.builder() + .setToken(fcmToken) + .setNotification( + Notification.builder().setTitle("Token Validation").setBody("This is a test message").build()) + .build(); + + // dry-run으로 토큰 검증 (실제 전송 안함) + firebaseMessaging.send(testMessage, true); + return true; + } + catch (FirebaseMessagingException e) { + log.warn("Invalid FCM token: {}, error: {}", fcmToken, e.getMessage()); + return false; + } + } + + /** + * FCM 토큰을 비활성화합니다. 전송 실패한 토큰을 비활성화하여 더 이상 사용하지 않도록 합니다. + */ + public void deactivateToken(String fcmToken) { + try { + Optional tokenOpt = fcmTokenRepository.findByFcmToken(fcmToken); + if (tokenOpt.isPresent()) { + FcmToken token = tokenOpt.get(); + token.setIsActive(false); + token.setUpdatedAt(LocalDateTime.now()); + fcmTokenRepository.save(token); + log.info("Deactivated invalid FCM token for user: {}, device: {}", token.getUserId(), + token.getDeviceId()); + } + else { + log.warn("FCM token not found in database for deactivation"); + } + } + catch (Exception e) { + log.error("Failed to deactivate FCM token", e); + } + } + + /** + * 특정 사용자의 특정 디바이스 FCM 토큰을 비활성화합니다. 로그아웃 시 사용됩니다. + */ + public void deactivateTokenByDevice(String userId, String deviceId) { + try { + Optional tokenOpt = fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId); + if (tokenOpt.isPresent()) { + FcmToken token = tokenOpt.get(); + token.setIsActive(false); + token.setUpdatedAt(LocalDateTime.now()); + fcmTokenRepository.save(token); + log.info("Deactivated FCM token on logout - user: {}, device: {}", userId, deviceId); + } + else { + log.debug("No FCM token found for user: {}, device: {}", userId, deviceId); + } + } + catch (Exception e) { + log.error("Failed to deactivate FCM token for user: {}, device: {}", userId, deviceId, e); + } + } + + /** + * 특정 사용자의 모든 FCM 토큰을 비활성화합니다. 전체 로그아웃 또는 계정 삭제 시 사용됩니다. + */ + public void deactivateAllTokens(String userId) { + try { + List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); + if (tokens.isEmpty()) { + log.debug("No active FCM tokens found for user: {}", userId); + return; + } + + int deactivatedCount = 0; + for (FcmToken token : tokens) { + token.setIsActive(false); + token.setUpdatedAt(LocalDateTime.now()); + fcmTokenRepository.save(token); + deactivatedCount++; + } + + log.info("Deactivated {} FCM token(s) for user: {}", deactivatedCount, userId); + } + catch (Exception e) { + log.error("Failed to deactivate all FCM tokens for user: {}", userId, e); + } + } + + public FcmTokenUpsertResult upsertFcmToken(String userId, FcmTokenUpsertRequest request) { + try { + // FCM 토큰 유효성 검증 + if (!validateFcmToken(request.getFcmToken())) { + throw new FcmException(FcmErrorCode.INVALID_FCM_TOKEN_FORMAT); + } + + // countryCode가 제공되지 않으면 기본값 US 사용 + CountryCode countryCode = request.getCountryCode() != null ? request.getCountryCode() : CountryCode.US; + + // 1. 같은 fcmToken이 다른 유저/기기에 할당되어 있는지 확인 + Optional existingFcmToken = fcmTokenRepository.findFirstByFcmToken(request.getFcmToken()); + if (existingFcmToken.isPresent()) { + FcmToken oldToken = existingFcmToken.get(); + if (!oldToken.getUserId().equals(userId) || !oldToken.getDeviceId().equals(request.getDeviceId())) { + fcmTokenRepository.delete(oldToken); + } + } + + Optional existingToken = fcmTokenRepository.findByUserIdAndDeviceId(userId, + request.getDeviceId()); + + if (existingToken.isPresent()) { + // 기존 토큰 업데이트 + FcmToken token = existingToken.get(); + token.setFcmToken(request.getFcmToken()); + token.setPlatform(request.getPlatform()); + token.setCountryCode(countryCode); + token.setAppVersion(request.getAppVersion()); + token.setOsVersion(request.getOsVersion()); + token.setUpdatedAt(LocalDateTime.now()); + token.setIsActive(true); + + FcmToken savedToken = fcmTokenRepository.save(token); + log.info("FCM token updated for user: {}, device: {}", userId, request.getDeviceId()); + + return FcmTokenUpsertResult.builder().tokenId(savedToken.getId()).created(false).build(); + } + else { + // 새 토큰 생성 + FcmToken newToken = FcmToken.builder() + .userId(userId) + .deviceId(request.getDeviceId()) + .fcmToken(request.getFcmToken()) + .platform(request.getPlatform()) + .countryCode(countryCode) + .appVersion(request.getAppVersion()) + .osVersion(request.getOsVersion()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .isActive(true) + .build(); + + FcmToken savedToken = fcmTokenRepository.save(newToken); + log.info("FCM token created for user: {}, device: {}", userId, request.getDeviceId()); + + return FcmTokenUpsertResult.builder().tokenId(savedToken.getId()).created(true).build(); + } + } + catch (Exception e) { + log.error("Failed to upsert FCM token for user: {}, device: {}", userId, request.getDeviceId(), e); + throw new FcmException(FcmErrorCode.TOKEN_CREATION_FAILED); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/fcm/service/PushCampaignService.java b/src/main/java/com/linglevel/api/fcm/service/PushCampaignService.java index 9515a913..9ac87b77 100644 --- a/src/main/java/com/linglevel/api/fcm/service/PushCampaignService.java +++ b/src/main/java/com/linglevel/api/fcm/service/PushCampaignService.java @@ -19,118 +19,108 @@ @Slf4j public class PushCampaignService { - private final PushLogRepository pushLogRepository; - - /** - * 특정 캠페인 그룹의 상세 통계 조회 - */ - public PushCampaignStats getStats(String campaignGroup) { - List logs = pushLogRepository.findByCampaignGroup(campaignGroup); - - if (logs.isEmpty()) { - return PushCampaignStats.builder() - .campaignId(campaignGroup) - .totalSent(0) - .sentSuccess(0) - .totalOpened(0) - .deliveryRate(0.0) - .openRate(0.0) - .build(); - } - - int totalSent = logs.size(); - int sentSuccess = (int) logs.stream() - .filter(log -> Boolean.TRUE.equals(log.getSentSuccess())) - .count(); - int totalOpened = (int) logs.stream() - .filter(log -> log.getOpenedAt() != null) - .count(); - - double deliveryRate = totalSent > 0 ? (double) sentSuccess / totalSent : 0.0; - double openRate = sentSuccess > 0 ? (double) totalOpened / sentSuccess : 0.0; - - LocalDateTime firstSentAt = logs.stream() - .map(PushLog::getSentAt) - .min(Comparator.naturalOrder()) - .orElse(null); - - return PushCampaignStats.builder() - .campaignId(campaignGroup) - .firstSentAt(firstSentAt) - .totalSent(totalSent) - .sentSuccess(sentSuccess) - .totalOpened(totalOpened) - .deliveryRate(deliveryRate) - .openRate(openRate) - .build(); - } - - /** - * 캠페인 그룹 목록 조회 (기간 필터링 가능) - */ - public List getCampaignSummaries(LocalDateTime startDate, LocalDateTime endDate) { - List logs; - - if (startDate != null && endDate != null) { - logs = pushLogRepository.findBySentAtBetween(startDate, endDate); - } else { - logs = pushLogRepository.findAll(); - } - - // campaignGroup으로 그룹핑하여 통계 계산 - Map> logsByCampaign = logs.stream() - .filter(log -> log.getCampaignGroup() != null) // null 체크 - .collect(Collectors.groupingBy(PushLog::getCampaignGroup)); - - return logsByCampaign.entrySet().stream() - .map(entry -> { - String campaignGroup = entry.getKey(); - List campaignLogs = entry.getValue(); - - int totalSent = campaignLogs.size(); - int sentSuccess = (int) campaignLogs.stream() - .filter(log -> Boolean.TRUE.equals(log.getSentSuccess())) - .count(); - int totalOpened = (int) campaignLogs.stream() - .filter(log -> log.getOpenedAt() != null) - .count(); - - double openRate = sentSuccess > 0 ? (double) totalOpened / sentSuccess : 0.0; - - LocalDateTime firstSentAt = campaignLogs.stream() - .map(PushLog::getSentAt) - .min(Comparator.naturalOrder()) - .orElse(null); - - String campaignType = extractCampaignType(campaignGroup); - - return PushCampaignSummary.builder() - .campaignId(campaignGroup) - .campaignType(campaignType) - .firstSentAt(firstSentAt) - .totalSent(totalSent) - .sentSuccess(sentSuccess) - .totalOpened(totalOpened) - .openRate(openRate) - .build(); - }) - .sorted(Comparator.comparing(PushCampaignSummary::getFirstSentAt).reversed()) - .collect(Collectors.toList()); - } - - /** - * campaignGroup에서 타입 추출 (예: "article-tech-123" -> "article") - */ - private String extractCampaignType(String campaignGroup) { - if (campaignGroup == null) { - return "unknown"; - } - - int firstDash = campaignGroup.indexOf('-'); - if (firstDash > 0) { - return campaignGroup.substring(0, firstDash); - } - - return "unknown"; - } + private final PushLogRepository pushLogRepository; + + /** + * 특정 캠페인 그룹의 상세 통계 조회 + */ + public PushCampaignStats getStats(String campaignGroup) { + List logs = pushLogRepository.findByCampaignGroup(campaignGroup); + + if (logs.isEmpty()) { + return PushCampaignStats.builder() + .campaignId(campaignGroup) + .totalSent(0) + .sentSuccess(0) + .totalOpened(0) + .deliveryRate(0.0) + .openRate(0.0) + .build(); + } + + int totalSent = logs.size(); + int sentSuccess = (int) logs.stream().filter(log -> Boolean.TRUE.equals(log.getSentSuccess())).count(); + int totalOpened = (int) logs.stream().filter(log -> log.getOpenedAt() != null).count(); + + double deliveryRate = totalSent > 0 ? (double) sentSuccess / totalSent : 0.0; + double openRate = sentSuccess > 0 ? (double) totalOpened / sentSuccess : 0.0; + + LocalDateTime firstSentAt = logs.stream().map(PushLog::getSentAt).min(Comparator.naturalOrder()).orElse(null); + + return PushCampaignStats.builder() + .campaignId(campaignGroup) + .firstSentAt(firstSentAt) + .totalSent(totalSent) + .sentSuccess(sentSuccess) + .totalOpened(totalOpened) + .deliveryRate(deliveryRate) + .openRate(openRate) + .build(); + } + + /** + * 캠페인 그룹 목록 조회 (기간 필터링 가능) + */ + public List getCampaignSummaries(LocalDateTime startDate, LocalDateTime endDate) { + List logs; + + if (startDate != null && endDate != null) { + logs = pushLogRepository.findBySentAtBetween(startDate, endDate); + } + else { + logs = pushLogRepository.findAll(); + } + + // campaignGroup으로 그룹핑하여 통계 계산 + Map> logsByCampaign = logs.stream() + .filter(log -> log.getCampaignGroup() != null) // null 체크 + .collect(Collectors.groupingBy(PushLog::getCampaignGroup)); + + return logsByCampaign.entrySet().stream().map(entry -> { + String campaignGroup = entry.getKey(); + List campaignLogs = entry.getValue(); + + int totalSent = campaignLogs.size(); + int sentSuccess = (int) campaignLogs.stream() + .filter(log -> Boolean.TRUE.equals(log.getSentSuccess())) + .count(); + int totalOpened = (int) campaignLogs.stream().filter(log -> log.getOpenedAt() != null).count(); + + double openRate = sentSuccess > 0 ? (double) totalOpened / sentSuccess : 0.0; + + LocalDateTime firstSentAt = campaignLogs.stream() + .map(PushLog::getSentAt) + .min(Comparator.naturalOrder()) + .orElse(null); + + String campaignType = extractCampaignType(campaignGroup); + + return PushCampaignSummary.builder() + .campaignId(campaignGroup) + .campaignType(campaignType) + .firstSentAt(firstSentAt) + .totalSent(totalSent) + .sentSuccess(sentSuccess) + .totalOpened(totalOpened) + .openRate(openRate) + .build(); + }).sorted(Comparator.comparing(PushCampaignSummary::getFirstSentAt).reversed()).collect(Collectors.toList()); + } + + /** + * campaignGroup에서 타입 추출 (예: "article-tech-123" -> "article") + */ + private String extractCampaignType(String campaignGroup) { + if (campaignGroup == null) { + return "unknown"; + } + + int firstDash = campaignGroup.indexOf('-'); + if (firstDash > 0) { + return campaignGroup.substring(0, firstDash); + } + + return "unknown"; + } + } diff --git a/src/main/java/com/linglevel/api/fcm/service/PushLogService.java b/src/main/java/com/linglevel/api/fcm/service/PushLogService.java index bbbbe030..2382388f 100644 --- a/src/main/java/com/linglevel/api/fcm/service/PushLogService.java +++ b/src/main/java/com/linglevel/api/fcm/service/PushLogService.java @@ -16,67 +16,71 @@ @Slf4j public class PushLogService { - private final PushLogRepository pushLogRepository; + private final PushLogRepository pushLogRepository; - /** - * 푸시 알림 송신 로그 저장 - * @param campaignId 각 메시지의 고유 ID (자체 UUID) - * @param userId 사용자 ID - * @param success 발송 성공 여부 - * @param campaignGroup 캠페인 그룹 (선택적, 내부 그룹화용) - * @param fcmMessageId FCM messageId (선택적, FCM 추적용) - */ - public void logSent(String campaignId, String userId, boolean success, String campaignGroup, String fcmMessageId) { - try { - PushLog pushLog = PushLog.builder() - .campaignId(campaignId) - .fcmMessageId(fcmMessageId) - .campaignGroup(campaignGroup) - .userId(userId) - .sentAt(LocalDateTime.now()) - .sentSuccess(success) - .createdAt(LocalDateTime.now()) - .build(); + /** + * 푸시 알림 송신 로그 저장 + * @param campaignId 각 메시지의 고유 ID (자체 UUID) + * @param userId 사용자 ID + * @param success 발송 성공 여부 + * @param campaignGroup 캠페인 그룹 (선택적, 내부 그룹화용) + * @param fcmMessageId FCM messageId (선택적, FCM 추적용) + */ + public void logSent(String campaignId, String userId, boolean success, String campaignGroup, String fcmMessageId) { + try { + PushLog pushLog = PushLog.builder() + .campaignId(campaignId) + .fcmMessageId(fcmMessageId) + .campaignGroup(campaignGroup) + .userId(userId) + .sentAt(LocalDateTime.now()) + .sentSuccess(success) + .createdAt(LocalDateTime.now()) + .build(); - pushLogRepository.save(pushLog); - } catch (Exception e) { - log.error("Failed to save push log - campaignId: {}, userId: {}", campaignId, userId, e); - throw new FcmException(FcmErrorCode.PUSH_LOG_SAVE_FAILED); - } - } + pushLogRepository.save(pushLog); + } + catch (Exception e) { + log.error("Failed to save push log - campaignId: {}, userId: {}", campaignId, userId, e); + throw new FcmException(FcmErrorCode.PUSH_LOG_SAVE_FAILED); + } + } - /** - * 푸시 알림 오픈 로그 업데이트 (낙관적 락을 통한 동시성 제어) - * @param userId 사용자 ID - * @param campaignId 메시지 고유 ID - * @param openedAt 오픈 시간 - */ - public void logOpened(String userId, String campaignId, LocalDateTime openedAt) { - try { - // campaignId가 유니크하므로 campaignId만으로 조회 - PushLog pushLog = pushLogRepository.findByCampaignId(campaignId) - .orElseThrow(() -> new FcmException(FcmErrorCode.PUSH_LOG_NOT_FOUND)); + /** + * 푸시 알림 오픈 로그 업데이트 (낙관적 락을 통한 동시성 제어) + * @param userId 사용자 ID + * @param campaignId 메시지 고유 ID + * @param openedAt 오픈 시간 + */ + public void logOpened(String userId, String campaignId, LocalDateTime openedAt) { + try { + // campaignId가 유니크하므로 campaignId만으로 조회 + PushLog pushLog = pushLogRepository.findByCampaignId(campaignId) + .orElseThrow(() -> new FcmException(FcmErrorCode.PUSH_LOG_NOT_FOUND)); - // 멱등성 보장: 이미 오픈되었으면 무시 - if (pushLog.getOpenedAt() != null) { - log.debug("Push already opened, ignoring duplicate - campaignId: {}, userId: {}", - campaignId, userId); - return; - } + // 멱등성 보장: 이미 오픈되었으면 무시 + if (pushLog.getOpenedAt() != null) { + log.debug("Push already opened, ignoring duplicate - campaignId: {}, userId: {}", campaignId, userId); + return; + } - pushLog.setOpenedAt(openedAt); - pushLogRepository.save(pushLog); - log.debug("Logged push opened - campaignId: {}, userId: {}", campaignId, userId); + pushLog.setOpenedAt(openedAt); + pushLogRepository.save(pushLog); + log.debug("Logged push opened - campaignId: {}, userId: {}", campaignId, userId); + + } + catch (OptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 요청이 먼저 업데이트함 (정상 케이스) + log.debug("Optimistic lock conflict on push opened - campaignId: {} (already updated by another request)", + campaignId); + } + catch (FcmException e) { + throw e; + } + catch (Exception e) { + log.error("Failed to update push opened log - campaignId: {}, userId: {}", campaignId, userId, e); + throw new FcmException(FcmErrorCode.PUSH_LOG_SAVE_FAILED); + } + } - } catch (OptimisticLockingFailureException e) { - // 낙관적 락 충돌: 다른 요청이 먼저 업데이트함 (정상 케이스) - log.debug("Optimistic lock conflict on push opened - campaignId: {} (already updated by another request)", - campaignId); - } catch (FcmException e) { - throw e; - } catch (Exception e) { - log.error("Failed to update push opened log - campaignId: {}, userId: {}", campaignId, userId, e); - throw new FcmException(FcmErrorCode.PUSH_LOG_SAVE_FAILED); - } - } } diff --git a/src/main/java/com/linglevel/api/i18n/CountryCode.java b/src/main/java/com/linglevel/api/i18n/CountryCode.java index 2d977961..0dd2fecf 100644 --- a/src/main/java/com/linglevel/api/i18n/CountryCode.java +++ b/src/main/java/com/linglevel/api/i18n/CountryCode.java @@ -6,10 +6,11 @@ @Getter @RequiredArgsConstructor public enum CountryCode { - KR("KR", "South Korea"), - US("US", "United States"), - JP("JP", "Japan"); - private final String code; - private final String description; + KR("KR", "South Korea"), US("US", "United States"), JP("JP", "Japan"); + + private final String code; + + private final String description; + } diff --git a/src/main/java/com/linglevel/api/i18n/LanguageCode.java b/src/main/java/com/linglevel/api/i18n/LanguageCode.java index 5e74bc4c..aba796ec 100644 --- a/src/main/java/com/linglevel/api/i18n/LanguageCode.java +++ b/src/main/java/com/linglevel/api/i18n/LanguageCode.java @@ -9,14 +9,15 @@ @Getter @RequiredArgsConstructor public enum LanguageCode { - KO("KO", "Korean"), - EN("EN", "English"), - JA("JA", "Japanese"); - private final String code; - private final String description; + KO("KO", "Korean"), EN("EN", "English"), JA("JA", "Japanese"); + + private final String code; + + private final String description; + + public static List getAllCodes() { + return Arrays.asList(LanguageCode.values()); + } - public static List getAllCodes() { - return Arrays.asList(LanguageCode.values()); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/config/S3Config.java b/src/main/java/com/linglevel/api/s3/config/S3Config.java index 61f52821..76cbcc3c 100644 --- a/src/main/java/com/linglevel/api/s3/config/S3Config.java +++ b/src/main/java/com/linglevel/api/s3/config/S3Config.java @@ -14,82 +14,82 @@ @Configuration public class S3Config { - // AWS S3 Configuration (for AI buckets) - @Value("${aws.s3.region}") - private String region; - - @Value("${aws.access-key}") - private String accessKey; - - @Value("${aws.secret-key}") - private String secretKey; - - @Value("${aws.s3.ai.input.bucket}") - private String aiInputBucketName; - - @Value("${aws.s3.ai.output.bucket}") - private String aiOutputBucketName; - - // Cloudflare R2 Configuration (for Static files) - @Value("${cf.r2.endpoint}") - private String r2Endpoint; - - @Value("${cf.r2.access-key}") - private String r2AccessKey; - - @Value("${cf.r2.secret-key}") - private String r2SecretKey; - - @Value("${cf.r2.static.bucket}") - private String r2StaticBucketName; - - /** - * AWS S3 클라이언트 (AI Input/Output 버킷용) - */ - @Bean("s3AiClient") - public S3Client s3AiClient() { - AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); - - return S3Client.builder() - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } - - /** - * Cloudflare R2 클라이언트 (Static 파일용) - * R2는 S3 호환 API를 제공하므로 endpoint만 변경하면 됩니다. - */ - @Bean("s3StaticClient") - public S3Client s3StaticClient() { - AwsBasicCredentials credentials = AwsBasicCredentials.create(r2AccessKey, r2SecretKey); - - // R2 전용 설정 객체 생성 - S3Configuration serviceConfiguration = S3Configuration.builder() - .pathStyleAccessEnabled(true) - .chunkedEncodingEnabled(false) - .build(); - - return S3Client.builder() - .endpointOverride(URI.create(r2Endpoint)) - .region(Region.of("auto")) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .serviceConfiguration(serviceConfiguration) - .build(); - } - - @Bean("aiInputBucketName") - public String aiInputBucketName() { - return aiInputBucketName; - } - - @Bean("aiOutputBucketName") - public String aiOutputBucketName() { - return aiOutputBucketName; - } - - @Bean("staticBucketName") - public String staticBucketName() { - return r2StaticBucketName; - } -} \ No newline at end of file + // AWS S3 Configuration (for AI buckets) + @Value("${aws.s3.region}") + private String region; + + @Value("${aws.access-key}") + private String accessKey; + + @Value("${aws.secret-key}") + private String secretKey; + + @Value("${aws.s3.ai.input.bucket}") + private String aiInputBucketName; + + @Value("${aws.s3.ai.output.bucket}") + private String aiOutputBucketName; + + // Cloudflare R2 Configuration (for Static files) + @Value("${cf.r2.endpoint}") + private String r2Endpoint; + + @Value("${cf.r2.access-key}") + private String r2AccessKey; + + @Value("${cf.r2.secret-key}") + private String r2SecretKey; + + @Value("${cf.r2.static.bucket}") + private String r2StaticBucketName; + + /** + * AWS S3 클라이언트 (AI Input/Output 버킷용) + */ + @Bean("s3AiClient") + public S3Client s3AiClient() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } + + /** + * Cloudflare R2 클라이언트 (Static 파일용) R2는 S3 호환 API를 제공하므로 endpoint만 변경하면 됩니다. + */ + @Bean("s3StaticClient") + public S3Client s3StaticClient() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(r2AccessKey, r2SecretKey); + + // R2 전용 설정 객체 생성 + S3Configuration serviceConfiguration = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .chunkedEncodingEnabled(false) + .build(); + + return S3Client.builder() + .endpointOverride(URI.create(r2Endpoint)) + .region(Region.of("auto")) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .serviceConfiguration(serviceConfiguration) + .build(); + } + + @Bean("aiInputBucketName") + public String aiInputBucketName() { + return aiInputBucketName; + } + + @Bean("aiOutputBucketName") + public String aiOutputBucketName() { + return aiOutputBucketName; + } + + @Bean("staticBucketName") + public String staticBucketName() { + return r2StaticBucketName; + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/service/ImageResizeService.java b/src/main/java/com/linglevel/api/s3/service/ImageResizeService.java index 5cc2c29c..93123717 100644 --- a/src/main/java/com/linglevel/api/s3/service/ImageResizeService.java +++ b/src/main/java/com/linglevel/api/s3/service/ImageResizeService.java @@ -20,103 +20,103 @@ @Slf4j public class ImageResizeService { - @Qualifier("s3StaticClient") - private final S3Client s3StaticClient; + @Qualifier("s3StaticClient") + private final S3Client s3StaticClient; - @Qualifier("staticBucketName") - private final String staticBucketName; + @Qualifier("staticBucketName") + private final String staticBucketName; - private final S3StaticService s3StaticService; + private final S3StaticService s3StaticService; - private static final int THUMBNAIL_SIZE = 256; + private static final int THUMBNAIL_SIZE = 256; - public String createSmallImage(String originalS3Key) { - try { - log.info("Creating small image from: {}", originalS3Key); + public String createSmallImage(String originalS3Key) { + try { + log.info("Creating small image from: {}", originalS3Key); - String thumbnailS3Key = generateSmallImagePath(originalS3Key); - InputStream originalImageStream = downloadImageFromS3(originalS3Key); - byte[] thumbnailBytes = resizeToThumbnail(originalImageStream); - uploadThumbnailToS3(thumbnailS3Key, thumbnailBytes); + String thumbnailS3Key = generateSmallImagePath(originalS3Key); + InputStream originalImageStream = downloadImageFromS3(originalS3Key); + byte[] thumbnailBytes = resizeToThumbnail(originalImageStream); + uploadThumbnailToS3(thumbnailS3Key, thumbnailBytes); - String thumbnailUrl = s3StaticService.getPublicUrl(thumbnailS3Key); - log.info("Small image created successfully: {}", thumbnailUrl); + String thumbnailUrl = s3StaticService.getPublicUrl(thumbnailS3Key); + log.info("Small image created successfully: {}", thumbnailUrl); - return thumbnailUrl; + return thumbnailUrl; - } catch (Exception e) { - log.error("Failed to create small image for {}", originalS3Key, e); - throw new RuntimeException("Small image creation failed", e); - } - } + } + catch (Exception e) { + log.error("Failed to create small image for {}", originalS3Key, e); + throw new RuntimeException("Small image creation failed", e); + } + } + private String generateSmallImagePath(String originalS3Key) { + int lastSlashIndex = originalS3Key.lastIndexOf('/'); + if (lastSlashIndex == -1) { + return "small_" + removeExtension(originalS3Key) + ".webp"; + } - private String generateSmallImagePath(String originalS3Key) { - int lastSlashIndex = originalS3Key.lastIndexOf('/'); - if (lastSlashIndex == -1) { - return "small_" + removeExtension(originalS3Key) + ".webp"; - } + String directoryPath = originalS3Key.substring(0, lastSlashIndex + 1); + String fileName = originalS3Key.substring(lastSlashIndex + 1); + String fileNameWithoutExt = removeExtension(fileName); - String directoryPath = originalS3Key.substring(0, lastSlashIndex + 1); - String fileName = originalS3Key.substring(lastSlashIndex + 1); - String fileNameWithoutExt = removeExtension(fileName); + return directoryPath + "small_" + fileNameWithoutExt + ".webp"; + } - return directoryPath + "small_" + fileNameWithoutExt + ".webp"; - } + private String removeExtension(String fileName) { + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex == -1) { + return fileName; + } + return fileName.substring(0, lastDotIndex); + } - private String removeExtension(String fileName) { - int lastDotIndex = fileName.lastIndexOf('.'); - if (lastDotIndex == -1) { - return fileName; - } - return fileName.substring(0, lastDotIndex); - } + private InputStream downloadImageFromS3(String s3Key) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket(staticBucketName).key(s3Key).build(); - private InputStream downloadImageFromS3(String s3Key) { - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(staticBucketName) - .key(s3Key) - .build(); + return s3StaticClient.getObject(getObjectRequest); + } + catch (Exception e) { + throw new RuntimeException("Failed to download image from S3: " + s3Key, e); + } + } - return s3StaticClient.getObject(getObjectRequest); - } catch (Exception e) { - throw new RuntimeException("Failed to download image from S3: " + s3Key, e); - } - } + private byte[] resizeToThumbnail(InputStream imageStream) throws IOException { + try { + ImmutableImage originalImage = ImmutableImage.loader().fromStream(imageStream); - private byte[] resizeToThumbnail(InputStream imageStream) throws IOException { - try { - ImmutableImage originalImage = ImmutableImage.loader().fromStream(imageStream); + ImmutableImage resizedImage = originalImage.scaleTo(THUMBNAIL_SIZE, THUMBNAIL_SIZE) + .cover(THUMBNAIL_SIZE, THUMBNAIL_SIZE); - ImmutableImage resizedImage = originalImage.scaleTo(THUMBNAIL_SIZE, THUMBNAIL_SIZE) - .cover(THUMBNAIL_SIZE, THUMBNAIL_SIZE); + byte[] webpBytes = resizedImage.bytes(WebpWriter.DEFAULT.withQ(85)); - byte[] webpBytes = resizedImage.bytes(WebpWriter.DEFAULT.withQ(85)); + log.info("Successfully converted to WebP using Scrimage, size: {} bytes", webpBytes.length); - log.info("Successfully converted to WebP using Scrimage, size: {} bytes", webpBytes.length); + return webpBytes; - return webpBytes; + } + catch (Exception e) { + log.error("Failed to convert image to WebP using Scrimage: {}", e.getMessage(), e); + throw new IOException("WebP conversion failed", e); + } + } - } catch (Exception e) { - log.error("Failed to convert image to WebP using Scrimage: {}", e.getMessage(), e); - throw new IOException("WebP conversion failed", e); - } - } + private void uploadThumbnailToS3(String s3Key, byte[] imageBytes) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(staticBucketName) + .key(s3Key) + .contentType("image/webp") + .build(); + s3StaticClient.putObject(putObjectRequest, RequestBody.fromBytes(imageBytes)); - private void uploadThumbnailToS3(String s3Key, byte[] imageBytes) { - try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(staticBucketName) - .key(s3Key) - .contentType("image/webp") - .build(); + } + catch (Exception e) { + throw new RuntimeException("Failed to upload thumbnail to S3: " + s3Key, e); + } + } - s3StaticClient.putObject(putObjectRequest, RequestBody.fromBytes(imageBytes)); - - } catch (Exception e) { - throw new RuntimeException("Failed to upload thumbnail to S3: " + s3Key, e); - } - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/service/S3AiService.java b/src/main/java/com/linglevel/api/s3/service/S3AiService.java index a37b8f04..66d655e4 100644 --- a/src/main/java/com/linglevel/api/s3/service/S3AiService.java +++ b/src/main/java/com/linglevel/api/s3/service/S3AiService.java @@ -22,90 +22,91 @@ @Slf4j public class S3AiService { - @Qualifier("s3AiClient") - private final S3Client s3AiClient; - - @Qualifier("aiInputBucketName") - private final String aiInputBucketName; - - @Qualifier("aiOutputBucketName") - private final String aiOutputBucketName; - - private final ObjectMapper objectMapper; - - public T downloadJsonFile(String fileId, Class targetClass, S3PathStrategy pathStrategy) { - try { - String key = pathStrategy.generateJsonFilePath(fileId); - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(aiOutputBucketName) - .key(key) - .build(); - - var response = s3AiClient.getObject(getObjectRequest); - String jsonContent = new String(response.readAllBytes()); - - return objectMapper.readValue(jsonContent, targetClass); - } catch (IOException e) { - log.error("Failed to read JSON from S3 AI: {}", e.getMessage()); - throw new RuntimeException("File download failed", e); - } catch (Exception e) { - log.error("S3 AI operation failed: {}", e.getMessage()); - throw new RuntimeException("S3 operation failed", e); - } - } - - public List listImagesInFolder(String folderId, S3PathStrategy pathStrategy) { - try { - String prefix = pathStrategy.generateImageFolderPath(folderId); - ListObjectsV2Request listObjectsRequest = ListObjectsV2Request.builder() - .bucket(aiOutputBucketName) - .prefix(prefix) - .build(); - - ListObjectsV2Response response = s3AiClient.listObjectsV2(listObjectsRequest); - - List rawKeys = response.contents().stream() - .map(S3Object::key) - .toList(); - - return pathStrategy.processImageKeys(rawKeys); - } catch (Exception e) { - log.error("Failed to list images from S3 AI folder {}: {}", folderId, e.getMessage()); - throw new RuntimeException("Failed to list images", e); - } - } - - public byte[] downloadImageFile(String imageKey) { - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(aiOutputBucketName) - .key(imageKey) - .build(); - - var response = s3AiClient.getObject(getObjectRequest); - return response.readAllBytes(); - } catch (Exception e) { - log.error("Failed to download image from S3 AI: {}", e.getMessage()); - throw new RuntimeException("Failed to download image", e); - } - } - - public void uploadJsonToInputBucket(String requestId, Object data, S3PathStrategy pathStrategy) { - try { - String key = pathStrategy.generateJsonFilePath(requestId); - String jsonContent = objectMapper.writeValueAsString(data); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(aiInputBucketName) - .key(key) - .contentType("application/json") - .build(); - - s3AiClient.putObject(putObjectRequest, RequestBody.fromString(jsonContent)); - log.info("Successfully uploaded JSON to AI input bucket: {}", key); - } catch (Exception e) { - log.error("Failed to upload JSON to S3 AI input bucket: {}", e.getMessage()); - throw new RuntimeException("Failed to upload to input bucket", e); - } - } + @Qualifier("s3AiClient") + private final S3Client s3AiClient; + + @Qualifier("aiInputBucketName") + private final String aiInputBucketName; + + @Qualifier("aiOutputBucketName") + private final String aiOutputBucketName; + + private final ObjectMapper objectMapper; + + public T downloadJsonFile(String fileId, Class targetClass, S3PathStrategy pathStrategy) { + try { + String key = pathStrategy.generateJsonFilePath(fileId); + GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket(aiOutputBucketName).key(key).build(); + + var response = s3AiClient.getObject(getObjectRequest); + String jsonContent = new String(response.readAllBytes()); + + return objectMapper.readValue(jsonContent, targetClass); + } + catch (IOException e) { + log.error("Failed to read JSON from S3 AI: {}", e.getMessage()); + throw new RuntimeException("File download failed", e); + } + catch (Exception e) { + log.error("S3 AI operation failed: {}", e.getMessage()); + throw new RuntimeException("S3 operation failed", e); + } + } + + public List listImagesInFolder(String folderId, S3PathStrategy pathStrategy) { + try { + String prefix = pathStrategy.generateImageFolderPath(folderId); + ListObjectsV2Request listObjectsRequest = ListObjectsV2Request.builder() + .bucket(aiOutputBucketName) + .prefix(prefix) + .build(); + + ListObjectsV2Response response = s3AiClient.listObjectsV2(listObjectsRequest); + + List rawKeys = response.contents().stream().map(S3Object::key).toList(); + + return pathStrategy.processImageKeys(rawKeys); + } + catch (Exception e) { + log.error("Failed to list images from S3 AI folder {}: {}", folderId, e.getMessage()); + throw new RuntimeException("Failed to list images", e); + } + } + + public byte[] downloadImageFile(String imageKey) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(aiOutputBucketName) + .key(imageKey) + .build(); + + var response = s3AiClient.getObject(getObjectRequest); + return response.readAllBytes(); + } + catch (Exception e) { + log.error("Failed to download image from S3 AI: {}", e.getMessage()); + throw new RuntimeException("Failed to download image", e); + } + } + + public void uploadJsonToInputBucket(String requestId, Object data, S3PathStrategy pathStrategy) { + try { + String key = pathStrategy.generateJsonFilePath(requestId); + String jsonContent = objectMapper.writeValueAsString(data); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(aiInputBucketName) + .key(key) + .contentType("application/json") + .build(); + + s3AiClient.putObject(putObjectRequest, RequestBody.fromString(jsonContent)); + log.info("Successfully uploaded JSON to AI input bucket: {}", key); + } + catch (Exception e) { + log.error("Failed to upload JSON to S3 AI input bucket: {}", e.getMessage()); + throw new RuntimeException("Failed to upload to input bucket", e); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/service/S3StaticService.java b/src/main/java/com/linglevel/api/s3/service/S3StaticService.java index 047e376c..91775379 100644 --- a/src/main/java/com/linglevel/api/s3/service/S3StaticService.java +++ b/src/main/java/com/linglevel/api/s3/service/S3StaticService.java @@ -21,116 +21,119 @@ @Slf4j public class S3StaticService { - @Qualifier("s3StaticClient") - private final S3Client s3StaticClient; - - @Qualifier("staticBucketName") - private final String staticBucketName; - - @Value("${aws.s3.static.url:}") - private String staticUrl; - - public String uploadFile(MultipartFile file, String path) { - try { - String key = path + "/" + file.getOriginalFilename(); - - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(staticBucketName) - .key(key) - .contentType(file.getContentType()) - .build(); - - s3StaticClient.putObject(putObjectRequest, - RequestBody.fromInputStream(file.getInputStream(), file.getSize())); - - log.info("Successfully uploaded file to S3 Static: {}", key); - return getPublicUrl(key); - - } catch (IOException e) { - log.error("Failed to upload file to S3 Static: {}", e.getMessage()); - throw new RuntimeException("File upload failed", e); - } catch (Exception e) { - log.error("S3 Static operation failed: {}", e.getMessage()); - throw new RuntimeException("S3 operation failed", e); - } - } - - public String uploadFileFromBytes(byte[] fileBytes, String key, String contentType) { - try { - PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(staticBucketName) - .key(key) - .contentType(contentType) - .build(); - - s3StaticClient.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); - - log.info("Successfully uploaded file to S3 Static: {}", key); - return getPublicUrl(key); - - } catch (Exception e) { - log.error("Failed to upload file to S3 Static: {}", e.getMessage()); - throw new RuntimeException("File upload failed", e); - } - } - - public String getPublicUrl(String key) { - return staticUrl + "/" + key; - } - - public void deleteFiles(String contentId, S3PathStrategy pathStrategy) { - try { - String prefix = pathStrategy.generateBasePath(contentId); - deleteFilesWithPrefix(prefix); - log.info("Successfully deleted files from S3 Static - contentId: {}, prefix: {}", contentId, prefix); - } catch (Exception e) { - log.error("Failed to delete files from S3 Static - contentId: {}, prefix: {}, error: {}", - contentId, pathStrategy.generateBasePath(contentId), e.getMessage()); - throw new RuntimeException("Failed to delete files from S3", e); - } - } - - private void deleteFilesWithPrefix(String prefix) { - try { - // List all objects with the given prefix - ListObjectsV2Request listRequest = ListObjectsV2Request.builder() - .bucket(staticBucketName) - .prefix(prefix) - .build(); - - ListObjectsV2Response listResponse; - do { - listResponse = s3StaticClient.listObjectsV2(listRequest); - - if (!listResponse.contents().isEmpty()) { - // Delete objects in batches - List objectsToDelete = listResponse.contents().stream() - .map(s3Object -> ObjectIdentifier.builder() - .key(s3Object.key()) - .build()) - .toList(); - - DeleteObjectsRequest deleteRequest = DeleteObjectsRequest.builder() - .bucket(staticBucketName) - .delete(Delete.builder() - .objects(objectsToDelete) - .build()) - .build(); - - DeleteObjectsResponse deleteResponse = s3StaticClient.deleteObjects(deleteRequest); - log.info("Deleted {} objects from S3 Static with prefix: {}", deleteResponse.deleted().size(), prefix); - } - - // Update request for next iteration if there are more objects - listRequest = listRequest.toBuilder() - .continuationToken(listResponse.nextContinuationToken()) - .build(); - - } while (listResponse.isTruncated()); - - } catch (Exception e) { - log.error("Failed to delete files with prefix {} from S3 Static: {}", prefix, e.getMessage()); - throw e; - } - } + @Qualifier("s3StaticClient") + private final S3Client s3StaticClient; + + @Qualifier("staticBucketName") + private final String staticBucketName; + + @Value("${aws.s3.static.url:}") + private String staticUrl; + + public String uploadFile(MultipartFile file, String path) { + try { + String key = path + "/" + file.getOriginalFilename(); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(staticBucketName) + .key(key) + .contentType(file.getContentType()) + .build(); + + s3StaticClient.putObject(putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + log.info("Successfully uploaded file to S3 Static: {}", key); + return getPublicUrl(key); + + } + catch (IOException e) { + log.error("Failed to upload file to S3 Static: {}", e.getMessage()); + throw new RuntimeException("File upload failed", e); + } + catch (Exception e) { + log.error("S3 Static operation failed: {}", e.getMessage()); + throw new RuntimeException("S3 operation failed", e); + } + } + + public String uploadFileFromBytes(byte[] fileBytes, String key, String contentType) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(staticBucketName) + .key(key) + .contentType(contentType) + .build(); + + s3StaticClient.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); + + log.info("Successfully uploaded file to S3 Static: {}", key); + return getPublicUrl(key); + + } + catch (Exception e) { + log.error("Failed to upload file to S3 Static: {}", e.getMessage()); + throw new RuntimeException("File upload failed", e); + } + } + + public String getPublicUrl(String key) { + return staticUrl + "/" + key; + } + + public void deleteFiles(String contentId, S3PathStrategy pathStrategy) { + try { + String prefix = pathStrategy.generateBasePath(contentId); + deleteFilesWithPrefix(prefix); + log.info("Successfully deleted files from S3 Static - contentId: {}, prefix: {}", contentId, prefix); + } + catch (Exception e) { + log.error("Failed to delete files from S3 Static - contentId: {}, prefix: {}, error: {}", contentId, + pathStrategy.generateBasePath(contentId), e.getMessage()); + throw new RuntimeException("Failed to delete files from S3", e); + } + } + + private void deleteFilesWithPrefix(String prefix) { + try { + // List all objects with the given prefix + ListObjectsV2Request listRequest = ListObjectsV2Request.builder() + .bucket(staticBucketName) + .prefix(prefix) + .build(); + + ListObjectsV2Response listResponse; + do { + listResponse = s3StaticClient.listObjectsV2(listRequest); + + if (!listResponse.contents().isEmpty()) { + // Delete objects in batches + List objectsToDelete = listResponse.contents() + .stream() + .map(s3Object -> ObjectIdentifier.builder().key(s3Object.key()).build()) + .toList(); + + DeleteObjectsRequest deleteRequest = DeleteObjectsRequest.builder() + .bucket(staticBucketName) + .delete(Delete.builder().objects(objectsToDelete).build()) + .build(); + + DeleteObjectsResponse deleteResponse = s3StaticClient.deleteObjects(deleteRequest); + log.info("Deleted {} objects from S3 Static with prefix: {}", deleteResponse.deleted().size(), + prefix); + } + + // Update request for next iteration if there are more objects + listRequest = listRequest.toBuilder().continuationToken(listResponse.nextContinuationToken()).build(); + + } + while (listResponse.isTruncated()); + + } + catch (Exception e) { + log.error("Failed to delete files with prefix {} from S3 Static: {}", prefix, e.getMessage()); + throw e; + } + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/service/S3TransferService.java b/src/main/java/com/linglevel/api/s3/service/S3TransferService.java index 94a4cd7b..69df3be2 100644 --- a/src/main/java/com/linglevel/api/s3/service/S3TransferService.java +++ b/src/main/java/com/linglevel/api/s3/service/S3TransferService.java @@ -13,28 +13,32 @@ @Slf4j public class S3TransferService { - private final S3AiService s3AiService; - private final S3StaticService s3StaticService; - - public void transferImagesFromAiToStatic(String sourceId, String targetId, S3PathStrategy pathStrategy) { - try { - log.info("Starting image transfer from AI bucket to Static bucket for sourceId: {} to targetId: {}", sourceId, targetId); - - List imageKeys = s3AiService.listImagesInFolder(sourceId, pathStrategy); - - for (String imageKey : imageKeys) { - byte[] imageBytes = s3AiService.downloadImageFile(imageKey); - String contentType = S3FileUtils.getContentTypeFromKey(imageKey); - - String newKey = imageKey.replace(sourceId, targetId); - s3StaticService.uploadFileFromBytes(imageBytes, newKey, contentType); - } - - log.info("Successfully transferred {} images to Static bucket", imageKeys.size()); - - } catch (Exception e) { - log.error("Failed to transfer images from AI to Static bucket: {}", e.getMessage()); - throw new RuntimeException("Image transfer failed", e); - } - } + private final S3AiService s3AiService; + + private final S3StaticService s3StaticService; + + public void transferImagesFromAiToStatic(String sourceId, String targetId, S3PathStrategy pathStrategy) { + try { + log.info("Starting image transfer from AI bucket to Static bucket for sourceId: {} to targetId: {}", + sourceId, targetId); + + List imageKeys = s3AiService.listImagesInFolder(sourceId, pathStrategy); + + for (String imageKey : imageKeys) { + byte[] imageBytes = s3AiService.downloadImageFile(imageKey); + String contentType = S3FileUtils.getContentTypeFromKey(imageKey); + + String newKey = imageKey.replace(sourceId, targetId); + s3StaticService.uploadFileFromBytes(imageBytes, newKey, contentType); + } + + log.info("Successfully transferred {} images to Static bucket", imageKeys.size()); + + } + catch (Exception e) { + log.error("Failed to transfer images from AI to Static bucket: {}", e.getMessage()); + throw new RuntimeException("Image transfer failed", e); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/service/S3UrlService.java b/src/main/java/com/linglevel/api/s3/service/S3UrlService.java index db27f2fd..4a6e6a78 100644 --- a/src/main/java/com/linglevel/api/s3/service/S3UrlService.java +++ b/src/main/java/com/linglevel/api/s3/service/S3UrlService.java @@ -8,19 +8,20 @@ @RequiredArgsConstructor public class S3UrlService { - private final S3StaticService s3StaticService; + private final S3StaticService s3StaticService; - public String getCoverImageUrl(String id, S3PathStrategy pathStrategy) { - String path = pathStrategy.generateCoverImagePath(id); - return s3StaticService.getPublicUrl(path); - } + public String getCoverImageUrl(String id, S3PathStrategy pathStrategy) { + String path = pathStrategy.generateCoverImagePath(id); + return s3StaticService.getPublicUrl(path); + } - public String getImageUrl(String id, String imageFileName, S3PathStrategy pathStrategy) { - String path = pathStrategy.generateImagePath(id, imageFileName); - return s3StaticService.getPublicUrl(path); - } + public String getImageUrl(String id, String imageFileName, S3PathStrategy pathStrategy) { + String path = pathStrategy.generateImagePath(id, imageFileName); + return s3StaticService.getPublicUrl(path); + } + + public String buildImageUrl(String id, String imageFileName, S3PathStrategy pathStrategy) { + return getImageUrl(id, imageFileName, pathStrategy); + } - public String buildImageUrl(String id, String imageFileName, S3PathStrategy pathStrategy) { - return getImageUrl(id, imageFileName, pathStrategy); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/strategy/ArticlePathStrategy.java b/src/main/java/com/linglevel/api/s3/strategy/ArticlePathStrategy.java index f192c6f8..a20d8ba9 100644 --- a/src/main/java/com/linglevel/api/s3/strategy/ArticlePathStrategy.java +++ b/src/main/java/com/linglevel/api/s3/strategy/ArticlePathStrategy.java @@ -7,40 +7,42 @@ @Component public class ArticlePathStrategy implements S3PathStrategy { - private static final String BASE_DIR = "article"; - private static final String IMAGES_DIR = "/images/"; - private static final String COVER_FILENAME = "cover.jpg"; - private static final String METADATA_FILENAME = "metadata.json"; - - @Override - public String generateJsonFilePath(String articleId) { - return generateBasePath(articleId) + "/" + METADATA_FILENAME; - } - - @Override - public String generateImageFolderPath(String articleId) { - return generateBasePath(articleId) + IMAGES_DIR; - } - - @Override - public String generateCoverImagePath(String articleId) { - return generateBasePath(articleId) + IMAGES_DIR + COVER_FILENAME; - } - - @Override - public String generateImagePath(String articleId, String imageFileName) { - return generateBasePath(articleId) + IMAGES_DIR + imageFileName; - } - - @Override - public String generateBasePath(String articleId) { - return BASE_DIR + "/" + articleId; - } - - @Override - public List processImageKeys(List rawKeys) { - return rawKeys.stream() - .filter(key -> !key.endsWith("/")) - .toList(); - } + private static final String BASE_DIR = "article"; + + private static final String IMAGES_DIR = "/images/"; + + private static final String COVER_FILENAME = "cover.jpg"; + + private static final String METADATA_FILENAME = "metadata.json"; + + @Override + public String generateJsonFilePath(String articleId) { + return generateBasePath(articleId) + "/" + METADATA_FILENAME; + } + + @Override + public String generateImageFolderPath(String articleId) { + return generateBasePath(articleId) + IMAGES_DIR; + } + + @Override + public String generateCoverImagePath(String articleId) { + return generateBasePath(articleId) + IMAGES_DIR + COVER_FILENAME; + } + + @Override + public String generateImagePath(String articleId, String imageFileName) { + return generateBasePath(articleId) + IMAGES_DIR + imageFileName; + } + + @Override + public String generateBasePath(String articleId) { + return BASE_DIR + "/" + articleId; + } + + @Override + public List processImageKeys(List rawKeys) { + return rawKeys.stream().filter(key -> !key.endsWith("/")).toList(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/strategy/BookPathStrategy.java b/src/main/java/com/linglevel/api/s3/strategy/BookPathStrategy.java index 2db9dad4..f1b4d992 100644 --- a/src/main/java/com/linglevel/api/s3/strategy/BookPathStrategy.java +++ b/src/main/java/com/linglevel/api/s3/strategy/BookPathStrategy.java @@ -7,44 +7,46 @@ @Component public class BookPathStrategy implements S3PathStrategy { - private static final String BASE_DIR = "literature"; - private static final String IMAGES_DIR = "/images/"; - private static final String COVER_FILENAME = "cover.jpg"; - private static final String METADATA_FILENAME = "metadata.json"; - - private String getBasePathWithId(String bookId) { - return BASE_DIR + "/" + bookId; - } - - @Override - public String generateJsonFilePath(String bookId) { - return getBasePathWithId(bookId) + "/" + METADATA_FILENAME; - } - - @Override - public String generateImageFolderPath(String bookId) { - return getBasePathWithId(bookId) + IMAGES_DIR; - } - - @Override - public String generateCoverImagePath(String bookId) { - return getBasePathWithId(bookId) + IMAGES_DIR + COVER_FILENAME; - } - - @Override - public String generateImagePath(String bookId, String imageFileName) { - return getBasePathWithId(bookId) + IMAGES_DIR + imageFileName; - } - - @Override - public String generateBasePath(String bookId) { - return getBasePathWithId(bookId); - } - - @Override - public List processImageKeys(List rawKeys) { - return rawKeys.stream() - .filter(key -> !key.endsWith("/")) - .toList(); - } + private static final String BASE_DIR = "literature"; + + private static final String IMAGES_DIR = "/images/"; + + private static final String COVER_FILENAME = "cover.jpg"; + + private static final String METADATA_FILENAME = "metadata.json"; + + private String getBasePathWithId(String bookId) { + return BASE_DIR + "/" + bookId; + } + + @Override + public String generateJsonFilePath(String bookId) { + return getBasePathWithId(bookId) + "/" + METADATA_FILENAME; + } + + @Override + public String generateImageFolderPath(String bookId) { + return getBasePathWithId(bookId) + IMAGES_DIR; + } + + @Override + public String generateCoverImagePath(String bookId) { + return getBasePathWithId(bookId) + IMAGES_DIR + COVER_FILENAME; + } + + @Override + public String generateImagePath(String bookId, String imageFileName) { + return getBasePathWithId(bookId) + IMAGES_DIR + imageFileName; + } + + @Override + public String generateBasePath(String bookId) { + return getBasePathWithId(bookId); + } + + @Override + public List processImageKeys(List rawKeys) { + return rawKeys.stream().filter(key -> !key.endsWith("/")).toList(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/strategy/CustomContentPathStrategy.java b/src/main/java/com/linglevel/api/s3/strategy/CustomContentPathStrategy.java index 0d4f9dab..e3b93df2 100644 --- a/src/main/java/com/linglevel/api/s3/strategy/CustomContentPathStrategy.java +++ b/src/main/java/com/linglevel/api/s3/strategy/CustomContentPathStrategy.java @@ -6,40 +6,42 @@ @Component public class CustomContentPathStrategy implements S3PathStrategy { - private static final String BASE_DIR = "custom"; - private static final String IMAGES_DIR = "/images/"; - private static final String COVER_FILENAME = "cover.jpg"; - private static final String METADATA_FILENAME = "metadata.json"; - - @Override - public String generateJsonFilePath(String id) { - return generateBasePath(id) + "/" + METADATA_FILENAME; - } - - @Override - public String generateImageFolderPath(String id) { - return generateBasePath(id) + IMAGES_DIR; - } - - @Override - public String generateCoverImagePath(String id) { - return generateBasePath(id) + IMAGES_DIR + COVER_FILENAME; - } - - @Override - public String generateImagePath(String id, String imageFileName) { - return generateBasePath(id) + IMAGES_DIR + imageFileName; - } - - @Override - public String generateBasePath(String id) { - return BASE_DIR + "/" + id; - } - - @Override - public List processImageKeys(List rawKeys) { - return rawKeys.stream() - .filter(key -> !key.endsWith("/")) - .toList(); - } + private static final String BASE_DIR = "custom"; + + private static final String IMAGES_DIR = "/images/"; + + private static final String COVER_FILENAME = "cover.jpg"; + + private static final String METADATA_FILENAME = "metadata.json"; + + @Override + public String generateJsonFilePath(String id) { + return generateBasePath(id) + "/" + METADATA_FILENAME; + } + + @Override + public String generateImageFolderPath(String id) { + return generateBasePath(id) + IMAGES_DIR; + } + + @Override + public String generateCoverImagePath(String id) { + return generateBasePath(id) + IMAGES_DIR + COVER_FILENAME; + } + + @Override + public String generateImagePath(String id, String imageFileName) { + return generateBasePath(id) + IMAGES_DIR + imageFileName; + } + + @Override + public String generateBasePath(String id) { + return BASE_DIR + "/" + id; + } + + @Override + public List processImageKeys(List rawKeys) { + return rawKeys.stream().filter(key -> !key.endsWith("/")).toList(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/strategy/S3PathStrategy.java b/src/main/java/com/linglevel/api/s3/strategy/S3PathStrategy.java index 96c42ead..fd842de7 100644 --- a/src/main/java/com/linglevel/api/s3/strategy/S3PathStrategy.java +++ b/src/main/java/com/linglevel/api/s3/strategy/S3PathStrategy.java @@ -3,10 +3,17 @@ import java.util.List; public interface S3PathStrategy { - String generateJsonFilePath(String id); - String generateImageFolderPath(String id); - String generateCoverImagePath(String id); - String generateImagePath(String id, String imageFileName); - String generateBasePath(String id); - List processImageKeys(List rawKeys); + + String generateJsonFilePath(String id); + + String generateImageFolderPath(String id); + + String generateCoverImagePath(String id); + + String generateImagePath(String id, String imageFileName); + + String generateBasePath(String id); + + List processImageKeys(List rawKeys); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/s3/utils/S3FileUtils.java b/src/main/java/com/linglevel/api/s3/utils/S3FileUtils.java index 954ce19a..fbe1b7e2 100644 --- a/src/main/java/com/linglevel/api/s3/utils/S3FileUtils.java +++ b/src/main/java/com/linglevel/api/s3/utils/S3FileUtils.java @@ -2,17 +2,21 @@ public class S3FileUtils { - public static String getContentTypeFromKey(String key) { - String lowerKey = key.toLowerCase(); - if (lowerKey.endsWith(".jpg") || lowerKey.endsWith(".jpeg")) { - return "image/jpeg"; - } else if (lowerKey.endsWith(".png")) { - return "image/png"; - } else if (lowerKey.endsWith(".gif")) { - return "image/gif"; - } else if (lowerKey.endsWith(".webp")) { - return "image/webp"; - } - return "image/jpeg"; - } + public static String getContentTypeFromKey(String key) { + String lowerKey = key.toLowerCase(); + if (lowerKey.endsWith(".jpg") || lowerKey.endsWith(".jpeg")) { + return "image/jpeg"; + } + else if (lowerKey.endsWith(".png")) { + return "image/png"; + } + else if (lowerKey.endsWith(".gif")) { + return "image/gif"; + } + else if (lowerKey.endsWith(".webp")) { + return "image/webp"; + } + return "image/jpeg"; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/controller/StreakController.java b/src/main/java/com/linglevel/api/streak/controller/StreakController.java index c238477a..18487aab 100644 --- a/src/main/java/com/linglevel/api/streak/controller/StreakController.java +++ b/src/main/java/com/linglevel/api/streak/controller/StreakController.java @@ -34,131 +34,72 @@ @Tag(name = "Streaks", description = "스트릭 관련 API") public class StreakController { - private final StreakService streakService; - private final ReadingSessionService readingSessionService; - - @GetMapping("/me") - @Operation( - summary = "내 스트릭 정보 조회", - description = "현재 로그인한 사용자의 스트릭 정보를 조회합니다. " + - "현재 연속 일수, 총 학습 시간, 상위 몇%, 격려 메시지 등을 포함합니다." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "스트릭 정보 조회 성공", - useReturnTypeSchema = true - ) - }) - public ResponseEntity getMyStreak( - @AuthenticationPrincipal JwtClaims claims, - @ParameterObject @Valid @ModelAttribute GetStreakInfoRequest request) { - - StreakResponse response = streakService.getStreakInfo(claims.getId(), request.getLanguageCode()); - return ResponseEntity.ok(response); - } - - @GetMapping("/me/freeze-transactions") - @Operation( - summary = "내 프리즈 내역 조회", - description = "현재 로그인한 사용자의 프리즈 획득 및 사용 내역을 조회합니다." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "프리즈 내역 조회 성공", - useReturnTypeSchema = true - ), - @ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - public ResponseEntity> getMyFreezeTransactions( - @AuthenticationPrincipal JwtClaims claims, - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "10") int limit) { - - Page response = streakService.getFreezeTransactions(claims.getId(), page, limit); - return ResponseEntity.ok(response); - } - - @GetMapping("/calendar") - @Operation( - summary = "달력 조회", - description = "특정 년월의 달력 정보를 조회합니다. 각 날짜별 스트릭 상태, 완료한 학습 개수, 보상 정보를 포함합니다." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "달력 조회 성공", - useReturnTypeSchema = true - ), - @ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - public ResponseEntity getCalendar( - @AuthenticationPrincipal JwtClaims claims, - @RequestParam @Schema(description = "년도", example = "2025") int year, - @RequestParam @Schema(description = "월", example = "10") int month) { - - CalendarResponse response = streakService.getCalendar(claims.getId(), year, month); - return ResponseEntity.ok(response); - } - - @GetMapping("/this-week") - @Operation( - summary = "이번 주 스트릭 조회", - description = "이번 주의 스트릭 정보를 조회합니다. 월요일부터 일요일까지의 스트릭 상태와 보상 정보를 포함합니다." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "주간 스트릭 조회 성공", - useReturnTypeSchema = true - ), - @ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) - ) - }) - public ResponseEntity getThisWeekStreak( - @AuthenticationPrincipal JwtClaims claims) { - - WeekStreakResponse response = streakService.getThisWeekStreak(claims.getId()); - return ResponseEntity.ok(response); - } - - @GetMapping("/me/reading-session") - @Operation( - summary = "내 읽기 세션 조회", - description = "현재 로그인한 사용자의 활성화된 읽기 세션 정보를 조회합니다. " + - "세션이 없는 경우 404를 반환합니다." - ) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "읽기 세션 조회 성공", - useReturnTypeSchema = true - ) - }) - public ResponseEntity getMyReadingSession( - @AuthenticationPrincipal JwtClaims claims) { - - ReadingSession session = readingSessionService.getReadingSessionOrThrow(claims.getId()); - ReadingSessionResponse response = ReadingSessionResponse.from(session); - return ResponseEntity.ok(response); - } - - @ExceptionHandler(StreakException.class) - public ResponseEntity handleStreakException(StreakException e) { - log.error("Streak Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } + private final StreakService streakService; + + private final ReadingSessionService readingSessionService; + + @GetMapping("/me") + @Operation(summary = "내 스트릭 정보 조회", + description = "현재 로그인한 사용자의 스트릭 정보를 조회합니다. " + "현재 연속 일수, 총 학습 시간, 상위 몇%, 격려 메시지 등을 포함합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "스트릭 정보 조회 성공", useReturnTypeSchema = true) }) + public ResponseEntity getMyStreak(@AuthenticationPrincipal JwtClaims claims, + @ParameterObject @Valid @ModelAttribute GetStreakInfoRequest request) { + + StreakResponse response = streakService.getStreakInfo(claims.getId(), request.getLanguageCode()); + return ResponseEntity.ok(response); + } + + @GetMapping("/me/freeze-transactions") + @Operation(summary = "내 프리즈 내역 조회", description = "현재 로그인한 사용자의 프리즈 획득 및 사용 내역을 조회합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "프리즈 내역 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + public ResponseEntity> getMyFreezeTransactions( + @AuthenticationPrincipal JwtClaims claims, @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int limit) { + + Page response = streakService.getFreezeTransactions(claims.getId(), page, limit); + return ResponseEntity.ok(response); + } + + @GetMapping("/calendar") + @Operation(summary = "달력 조회", description = "특정 년월의 달력 정보를 조회합니다. 각 날짜별 스트릭 상태, 완료한 학습 개수, 보상 정보를 포함합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "달력 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + public ResponseEntity getCalendar(@AuthenticationPrincipal JwtClaims claims, + @RequestParam @Schema(description = "년도", example = "2025") int year, + @RequestParam @Schema(description = "월", example = "10") int month) { + + CalendarResponse response = streakService.getCalendar(claims.getId(), year, month); + return ResponseEntity.ok(response); + } + + @GetMapping("/this-week") + @Operation(summary = "이번 주 스트릭 조회", description = "이번 주의 스트릭 정보를 조회합니다. 월요일부터 일요일까지의 스트릭 상태와 보상 정보를 포함합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "주간 스트릭 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + public ResponseEntity getThisWeekStreak(@AuthenticationPrincipal JwtClaims claims) { + + WeekStreakResponse response = streakService.getThisWeekStreak(claims.getId()); + return ResponseEntity.ok(response); + } + + @GetMapping("/me/reading-session") + @Operation(summary = "내 읽기 세션 조회", description = "현재 로그인한 사용자의 활성화된 읽기 세션 정보를 조회합니다. " + "세션이 없는 경우 404를 반환합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "읽기 세션 조회 성공", useReturnTypeSchema = true) }) + public ResponseEntity getMyReadingSession(@AuthenticationPrincipal JwtClaims claims) { + + ReadingSession session = readingSessionService.getReadingSessionOrThrow(claims.getId()); + ReadingSessionResponse response = ReadingSessionResponse.from(session); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(StreakException.class) + public ResponseEntity handleStreakException(StreakException e) { + log.error("Streak Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/dto/CalendarDayResponse.java b/src/main/java/com/linglevel/api/streak/dto/CalendarDayResponse.java index 7f16b974..3481d8cc 100644 --- a/src/main/java/com/linglevel/api/streak/dto/CalendarDayResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/CalendarDayResponse.java @@ -18,31 +18,31 @@ @Schema(description = "달력 일자별 정보") public class CalendarDayResponse { - @Schema(description = "날짜 (yyyy-MM-dd)", example = "2025-10-01") - private LocalDate date; + @Schema(description = "날짜 (yyyy-MM-dd)", example = "2025-10-01") + private LocalDate date; - @Schema(description = "일자", example = "1") - private Integer dayOfMonth; + @Schema(description = "일자", example = "1") + private Integer dayOfMonth; - @Schema(description = "오늘 여부", example = "false") - private Boolean isToday; + @Schema(description = "오늘 여부", example = "false") + private Boolean isToday; - @Schema(description = "스트릭 상태 (COMPLETED: 완료, FREEZE_USED: 프리즈 사용, MISSED: 놓침, FUTURE: 미래)", - example = "COMPLETED") - private StreakStatus status; + @Schema(description = "스트릭 상태 (COMPLETED: 완료, FREEZE_USED: 프리즈 사용, MISSED: 놓침, FUTURE: 미래)", example = "COMPLETED") + private StreakStatus status; - @Schema(description = "해당 날짜의 스트릭 개수 (FUTURE인 경우 null)", example = "20") - private Integer streakCount; + @Schema(description = "해당 날짜의 스트릭 개수 (FUTURE인 경우 null)", example = "20") + private Integer streakCount; - @Schema(description = "해당 날짜에 첫 완료한 고유 콘텐츠 개수 (복습 제외, FUTURE인 경우 null)", example = "2") - private Integer firstCompletionCount; + @Schema(description = "해당 날짜에 첫 완료한 고유 콘텐츠 개수 (복습 제외, FUTURE인 경우 null)", example = "2") + private Integer firstCompletionCount; - @Schema(description = "해당 날짜에 완료한 총 콘텐츠 개수 (복습 포함, FUTURE인 경우 null)", example = "3") - private Integer totalCompletionCount; + @Schema(description = "해당 날짜에 완료한 총 콘텐츠 개수 (복습 포함, FUTURE인 경우 null)", example = "3") + private Integer totalCompletionCount; - @Schema(description = "획득한 보상 정보 (FUTURE가 아닌 경우)") - private RewardInfo rewards; + @Schema(description = "획득한 보상 정보 (FUTURE가 아닌 경우)") + private RewardInfo rewards; + + @Schema(description = "예상 보상 정보 (FUTURE인 경우)") + private RewardInfo expectedRewards; - @Schema(description = "예상 보상 정보 (FUTURE인 경우)") - private RewardInfo expectedRewards; } diff --git a/src/main/java/com/linglevel/api/streak/dto/CalendarResponse.java b/src/main/java/com/linglevel/api/streak/dto/CalendarResponse.java index bb3d01ee..700c2329 100644 --- a/src/main/java/com/linglevel/api/streak/dto/CalendarResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/CalendarResponse.java @@ -15,18 +15,19 @@ @Schema(description = "달력 응답") public class CalendarResponse { - @Schema(description = "년도", example = "2025") - private Integer year; + @Schema(description = "년도", example = "2025") + private Integer year; - @Schema(description = "월", example = "10") - private Integer month; + @Schema(description = "월", example = "10") + private Integer month; - @Schema(description = "오늘 일자", example = "24") - private Integer today; + @Schema(description = "오늘 일자", example = "24") + private Integer today; - @Schema(description = "현재 스트릭", example = "25") - private Integer currentStreak; + @Schema(description = "현재 스트릭", example = "25") + private Integer currentStreak; + + @Schema(description = "달력 일자별 정보 목록") + private List days; - @Schema(description = "달력 일자별 정보 목록") - private List days; } diff --git a/src/main/java/com/linglevel/api/streak/dto/ContentInfo.java b/src/main/java/com/linglevel/api/streak/dto/ContentInfo.java index e15f9ba1..329eee7a 100644 --- a/src/main/java/com/linglevel/api/streak/dto/ContentInfo.java +++ b/src/main/java/com/linglevel/api/streak/dto/ContentInfo.java @@ -12,8 +12,8 @@ import java.time.Instant; /** - * Content completion information DTO - * Used to transfer content completion data from Progress services to StreakService + * Content completion information DTO Used to transfer content completion data from + * Progress services to StreakService */ @Data @Builder @@ -22,24 +22,25 @@ @Schema(description = "콘텐츠 완료 정보") public class ContentInfo { - @Schema(description = "콘텐츠 타입 (BOOK, ARTICLE, CUSTOM)", example = "BOOK", required = true) - private ContentType type; + @Schema(description = "콘텐츠 타입 (BOOK, ARTICLE, CUSTOM)", example = "BOOK", required = true) + private ContentType type; - @Schema(description = "콘텐츠 ID", example = "60d5ec49f1b2c8a5d8e4f123", required = true) - private String contentId; + @Schema(description = "콘텐츠 ID", example = "60d5ec49f1b2c8a5d8e4f123", required = true) + private String contentId; - @Schema(description = "챕터 ID (책의 경우만 해당)", example = "60d5ec49f1b2c8a5d8e4f456") - private String chapterId; + @Schema(description = "챕터 ID (책의 경우만 해당)", example = "60d5ec49f1b2c8a5d8e4f456") + private String chapterId; - @Schema(description = "완료 시각", example = "2025-10-27T10:30:00Z", required = true) - private Instant completedAt; + @Schema(description = "완료 시각", example = "2025-10-27T10:30:00Z", required = true) + private Instant completedAt; - @Schema(description = "읽는데 걸린 시간 (초 단위)", example = "180", required = true) - private Integer readingTime; + @Schema(description = "읽는데 걸린 시간 (초 단위)", example = "180", required = true) + private Integer readingTime; - @Schema(description = "콘텐츠 카테고리", example = "TECH", required = true) - private ContentCategory category; + @Schema(description = "콘텐츠 카테고리", example = "TECH", required = true) + private ContentCategory category; + + @Schema(description = "난이도", example = "B1", required = true) + private DifficultyLevel difficultyLevel; - @Schema(description = "난이도", example = "B1", required = true) - private DifficultyLevel difficultyLevel; } diff --git a/src/main/java/com/linglevel/api/streak/dto/EncouragementMessage.java b/src/main/java/com/linglevel/api/streak/dto/EncouragementMessage.java index 654d355a..ecc06e78 100644 --- a/src/main/java/com/linglevel/api/streak/dto/EncouragementMessage.java +++ b/src/main/java/com/linglevel/api/streak/dto/EncouragementMessage.java @@ -13,12 +13,13 @@ @Schema(description = "격려 메시지") public class EncouragementMessage { - @Schema(description = "메시지 제목", example = "25일 연속!") - private String title; + @Schema(description = "메시지 제목", example = "25일 연속!") + private String title; - @Schema(description = "메시지 본문 (영문)", example = "You're in the top 5% of all users!") - private String body; + @Schema(description = "메시지 본문 (영문)", example = "You're in the top 5% of all users!") + private String body; + + @Schema(description = "메시지 번역 (한글)", example = "전체 사용자 중 상위 5%입니다!") + private String translation; - @Schema(description = "메시지 번역 (한글)", example = "전체 사용자 중 상위 5%입니다!") - private String translation; } diff --git a/src/main/java/com/linglevel/api/streak/dto/FreezeTransactionResponse.java b/src/main/java/com/linglevel/api/streak/dto/FreezeTransactionResponse.java index 8d7a2329..6c6ed3b5 100644 --- a/src/main/java/com/linglevel/api/streak/dto/FreezeTransactionResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/FreezeTransactionResponse.java @@ -8,8 +8,13 @@ @Getter @Builder public class FreezeTransactionResponse { - private String id; - private Integer amount; - private String description; - private Instant createdAt; + + private String id; + + private Integer amount; + + private String description; + + private Instant createdAt; + } diff --git a/src/main/java/com/linglevel/api/streak/dto/GetStreakInfoRequest.java b/src/main/java/com/linglevel/api/streak/dto/GetStreakInfoRequest.java index 3f7ddc4c..6ed9b5d2 100644 --- a/src/main/java/com/linglevel/api/streak/dto/GetStreakInfoRequest.java +++ b/src/main/java/com/linglevel/api/streak/dto/GetStreakInfoRequest.java @@ -15,7 +15,8 @@ @Schema(description = "스트릭 정보 조회 요청") public class GetStreakInfoRequest { - @Builder.Default - @Schema(description = "언어 코드 (기본값: EN)", example = "EN", required = false) - private LanguageCode languageCode = LanguageCode.EN; + @Builder.Default + @Schema(description = "언어 코드 (기본값: EN)", example = "EN", required = false) + private LanguageCode languageCode = LanguageCode.EN; + } diff --git a/src/main/java/com/linglevel/api/streak/dto/ReadingSession.java b/src/main/java/com/linglevel/api/streak/dto/ReadingSession.java index 93fcf058..0d0e3005 100644 --- a/src/main/java/com/linglevel/api/streak/dto/ReadingSession.java +++ b/src/main/java/com/linglevel/api/streak/dto/ReadingSession.java @@ -13,7 +13,11 @@ @AllArgsConstructor @Builder public class ReadingSession implements Serializable { - private ContentType contentType; - private String contentId; - private Long startedAtMillis; // Epoch milliseconds for Redis serialization + + private ContentType contentType; + + private String contentId; + + private Long startedAtMillis; // Epoch milliseconds for Redis serialization + } diff --git a/src/main/java/com/linglevel/api/streak/dto/ReadingSessionResponse.java b/src/main/java/com/linglevel/api/streak/dto/ReadingSessionResponse.java index 5437ebfc..f6eb5e21 100644 --- a/src/main/java/com/linglevel/api/streak/dto/ReadingSessionResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/ReadingSessionResponse.java @@ -16,27 +16,28 @@ @Schema(description = "읽기 세션 정보") public class ReadingSessionResponse { - @Schema(description = "콘텐츠 타입", example = "BOOK") - private ContentType contentType; + @Schema(description = "콘텐츠 타입", example = "BOOK") + private ContentType contentType; - @Schema(description = "콘텐츠 ID", example = "123") - private String contentId; + @Schema(description = "콘텐츠 ID", example = "123") + private String contentId; - @Schema(description = "세션 시작 시간 (ISO-8601)", example = "2025-10-31T12:34:56Z") - private Instant startedAt; + @Schema(description = "세션 시작 시간 (ISO-8601)", example = "2025-10-31T12:34:56Z") + private Instant startedAt; - @Schema(description = "현재까지 읽기 지속 시간 (초)", example = "120") - private long durationSeconds; + @Schema(description = "현재까지 읽기 지속 시간 (초)", example = "120") + private long durationSeconds; - public static ReadingSessionResponse from(ReadingSession session) { - Instant startedAt = Instant.ofEpochMilli(session.getStartedAtMillis()); - long durationSeconds = java.time.Duration.between(startedAt, Instant.now()).getSeconds(); + public static ReadingSessionResponse from(ReadingSession session) { + Instant startedAt = Instant.ofEpochMilli(session.getStartedAtMillis()); + long durationSeconds = java.time.Duration.between(startedAt, Instant.now()).getSeconds(); + + return ReadingSessionResponse.builder() + .contentType(session.getContentType()) + .contentId(session.getContentId()) + .startedAt(startedAt) + .durationSeconds(durationSeconds) + .build(); + } - return ReadingSessionResponse.builder() - .contentType(session.getContentType()) - .contentId(session.getContentId()) - .startedAt(startedAt) - .durationSeconds(durationSeconds) - .build(); - } } diff --git a/src/main/java/com/linglevel/api/streak/dto/RewardInfo.java b/src/main/java/com/linglevel/api/streak/dto/RewardInfo.java index 8fcf6c80..4ac6a471 100644 --- a/src/main/java/com/linglevel/api/streak/dto/RewardInfo.java +++ b/src/main/java/com/linglevel/api/streak/dto/RewardInfo.java @@ -13,9 +13,10 @@ @Schema(description = "보상 정보") public class RewardInfo { - @Schema(description = "획득한 티켓 개수", example = "1") - private Integer tickets; + @Schema(description = "획득한 티켓 개수", example = "1") + private Integer tickets; + + @Schema(description = "획득한 프리즈 개수", example = "1") + private Integer freezes; - @Schema(description = "획득한 프리즈 개수", example = "1") - private Integer freezes; } diff --git a/src/main/java/com/linglevel/api/streak/dto/StreakResponse.java b/src/main/java/com/linglevel/api/streak/dto/StreakResponse.java index 152a0220..066feb9b 100644 --- a/src/main/java/com/linglevel/api/streak/dto/StreakResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/StreakResponse.java @@ -12,39 +12,40 @@ @Schema(description = "스트릭 정보 응답") public class StreakResponse { - @Schema(description = "현재 연속 일수", example = "25") - private int currentStreak; + @Schema(description = "현재 연속 일수", example = "25") + private int currentStreak; - @Schema(description = "오늘의 스트릭 상태 (COMPLETED, FREEZE_USED, MISSED)") - private StreakStatus todayStatus; + @Schema(description = "오늘의 스트릭 상태 (COMPLETED, FREEZE_USED, MISSED)") + private StreakStatus todayStatus; - @Schema(description = "어제의 스트릭 상태 (COMPLETED, FREEZE_USED, MISSED)") - private StreakStatus yesterdayStatus; + @Schema(description = "어제의 스트릭 상태 (COMPLETED, FREEZE_USED, MISSED)") + private StreakStatus yesterdayStatus; - @Schema(description = "최장 연속 일수", example = "30") - private int longestStreak; + @Schema(description = "최장 연속 일수", example = "30") + private int longestStreak; - @Schema(description = "현재 스트릭 시작 날짜 (yyyy-MM-dd, KST)", example = "2025-10-01") - private LocalDate streakStartDate; + @Schema(description = "현재 스트릭 시작 날짜 (yyyy-MM-dd, KST)", example = "2025-10-01") + private LocalDate streakStartDate; - @Schema(description = "전체 학습일 (dailyCompletions count)", example = "45") - private long totalStudyDays; + @Schema(description = "전체 학습일 (dailyCompletions count)", example = "45") + private long totalStudyDays; - @Schema(description = "읽은 고유 콘텐츠 개수 (dailyCompletions.firstCompletionCount의 합)", example = "120") - private long totalContentsRead; + @Schema(description = "읽은 고유 콘텐츠 개수 (dailyCompletions.firstCompletionCount의 합)", example = "120") + private long totalContentsRead; - @Schema(description = "사용 가능한 프리즈 개수", example = "2") - private int availableFreezes; + @Schema(description = "사용 가능한 프리즈 개수", example = "2") + private int availableFreezes; - @Schema(description = "총 누적 학습 시간 (초)", example = "36000") - private long totalReadingTimeSeconds; + @Schema(description = "총 누적 학습 시간 (초)", example = "36000") + private long totalReadingTimeSeconds; - @Schema(description = "상위 몇% (전체 유저 중 currentStreak 기준)", example = "5.0") - private double percentile; + @Schema(description = "상위 몇% (전체 유저 중 currentStreak 기준)", example = "5.0") + private double percentile; - @Schema(description = "격려 메시지") - private EncouragementMessage encouragementMessage; + @Schema(description = "격려 메시지") + private EncouragementMessage encouragementMessage; + + @Schema(description = "오늘의 예상 보상 정보") + private RewardInfo expectedRewards; - @Schema(description = "오늘의 예상 보상 정보") - private RewardInfo expectedRewards; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/dto/WeekDayResponse.java b/src/main/java/com/linglevel/api/streak/dto/WeekDayResponse.java index 621dfafe..2e114994 100644 --- a/src/main/java/com/linglevel/api/streak/dto/WeekDayResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/WeekDayResponse.java @@ -18,22 +18,22 @@ @Schema(description = "주간 일자별 정보") public class WeekDayResponse { - @Schema(description = "요일 (MON, TUE, WED, THU, FRI, SAT, SUN)", example = "MON") - private String dayOfWeek; + @Schema(description = "요일 (MON, TUE, WED, THU, FRI, SAT, SUN)", example = "MON") + private String dayOfWeek; - @Schema(description = "날짜 (yyyy-MM-dd)", example = "2025-10-20") - private LocalDate date; + @Schema(description = "날짜 (yyyy-MM-dd)", example = "2025-10-20") + private LocalDate date; - @Schema(description = "오늘 여부", example = "false") - private Boolean isToday; + @Schema(description = "오늘 여부", example = "false") + private Boolean isToday; - @Schema(description = "스트릭 상태 (COMPLETED: 완료, FREEZE_USED: 프리즈 사용, MISSED: 놓침, FUTURE: 미래)", - example = "COMPLETED") - private StreakStatus status; + @Schema(description = "스트릭 상태 (COMPLETED: 완료, FREEZE_USED: 프리즈 사용, MISSED: 놓침, FUTURE: 미래)", example = "COMPLETED") + private StreakStatus status; - @Schema(description = "획득한 보상 정보 (FUTURE가 아닌 경우)") - private RewardInfo rewards; + @Schema(description = "획득한 보상 정보 (FUTURE가 아닌 경우)") + private RewardInfo rewards; + + @Schema(description = "예상 보상 정보 (FUTURE인 경우)") + private RewardInfo expectedRewards; - @Schema(description = "예상 보상 정보 (FUTURE인 경우)") - private RewardInfo expectedRewards; } diff --git a/src/main/java/com/linglevel/api/streak/dto/WeekStreakResponse.java b/src/main/java/com/linglevel/api/streak/dto/WeekStreakResponse.java index 25ebde23..73e130d7 100644 --- a/src/main/java/com/linglevel/api/streak/dto/WeekStreakResponse.java +++ b/src/main/java/com/linglevel/api/streak/dto/WeekStreakResponse.java @@ -15,12 +15,13 @@ @Schema(description = "주간 스트릭 응답") public class WeekStreakResponse { - @Schema(description = "현재 스트릭", example = "25") - private Integer currentStreak; + @Schema(description = "현재 스트릭", example = "25") + private Integer currentStreak; - @Schema(description = "보유한 프리즈 개수", example = "2") - private Integer freezeCount; + @Schema(description = "보유한 프리즈 개수", example = "2") + private Integer freezeCount; + + @Schema(description = "이번 주 요일별 정보 목록 (월요일부터 시작)") + private List weekDays; - @Schema(description = "이번 주 요일별 정보 목록 (월요일부터 시작)") - private List weekDays; } diff --git a/src/main/java/com/linglevel/api/streak/entity/CompletedContent.java b/src/main/java/com/linglevel/api/streak/entity/CompletedContent.java index d7d8cbef..b0535d88 100644 --- a/src/main/java/com/linglevel/api/streak/entity/CompletedContent.java +++ b/src/main/java/com/linglevel/api/streak/entity/CompletedContent.java @@ -18,11 +18,18 @@ @Builder public class CompletedContent { - private ContentType type; - private String contentId; - private String chapterId; - private Instant completedAt; - private Integer readingTime; - private ContentCategory category; - private DifficultyLevel difficultyLevel; + private ContentType type; + + private String contentId; + + private String chapterId; + + private Instant completedAt; + + private Integer readingTime; + + private ContentCategory category; + + private DifficultyLevel difficultyLevel; + } diff --git a/src/main/java/com/linglevel/api/streak/entity/DailyCompletion.java b/src/main/java/com/linglevel/api/streak/entity/DailyCompletion.java index 64fc8722..add6c5cc 100644 --- a/src/main/java/com/linglevel/api/streak/entity/DailyCompletion.java +++ b/src/main/java/com/linglevel/api/streak/entity/DailyCompletion.java @@ -23,41 +23,52 @@ @NoArgsConstructor @AllArgsConstructor public class DailyCompletion { - @Id - private String id; - @Indexed - private String userId; + @Id + private String id; - private LocalDate completionDate; + @Indexed + private String userId; - @Builder.Default - private Integer firstCompletionCount = 0; + private LocalDate completionDate; - @Builder.Default - private Integer totalCompletionCount = 0; + @Builder.Default + private Integer firstCompletionCount = 0; - private List completedContents; + @Builder.Default + private Integer totalCompletionCount = 0; - private Integer streakCount; + private List completedContents; - private StreakStatus streakStatus; + private Integer streakCount; - private Instant createdAt; + private StreakStatus streakStatus; + + private Instant createdAt; + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CompletedContent { + + private ContentType type; + + private String contentId; + + private String chapterId; + + private Instant completedAt; + + private Integer readingTime; + + private String category; + + private String difficultyLevel; + + private StreakStatus streakStatus; + + } - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class CompletedContent { - private ContentType type; - private String contentId; - private String chapterId; - private Instant completedAt; - private Integer readingTime; - private String category; - private String difficultyLevel; - private StreakStatus streakStatus; - } } diff --git a/src/main/java/com/linglevel/api/streak/entity/FreezeTransaction.java b/src/main/java/com/linglevel/api/streak/entity/FreezeTransaction.java index f95997bf..227ede91 100644 --- a/src/main/java/com/linglevel/api/streak/entity/FreezeTransaction.java +++ b/src/main/java/com/linglevel/api/streak/entity/FreezeTransaction.java @@ -14,15 +14,17 @@ @Setter @Builder public class FreezeTransaction { - @Id - private String id; - @Indexed - private String userId; + @Id + private String id; - private Integer amount; + @Indexed + private String userId; - private String description; + private Integer amount; + + private String description; + + private Instant createdAt; - private Instant createdAt; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/entity/InspirationQuote.java b/src/main/java/com/linglevel/api/streak/entity/InspirationQuote.java index cdc26fef..8110c51e 100644 --- a/src/main/java/com/linglevel/api/streak/entity/InspirationQuote.java +++ b/src/main/java/com/linglevel/api/streak/entity/InspirationQuote.java @@ -8,528 +8,270 @@ import java.util.Random; /** - * 영감을 주는 명언 정의 - * 마일스톤이 아닌 일반 날짜에 랜덤으로 보여줄 명언을 관리합니다. + * 영감을 주는 명언 정의 마일스톤이 아닌 일반 날짜에 랜덤으로 보여줄 명언을 관리합니다. */ @Getter @RequiredArgsConstructor public enum InspirationQuote { - QUOTE_1( - "The secret of getting ahead is getting started.", - Map.of( - LanguageCode.KO, "앞서 나가는 비결은 바로 시작하는 것이다.", - LanguageCode.EN, "The secret of getting ahead is getting started.", - LanguageCode.JA, "先んずる秘訣は、まず始めることである。" - ) - ), - QUOTE_2( - "A journey of a thousand miles begins with a single step.", - Map.of( - LanguageCode.KO, "천 리 길도 한 걸음부터 시작된다.", - LanguageCode.EN, "A journey of a thousand miles begins with a single step.", - LanguageCode.JA, "千里の道も一歩から。" - ) - ), - QUOTE_3( - "The beginning is always the hardest.", - Map.of( - LanguageCode.KO, "시작이 언제나 가장 어렵다.", - LanguageCode.EN, "The beginning is always the hardest.", - LanguageCode.JA, "何事も初めが一番難しい。" - ) - ), - QUOTE_4( - "Don't be afraid to give up the good to go for the great.", - Map.of( - LanguageCode.KO, "위대함을 위해 좋은 것을 포기하는 것을 두려워하지 마라.", - LanguageCode.EN, "Don't be afraid to give up the good to go for the great.", - LanguageCode.JA, "偉大さのために、良いものを諦めることを恐れるな。" - ) - ), - QUOTE_5( - "Every accomplishment starts with the decision to try.", - Map.of( - LanguageCode.KO, "모든 성취는 '해보기로' 결심하는 것에서 시작된다.", - LanguageCode.EN, "Every accomplishment starts with the decision to try.", - LanguageCode.JA, "すべての達成は、「やってみよう」と決心することから始まる。" - ) - ), - QUOTE_6( - "The expert in anything was once a beginner.", - Map.of( - LanguageCode.KO, "어떤 분야의 전문가든 처음에는 초보자였다.", - LanguageCode.EN, "The expert in anything was once a beginner.", - LanguageCode.JA, "どんな分野の専門家も、かつては初心者だった。" - ) - ), - QUOTE_7( - "It does not matter how slowly you go as long as you do not stop.", - Map.of( - LanguageCode.KO, "멈추지만 않는다면 얼마나 천천히 가는지는 중요하지 않다.", - LanguageCode.EN, "It does not matter how slowly you go as long as you do not stop.", - LanguageCode.JA, "止まらない限り、どれだけゆっくり進んでも問題ない。" - ) - ), - QUOTE_8( - "You don’t have to be great to start, but you have to start to be great.", - Map.of( - LanguageCode.KO, "시작하기 위해 위대할 필요는 없지만, 위대해지기 위해선 시작해야 한다.", - LanguageCode.EN, "You don’t have to be great to start, but you have to start to be great.", - LanguageCode.JA, "始めるために偉大である必要はないが、偉大になるためには始めなければならない。" - ) - ), - QUOTE_9( - "Do not wait; the time will never be 'just right.' Start where you stand.", - Map.of( - LanguageCode.KO, "기다리지 마라. '딱 맞는' 때는 결코 오지 않는다. 당신이 서 있는 곳에서 시작하라.", - LanguageCode.EN, "Do not wait; the time will never be 'just right.' Start where you stand.", - LanguageCode.JA, "待っていてはだめだ。「ちょうど良い」時など決して来ない。今いる場所から始めなさい。" - ) - ), - QUOTE_10( - "The first step is you have to say that you can.", - Map.of( - LanguageCode.KO, "첫 번째 단계는 '할 수 있다'고 말하는 것이다.", - LanguageCode.EN, "The first step is you have to say that you can.", - LanguageCode.JA, "最初のステップは、「できる」と言うことだ。" - ) - ), - QUOTE_11( - "What is not started today is never finished tomorrow.", - Map.of( - LanguageCode.KO, "오늘 시작하지 않은 일은 내일 결코 끝마칠 수 없다.", - LanguageCode.EN, "What is not started today is never finished tomorrow.", - LanguageCode.JA, "今日始めなかったことは、明日決して終わらない。" - ) - ), - QUOTE_12( - "All great achievements require time.", - Map.of( - LanguageCode.KO, "모든 위대한 성취는 시간을 필요로 한다.", - LanguageCode.EN, "All great achievements require time.", - LanguageCode.JA, "すべての偉大な達成には時間が必要だ。" - ) - ), - QUOTE_13( - "The journey is the reward.", - Map.of( - LanguageCode.KO, "여정 그 자체가 보상이다.", - LanguageCode.EN, "The journey is the reward.", - LanguageCode.JA, "旅路そのものが報酬である。" - ) - ), - QUOTE_14( - "Start by doing what's necessary; then do what's possible; and suddenly you are doing the impossible.", - Map.of( - LanguageCode.KO, "필요한 것부터 시작하라. 그다음 가능한 것을 하라. 그러면 불가능한 것을 하고 있는 자신을 발견하게 될 것이다.", - LanguageCode.EN, "Start by doing what's necessary; then do what's possible; and suddenly you are doing the impossible.", - LanguageCode.JA, "まず必要なことをしなさい。次に可能なことをしなさい。そうすればいつの間にか、不可能なことをしている自分に気づくだろう。" - ) - ), - QUOTE_15( - "A year from now you may wish you had started today.", - Map.of( - LanguageCode.KO, "1년 뒤, 당신은 오늘 시작했기를 바랄지도 모른다.", - LanguageCode.EN, "A year from now you may wish you had started today.", - LanguageCode.JA, "1年後、あなたは今日始めていればよかったと願うかもしれない。" - ) - ), - QUOTE_16( - "Success is not final, failure is not fatal: it is the courage to continue that counts.", - Map.of( - LanguageCode.KO, "성공은 끝이 아니며, 실패는 치명적이지 않다. 중요한 것은 계속 나아갈 용기다.", - LanguageCode.EN, "Success is not final, failure is not fatal: it is the courage to continue that counts.", - LanguageCode.JA, "成功は終わりではなく、失敗は致命的ではない。重要なのは続ける勇気だ。" - ) - ), - QUOTE_17( - "It's not whether you get knocked down, it's whether you get up.", - Map.of( - LanguageCode.KO, "쓰러지는 것이 중요한 게 아니라, 다시 일어서는 것이 중요하다.", - LanguageCode.EN, "It's not whether you get knocked down, it's whether you get up.", - LanguageCode.JA, "打ちのめされたかどうかではなく、立ち上がるかどうかが問題だ。" - ) - ), - QUOTE_18( - "Fall seven times and stand up eight.", - Map.of( - LanguageCode.KO, "일곱 번 넘어지면 여덟 번 일어나라.", - LanguageCode.EN, "Fall seven times and stand up eight.", - LanguageCode.JA, "七転び八起き。" - ) - ), - QUOTE_19( - "Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.", - Map.of( - LanguageCode.KO, "우리의 가장 큰 약점은 포기하는 것이다. 성공으로 가는 가장 확실한 방법은 언제나 한 번 더 시도해 보는 것이다.", - LanguageCode.EN, "Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.", - LanguageCode.JA, "我々の最大の弱点は諦めることにある。成功への最も確実な道は、常にもう一度だけ試してみることだ。" - ) - ), - QUOTE_20( - "Perseverance is failing 19 times and succeeding the 20th.", - Map.of( - LanguageCode.KO, "인내란 19번 실패하고 20번째에 성공하는 것이다.", - LanguageCode.EN, "Perseverance is failing 19 times and succeeding the 20th.", - LanguageCode.JA, "忍耐とは、19回失敗し、20回目に成功することである。" - ) - ), - QUOTE_21( - "I am a slow walker, but I never walk back.", - Map.of( - LanguageCode.KO, "나는 천천히 걷지만, 결코 뒷걸음질 치지 않는다.", - LanguageCode.EN, "I am a slow walker, but I never walk back.", - LanguageCode.JA, "私は歩みが遅いが、決して後戻りはしない。" - ) - ), - QUOTE_22( - "Smooth seas do not make skillful sailors.", - Map.of( - LanguageCode.KO, "잔잔한 바다는 노련한 뱃사공을 만들지 못한다.", - LanguageCode.EN, "Smooth seas do not make skillful sailors.", - LanguageCode.JA, "穏やかな海は、熟練した船乗りを育てない。" - ) - ), - QUOTE_23( - "Effort is the great equalizer.", - Map.of( - LanguageCode.KO, "노력은 위대한 평형 장치이다.", - LanguageCode.EN, "Effort is the great equalizer.", - LanguageCode.JA, "努力は偉大なる平等化装置である。" - ) - ), - QUOTE_24( - "I have not failed. I've just found 10,000 ways that won't work.", - Map.of( - LanguageCode.KO, "나는 실패하지 않았다. 단지 작동하지 않는 1만 가지 방법을 찾았을 뿐이다.", - LanguageCode.EN, "I have not failed. I've just found 10,000 ways that won't work.", - LanguageCode.JA, "私は失敗したことがない。ただ、1万通りのうまくいかない方法を見つけただけだ。" - ) - ), - QUOTE_25( - "When you feel like quitting, think about why you started.", - Map.of( - LanguageCode.KO, "포기하고 싶어질 때, 당신이 왜 시작했는지를 생각하라.", - LanguageCode.EN, "When you feel like quitting, think about why you started.", - LanguageCode.JA, "やめたくなった時、なぜ始めたのかを思い出せ。" - ) - ), - QUOTE_26( - "We may encounter many defeats but we must not be defeated.", - Map.of( - LanguageCode.KO, "우리는 수많은 패배를 겪을지라도, 결코 패배해서는 안 된다.", - LanguageCode.EN, "We may encounter many defeats but we must not be defeated.", - LanguageCode.JA, "我々は多くの敗北に出会うかもしれないが、決して打ち負かされてはならない。" - ) - ), - QUOTE_27( - "The harder the conflict, the more glorious the triumph.", - Map.of( - LanguageCode.KO, "고난이 클수록, 승리는 더욱 영광스럽다.", - LanguageCode.EN, "The harder the conflict, the more glorious the triumph.", - LanguageCode.JA, "困難が大きければ大きいほど、勝利はより栄光に満ちたものになる。" - ) - ), - QUOTE_28( - "A diamond is a chunk of coal that did well under pressure.", - Map.of( - LanguageCode.KO, "다이아몬드는 압력을 잘 견뎌낸 석탄 덩어리다.", - LanguageCode.EN, "A diamond is a chunk of coal that did well under pressure.", - LanguageCode.JA, "ダイヤモンドとは、プレッシャーをうまく乗り越えた石炭の塊である。" - ) - ), - QUOTE_29( - "It is hard to fail, but it is worse never to have tried to succeed.", - Map.of( - LanguageCode.KO, "실패하는 것은 힘들지만, 성공하려 시도조차 해보지 않는 것은 더 나쁘다.", - LanguageCode.EN, "It is hard to fail, but it is worse never to have tried to succeed.", - LanguageCode.JA, "失敗することは辛いが、成功しようと試みないことはもっと悪い。" - ) - ), - QUOTE_30( - "Patience and perseverance have a magical effect before which difficulties disappear and obstacles vanish.", - Map.of( - LanguageCode.KO, "인내와 끈기에는 어려움이 사라지고 장애물이 없어지는 마법 같은 효과가 있다.", - LanguageCode.EN, "Patience and perseverance have a magical effect before which difficulties disappear and obstacles vanish.", - LanguageCode.JA, "忍耐と不屈の精神には、困難が消え去り、障害がなくなるという魔法のような効果がある。" - ) - ), - QUOTE_31( - "The more that you read, the more things you will know. The more that you learn, the more places you'll go.", - Map.of( - LanguageCode.KO, "더 많이 읽을수록, 더 많은 것을 알게 될 것이다. 더 많이 배울수록, 더 많은 곳에 가게 될 것이다.", - LanguageCode.EN, "The more that you read, the more things you will know. The more that you learn, the more places you'll go.", - LanguageCode.JA, "読めば読むほど、多くのことを知るようになる。学べば学ぶほど、多くの場所へ行けるようになる。" - ) - ), - QUOTE_32( - "A reader lives a thousand lives before he dies . . . The man who never reads lives only one.", - Map.of( - LanguageCode.KO, "책을 읽는 사람은 죽기 전에 천 번의 삶을 산다. 책을 읽지 않는 사람은 단 한 번의 삶을 살 뿐이다.", - LanguageCode.EN, "A reader lives a thousand lives before he dies . . . The man who never reads lives only one.", - LanguageCode.JA, "本を読む者は、死ぬ前に千の人生を生きる。本を読まぬ者は、たった一度の人生しか生きない。" - ) - ), - QUOTE_33( - "Live as if you were to die tomorrow. Learn as if you were to live forever.", - Map.of( - LanguageCode.KO, "내일 죽을 것처럼 살고, 영원히 살 것처럼 배워라.", - LanguageCode.EN, "Live as if you were to die tomorrow. Learn as if you were to live forever.", - LanguageCode.JA, "明日死ぬかのように生きよ。永遠に生きるかのように学べ。" - ) - ), - QUOTE_34( - "Learning is a treasure that will follow its owner everywhere.", - Map.of( - LanguageCode.KO, "배움은 그 주인을 어디든 따라다니는 보물이다.", - LanguageCode.EN, "Learning is a treasure that will follow its owner everywhere.", - LanguageCode.JA, "学びは、持ち主がどこへ行こうともついてくる宝である。" - ) - ), - QUOTE_35( - "An investment in knowledge pays the best interest.", - Map.of( - LanguageCode.KO, "지식에 대한 투자는 최고의 이자를 낳는다.", - LanguageCode.EN, "An investment in knowledge pays the best interest.", - LanguageCode.JA, "知識への投資は、常に最高のリターンをもたらす。" - ) - ), - QUOTE_36( - "Today a reader, tomorrow a leader.", - Map.of( - LanguageCode.KO, "오늘 책을 읽는 사람이 내일의 리더가 된다.", - LanguageCode.EN, "Today a reader, tomorrow a leader.", - LanguageCode.JA, "今日の読書家は、明日の指導者となる。" - ) - ), - QUOTE_37( - "Reading is to the mind what exercise is to the body.", - Map.of( - LanguageCode.KO, "독서는 정신에게 운동이 육체에게 주는 것과 같다.", - LanguageCode.EN, "Reading is to the mind what exercise is to the body.", - LanguageCode.JA, "読書が精神に与える影響は、運動が身体に与える影響と同じである。" - ) - ), - QUOTE_38( - "To learn a language is to have one more window from which to look at the world.", - Map.of( - LanguageCode.KO, "언어를 배운다는 것은 세상을 바라보는 창문을 하나 더 갖는 것이다.", - LanguageCode.EN, "To learn a language is to have one more window from which to look at the world.", - LanguageCode.JA, "言語を学ぶことは、世界を見る窓をもう一つ持つことだ。" - ) - ), - QUOTE_39( - "Books are a uniquely portable magic.", - Map.of( - LanguageCode.KO, "책은 독특하게 휴대 가능한 마법이다.", - LanguageCode.EN, "Books are a uniquely portable magic.", - LanguageCode.JA, "本とは、他に類を見ない、持ち運び可能な魔法である。" - ) - ), - QUOTE_40( - "The man who does not read has no advantage over the man who cannot read.", - Map.of( - LanguageCode.KO, "책을 읽지 않는 사람은 글을 읽지 못하는 사람보다 나을 것이 없다.", - LanguageCode.EN, "The man who does not read has no advantage over the man who cannot read.", - LanguageCode.JA, "本を読まない人は、字が読めない人よりも優れている点はない。" - ) - ), - QUOTE_41( - "Education is the passport to the future, for tomorrow belongs to those who prepare for it today.", - Map.of( - LanguageCode.KO, "교육은 미래로 가는 여권이다. 내일은 오늘 준비하는 자의 것이기 때문이다.", - LanguageCode.EN, "Education is the passport to the future, for tomorrow belongs to those who prepare for it today.", - LanguageCode.JA, "教育とは未来へのパスポートである。明日は今日準備した者のものであるからだ。" - ) - ), - QUOTE_42( - "Develop a passion for learning. If you do, you will never cease to grow.", - Map.of( - LanguageCode.KO, "배움에 대한 열정을 키워라. 그렇게 한다면, 당신은 결코 성장을 멈추지 않을 것이다.", - LanguageCode.EN, "Develop a passion for learning. If you do, you will never cease to grow.", - LanguageCode.JA, "学ぶことへの情熱を育てなさい。そうすれば、あなたは決して成長を止めないだろう。" - ) - ), - QUOTE_43( - "Language is the road map of a culture. It tells you where its people come from and where they are going.", - Map.of( - LanguageCode.KO, "언어는 한 문화의 로드맵이다. 그것은 그 민족이 어디에서 왔고 어디로 가고 있는지를 말해준다.", - LanguageCode.EN, "Language is the road map of a culture. It tells you where its people come from and where they are going.", - LanguageCode.JA, "言語は文化のロードマップである。それは、その人々がどこから来て、どこへ行こうとしているのかを教えてくれる。" - ) - ), - QUOTE_44( - "Reading is a conversation. All books talk. But a good book listens as well.", - Map.of( - LanguageCode.KO, "독서는 대화다. 모든 책은 말을 걸지만, 좋은 책은 귀 기울여 듣기도 한다.", - LanguageCode.EN, "Reading is a conversation. All books talk. But a good book listens as well.", - LanguageCode.JA, "読書とは会話である。全ての本は語りかける。しかし、良い本は傾聴もしてくれる。" - ) - ), - QUOTE_45( - "Reading brings us unknown friends.", - Map.of( - LanguageCode.KO, "독서는 우리에게 미지의 친구들을 데려다준다.", - LanguageCode.EN, "Reading brings us unknown friends.", - LanguageCode.JA, "読書は、我々に見知らぬ友人をもたらしてくれる。" - ) - ), - QUOTE_46( - "Believe you can and you're halfway there.", - Map.of( - LanguageCode.KO, "할 수 있다고 믿으면, 당신은 이미 절반은 온 것이다.", - LanguageCode.EN, "Believe you can and you're halfway there.", - LanguageCode.JA, "できると信じれば、もう半分は達成している。" - ) - ), - QUOTE_47( - "Success is the sum of small efforts, repeated day in and day out.", - Map.of( - LanguageCode.KO, "성공은 매일 반복되는 작은 노력들의 합이다.", - LanguageCode.EN, "Success is the sum of small efforts, repeated day in and day out.", - LanguageCode.JA, "成功とは、日々繰り返される小さな努力の積み重ねである。" - ) - ), - QUOTE_48( - "The only way to do great work is to love what you do.", - Map.of( - LanguageCode.KO, "위대한 일을 하는 유일한 방법은 당신이 하는 일을 사랑하는 것이다.", - LanguageCode.EN, "The only way to do great work is to love what you do.", - LanguageCode.JA, "素晴らしい仕事をする唯一の方法は、自分のしていることを愛することだ。" - ) - ), - QUOTE_49( - "You are never too old to set another goal or to dream a new dream.", - Map.of( - LanguageCode.KO, "또 다른 목표를 세우거나 새로운 꿈을 꾸기에 너무 늦은 나이란 없다.", - LanguageCode.EN, "You are never too old to set another goal or to dream a new dream.", - LanguageCode.JA, "新しい目標を立てたり、新しい夢を見るのに遅すぎることはない。" - ) - ), - QUOTE_50( - "Our dreams can come true if we have the courage to pursue them.", - Map.of( - LanguageCode.KO, "꿈을 추구할 용기만 있다면, 우리의 모든 꿈은 이루어질 수 있다.", - LanguageCode.EN, "Our dreams can come true if we have the courage to pursue them.", - LanguageCode.JA, "追い求める勇気さえあれば、夢は必ず叶う。" - ) - ), - QUOTE_51( - "Don't watch the clock; do what it does. Keep going.", - Map.of( - LanguageCode.KO, "시계를 보지 마라. 시계가 하는 것처럼 계속 나아가라.", - LanguageCode.EN, "Don't watch the clock; do what it does. Keep going.", - LanguageCode.JA, "時計を見るな。時計がするように、進み続けろ。" - ) - ), - QUOTE_52( - "The future belongs to those who believe in the beauty of their dreams.", - Map.of( - LanguageCode.KO, "미래는 자기 꿈의 아름다움을 믿는 사람들의 것이다.", - LanguageCode.EN, "The future belongs to those who believe in the beauty of their dreams.", - LanguageCode.JA, "未来とは、自分の夢の美しさを信じる者のものである。" - ) - ), - QUOTE_53( - "You miss 100% of the shots you don’t take.", - Map.of( - LanguageCode.KO, "시도하지 않은 슛은 100% 빗나간다.", - LanguageCode.EN, "You miss 100% of the shots you don’t take.", - LanguageCode.JA, "打たなかったシュートは、100%外れる。" - ) - ), - QUOTE_54( - "It always seems impossible until it's done.", - Map.of( - LanguageCode.KO, "어떤 일이든 그것이 끝나기 전까지는 항상 불가능해 보인다.", - LanguageCode.EN, "It always seems impossible until it's done.", - LanguageCode.JA, "何事も、成し遂げられるまでは不可能に見える。" - ) - ), - QUOTE_55( - "If you can dream it, you can do it.", - Map.of( - LanguageCode.KO, "당신이 꿈꿀 수 있다면, 당신은 그것을 해낼 수 있다.", - LanguageCode.EN, "If you can dream it, you can do it.", - LanguageCode.JA, "夢見ることができれば、それは実現できる。" - ) - ), - QUOTE_56( - "What you get by achieving your goals is not as important as what you become by achieving your goals.", - Map.of( - LanguageCode.KO, "목표를 달성함으로써 얻는 것보다, 목표를 달성함으로써 당신이 어떤 사람이 되는지가 더 중요하다.", - LanguageCode.EN, "What you get by achieving your goals is not as important as what you become by achieving your goals.", - LanguageCode.JA, "目標を達成して得られるものよりも、目標を達成する過程で自分がどう成長するかが重要だ。" - ) - ), - QUOTE_57( - "Act as if what you do makes a difference. It does.", - Map.of( - LanguageCode.KO, "당신의 행동이 변화를 만든다고 생각하며 행동하라. 실제로 그러니까.", - LanguageCode.EN, "Act as if what you do makes a difference. It does.", - LanguageCode.JA, "自分の行動が変化をもたらすと信じて行動しなさい。実際、その通りなのだから。" - ) - ), - QUOTE_58( - "Don't let what you cannot do interfere with what you can do.", - Map.of( - LanguageCode.KO, "당신이 할 수 없는 일이 당신이 할 수 있는 일을 방해하게 두지 마라.", - LanguageCode.EN, "Don't let what you cannot do interfere with what you can do.", - LanguageCode.JA, "できないことに、できることの邪魔をさせてはいけない。" - ) - ), - QUOTE_59( - "He who has a why to live can bear almost any how.", - Map.of( - LanguageCode.KO, "살아야 할 '이유'를 아는 사람은 거의 모든 '어떻게'를 견뎌낼 수 있다.", - LanguageCode.EN, "He who has a why to live can bear almost any how.", - LanguageCode.JA, "生きる「なぜ」を持つ者は、ほとんどあらゆる「どのように」にも耐えられる。" - ) - ), - QUOTE_60( - "Your time is limited, so don’t waste it living someone else’s life.", - Map.of( - LanguageCode.KO, "당신의 시간은 한정되어 있다. 그러니 다른 사람의 삶을 사느라 시간을 낭비하지 마라.", - LanguageCode.EN, "Your time is limited, so don’t waste it living someone else’s life.", - LanguageCode.JA, "あなたの時間は限られている。だから、他人の人生を生きて無駄にしてはいけない。" - ) - ); - private final String original; - private final Map translations; + QUOTE_1("The secret of getting ahead is getting started.", + Map.of(LanguageCode.KO, "앞서 나가는 비결은 바로 시작하는 것이다.", LanguageCode.EN, + "The secret of getting ahead is getting started.", LanguageCode.JA, "先んずる秘訣は、まず始めることである。")), + QUOTE_2("A journey of a thousand miles begins with a single step.", + Map.of(LanguageCode.KO, "천 리 길도 한 걸음부터 시작된다.", LanguageCode.EN, + "A journey of a thousand miles begins with a single step.", LanguageCode.JA, "千里の道も一歩から。")), + QUOTE_3("The beginning is always the hardest.", + Map.of(LanguageCode.KO, "시작이 언제나 가장 어렵다.", LanguageCode.EN, "The beginning is always the hardest.", + LanguageCode.JA, "何事も初めが一番難しい。")), + QUOTE_4("Don't be afraid to give up the good to go for the great.", + Map.of(LanguageCode.KO, "위대함을 위해 좋은 것을 포기하는 것을 두려워하지 마라.", LanguageCode.EN, + "Don't be afraid to give up the good to go for the great.", LanguageCode.JA, + "偉大さのために、良いものを諦めることを恐れるな。")), + QUOTE_5("Every accomplishment starts with the decision to try.", + Map.of(LanguageCode.KO, "모든 성취는 '해보기로' 결심하는 것에서 시작된다.", LanguageCode.EN, + "Every accomplishment starts with the decision to try.", LanguageCode.JA, + "すべての達成は、「やってみよう」と決心することから始まる。")), + QUOTE_6("The expert in anything was once a beginner.", + Map.of(LanguageCode.KO, "어떤 분야의 전문가든 처음에는 초보자였다.", LanguageCode.EN, + "The expert in anything was once a beginner.", LanguageCode.JA, "どんな分野の専門家も、かつては初心者だった。")), + QUOTE_7("It does not matter how slowly you go as long as you do not stop.", + Map.of(LanguageCode.KO, "멈추지만 않는다면 얼마나 천천히 가는지는 중요하지 않다.", LanguageCode.EN, + "It does not matter how slowly you go as long as you do not stop.", LanguageCode.JA, + "止まらない限り、どれだけゆっくり進んでも問題ない。")), + QUOTE_8("You don’t have to be great to start, but you have to start to be great.", + Map.of(LanguageCode.KO, "시작하기 위해 위대할 필요는 없지만, 위대해지기 위해선 시작해야 한다.", LanguageCode.EN, + "You don’t have to be great to start, but you have to start to be great.", LanguageCode.JA, + "始めるために偉大である必要はないが、偉大になるためには始めなければならない。")), + QUOTE_9("Do not wait; the time will never be 'just right.' Start where you stand.", + Map.of(LanguageCode.KO, "기다리지 마라. '딱 맞는' 때는 결코 오지 않는다. 당신이 서 있는 곳에서 시작하라.", LanguageCode.EN, + "Do not wait; the time will never be 'just right.' Start where you stand.", LanguageCode.JA, + "待っていてはだめだ。「ちょうど良い」時など決して来ない。今いる場所から始めなさい。")), + QUOTE_10("The first step is you have to say that you can.", + Map.of(LanguageCode.KO, "첫 번째 단계는 '할 수 있다'고 말하는 것이다.", LanguageCode.EN, + "The first step is you have to say that you can.", LanguageCode.JA, "最初のステップは、「できる」と言うことだ。")), + QUOTE_11("What is not started today is never finished tomorrow.", + Map.of(LanguageCode.KO, "오늘 시작하지 않은 일은 내일 결코 끝마칠 수 없다.", LanguageCode.EN, + "What is not started today is never finished tomorrow.", LanguageCode.JA, + "今日始めなかったことは、明日決して終わらない。")), + QUOTE_12("All great achievements require time.", + Map.of(LanguageCode.KO, "모든 위대한 성취는 시간을 필요로 한다.", LanguageCode.EN, "All great achievements require time.", + LanguageCode.JA, "すべての偉大な達成には時間が必要だ。")), + QUOTE_13("The journey is the reward.", + Map.of(LanguageCode.KO, "여정 그 자체가 보상이다.", LanguageCode.EN, "The journey is the reward.", LanguageCode.JA, + "旅路そのものが報酬である。")), + QUOTE_14("Start by doing what's necessary; then do what's possible; and suddenly you are doing the impossible.", Map + .of(LanguageCode.KO, "필요한 것부터 시작하라. 그다음 가능한 것을 하라. 그러면 불가능한 것을 하고 있는 자신을 발견하게 될 것이다.", LanguageCode.EN, + "Start by doing what's necessary; then do what's possible; and suddenly you are doing the impossible.", + LanguageCode.JA, "まず必要なことをしなさい。次に可能なことをしなさい。そうすればいつの間にか、不可能なことをしている自分に気づくだろう。")), + QUOTE_15("A year from now you may wish you had started today.", + Map.of(LanguageCode.KO, "1년 뒤, 당신은 오늘 시작했기를 바랄지도 모른다.", LanguageCode.EN, + "A year from now you may wish you had started today.", LanguageCode.JA, + "1年後、あなたは今日始めていればよかったと願うかもしれない。")), + QUOTE_16("Success is not final, failure is not fatal: it is the courage to continue that counts.", + Map.of(LanguageCode.KO, "성공은 끝이 아니며, 실패는 치명적이지 않다. 중요한 것은 계속 나아갈 용기다.", LanguageCode.EN, + "Success is not final, failure is not fatal: it is the courage to continue that counts.", + LanguageCode.JA, "成功は終わりではなく、失敗は致命的ではない。重要なのは続ける勇気だ。")), + QUOTE_17("It's not whether you get knocked down, it's whether you get up.", + Map.of(LanguageCode.KO, "쓰러지는 것이 중요한 게 아니라, 다시 일어서는 것이 중요하다.", LanguageCode.EN, + "It's not whether you get knocked down, it's whether you get up.", LanguageCode.JA, + "打ちのめされたかどうかではなく、立ち上がるかどうかが問題だ。")), + QUOTE_18("Fall seven times and stand up eight.", + Map.of(LanguageCode.KO, "일곱 번 넘어지면 여덟 번 일어나라.", LanguageCode.EN, "Fall seven times and stand up eight.", + LanguageCode.JA, "七転び八起き。")), + QUOTE_19( + "Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.", + Map.of(LanguageCode.KO, "우리의 가장 큰 약점은 포기하는 것이다. 성공으로 가는 가장 확실한 방법은 언제나 한 번 더 시도해 보는 것이다.", LanguageCode.EN, + "Our greatest weakness lies in giving up. The most certain way to succeed is always to try just one more time.", + LanguageCode.JA, "我々の最大の弱点は諦めることにある。成功への最も確実な道は、常にもう一度だけ試してみることだ。")), + QUOTE_20("Perseverance is failing 19 times and succeeding the 20th.", + Map.of(LanguageCode.KO, "인내란 19번 실패하고 20번째에 성공하는 것이다.", LanguageCode.EN, + "Perseverance is failing 19 times and succeeding the 20th.", LanguageCode.JA, + "忍耐とは、19回失敗し、20回目に成功することである。")), + QUOTE_21("I am a slow walker, but I never walk back.", + Map.of(LanguageCode.KO, "나는 천천히 걷지만, 결코 뒷걸음질 치지 않는다.", LanguageCode.EN, + "I am a slow walker, but I never walk back.", LanguageCode.JA, "私は歩みが遅いが、決して後戻りはしない。")), + QUOTE_22("Smooth seas do not make skillful sailors.", + Map.of(LanguageCode.KO, "잔잔한 바다는 노련한 뱃사공을 만들지 못한다.", LanguageCode.EN, + "Smooth seas do not make skillful sailors.", LanguageCode.JA, "穏やかな海は、熟練した船乗りを育てない。")), + QUOTE_23("Effort is the great equalizer.", + Map.of(LanguageCode.KO, "노력은 위대한 평형 장치이다.", LanguageCode.EN, "Effort is the great equalizer.", + LanguageCode.JA, "努力は偉大なる平等化装置である。")), + QUOTE_24("I have not failed. I've just found 10,000 ways that won't work.", + Map.of(LanguageCode.KO, "나는 실패하지 않았다. 단지 작동하지 않는 1만 가지 방법을 찾았을 뿐이다.", LanguageCode.EN, + "I have not failed. I've just found 10,000 ways that won't work.", LanguageCode.JA, + "私は失敗したことがない。ただ、1万通りのうまくいかない方法を見つけただけだ。")), + QUOTE_25("When you feel like quitting, think about why you started.", + Map.of(LanguageCode.KO, "포기하고 싶어질 때, 당신이 왜 시작했는지를 생각하라.", LanguageCode.EN, + "When you feel like quitting, think about why you started.", LanguageCode.JA, + "やめたくなった時、なぜ始めたのかを思い出せ。")), + QUOTE_26("We may encounter many defeats but we must not be defeated.", + Map.of(LanguageCode.KO, "우리는 수많은 패배를 겪을지라도, 결코 패배해서는 안 된다.", LanguageCode.EN, + "We may encounter many defeats but we must not be defeated.", LanguageCode.JA, + "我々は多くの敗北に出会うかもしれないが、決して打ち負かされてはならない。")), + QUOTE_27("The harder the conflict, the more glorious the triumph.", + Map.of(LanguageCode.KO, "고난이 클수록, 승리는 더욱 영광스럽다.", LanguageCode.EN, + "The harder the conflict, the more glorious the triumph.", LanguageCode.JA, + "困難が大きければ大きいほど、勝利はより栄光に満ちたものになる。")), + QUOTE_28("A diamond is a chunk of coal that did well under pressure.", + Map.of(LanguageCode.KO, "다이아몬드는 압력을 잘 견뎌낸 석탄 덩어리다.", LanguageCode.EN, + "A diamond is a chunk of coal that did well under pressure.", LanguageCode.JA, + "ダイヤモンドとは、プレッシャーをうまく乗り越えた石炭の塊である。")), + QUOTE_29("It is hard to fail, but it is worse never to have tried to succeed.", + Map.of(LanguageCode.KO, "실패하는 것은 힘들지만, 성공하려 시도조차 해보지 않는 것은 더 나쁘다.", LanguageCode.EN, + "It is hard to fail, but it is worse never to have tried to succeed.", LanguageCode.JA, + "失敗することは辛いが、成功しようと試みないことはもっと悪い。")), + QUOTE_30( + "Patience and perseverance have a magical effect before which difficulties disappear and obstacles vanish.", + Map.of(LanguageCode.KO, "인내와 끈기에는 어려움이 사라지고 장애물이 없어지는 마법 같은 효과가 있다.", LanguageCode.EN, + "Patience and perseverance have a magical effect before which difficulties disappear and obstacles vanish.", + LanguageCode.JA, "忍耐と不屈の精神には、困難が消え去り、障害がなくなるという魔法のような効果がある。")), + QUOTE_31( + "The more that you read, the more things you will know. The more that you learn, the more places you'll go.", + Map.of(LanguageCode.KO, "더 많이 읽을수록, 더 많은 것을 알게 될 것이다. 더 많이 배울수록, 더 많은 곳에 가게 될 것이다.", LanguageCode.EN, + "The more that you read, the more things you will know. The more that you learn, the more places you'll go.", + LanguageCode.JA, "読めば読むほど、多くのことを知るようになる。学べば学ぶほど、多くの場所へ行けるようになる。")), + QUOTE_32("A reader lives a thousand lives before he dies . . . The man who never reads lives only one.", + Map.of(LanguageCode.KO, "책을 읽는 사람은 죽기 전에 천 번의 삶을 산다. 책을 읽지 않는 사람은 단 한 번의 삶을 살 뿐이다.", LanguageCode.EN, + "A reader lives a thousand lives before he dies . . . The man who never reads lives only one.", + LanguageCode.JA, "本を読む者は、死ぬ前に千の人生を生きる。本を読まぬ者は、たった一度の人生しか生きない。")), + QUOTE_33("Live as if you were to die tomorrow. Learn as if you were to live forever.", + Map.of(LanguageCode.KO, "내일 죽을 것처럼 살고, 영원히 살 것처럼 배워라.", LanguageCode.EN, + "Live as if you were to die tomorrow. Learn as if you were to live forever.", LanguageCode.JA, + "明日死ぬかのように生きよ。永遠に生きるかのように学べ。")), + QUOTE_34("Learning is a treasure that will follow its owner everywhere.", + Map.of(LanguageCode.KO, "배움은 그 주인을 어디든 따라다니는 보물이다.", LanguageCode.EN, + "Learning is a treasure that will follow its owner everywhere.", LanguageCode.JA, + "学びは、持ち主がどこへ行こうともついてくる宝である。")), + QUOTE_35("An investment in knowledge pays the best interest.", + Map.of(LanguageCode.KO, "지식에 대한 투자는 최고의 이자를 낳는다.", LanguageCode.EN, + "An investment in knowledge pays the best interest.", LanguageCode.JA, "知識への投資は、常に最高のリターンをもたらす。")), + QUOTE_36("Today a reader, tomorrow a leader.", + Map.of(LanguageCode.KO, "오늘 책을 읽는 사람이 내일의 리더가 된다.", LanguageCode.EN, "Today a reader, tomorrow a leader.", + LanguageCode.JA, "今日の読書家は、明日の指導者となる。")), + QUOTE_37("Reading is to the mind what exercise is to the body.", + Map.of(LanguageCode.KO, "독서는 정신에게 운동이 육체에게 주는 것과 같다.", LanguageCode.EN, + "Reading is to the mind what exercise is to the body.", LanguageCode.JA, + "読書が精神に与える影響は、運動が身体に与える影響と同じである。")), + QUOTE_38("To learn a language is to have one more window from which to look at the world.", + Map.of(LanguageCode.KO, "언어를 배운다는 것은 세상을 바라보는 창문을 하나 더 갖는 것이다.", LanguageCode.EN, + "To learn a language is to have one more window from which to look at the world.", LanguageCode.JA, + "言語を学ぶことは、世界を見る窓をもう一つ持つことだ。")), + QUOTE_39("Books are a uniquely portable magic.", + Map.of(LanguageCode.KO, "책은 독특하게 휴대 가능한 마법이다.", LanguageCode.EN, "Books are a uniquely portable magic.", + LanguageCode.JA, "本とは、他に類を見ない、持ち運び可能な魔法である。")), + QUOTE_40("The man who does not read has no advantage over the man who cannot read.", + Map.of(LanguageCode.KO, "책을 읽지 않는 사람은 글을 읽지 못하는 사람보다 나을 것이 없다.", LanguageCode.EN, + "The man who does not read has no advantage over the man who cannot read.", LanguageCode.JA, + "本を読まない人は、字が読めない人よりも優れている点はない。")), + QUOTE_41("Education is the passport to the future, for tomorrow belongs to those who prepare for it today.", + Map.of(LanguageCode.KO, "교육은 미래로 가는 여권이다. 내일은 오늘 준비하는 자의 것이기 때문이다.", LanguageCode.EN, + "Education is the passport to the future, for tomorrow belongs to those who prepare for it today.", + LanguageCode.JA, "教育とは未来へのパスポートである。明日は今日準備した者のものであるからだ。")), + QUOTE_42("Develop a passion for learning. If you do, you will never cease to grow.", + Map.of(LanguageCode.KO, "배움에 대한 열정을 키워라. 그렇게 한다면, 당신은 결코 성장을 멈추지 않을 것이다.", LanguageCode.EN, + "Develop a passion for learning. If you do, you will never cease to grow.", LanguageCode.JA, + "学ぶことへの情熱を育てなさい。そうすれば、あなたは決して成長を止めないだろう。")), + QUOTE_43("Language is the road map of a culture. It tells you where its people come from and where they are going.", + Map.of(LanguageCode.KO, "언어는 한 문화의 로드맵이다. 그것은 그 민족이 어디에서 왔고 어디로 가고 있는지를 말해준다.", LanguageCode.EN, + "Language is the road map of a culture. It tells you where its people come from and where they are going.", + LanguageCode.JA, "言語は文化のロードマップである。それは、その人々がどこから来て、どこへ行こうとしているのかを教えてくれる。")), + QUOTE_44("Reading is a conversation. All books talk. But a good book listens as well.", + Map.of(LanguageCode.KO, "독서는 대화다. 모든 책은 말을 걸지만, 좋은 책은 귀 기울여 듣기도 한다.", LanguageCode.EN, + "Reading is a conversation. All books talk. But a good book listens as well.", LanguageCode.JA, + "読書とは会話である。全ての本は語りかける。しかし、良い本は傾聴もしてくれる。")), + QUOTE_45("Reading brings us unknown friends.", + Map.of(LanguageCode.KO, "독서는 우리에게 미지의 친구들을 데려다준다.", LanguageCode.EN, "Reading brings us unknown friends.", + LanguageCode.JA, "読書は、我々に見知らぬ友人をもたらしてくれる。")), + QUOTE_46("Believe you can and you're halfway there.", + Map.of(LanguageCode.KO, "할 수 있다고 믿으면, 당신은 이미 절반은 온 것이다.", LanguageCode.EN, + "Believe you can and you're halfway there.", LanguageCode.JA, "できると信じれば、もう半分は達成している。")), + QUOTE_47("Success is the sum of small efforts, repeated day in and day out.", + Map.of(LanguageCode.KO, "성공은 매일 반복되는 작은 노력들의 합이다.", LanguageCode.EN, + "Success is the sum of small efforts, repeated day in and day out.", LanguageCode.JA, + "成功とは、日々繰り返される小さな努力の積み重ねである。")), + QUOTE_48("The only way to do great work is to love what you do.", + Map.of(LanguageCode.KO, "위대한 일을 하는 유일한 방법은 당신이 하는 일을 사랑하는 것이다.", LanguageCode.EN, + "The only way to do great work is to love what you do.", LanguageCode.JA, + "素晴らしい仕事をする唯一の方法は、自分のしていることを愛することだ。")), + QUOTE_49("You are never too old to set another goal or to dream a new dream.", + Map.of(LanguageCode.KO, "또 다른 목표를 세우거나 새로운 꿈을 꾸기에 너무 늦은 나이란 없다.", LanguageCode.EN, + "You are never too old to set another goal or to dream a new dream.", LanguageCode.JA, + "新しい目標を立てたり、新しい夢を見るのに遅すぎることはない。")), + QUOTE_50("Our dreams can come true if we have the courage to pursue them.", + Map.of(LanguageCode.KO, "꿈을 추구할 용기만 있다면, 우리의 모든 꿈은 이루어질 수 있다.", LanguageCode.EN, + "Our dreams can come true if we have the courage to pursue them.", LanguageCode.JA, + "追い求める勇気さえあれば、夢は必ず叶う。")), + QUOTE_51("Don't watch the clock; do what it does. Keep going.", + Map.of(LanguageCode.KO, "시계를 보지 마라. 시계가 하는 것처럼 계속 나아가라.", LanguageCode.EN, + "Don't watch the clock; do what it does. Keep going.", LanguageCode.JA, "時計を見るな。時計がするように、進み続けろ。")), + QUOTE_52("The future belongs to those who believe in the beauty of their dreams.", + Map.of(LanguageCode.KO, "미래는 자기 꿈의 아름다움을 믿는 사람들의 것이다.", LanguageCode.EN, + "The future belongs to those who believe in the beauty of their dreams.", LanguageCode.JA, + "未来とは、自分の夢の美しさを信じる者のものである。")), + QUOTE_53("You miss 100% of the shots you don’t take.", + Map.of(LanguageCode.KO, "시도하지 않은 슛은 100% 빗나간다.", LanguageCode.EN, + "You miss 100% of the shots you don’t take.", LanguageCode.JA, "打たなかったシュートは、100%外れる。")), + QUOTE_54("It always seems impossible until it's done.", + Map.of(LanguageCode.KO, "어떤 일이든 그것이 끝나기 전까지는 항상 불가능해 보인다.", LanguageCode.EN, + "It always seems impossible until it's done.", LanguageCode.JA, "何事も、成し遂げられるまでは不可能に見える。")), + QUOTE_55("If you can dream it, you can do it.", + Map.of(LanguageCode.KO, "당신이 꿈꿀 수 있다면, 당신은 그것을 해낼 수 있다.", LanguageCode.EN, + "If you can dream it, you can do it.", LanguageCode.JA, "夢見ることができれば、それは実現できる。")), + QUOTE_56("What you get by achieving your goals is not as important as what you become by achieving your goals.", Map + .of(LanguageCode.KO, "목표를 달성함으로써 얻는 것보다, 목표를 달성함으로써 당신이 어떤 사람이 되는지가 더 중요하다.", LanguageCode.EN, + "What you get by achieving your goals is not as important as what you become by achieving your goals.", + LanguageCode.JA, "目標を達成して得られるものよりも、目標を達成する過程で自分がどう成長するかが重要だ。")), + QUOTE_57("Act as if what you do makes a difference. It does.", + Map.of(LanguageCode.KO, "당신의 행동이 변화를 만든다고 생각하며 행동하라. 실제로 그러니까.", LanguageCode.EN, + "Act as if what you do makes a difference. It does.", LanguageCode.JA, + "自分の行動が変化をもたらすと信じて行動しなさい。実際、その通りなのだから。")), + QUOTE_58("Don't let what you cannot do interfere with what you can do.", + Map.of(LanguageCode.KO, "당신이 할 수 없는 일이 당신이 할 수 있는 일을 방해하게 두지 마라.", LanguageCode.EN, + "Don't let what you cannot do interfere with what you can do.", LanguageCode.JA, + "できないことに、できることの邪魔をさせてはいけない。")), + QUOTE_59("He who has a why to live can bear almost any how.", + Map.of(LanguageCode.KO, "살아야 할 '이유'를 아는 사람은 거의 모든 '어떻게'를 견뎌낼 수 있다.", LanguageCode.EN, + "He who has a why to live can bear almost any how.", LanguageCode.JA, + "生きる「なぜ」を持つ者は、ほとんどあらゆる「どのように」にも耐えられる。")), + QUOTE_60("Your time is limited, so don’t waste it living someone else’s life.", + Map.of(LanguageCode.KO, "당신의 시간은 한정되어 있다. 그러니 다른 사람의 삶을 사느라 시간을 낭비하지 마라.", LanguageCode.EN, + "Your time is limited, so don’t waste it living someone else’s life.", LanguageCode.JA, + "あなたの時間は限られている。だから、他人の人生を生きて無駄にしてはいけない。")); - private static final Random RANDOM = new Random(); + private final String original; - /** - * 랜덤으로 명언을 선택합니다. - * - * @return 랜덤하게 선택된 명언 - */ - public static InspirationQuote random() { - InspirationQuote[] quotes = values(); - return quotes[RANDOM.nextInt(quotes.length)]; - } + private final Map translations; - /** - * 원문 명언을 가져옵니다. - * - * @return 명언 원문 (영어) - */ - public String getOriginal() { - return original; - } + private static final Random RANDOM = new Random(); - /** - * 지정된 언어로 번역된 명언을 가져옵니다. - * - * @param languageCode 언어 코드 - * @return 해당 언어의 번역, 없으면 영어 원문 - */ - public String getTranslation(LanguageCode languageCode) { - if (languageCode == LanguageCode.EN) { - return null; - } + /** + * 랜덤으로 명언을 선택합니다. + * @return 랜덤하게 선택된 명언 + */ + public static InspirationQuote random() { + InspirationQuote[] quotes = values(); + return quotes[RANDOM.nextInt(quotes.length)]; + } + + /** + * 원문 명언을 가져옵니다. + * @return 명언 원문 (영어) + */ + public String getOriginal() { + return original; + } + + /** + * 지정된 언어로 번역된 명언을 가져옵니다. + * @param languageCode 언어 코드 + * @return 해당 언어의 번역, 없으면 영어 원문 + */ + public String getTranslation(LanguageCode languageCode) { + if (languageCode == LanguageCode.EN) { + return null; + } + + return translations.getOrDefault(languageCode, original); + } - return translations.getOrDefault(languageCode, original); - } } diff --git a/src/main/java/com/linglevel/api/streak/entity/StreakMilestone.java b/src/main/java/com/linglevel/api/streak/entity/StreakMilestone.java index 21808b46..c1400b95 100644 --- a/src/main/java/com/linglevel/api/streak/entity/StreakMilestone.java +++ b/src/main/java/com/linglevel/api/streak/entity/StreakMilestone.java @@ -8,157 +8,86 @@ import java.util.Optional; /** - * 스트릭 마일스톤 정의 - * 특별한 날들(1, 3, 7, 14, 30일 등)에 대한 축하 메시지를 관리합니다. + * 스트릭 마일스톤 정의 특별한 날들(1, 3, 7, 14, 30일 등)에 대한 축하 메시지를 관리합니다. */ @Getter @RequiredArgsConstructor public enum StreakMilestone { - DAY_0(0, - Map.of( - LanguageCode.KO, "새로운 시작", - LanguageCode.EN, "A New Start", - LanguageCode.JA, "新しい始まり" - ), - Map.of( - LanguageCode.KO, "첫 학습을 시작하고 스트릭을 만들어보세요! 당신의 도전을 응원합니다.", - LanguageCode.EN, "Start your first lesson and build a streak! We're cheering for your challenge.", - LanguageCode.JA, "最初の学習を始めて、ストリークを作りましょう!あなたの挑戦を応援します。" - ) - ), - DAY_1(1, - Map.of( - LanguageCode.KO, "첫 시작!", - LanguageCode.EN, "First Step!", - LanguageCode.JA, "初めの一歩!" - ), - Map.of( - LanguageCode.KO, "첫 스트릭을 달성했어요. 작은 시작이 큰 변화를 만들어요.", - LanguageCode.EN, "First streak achieved. Small starts lead to big changes.", - LanguageCode.JA, "最初のストリークを達成しました。小さな始まりが大きな変化を生みます。" - ) - ), - DAY_3(3, - Map.of( - LanguageCode.KO, "3일 연속!", - LanguageCode.EN, "3 Days in a Row!", - LanguageCode.JA, "3日連続!" - ), - Map.of( - LanguageCode.KO, "3일째 함께하고 있어요. 습관이 만들어지기 시작했어요!", - LanguageCode.EN, "Three days together. A habit is starting to form!", - LanguageCode.JA, "3日間一緒にやっています。習慣が形成され始めました!" - ) - ), - DAY_7(7, - Map.of( - LanguageCode.KO, "일주일 달성!", - LanguageCode.EN, "One Week Milestone!", - LanguageCode.JA, "一週間達成!" - ), - Map.of( - LanguageCode.KO, "7일 동안 매일 학습했어요. 이제 루틴이 되어가고 있네요!", - LanguageCode.EN, "Seven days of daily learning. It's becoming a routine now!", - LanguageCode.JA, "7日間毎日学習しました。もうルーティンになりつつありますね!" - ) - ), - DAY_14(14, - Map.of( - LanguageCode.KO, "2주 달성!", - LanguageCode.EN, "Two Weeks Strong!", - LanguageCode.JA, "2週間達成!" - ), - Map.of( - LanguageCode.KO, "2주째 꾸준히 해오고 있어요. 이제 습관으로 자리잡았네요.", - LanguageCode.EN, "Two weeks of consistency. It's now part of your routine.", - LanguageCode.JA, "2週間着実に続けています。もう習慣として定着しましたね。" - ) - ), - DAY_30(30, - Map.of( - LanguageCode.KO, "한 달 달성!", - LanguageCode.EN, "One Month Achievement!", - LanguageCode.JA, "1ヶ月達成!" - ), - Map.of( - LanguageCode.KO, "30일 동안 하루도 빠짐없이 학습했어요. 대단한 꾸준함이에요!", - LanguageCode.EN, "30 days without missing a single day. That's remarkable consistency!", - LanguageCode.JA, "30日間一日も欠かさず学習しました。素晴らしい継続力です!" - ) - ), - DAY_50(50, - Map.of( - LanguageCode.KO, "50일 돌파!", - LanguageCode.EN, "50 Days Breakthrough!", - LanguageCode.JA, "50日突破!" - ), - Map.of( - LanguageCode.KO, "벌써 50일째 함께하고 있어요. 학습이 일상의 한 부분이 됐네요.", - LanguageCode.EN, "Already 50 days together. Learning has become part of your daily life.", - LanguageCode.JA, "もう50日間一緒にやっています。学習が日常の一部になりましたね。" - ) - ), - DAY_100(100, - Map.of( - LanguageCode.KO, "100일 기념!", - LanguageCode.EN, "100 Days Celebration!", - LanguageCode.JA, "100日記念!" - ), - Map.of( - LanguageCode.KO, "100일 동안 매일 학습했어요. 누구나 할 수 있는 일은 아니에요.", - LanguageCode.EN, "100 days of daily learning. Not everyone can do this.", - LanguageCode.JA, "100日間毎日学習しました。誰にでもできることではありません。" - ) - ), - DAY_365(365, - Map.of( - LanguageCode.KO, "1년 달성!", - LanguageCode.EN, "One Year Achievement!", - LanguageCode.JA, "1年達成!" - ), - Map.of( - LanguageCode.KO, "365일 동안 하루도 빠짐없이 함께했어요. 정말 대단한 여정이었어요.", - LanguageCode.EN, "365 days together, never missing a day. What an incredible journey.", - LanguageCode.JA, "365日間一日も欠かさず一緒にやってきました。本当に素晴らしい旅でした。" - ) - ); - private final int day; - private final Map titles; - private final Map messages; + DAY_0(0, Map.of(LanguageCode.KO, "새로운 시작", LanguageCode.EN, "A New Start", LanguageCode.JA, "新しい始まり"), + Map.of(LanguageCode.KO, "첫 학습을 시작하고 스트릭을 만들어보세요! 당신의 도전을 응원합니다.", LanguageCode.EN, + "Start your first lesson and build a streak! We're cheering for your challenge.", LanguageCode.JA, + "最初の学習を始めて、ストリークを作りましょう!あなたの挑戦を応援します。")), + DAY_1(1, Map.of(LanguageCode.KO, "첫 시작!", LanguageCode.EN, "First Step!", LanguageCode.JA, "初めの一歩!"), + Map.of(LanguageCode.KO, "첫 스트릭을 달성했어요. 작은 시작이 큰 변화를 만들어요.", LanguageCode.EN, + "First streak achieved. Small starts lead to big changes.", LanguageCode.JA, + "最初のストリークを達成しました。小さな始まりが大きな変化を生みます。")), + DAY_3(3, Map.of(LanguageCode.KO, "3일 연속!", LanguageCode.EN, "3 Days in a Row!", LanguageCode.JA, "3日連続!"), + Map.of(LanguageCode.KO, "3일째 함께하고 있어요. 습관이 만들어지기 시작했어요!", LanguageCode.EN, + "Three days together. A habit is starting to form!", LanguageCode.JA, + "3日間一緒にやっています。習慣が形成され始めました!")), + DAY_7(7, Map.of(LanguageCode.KO, "일주일 달성!", LanguageCode.EN, "One Week Milestone!", LanguageCode.JA, "一週間達成!"), + Map.of(LanguageCode.KO, "7일 동안 매일 학습했어요. 이제 루틴이 되어가고 있네요!", LanguageCode.EN, + "Seven days of daily learning. It's becoming a routine now!", LanguageCode.JA, + "7日間毎日学習しました。もうルーティンになりつつありますね!")), + DAY_14(14, Map.of(LanguageCode.KO, "2주 달성!", LanguageCode.EN, "Two Weeks Strong!", LanguageCode.JA, "2週間達成!"), + Map.of(LanguageCode.KO, "2주째 꾸준히 해오고 있어요. 이제 습관으로 자리잡았네요.", LanguageCode.EN, + "Two weeks of consistency. It's now part of your routine.", LanguageCode.JA, + "2週間着実に続けています。もう習慣として定着しましたね。")), + DAY_30(30, Map.of(LanguageCode.KO, "한 달 달성!", LanguageCode.EN, "One Month Achievement!", LanguageCode.JA, "1ヶ月達成!"), + Map.of(LanguageCode.KO, "30일 동안 하루도 빠짐없이 학습했어요. 대단한 꾸준함이에요!", LanguageCode.EN, + "30 days without missing a single day. That's remarkable consistency!", LanguageCode.JA, + "30日間一日も欠かさず学習しました。素晴らしい継続力です!")), + DAY_50(50, Map.of(LanguageCode.KO, "50일 돌파!", LanguageCode.EN, "50 Days Breakthrough!", LanguageCode.JA, "50日突破!"), + Map.of(LanguageCode.KO, "벌써 50일째 함께하고 있어요. 학습이 일상의 한 부분이 됐네요.", LanguageCode.EN, + "Already 50 days together. Learning has become part of your daily life.", LanguageCode.JA, + "もう50日間一緒にやっています。学習が日常の一部になりましたね。")), + DAY_100(100, + Map.of(LanguageCode.KO, "100일 기념!", LanguageCode.EN, "100 Days Celebration!", LanguageCode.JA, "100日記念!"), + Map.of(LanguageCode.KO, "100일 동안 매일 학습했어요. 누구나 할 수 있는 일은 아니에요.", LanguageCode.EN, + "100 days of daily learning. Not everyone can do this.", LanguageCode.JA, + "100日間毎日学習しました。誰にでもできることではありません。")), + DAY_365(365, Map.of(LanguageCode.KO, "1년 달성!", LanguageCode.EN, "One Year Achievement!", LanguageCode.JA, "1年達成!"), + Map.of(LanguageCode.KO, "365일 동안 하루도 빠짐없이 함께했어요. 정말 대단한 여정이었어요.", LanguageCode.EN, + "365 days together, never missing a day. What an incredible journey.", LanguageCode.JA, + "365日間一日も欠かさず一緒にやってきました。本当に素晴らしい旅でした。")); - /** - * 주어진 날짜에 해당하는 마일스톤을 찾습니다. - * - * @param day 스트릭 일수 - * @return 마일스톤이 있으면 해당 마일스톤, 없으면 Optional.empty() - */ - public static Optional fromDay(int day) { - for (StreakMilestone milestone : values()) { - if (milestone.day == day) { - return Optional.of(milestone); - } - } - return Optional.empty(); - } + private final int day; - /** - * 지정된 언어로 제목을 가져옵니다. - * - * @param languageCode 언어 코드 - * @return 해당 언어의 제목, 없으면 영어 제목 - */ - public String getTitle(LanguageCode languageCode) { - return titles.getOrDefault(languageCode, titles.get(LanguageCode.EN)); - } + private final Map titles; + + private final Map messages; + + /** + * 주어진 날짜에 해당하는 마일스톤을 찾습니다. + * @param day 스트릭 일수 + * @return 마일스톤이 있으면 해당 마일스톤, 없으면 Optional.empty() + */ + public static Optional fromDay(int day) { + for (StreakMilestone milestone : values()) { + if (milestone.day == day) { + return Optional.of(milestone); + } + } + return Optional.empty(); + } + + /** + * 지정된 언어로 제목을 가져옵니다. + * @param languageCode 언어 코드 + * @return 해당 언어의 제목, 없으면 영어 제목 + */ + public String getTitle(LanguageCode languageCode) { + return titles.getOrDefault(languageCode, titles.get(LanguageCode.EN)); + } + + /** + * 지정된 언어로 메시지를 가져옵니다. + * @param languageCode 언어 코드 + * @return 해당 언어의 메시지, 없으면 영어 메시지 + */ + public String getMessage(LanguageCode languageCode) { + return messages.getOrDefault(languageCode, messages.get(LanguageCode.EN)); + } - /** - * 지정된 언어로 메시지를 가져옵니다. - * - * @param languageCode 언어 코드 - * @return 해당 언어의 메시지, 없으면 영어 메시지 - */ - public String getMessage(LanguageCode languageCode) { - return messages.getOrDefault(languageCode, messages.get(LanguageCode.EN)); - } } diff --git a/src/main/java/com/linglevel/api/streak/entity/StreakReminderMessage.java b/src/main/java/com/linglevel/api/streak/entity/StreakReminderMessage.java index 7dc83db5..e82e97eb 100644 --- a/src/main/java/com/linglevel/api/streak/entity/StreakReminderMessage.java +++ b/src/main/java/com/linglevel/api/streak/entity/StreakReminderMessage.java @@ -9,257 +9,195 @@ import java.util.Random; /** - * 스트릭 리마인더 메시지 템플릿 정의 - * 사용자의 학습 상태에 따라 다양한 리마인더 메시지를 제공합니다. + * 스트릭 리마인더 메시지 템플릿 정의 사용자의 학습 상태에 따라 다양한 리마인더 메시지를 제공합니다. */ @Getter @RequiredArgsConstructor public enum StreakReminderMessage { - /** - * 시나리오 0: 학습 권장 (활성 유저 대상, 평소 학습 시간에 발송) - * 매시간 발송되므로 가장 다양한 메시지 필요 - */ - LEARNING_ENCOURAGEMENT( - Map.of( - LanguageCode.KO, List.of( - new Message("평소 이 시간에 학습하시잖아요?", "습관의 힘으로 오늘도 시작해 볼까요?"), - new Message("언제나처럼 이 시간!", "익숙한 루틴으로 오늘의 학습을 시작해요"), - new Message("당신의 학습 시간이에요", "매일 이 시간을 지켜온 당신, 오늘도 해냅시다!"), - new Message("골든 타임이에요!", "평소처럼 집중력 좋은 이 시간, 놓치지 마세요"), - new Message("학습할 시간이에요", "오늘도 작은 한 걸음을 시작해 볼까요?"), - new Message("매일 이 시간, 당신을 기다려요", "꾸준함이 실력이 되는 순간이에요!"), - new Message("오늘도 시작해볼까요?", "언제나처럼 이 시간, 편안하게 학습해요"), - new Message("평소 학습 시간입니다", "루틴의 힘을 믿고 오늘도 함께해요!") - ), - LanguageCode.EN, List.of( - new Message("Your usual study time!", "Let's start with the power of habit today?"), - new Message("As always, it's time!", "Start today's learning with your familiar routine"), - new Message("It's your learning time", "You've kept this time every day, let's do it today!"), - new Message("Your golden hour!", "Don't miss this focused time as usual"), - new Message("Time to learn!", "How about taking a small step today?"), - new Message("This time waits for you daily", "The moment consistency becomes skill!"), - new Message("Shall we start today?", "Like always, let's learn comfortably at this time"), - new Message("Your regular study time", "Trust the power of routine and let's go today!") - ), - LanguageCode.JA, List.of( - new Message("いつもこの時間に学習してますよね?", "習慣の力で今日も始めてみませんか?"), - new Message("いつものこの時間!", "慣れ親しんだルーティンで今日の学習を始めましょう"), - new Message("あなたの学習時間です", "毎日この時間を守ってきたあなた、今日もやりましょう!"), - new Message("ゴールデンタイムです!", "いつものように集中力の良いこの時間、逃さないで"), - new Message("学習の時間です!", "今日も小さな一歩を始めてみませんか?"), - new Message("毎日この時間、あなたを待っています", "継続が実力になる瞬間です!"), - new Message("今日も始めましょうか?", "いつものようにこの時間、気楽に学習しましょう"), - new Message("いつもの学習時間です", "ルーティンの力を信じて今日も一緒に!") - ) - ) - ), - /** - * 스트릭 보호 (밤 9시 고정, currentStreak > 0 && 오늘 학습 미완료) - * 긴박하지만 친근하게, 부담 줄이기 - */ - STREAK_PROTECTION( - Map.of( - LanguageCode.KO, List.of( - new Message("자기 전 5분만요!", "%d일의 노력이 사라지기 전에 짧은 학습 한 번 어때요?"), - new Message("오늘 하루만 남았어요", "잠들기 전 %d일 스트릭을 지키고 푹 자요!"), - new Message("%d일 스트릭이 기다려요", "자기 전에 간단히 끝내고 마음 편히 주무세요"), - new Message("거의 다 왔어요!", "하루를 멋지게 마무리하고 %d일 스트릭을 지켜요"), - new Message("불꽃🔥을 지켜주세요", "오늘만 완료하면 %d일 스트릭이 계속돼요!"), - new Message("아직 늦지 않았어요", "지금 시작하면 %d일의 기록을 이어갈 수 있어요"), - new Message("마지막 기회!", "오늘이 가기 전에 %d일 스트릭을 완성하세요") - ), - LanguageCode.EN, List.of( - new Message("Just 5 minutes before bed!", "How about a quick lesson before losing %d days of effort?"), - new Message("Only today left", "Keep your %d-day streak before bed and sleep well!"), - new Message("Your %d-day streak is waiting", "Finish quickly before bed and sleep peacefully"), - new Message("Almost there!", "End the day beautifully and keep your %d-day streak"), - new Message("Keep the flame🔥 alive", "Complete today and your %d-day streak continues!"), - new Message("Not too late yet", "Start now and continue your %d-day record"), - new Message("Last chance!", "Complete your %d-day streak before the day ends") - ), - LanguageCode.JA, List.of( - new Message("寝る前に5分だけ!", "%d日の努力が消える前に短い学習一回どうですか?"), - new Message("今日一日だけ残っています", "寝る前に%d日のストリークを守ってぐっすり寝ましょう!"), - new Message("%d日のストリークが待っています", "寝る前に簡単に終えて安心して眠りましょう"), - new Message("もうすぐです!", "一日を素敵に締めくくって%d日のストリークを守りましょう"), - new Message("炎🔥を守ろう", "今日だけ完了すれば%d日のストリークが続きます!"), - new Message("まだ遅くありません", "今始めれば%d日の記録を続けられます"), - new Message("最後のチャンス!", "今日が終わる前に%d日のストリークを完成させましょう") - ) - ) - ), + /** + * 시나리오 0: 학습 권장 (활성 유저 대상, 평소 학습 시간에 발송) 매시간 발송되므로 가장 다양한 메시지 필요 + */ + LEARNING_ENCOURAGEMENT(Map.of(LanguageCode.KO, List.of(new Message("평소 이 시간에 학습하시잖아요?", "습관의 힘으로 오늘도 시작해 볼까요?"), + new Message("언제나처럼 이 시간!", "익숙한 루틴으로 오늘의 학습을 시작해요"), + new Message("당신의 학습 시간이에요", "매일 이 시간을 지켜온 당신, 오늘도 해냅시다!"), + new Message("골든 타임이에요!", "평소처럼 집중력 좋은 이 시간, 놓치지 마세요"), new Message("학습할 시간이에요", "오늘도 작은 한 걸음을 시작해 볼까요?"), + new Message("매일 이 시간, 당신을 기다려요", "꾸준함이 실력이 되는 순간이에요!"), new Message("오늘도 시작해볼까요?", "언제나처럼 이 시간, 편안하게 학습해요"), + new Message("평소 학습 시간입니다", "루틴의 힘을 믿고 오늘도 함께해요!")), LanguageCode.EN, + List.of(new Message("Your usual study time!", "Let's start with the power of habit today?"), + new Message("As always, it's time!", "Start today's learning with your familiar routine"), + new Message("It's your learning time", "You've kept this time every day, let's do it today!"), + new Message("Your golden hour!", "Don't miss this focused time as usual"), + new Message("Time to learn!", "How about taking a small step today?"), + new Message("This time waits for you daily", "The moment consistency becomes skill!"), + new Message("Shall we start today?", "Like always, let's learn comfortably at this time"), + new Message("Your regular study time", "Trust the power of routine and let's go today!")), + LanguageCode.JA, + List.of(new Message("いつもこの時間に学習してますよね?", "習慣の力で今日も始めてみませんか?"), + new Message("いつものこの時間!", "慣れ親しんだルーティンで今日の学習を始めましょう"), + new Message("あなたの学習時間です", "毎日この時間を守ってきたあなた、今日もやりましょう!"), + new Message("ゴールデンタイムです!", "いつものように集中力の良いこの時間、逃さないで"), + new Message("学習の時間です!", "今日も小さな一歩を始めてみませんか?"), new Message("毎日この時間、あなたを待っています", "継続が実力になる瞬間です!"), + new Message("今日も始めましょうか?", "いつものようにこの時間、気楽に学習しましょう"), + new Message("いつもの学習時間です", "ルーティンの力を信じて今日も一緒に!")))), - /** - * 어제 프리즈로 스트릭이 유지됨 (밤 9시 알림에서 사용) - * 어제 프리즈 덕분에 살았으니 오늘은 꼭 학습해야 함을 강조 - */ - STREAK_SAVED_BY_FREEZE( - Map.of( - LanguageCode.KO, List.of( - new Message("프리즈가 지켜줬어요!", "어제는 프리즈 덕분에 %d일 스트릭이 유지됐어요. 오늘은 꼭 학습해야 해요!"), - new Message("위기 넘겼어요", "프리즈로 %d일 스트릭을 살렸어요. 오늘은 반드시 학습해 주세요!"), - new Message("한 번 살았어요", "어제는 프리즈가 %d일 스트릭을 지켜줬어요. 오늘은 꼭 이어가요!"), - new Message("프리즈 덕분이에요", "어제 프리즈로 %d일 스트릭 유지! 오늘 학습하고 다시 증가시켜요"), - new Message("세이프! 하지만", "프리즈로 %d일 스트릭은 지켰지만, 오늘은 직접 학습해야 해요!") - ), - LanguageCode.EN, List.of( - new Message("Freeze saved you!", "Yesterday, Freeze kept your %d-day streak alive. You must study today!"), - new Message("Crisis averted", "Freeze saved your %d-day streak. Please study today for sure!"), - new Message("Got a second chance", "Yesterday, Freeze protected your %d-day streak. Let's continue today!"), - new Message("Thanks to Freeze", "Freeze kept your %d-day streak yesterday! Study today to grow it again"), - new Message("Safe! But", "Freeze protected your %d-day streak, but today you need to study yourself!") - ), - LanguageCode.JA, List.of( - new Message("フリーズが守りました!", "昨日はフリーズのおかげで%d日のストリークが維持されました。今日は必ず学習してください!"), - new Message("危機を乗り越えました", "フリーズで%d日のストリークを救いました。今日は必ず学習してください!"), - new Message("一度助かりました", "昨日はフリーズが%d日のストリークを守りました。今日は必ず続けましょう!"), - new Message("フリーズのおかげです", "昨日フリーズで%d日のストリーク維持!今日学習して再び増やしましょう"), - new Message("セーフ!でも", "フリーズで%d日のストリークは守りましたが、今日は自分で学習する必要があります!") - ) - ) - ), + /** + * 스트릭 보호 (밤 9시 고정, currentStreak > 0 && 오늘 학습 미완료) 긴박하지만 친근하게, 부담 줄이기 + */ + STREAK_PROTECTION(Map.of(LanguageCode.KO, List.of(new Message("자기 전 5분만요!", "%d일의 노력이 사라지기 전에 짧은 학습 한 번 어때요?"), + new Message("오늘 하루만 남았어요", "잠들기 전 %d일 스트릭을 지키고 푹 자요!"), + new Message("%d일 스트릭이 기다려요", "자기 전에 간단히 끝내고 마음 편히 주무세요"), + new Message("거의 다 왔어요!", "하루를 멋지게 마무리하고 %d일 스트릭을 지켜요"), + new Message("불꽃🔥을 지켜주세요", "오늘만 완료하면 %d일 스트릭이 계속돼요!"), new Message( + "아직 늦지 않았어요", "지금 시작하면 %d일의 기록을 이어갈 수 있어요"), + new Message("마지막 기회!", "오늘이 가기 전에 %d일 스트릭을 완성하세요")), LanguageCode.EN, + List.of(new Message("Just 5 minutes before bed!", + "How about a quick lesson before losing %d days of effort?"), + new Message("Only today left", "Keep your %d-day streak before bed and sleep well!"), + new Message("Your %d-day streak is waiting", "Finish quickly before bed and sleep peacefully"), + new Message("Almost there!", "End the day beautifully and keep your %d-day streak"), + new Message("Keep the flame🔥 alive", "Complete today and your %d-day streak continues!"), + new Message("Not too late yet", "Start now and continue your %d-day record"), + new Message("Last chance!", "Complete your %d-day streak before the day ends")), + LanguageCode.JA, + List.of(new Message("寝る前に5分だけ!", "%d日の努力が消える前に短い学習一回どうですか?"), + new Message("今日一日だけ残っています", "寝る前に%d日のストリークを守ってぐっすり寝ましょう!"), + new Message("%d日のストリークが待っています", "寝る前に簡単に終えて安心して眠りましょう"), + new Message("もうすぐです!", "一日を素敵に締めくくって%d日のストリークを守りましょう"), + new Message("炎🔥を守ろう", "今日だけ完了すれば%d日のストリークが続きます!"), new Message("まだ遅くありません", "今始めれば%d日の記録を続けられます"), + new Message("最後のチャンス!", "今日が終わる前に%d日のストリークを完成させましょう")))), - /** - * 시나리오 3: 스트릭이 깨진 후 Day 1 (23-24시간) - 즉시 재시작 독려 - * 위로하고 재시작의 부담 낮추기 - */ - STREAK_LOST_DAY1( - Map.of( - LanguageCode.KO, List.of( - new Message("완벽하지 않아도 괜찮아요", "스트릭이 끊겼지만, 오늘부터 새로운 기록을 만들어요!"), - new Message("0일부터 다시 시작", "전에도 해냈으니 이번에도 할 수 있어요!"), - new Message("리셋은 새로운 기회", "과거 기록은 경험으로, 오늘은 1일차로 출발해요"), - new Message("새로운 시작!", "스트릭이 리셋됐어도 괜찮아요. 오늘부터 다시 쌓아가요"), - new Message("다시 일어설 시간", "넘어졌다면 다시 일어서면 되죠! 오늘 1일차 시작해요"), - new Message("경험은 사라지지 않아요", "스트릭은 리셋돼도 당신의 실력은 그대로예요") - ), - LanguageCode.EN, List.of( - new Message("It's okay not to be perfect", "Your streak ended, but let's create a new record from today!"), - new Message("Starting from day 0 again", "You did it before, you can do it again!"), - new Message("Reset is a new opportunity", "Past records become experience, today starts as day 1"), - new Message("A fresh start!", "Even if your streak reset, it's okay. Let's build again from today"), - new Message("Time to get back up", "If you fall, just get back up! Starting day 1 today"), - new Message("Experience doesn't disappear", "Even if streak resets, your skills remain") - ), - LanguageCode.JA, List.of( - new Message("完璧じゃなくても大丈夫", "ストリークが途切れましたが、今日から新しい記録を作りましょう!"), - new Message("0日からまた始める", "前にもできたから今回もできます!"), - new Message("リセットは新しいチャンス", "過去の記録は経験として、今日は1日目として出発しましょう"), - new Message("新しいスタート!", "ストリークがリセットされても大丈夫。今日からまた積み上げましょう"), - new Message("立ち上がる時間", "転んだらまた起き上がればいいんです!今日1日目を始めます"), - new Message("経験は消えません", "ストリークがリセットされてもあなたのスキルはそのままです") - ) - ) - ), + /** + * 어제 프리즈로 스트릭이 유지됨 (밤 9시 알림에서 사용) 어제 프리즈 덕분에 살았으니 오늘은 꼭 학습해야 함을 강조 + */ + STREAK_SAVED_BY_FREEZE(Map.of(LanguageCode.KO, + List.of(new Message("프리즈가 지켜줬어요!", "어제는 프리즈 덕분에 %d일 스트릭이 유지됐어요. 오늘은 꼭 학습해야 해요!"), + new Message("위기 넘겼어요", "프리즈로 %d일 스트릭을 살렸어요. 오늘은 반드시 학습해 주세요!"), + new Message("한 번 살았어요", "어제는 프리즈가 %d일 스트릭을 지켜줬어요. 오늘은 꼭 이어가요!"), + new Message("프리즈 덕분이에요", + "어제 프리즈로 %d일 스트릭 유지! 오늘 학습하고 다시 증가시켜요"), + new Message("세이프! 하지만", "프리즈로 %d일 스트릭은 지켰지만, 오늘은 직접 학습해야 해요!")), + LanguageCode.EN, + List.of(new Message("Freeze saved you!", + "Yesterday, Freeze kept your %d-day streak alive. You must study today!"), + new Message("Crisis averted", "Freeze saved your %d-day streak. Please study today for sure!"), + new Message("Got a second chance", + "Yesterday, Freeze protected your %d-day streak. Let's continue today!"), + new Message("Thanks to Freeze", + "Freeze kept your %d-day streak yesterday! Study today to grow it again"), + new Message("Safe! But", + "Freeze protected your %d-day streak, but today you need to study yourself!")), + LanguageCode.JA, + List.of(new Message("フリーズが守りました!", "昨日はフリーズのおかげで%d日のストリークが維持されました。今日は必ず学習してください!"), + new Message("危機を乗り越えました", "フリーズで%d日のストリークを救いました。今日は必ず学習してください!"), + new Message("一度助かりました", "昨日はフリーズが%d日のストリークを守りました。今日は必ず続けましょう!"), + new Message("フリーズのおかげです", "昨日フリーズで%d日のストリーク維持!今日学習して再び増やしましょう"), + new Message("セーフ!でも", "フリーズで%d日のストリークは守りましたが、今日は自分で学習する必要があります!")))), - /** - * 시나리오 3-2: 스트릭이 깨진 후 Day 2 (47-48시간) - 부드러운 복귀 유도 - */ - STREAK_LOST_DAY2( - Map.of( - LanguageCode.KO, List.of( - new Message("어제 못했어도 괜찮아요", "오늘이 진짜 재시작의 날이 될 수 있어요"), - new Message("두 번째 기회", "한 번 더 도전할 용기, 오늘 보여주세요!"), - new Message("함께 다시 시작해요", "어제 못했지만, 오늘이 바로 그날이에요!"), - new Message("기다리고 있어요", "언제든 돌아올 수 있어요. 오늘 시작해 볼까요?"), - new Message("완벽하지 않아도 돼요", "중요한 건 다시 시작하는 것. 오늘 해봐요!") - ), - LanguageCode.EN, List.of( - new Message("It's okay you missed yesterday", "Today can be your real restart day"), - new Message("Second chance", "Show your courage to try once more today!"), - new Message("Let's start together", "You didn't yesterday, but today is the day!"), - new Message("We're waiting", "You can come back anytime. Shall we start today?"), - new Message("You don't have to be perfect", "What matters is restarting. Try today!") - ), - LanguageCode.JA, List.of( - new Message("昨日できなくても大丈夫", "今日が本当の再スタートの日になれます"), - new Message("2度目のチャンス", "もう一度挑戦する勇気、今日見せてください!"), - new Message("一緒に再スタート", "昨日はできませんでしたが、今日がその日です!"), - new Message("待っています", "いつでも戻ってこれます。今日始めてみませんか?"), - new Message("完璧じゃなくていい", "大切なのは再スタートすること。今日やってみて!") - ) - ) - ), + /** + * 시나리오 3: 스트릭이 깨진 후 Day 1 (23-24시간) - 즉시 재시작 독려 위로하고 재시작의 부담 낮추기 + */ + STREAK_LOST_DAY1(Map.of(LanguageCode.KO, + List.of(new Message("완벽하지 않아도 괜찮아요", "스트릭이 끊겼지만, 오늘부터 새로운 기록을 만들어요!"), + new Message("0일부터 다시 시작", "전에도 해냈으니 이번에도 할 수 있어요!"), + new Message("리셋은 새로운 기회", "과거 기록은 경험으로, 오늘은 1일차로 출발해요"), + new Message("새로운 시작!", "스트릭이 리셋됐어도 괜찮아요. 오늘부터 다시 쌓아가요"), + new Message("다시 일어설 시간", + "넘어졌다면 다시 일어서면 되죠! 오늘 1일차 시작해요"), + new Message("경험은 사라지지 않아요", "스트릭은 리셋돼도 당신의 실력은 그대로예요")), + LanguageCode.EN, + List.of(new Message("It's okay not to be perfect", + "Your streak ended, but let's create a new record from today!"), + new Message("Starting from day 0 again", "You did it before, you can do it again!"), + new Message("Reset is a new opportunity", "Past records become experience, today starts as day 1"), + new Message("A fresh start!", "Even if your streak reset, it's okay. Let's build again from today"), + new Message("Time to get back up", "If you fall, just get back up! Starting day 1 today"), + new Message("Experience doesn't disappear", "Even if streak resets, your skills remain")), + LanguageCode.JA, + List.of(new Message("完璧じゃなくても大丈夫", "ストリークが途切れましたが、今日から新しい記録を作りましょう!"), + new Message("0日からまた始める", "前にもできたから今回もできます!"), + new Message("リセットは新しいチャンス", "過去の記録は経験として、今日は1日目として出発しましょう"), + new Message("新しいスタート!", "ストリークがリセットされても大丈夫。今日からまた積み上げましょう"), + new Message("立ち上がる時間", "転んだらまた起き上がればいいんです!今日1日目を始めます"), + new Message("経験は消えません", "ストリークがリセットされてもあなたのスキルはそのままです")))), - /** - * 시나리오 3-3: 스트릭이 깨진 후 Day 3 (71-72시간) - 강한 복귀 유도 - */ - STREAK_LOST_DAY3( - Map.of( - LanguageCode.KO, List.of( - new Message("그동안의 노력이 사라지지 않아요", "경험은 남습니다. 오늘 다시 1일차 시작해요"), - new Message("당신을 믿어요", "전에 해냈던 것처럼 다시 할 수 있어요"), - new Message("당신을 기억해요", "쌓아온 실력은 그대로예요. 오늘 다시 시작해 보세요"), - new Message("아직 늦지 않았어요", "지금 돌아오면 다시 성장할 수 있어요"), - new Message("마지막 기회일지도", "오늘이 재시작하기 좋은 타이밍일 수 있어요") - ), - LanguageCode.EN, List.of( - new Message("Your past effort doesn't disappear", "Experience remains. Let's start day 1 again today"), - new Message("We believe in you", "You can do it again like you did before"), - new Message("We remember you", "Your skills remain intact. Start again today"), - new Message("It's not too late", "Come back now and you can grow again"), - new Message("Maybe the last chance", "Today might be a good timing to restart") - ), - LanguageCode.JA, List.of( - new Message("これまでの努力は消えません", "経験は残ります。今日また1日目を始めましょう"), - new Message("あなたを信じています", "前にできたように、また できます"), - new Message("あなたを覚えています", "積み上げたスキルはそのままです。今日再スタートしてみてください"), - new Message("まだ遅くありません", "今戻れば、また成長できます"), - new Message("最後のチャンスかも", "今日が再スタートに良いタイミングかもしれません") - ) - ) - ), + /** + * 시나리오 3-2: 스트릭이 깨진 후 Day 2 (47-48시간) - 부드러운 복귀 유도 + */ + STREAK_LOST_DAY2(Map.of(LanguageCode.KO, List.of(new Message("어제 못했어도 괜찮아요", "오늘이 진짜 재시작의 날이 될 수 있어요"), + new Message("두 번째 기회", "한 번 더 도전할 용기, 오늘 보여주세요!"), new Message("함께 다시 시작해요", "어제 못했지만, 오늘이 바로 그날이에요!"), + new Message("기다리고 있어요", "언제든 돌아올 수 있어요. 오늘 시작해 볼까요?"), + new Message("완벽하지 않아도 돼요", "중요한 건 다시 시작하는 것. 오늘 해봐요!")), LanguageCode.EN, + List.of(new Message("It's okay you missed yesterday", "Today can be your real restart day"), + new Message("Second chance", "Show your courage to try once more today!"), + new Message("Let's start together", "You didn't yesterday, but today is the day!"), + new Message("We're waiting", "You can come back anytime. Shall we start today?"), + new Message("You don't have to be perfect", "What matters is restarting. Try today!")), + LanguageCode.JA, + List.of(new Message("昨日できなくても大丈夫", "今日が本当の再スタートの日になれます"), new Message("2度目のチャンス", "もう一度挑戦する勇気、今日見せてください!"), + new Message("一緒に再スタート", "昨日はできませんでしたが、今日がその日です!"), new Message("待っています", "いつでも戻ってこれます。今日始めてみませんか?"), + new Message("完璧じゃなくていい", "大切なのは再スタートすること。今日やってみて!")))), - /** - * 시나리오 3-4: 스트릭이 깨진 후 Day 4 (95-96시간) - 최후의 메시지 - * 강요하지 않고 따뜻하게 배웅 - */ - STREAK_LOST_DAY4( - Map.of( - LanguageCode.KO, List.of( - new Message("언제든 돌아올 수 있어요", "준비됐을 때 다시 만나요. 기다릴게요"), - new Message("쉬어가도 돼요", "학습은 언제나 여기 있어요. 편할 때 돌아오세요"), - new Message("마지막 인사", "언제든 돌아오고 싶으면 여기 있을게요"), - new Message("문은 열려 있어요", "준비되면 언제든 다시 시작할 수 있어요"), - new Message("당신의 페이스로", "서두를 필요 없어요. 준비됐을 때 다시 만나요") - ), - LanguageCode.EN, List.of( - new Message("You can always come back", "See you again when you're ready. We'll wait"), - new Message("It's okay to take a break", "Learning is always here. Come back when it's comfortable"), - new Message("Final goodbye", "We'll be here whenever you want to come back"), - new Message("The door is open", "You can restart anytime when you're ready"), - new Message("At your own pace", "No need to rush. See you when you're ready") - ), - LanguageCode.JA, List.of( - new Message("いつでも戻ってこれます", "準備ができたらまた会いましょう。待っています"), - new Message("休んでもいいです", "学習はいつもここにあります。楽な時に戻ってきてください"), - new Message("最後の挨拶", "戻りたくなったらいつでもここにいます"), - new Message("ドアは開いています", "準備ができたらいつでも再スタートできます"), - new Message("自分のペースで", "急ぐ必要はありません。準備ができたらまた会いましょう") - ) - ) - ); + /** + * 시나리오 3-3: 스트릭이 깨진 후 Day 3 (71-72시간) - 강한 복귀 유도 + */ + STREAK_LOST_DAY3(Map.of(LanguageCode.KO, List.of(new Message("그동안의 노력이 사라지지 않아요", "경험은 남습니다. 오늘 다시 1일차 시작해요"), + new Message("당신을 믿어요", "전에 해냈던 것처럼 다시 할 수 있어요"), new Message("당신을 기억해요", "쌓아온 실력은 그대로예요. 오늘 다시 시작해 보세요"), + new Message("아직 늦지 않았어요", "지금 돌아오면 다시 성장할 수 있어요"), new Message("마지막 기회일지도", "오늘이 재시작하기 좋은 타이밍일 수 있어요")), + LanguageCode.EN, + List.of(new Message("Your past effort doesn't disappear", + "Experience remains. Let's start day 1 again today"), + new Message("We believe in you", "You can do it again like you did before"), + new Message("We remember you", "Your skills remain intact. Start again today"), + new Message("It's not too late", "Come back now and you can grow again"), + new Message("Maybe the last chance", "Today might be a good timing to restart")), + LanguageCode.JA, + List.of(new Message("これまでの努力は消えません", "経験は残ります。今日また1日目を始めましょう"), + new Message("あなたを信じています", "前にできたように、また できます"), + new Message("あなたを覚えています", "積み上げたスキルはそのままです。今日再スタートしてみてください"), + new Message("まだ遅くありません", "今戻れば、また成長できます"), new Message("最後のチャンスかも", "今日が再スタートに良いタイミングかもしれません")))), - private static final Random RANDOM = new Random(); + /** + * 시나리오 3-4: 스트릭이 깨진 후 Day 4 (95-96시간) - 최후의 메시지 강요하지 않고 따뜻하게 배웅 + */ + STREAK_LOST_DAY4(Map.of(LanguageCode.KO, List.of(new Message("언제든 돌아올 수 있어요", "준비됐을 때 다시 만나요. 기다릴게요"), + new Message("쉬어가도 돼요", "학습은 언제나 여기 있어요. 편할 때 돌아오세요"), new Message("마지막 인사", "언제든 돌아오고 싶으면 여기 있을게요"), + new Message("문은 열려 있어요", "준비되면 언제든 다시 시작할 수 있어요"), new Message("당신의 페이스로", "서두를 필요 없어요. 준비됐을 때 다시 만나요")), + LanguageCode.EN, + List.of(new Message("You can always come back", "See you again when you're ready. We'll wait"), + new Message("It's okay to take a break", + "Learning is always here. Come back when it's comfortable"), + new Message("Final goodbye", "We'll be here whenever you want to come back"), + new Message("The door is open", "You can restart anytime when you're ready"), + new Message("At your own pace", "No need to rush. See you when you're ready")), + LanguageCode.JA, + List.of(new Message("いつでも戻ってこれます", "準備ができたらまた会いましょう。待っています"), + new Message("休んでもいいです", "学習はいつもここにあります。楽な時に戻ってきてください"), new Message("最後の挨拶", "戻りたくなったらいつでもここにいます"), + new Message("ドアは開いています", "準備ができたらいつでも再スタートできます"), + new Message("自分のペースで", "急ぐ必要はありません。準備ができたらまた会いましょう")))); - @Getter - @RequiredArgsConstructor - public static class Message { - private final String title; - private final String bodyFormat; - } + private static final Random RANDOM = new Random(); - private final Map> messages; + @Getter + @RequiredArgsConstructor + public static class Message { + + private final String title; + + private final String bodyFormat; + + } + + private final Map> messages; + + /** + * 지정된 언어의 메시지 목록에서 임의의 메시지(제목+본문 쌍)를 가져옵니다. + * @param languageCode 언어 코드 + * @return 해당 언어의 임의 메시지 객체, 없으면 영어 메시지 + */ + public Message getRandomMessage(LanguageCode languageCode) { + List messageList = messages.getOrDefault(languageCode, messages.get(LanguageCode.EN)); + return messageList.get(RANDOM.nextInt(messageList.size())); + } - /** - * 지정된 언어의 메시지 목록에서 임의의 메시지(제목+본문 쌍)를 가져옵니다. - * - * @param languageCode 언어 코드 - * @return 해당 언어의 임의 메시지 객체, 없으면 영어 메시지 - */ - public Message getRandomMessage(LanguageCode languageCode) { - List messageList = messages.getOrDefault(languageCode, messages.get(LanguageCode.EN)); - return messageList.get(RANDOM.nextInt(messageList.size())); - } } diff --git a/src/main/java/com/linglevel/api/streak/entity/StreakStatus.java b/src/main/java/com/linglevel/api/streak/entity/StreakStatus.java index 1acabc8d..b099f31c 100644 --- a/src/main/java/com/linglevel/api/streak/entity/StreakStatus.java +++ b/src/main/java/com/linglevel/api/streak/entity/StreakStatus.java @@ -7,11 +7,10 @@ @RequiredArgsConstructor public enum StreakStatus { - COMPLETED("COMPLETED", "완료"), - FREEZE_USED("FREEZE_USED", "프리즈 사용"), - MISSED("MISSED", "놓침"), - FUTURE("FUTURE", "미래"); + COMPLETED("COMPLETED", "완료"), FREEZE_USED("FREEZE_USED", "프리즈 사용"), MISSED("MISSED", "놓침"), FUTURE("FUTURE", "미래"); + + private final String code; + + private final String name; - private final String code; - private final String name; } diff --git a/src/main/java/com/linglevel/api/streak/entity/UserStudyReport.java b/src/main/java/com/linglevel/api/streak/entity/UserStudyReport.java index 47cd158f..eb86681b 100644 --- a/src/main/java/com/linglevel/api/streak/entity/UserStudyReport.java +++ b/src/main/java/com/linglevel/api/streak/entity/UserStudyReport.java @@ -15,32 +15,35 @@ @Getter @Setter public class UserStudyReport { - @Id - private String id; - @Indexed(unique = true) - private String userId; + @Id + private String id; - private Integer currentStreak = 0; + @Indexed(unique = true) + private String userId; - private Integer longestStreak = 0; + private Integer currentStreak = 0; - private LocalDate lastCompletionDate; + private Integer longestStreak = 0; - private LocalDate streakStartDate; + private LocalDate lastCompletionDate; - private Instant lastLearningTimestamp; + private LocalDate streakStartDate; - private Integer availableFreezes = 0; + private Instant lastLearningTimestamp; - private Long totalReadingTimeSeconds = 0L; + private Integer availableFreezes = 0; - private Set completedContentIds = new HashSet<>(); + private Long totalReadingTimeSeconds = 0L; - private Integer preferredStudyHour; + private Set completedContentIds = new HashSet<>(); - private Instant preferredStudyHourUpdatedAt; + private Integer preferredStudyHour; + + private Instant preferredStudyHourUpdatedAt; + + private Instant createdAt; + + private Instant updatedAt; - private Instant createdAt; - private Instant updatedAt; } diff --git a/src/main/java/com/linglevel/api/streak/exception/StreakErrorCode.java b/src/main/java/com/linglevel/api/streak/exception/StreakErrorCode.java index c25a5dc0..3b2a843e 100644 --- a/src/main/java/com/linglevel/api/streak/exception/StreakErrorCode.java +++ b/src/main/java/com/linglevel/api/streak/exception/StreakErrorCode.java @@ -7,9 +7,12 @@ @Getter @RequiredArgsConstructor public enum StreakErrorCode { - STREAK_NOT_FOUND(HttpStatus.NOT_FOUND, "스트릭 기록을 찾을 수 없습니다."), - READING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "활성화된 읽기 세션을 찾을 수 없습니다."); - private final HttpStatus status; - private final String message; + STREAK_NOT_FOUND(HttpStatus.NOT_FOUND, "스트릭 기록을 찾을 수 없습니다."), + READING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "활성화된 읽기 세션을 찾을 수 없습니다."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/exception/StreakException.java b/src/main/java/com/linglevel/api/streak/exception/StreakException.java index 5dc24b38..f737f8c1 100644 --- a/src/main/java/com/linglevel/api/streak/exception/StreakException.java +++ b/src/main/java/com/linglevel/api/streak/exception/StreakException.java @@ -5,10 +5,12 @@ @Getter public class StreakException extends RuntimeException { - private final HttpStatus status; - public StreakException(StreakErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public StreakException(StreakErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java b/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java index 03d71100..83752bd7 100644 --- a/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java +++ b/src/main/java/com/linglevel/api/streak/repository/DailyCompletionRepository.java @@ -9,35 +9,38 @@ import java.util.Optional; public interface DailyCompletionRepository extends MongoRepository { - boolean existsByUserIdAndCompletionDate(String userId, LocalDate completionDate); - Optional findByUserIdAndCompletionDate(String userId, LocalDate completionDate); + boolean existsByUserIdAndCompletionDate(String userId, LocalDate completionDate); - Optional findTopByUserIdAndCompletionDateBeforeOrderByCompletionDateDesc(String userId, LocalDate date); + Optional findByUserIdAndCompletionDate(String userId, LocalDate completionDate); - long countByUserId(String userId); + Optional findTopByUserIdAndCompletionDateBeforeOrderByCompletionDateDesc(String userId, + LocalDate date); - /** - * 경계값을 포함하는 범위 조회 ($gte, $lte 사용) - * Between은 $gt, $lt를 사용하여 경계값을 제외하므로 커스텀 쿼리 사용 - */ - @Query("{ 'userId': ?0, 'completionDate': { $gte: ?1, $lte: ?2 } }") - List findByUserIdAndCompletionDateBetween(String userId, LocalDate startDate, LocalDate endDate); + long countByUserId(String userId); - /** - * 최근 N일간의 학습 기록 조회 (학습 시간대 분석용) - */ - @Query("{ 'userId': ?0, 'completionDate': { $gte: ?1 } }") - List findByUserIdAndCompletionDateAfter(String userId, LocalDate startDate); + /** + * 경계값을 포함하는 범위 조회 ($gte, $lte 사용) Between은 $gt, $lt를 사용하여 경계값을 제외하므로 커스텀 쿼리 사용 + */ + @Query("{ 'userId': ?0, 'completionDate': { $gte: ?1, $lte: ?2 } }") + List findByUserIdAndCompletionDateBetween(String userId, LocalDate startDate, LocalDate endDate); - /** - * 특정 사용자의 모든 DailyCompletion을 날짜 오름차순으로 조회 - */ - List findByUserIdOrderByCompletionDateAsc(String userId); + /** + * 최근 N일간의 학습 기록 조회 (학습 시간대 분석용) + */ + @Query("{ 'userId': ?0, 'completionDate': { $gte: ?1 } }") + List findByUserIdAndCompletionDateAfter(String userId, LocalDate startDate); + + /** + * 특정 사용자의 모든 DailyCompletion을 날짜 오름차순으로 조회 + */ + List findByUserIdOrderByCompletionDateAsc(String userId); + + /** + * 특정 날짜 이상의 DailyCompletion을 날짜 오름차순으로 조회 (스트릭 복구용) + */ + @Query("{ 'userId': ?0, 'completionDate': { $gte: ?1 } }") + List findByUserIdAndCompletionDateGreaterThanEqualOrderByCompletionDateAsc(String userId, + LocalDate startDate); - /** - * 특정 날짜 이상의 DailyCompletion을 날짜 오름차순으로 조회 (스트릭 복구용) - */ - @Query("{ 'userId': ?0, 'completionDate': { $gte: ?1 } }") - List findByUserIdAndCompletionDateGreaterThanEqualOrderByCompletionDateAsc(String userId, LocalDate startDate); } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/repository/FreezeTransactionRepository.java b/src/main/java/com/linglevel/api/streak/repository/FreezeTransactionRepository.java index 679985c2..1056fe39 100644 --- a/src/main/java/com/linglevel/api/streak/repository/FreezeTransactionRepository.java +++ b/src/main/java/com/linglevel/api/streak/repository/FreezeTransactionRepository.java @@ -13,10 +13,11 @@ public interface FreezeTransactionRepository extends MongoRepository { - Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); - boolean existsByUserIdAndAmountAndCreatedAtBetween(String userId, int amount, Instant start, Instant end); + boolean existsByUserIdAndAmountAndCreatedAtBetween(String userId, int amount, Instant start, Instant end); - List findByUserIdAndAmountAndCreatedAtBetween(String userId, int amount, Instant start, Instant end); + List findByUserIdAndAmountAndCreatedAtBetween(String userId, int amount, Instant start, + Instant end); } diff --git a/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java b/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java index e2d8923c..450ff89e 100644 --- a/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java +++ b/src/main/java/com/linglevel/api/streak/repository/UserStudyReportRepository.java @@ -9,20 +9,20 @@ import java.util.Optional; public interface UserStudyReportRepository extends MongoRepository { - Optional findByUserId(String userId); - - long countByCurrentStreakGreaterThanEqual(int currentStreak); - - List findByCurrentStreakGreaterThan(int currentStreak); - - /** - * 이탈 유저 복귀 알림을 위한 사용자 조회 (currentStreak = 0) - * 마지막 학습 시간이 특정 범위 내에 있는 이탈 유저를 찾습니다. - * - * @param startTime 시작 시간 - * @param endTime 종료 시간 - * @return 해당 조건을 만족하는 이탈 유저 리포트 목록 - */ - @Query("{ 'currentStreak': 0, 'lastLearningTimestamp': { $gte: ?0, $lt: ?1 } }") - List findChurnedUsersInTimeWindow(Instant startTime, Instant endTime); + + Optional findByUserId(String userId); + + long countByCurrentStreakGreaterThanEqual(int currentStreak); + + List findByCurrentStreakGreaterThan(int currentStreak); + + /** + * 이탈 유저 복귀 알림을 위한 사용자 조회 (currentStreak = 0) 마지막 학습 시간이 특정 범위 내에 있는 이탈 유저를 찾습니다. + * @param startTime 시작 시간 + * @param endTime 종료 시간 + * @return 해당 조건을 만족하는 이탈 유저 리포트 목록 + */ + @Query("{ 'currentStreak': 0, 'lastLearningTimestamp': { $gte: ?0, $lt: ?1 } }") + List findChurnedUsersInTimeWindow(Instant startTime, Instant endTime); + } diff --git a/src/main/java/com/linglevel/api/streak/scheduler/DailyStreakValidationScheduler.java b/src/main/java/com/linglevel/api/streak/scheduler/DailyStreakValidationScheduler.java index b60d07b1..e9cda092 100644 --- a/src/main/java/com/linglevel/api/streak/scheduler/DailyStreakValidationScheduler.java +++ b/src/main/java/com/linglevel/api/streak/scheduler/DailyStreakValidationScheduler.java @@ -17,78 +17,81 @@ /** * 매일 자정(KST)에 실행되어 스트릭 검증 및 프리즈 자동 소모를 처리하는 배치 작업 * - * 주요 기능: - * 1. 어제 학습하지 않은 사용자 감지 - * 2. 프리즈 자동 소모 (있는 경우) - * 3. 프리즈 없으면 스트릭 리셋 - * 4. FreezeTransaction 기록 + * 주요 기능: 1. 어제 학습하지 않은 사용자 감지 2. 프리즈 자동 소모 (있는 경우) 3. 프리즈 없으면 스트릭 리셋 4. FreezeTransaction + * 기록 */ @Component @RequiredArgsConstructor @Slf4j public class DailyStreakValidationScheduler { - private final UserStudyReportRepository userStudyReportRepository; - private final StreakService streakService; + private final UserStudyReportRepository userStudyReportRepository; - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final StreakService streakService; - @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") - public void validateDailyStreaks() { - Instant startTime = Instant.now(); - LocalDate today = LocalDate.now(KST); - LocalDate yesterday = today.minusDays(1); + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - log.info("[Streak Validation] Starting daily streak validation for date: {}", yesterday); + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + public void validateDailyStreaks() { + Instant startTime = Instant.now(); + LocalDate today = LocalDate.now(KST); + LocalDate yesterday = today.minusDays(1); - int processedCount = 0; - int freezeUsedCount = 0; - int streakResetCount = 0; - int maintainedCount = 0; + log.info("[Streak Validation] Starting daily streak validation for date: {}", yesterday); - try { - List activeReports = userStudyReportRepository - .findByCurrentStreakGreaterThan(0); + int processedCount = 0; + int freezeUsedCount = 0; + int streakResetCount = 0; + int maintainedCount = 0; - log.info("[Streak Validation] Found {} active users with streak > 0", activeReports.size()); + try { + List activeReports = userStudyReportRepository.findByCurrentStreakGreaterThan(0); - for (UserStudyReport report : activeReports) { - try { - processedCount++; + log.info("[Streak Validation] Found {} active users with streak > 0", activeReports.size()); - boolean wasReset = streakService.processMissedDays(report, today); + for (UserStudyReport report : activeReports) { + try { + processedCount++; - if (wasReset) { - streakResetCount++; - } else { - // 스트릭 유지됨 (어제 완료 또는 프리즈 소진) - long daysSinceLastCompletion = ChronoUnit.DAYS.between( - report.getLastCompletionDate(), today); + boolean wasReset = streakService.processMissedDays(report, today); - if (daysSinceLastCompletion == 1) { - maintainedCount++; - } else if (daysSinceLastCompletion > 1) { - freezeUsedCount++; - } - } + if (wasReset) { + streakResetCount++; + } + else { + // 스트릭 유지됨 (어제 완료 또는 프리즈 소진) + long daysSinceLastCompletion = ChronoUnit.DAYS.between(report.getLastCompletionDate(), today); - report.setUpdatedAt(Instant.now()); - userStudyReportRepository.save(report); + if (daysSinceLastCompletion == 1) { + maintainedCount++; + } + else if (daysSinceLastCompletion > 1) { + freezeUsedCount++; + } + } - } catch (Exception e) { - log.error("[Streak Validation] Failed to process user: {}", report.getUserId(), e); - } - } + report.setUpdatedAt(Instant.now()); + userStudyReportRepository.save(report); - Instant endTime = Instant.now(); - long durationMillis = java.time.Duration.between(startTime, endTime).toMillis(); + } + catch (Exception e) { + log.error("[Streak Validation] Failed to process user: {}", report.getUserId(), e); + } + } - log.info("[Streak Validation] Completed. Processed: {}, Maintained: {}, Freeze Used: {}, Reset: {}, Duration: {}ms", - processedCount, maintainedCount, freezeUsedCount, streakResetCount, durationMillis); + Instant endTime = Instant.now(); + long durationMillis = java.time.Duration.between(startTime, endTime).toMillis(); + + log.info( + "[Streak Validation] Completed. Processed: {}, Maintained: {}, Freeze Used: {}, Reset: {}, Duration: {}ms", + processedCount, maintainedCount, freezeUsedCount, streakResetCount, durationMillis); + + } + catch (Exception e) { + log.error( + "[Streak Validation] Critical error during streak validation. Processed: {}, Freeze Used: {}, Reset: {}", + processedCount, freezeUsedCount, streakResetCount, e); + } + } - } catch (Exception e) { - log.error("[Streak Validation] Critical error during streak validation. Processed: {}, Freeze Used: {}, Reset: {}", - processedCount, freezeUsedCount, streakResetCount, e); - } - } } diff --git a/src/main/java/com/linglevel/api/streak/scheduler/LearningEncouragementScheduler.java b/src/main/java/com/linglevel/api/streak/scheduler/LearningEncouragementScheduler.java index 2b2c8a49..718bd42e 100644 --- a/src/main/java/com/linglevel/api/streak/scheduler/LearningEncouragementScheduler.java +++ b/src/main/java/com/linglevel/api/streak/scheduler/LearningEncouragementScheduler.java @@ -31,385 +31,403 @@ @Slf4j public class LearningEncouragementScheduler { - private final UserStudyReportRepository userStudyReportRepository; - private final DailyCompletionRepository dailyCompletionRepository; - private final FcmTokenRepository fcmTokenRepository; - private final FcmMessagingService fcmMessagingService; - private final StudyTimeAnalysisService studyTimeAnalysisService; - private final com.linglevel.api.fcm.service.FcmTokenService fcmTokenService; - - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final String NOTIFICATION_TYPE = "learning_encouragement"; - private static final String CAMPAIGN_ID = "learning_encouragement"; - - // 새벽 시간대 제외 (조용한 시간) - private static final int QUIET_HOURS_START = 0; // 00:00 - private static final int QUIET_HOURS_END = 6; // 06:00 - - /** - * 매시간 실행: 활성 유저의 평소 학습 시간에 맞춰 개인화된 학습 권장 알림 전송 - * + 이탈 유저 복귀 유도 알림 (Day 1-4) - * 새벽 시간대(00:00-06:00)는 알림 미발송 - */ - @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") - public void sendLearningEncouragementNotifications() { - Instant startTime = Instant.now(); - int currentHour = startTime.atZone(KST).getHour(); - - // 새벽 시간대는 알림 미발송 - if (currentHour >= QUIET_HOURS_START && currentHour < QUIET_HOURS_END) { - log.info("[Learning Encouragement] Skipping quiet hours ({}:00 KST)", currentHour); - return; - } - - LocalDate today = LocalDate.now(KST); - log.info("[Learning Encouragement] Starting at {} (KST {}:00)", startTime, currentHour); - - int activeUserCount = 0; - int churnedUserCount = 0; - int usersMatchingTime = 0; - int usersWithoutCompletion = 0; - int usersWithTokens = 0; - int notificationsSent = 0; - int notificationsFailed = 0; - - try { - // 1. 활성 유저 알림 처리 - int[] activeResults = processActiveUsers(today, currentHour); - activeUserCount = activeResults[0]; - usersMatchingTime += activeResults[1]; - usersWithoutCompletion += activeResults[2]; - usersWithTokens += activeResults[3]; - notificationsSent += activeResults[4]; - notificationsFailed += activeResults[5]; - - // 2. 이탈 유저 복귀 알림 처리 (Day 1-4) - int[] churnedResults = processChurnedUsers(today, currentHour, startTime); - churnedUserCount = churnedResults[0]; - usersMatchingTime += churnedResults[1]; - usersWithoutCompletion += churnedResults[2]; - usersWithTokens += churnedResults[3]; - notificationsSent += churnedResults[4]; - notificationsFailed += churnedResults[5]; - - Instant endTime = Instant.now(); - long durationMillis = Duration.between(startTime, endTime).toMillis(); - - log.info("[Learning Encouragement] Completed. Active: {}, Churned: {}, Matching Time: {}, " + - "Without Completion: {}, With Tokens: {}, Sent: {}, Failed: {}, Duration: {}ms", - activeUserCount, churnedUserCount, usersMatchingTime, usersWithoutCompletion, usersWithTokens, - notificationsSent, notificationsFailed, durationMillis); - - } catch (Exception e) { - log.error("[Learning Encouragement] Critical error. " + - "Active: {}, Churned: {}, Matching Time: {}, Without Completion: {}, With Tokens: {}, " + - "Sent: {}, Failed: {}", - activeUserCount, churnedUserCount, usersMatchingTime, usersWithoutCompletion, usersWithTokens, - notificationsSent, notificationsFailed, e); - } - } - - /** - * 활성 유저 알림 처리 - * @return [candidateUsers, usersMatchingTime, usersWithoutCompletion, usersWithTokens, notificationsSent, notificationsFailed] - */ - private int[] processActiveUsers(LocalDate today, int currentHour) { - int candidateUsers = 0; - int usersMatchingTime = 0; - int usersWithoutCompletion = 0; - int usersWithTokens = 0; - int notificationsSent = 0; - int notificationsFailed = 0; - - try { - // 1. 활성 유저 조회 (currentStreak > 0) - List activeUsers = userStudyReportRepository.findByCurrentStreakGreaterThan(0); - candidateUsers = activeUsers.size(); - - log.debug("[Learning Encouragement - Active] Found {} active users", candidateUsers); - - // 2. 평소 학습 시간이 현재 시각과 일치하는 사용자 필터링 - for (UserStudyReport report : activeUsers) { - String userId = report.getUserId(); - - // 2-1. 평소 학습 시간 확인 (DB에서 조회) - Optional usualStudyHour = studyTimeAnalysisService.getPreferredStudyHour(userId); - if (usualStudyHour.isEmpty() || usualStudyHour.get() != currentHour) { - continue; - } - usersMatchingTime++; - - // 2-2. 오늘 학습 완료 여부 확인 - boolean hasCompletedToday = dailyCompletionRepository - .existsByUserIdAndCompletionDate(userId, today); - if (hasCompletedToday) { - continue; - } - usersWithoutCompletion++; - - // 2-3. FCM 토큰 조회 - List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); - if (tokens.isEmpty()) { - log.debug("[Learning Encouragement] No active FCM tokens for user: {}", userId); - continue; - } - usersWithTokens++; - - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - // 2-4. 언어 결정 - LanguageCode languageCode = determineLanguageFromTokens(tokens); - - // 2-5. 학습 권장 메시지 생성 - StreakReminderMessage.Message message = StreakReminderMessage.LEARNING_ENCOURAGEMENT - .getRandomMessage(languageCode); - - FcmMessageRequest messageRequest = FcmMessageRequest.builder() - .title(message.getTitle()) - .body(message.getBodyFormat()) // 스트릭 수 포맷팅 불필요 - .type(NOTIFICATION_TYPE) - .campaignId(CAMPAIGN_ID) - .action("open_app") - .build(); - - // 2-6. 알림 전송 - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); - notificationsSent++; - log.debug("[Learning Encouragement - Active] Sent to user: {} (lang: {}, hour: {})", - userId, languageCode, currentHour); - } else { - BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); - - for (int i = 0; i < response.getResponses().size(); i++) { - if (response.getResponses().get(i).isSuccessful()) { - notificationsSent++; - } else { - notificationsFailed++; - String failedToken = fcmTokens.get(i); - log.warn("[Learning Encouragement] Failed to send to user: {}, token error: {}", - userId, response.getResponses().get(i).getException().getMessage()); - fcmTokenService.deactivateToken(failedToken); - } - } - - log.debug("[Learning Encouragement] Multicast to user: {} - Success: {}, Failed: {}", - userId, response.getSuccessCount(), response.getFailureCount()); - } - } catch (Exception e) { - notificationsFailed += fcmTokens.size(); - log.error("[Learning Encouragement - Active] Failed to send notification to user: {}", userId, e); - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - fcmTokens.forEach(fcmTokenService::deactivateToken); - } - } - } - } catch (Exception e) { - log.error("[Learning Encouragement - Active] Error processing active users", e); - } - - return new int[]{candidateUsers, usersMatchingTime, usersWithoutCompletion, usersWithTokens, notificationsSent, notificationsFailed}; - } - - /** - * 이탈 유저 복귀 알림 처리 (Day 1-4) - * @return [candidateUsers, usersMatchingTime, usersWithoutCompletion, usersWithTokens, notificationsSent, notificationsFailed] - */ - private int[] processChurnedUsers(LocalDate today, int currentHour, Instant now) { - int candidateUsers = 0; - int usersMatchingTime = 0; - int usersWithoutCompletion = 0; - int usersWithTokens = 0; - int notificationsSent = 0; - int notificationsFailed = 0; - - try { - // Day 1-4 시간 윈도우에서 이탈 유저 조회 - List allChurnedCandidates = new ArrayList<>(); - - // Day 1: 23-24시간 전 - allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 23, 24)); - - // Day 2: 47-48시간 전 - allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 47, 48)); - - // Day 3: 71-72시간 전 - allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 71, 72)); - - // Day 4: 95-96시간 전 - allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 95, 96)); - - candidateUsers = allChurnedCandidates.size(); - - log.debug("[Learning Encouragement - Churned] Found {} churned users across all time windows", candidateUsers); - - if (allChurnedCandidates.isEmpty()) { - return new int[]{0, 0, 0, 0, 0, 0}; - } - - // 각 이탈 유저에 대해 처리 - for (UserStudyReport report : allChurnedCandidates) { - String userId = report.getUserId(); - - // 1. 평소 학습 시간 확인 (DB에서 조회) - Optional usualStudyHour = studyTimeAnalysisService.getPreferredStudyHour(userId); - if (usualStudyHour.isEmpty() || usualStudyHour.get() != currentHour) { - continue; - } - usersMatchingTime++; - - // 2. 오늘 학습 완료 여부 확인 (복귀했으면 스킵) - boolean hasCompletedToday = dailyCompletionRepository - .existsByUserIdAndCompletionDate(userId, today); - if (hasCompletedToday) { - continue; - } - usersWithoutCompletion++; - - // 3. FCM 토큰 조회 - List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); - if (tokens.isEmpty()) { - log.debug("[Learning Encouragement - Churned] No active FCM tokens for user: {}", userId); - continue; - } - usersWithTokens++; - - List fcmTokens = tokens.stream() - .map(FcmToken::getFcmToken) - .collect(Collectors.toList()); - - // 4. 언어 결정 - LanguageCode languageCode = determineLanguageFromTokens(tokens); - - // 5. 메시지 타입 결정 (Day 1-4에 따라) - StreakReminderMessage messageType = determineChurnedUserMessageType(report, now); - StreakReminderMessage.Message message = messageType.getRandomMessage(languageCode); - - // 스트릭이 0이므로 표시할 때는 이전 스트릭 사용 (없으면 1) - int displayStreak = report.getLongestStreak() > 0 ? report.getLongestStreak() : 1; - String body = message.getBodyFormat().contains("%d") - ? String.format(message.getBodyFormat(), displayStreak) - : message.getBodyFormat(); - - FcmMessageRequest messageRequest = FcmMessageRequest.builder() - .title(message.getTitle()) - .body(body) - .type(NOTIFICATION_TYPE) - .campaignId(CAMPAIGN_ID + "_churned") - .action("open_app") - .build(); - - // 6. 알림 전송 - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); - notificationsSent++; - log.debug("[Learning Encouragement - Churned] Sent to user: {} (lang: {}, type: {})", - userId, languageCode, messageType); - } else { - BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); - - for (int i = 0; i < response.getResponses().size(); i++) { - if (response.getResponses().get(i).isSuccessful()) { - notificationsSent++; - } else { - notificationsFailed++; - String failedToken = fcmTokens.get(i); - log.warn("[Learning Encouragement - Churned] Failed to send to user: {}, token error: {}", - userId, response.getResponses().get(i).getException().getMessage()); - fcmTokenService.deactivateToken(failedToken); - } - } - - log.debug("[Learning Encouragement - Churned] Multicast to user: {} - Success: {}, Failed: {}", - userId, response.getSuccessCount(), response.getFailureCount()); - } - } catch (Exception e) { - notificationsFailed += fcmTokens.size(); - log.error("[Learning Encouragement - Churned] Failed to send notification to user: {}", userId, e); - if (e instanceof com.linglevel.api.fcm.exception.FcmException) { - fcmTokens.forEach(fcmTokenService::deactivateToken); - } - } - } - } catch (Exception e) { - log.error("[Learning Encouragement - Churned] Error processing churned users", e); - } - - return new int[]{candidateUsers, usersMatchingTime, usersWithoutCompletion, usersWithTokens, notificationsSent, notificationsFailed}; - } - - /** - * 시간 윈도우 내의 이탈 유저 조회 - */ - private List getChurnedUsersInTimeWindow(Instant now, int hoursAgoStart, int hoursAgoEnd) { - Instant windowEnd = now.minus(Duration.ofHours(hoursAgoEnd)); - Instant windowStart = now.minus(Duration.ofHours(hoursAgoStart)); - - return userStudyReportRepository.findChurnedUsersInTimeWindow(windowStart, windowEnd); - } - - /** - * 이탈 유저의 마지막 학습 시간을 기반으로 메시지 타입 결정 - */ - private StreakReminderMessage determineChurnedUserMessageType(UserStudyReport report, Instant now) { - Instant lastLearning = report.getLastLearningTimestamp(); - if (lastLearning == null) { - return StreakReminderMessage.STREAK_LOST_DAY1; - } - - long hoursSinceLastLearning = Duration.between(lastLearning, now).toHours(); - - // Day 4 (95-96h) - if (hoursSinceLastLearning >= 95 && hoursSinceLastLearning < 96) { - return StreakReminderMessage.STREAK_LOST_DAY4; - } - - // Day 3 (71-72h) - if (hoursSinceLastLearning >= 71 && hoursSinceLastLearning < 72) { - return StreakReminderMessage.STREAK_LOST_DAY3; - } - - // Day 2 (47-48h) - if (hoursSinceLastLearning >= 47 && hoursSinceLastLearning < 48) { - return StreakReminderMessage.STREAK_LOST_DAY2; - } - - // Day 1 (23-24h) - return StreakReminderMessage.STREAK_LOST_DAY1; - } - - /** - * FcmToken 리스트에서 사용자의 선호 언어를 결정합니다. - */ - private LanguageCode determineLanguageFromTokens(List tokens) { - if (tokens.isEmpty()) { - return LanguageCode.EN; - } - - CountryCode countryCode = tokens.get(0).getCountryCode(); - return convertCountryCodeToLanguageCode(countryCode); - } - - /** - * CountryCode를 LanguageCode로 변환합니다. - */ - private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { - if (countryCode == null) { - return LanguageCode.EN; - } - - switch (countryCode) { - case KR: - return LanguageCode.KO; - case JP: - return LanguageCode.JA; - case US: - default: - return LanguageCode.EN; - } - } + private final UserStudyReportRepository userStudyReportRepository; + + private final DailyCompletionRepository dailyCompletionRepository; + + private final FcmTokenRepository fcmTokenRepository; + + private final FcmMessagingService fcmMessagingService; + + private final StudyTimeAnalysisService studyTimeAnalysisService; + + private final com.linglevel.api.fcm.service.FcmTokenService fcmTokenService; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final String NOTIFICATION_TYPE = "learning_encouragement"; + + private static final String CAMPAIGN_ID = "learning_encouragement"; + + // 새벽 시간대 제외 (조용한 시간) + private static final int QUIET_HOURS_START = 0; // 00:00 + + private static final int QUIET_HOURS_END = 6; // 06:00 + + /** + * 매시간 실행: 활성 유저의 평소 학습 시간에 맞춰 개인화된 학습 권장 알림 전송 + 이탈 유저 복귀 유도 알림 (Day 1-4) 새벽 + * 시간대(00:00-06:00)는 알림 미발송 + */ + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + public void sendLearningEncouragementNotifications() { + Instant startTime = Instant.now(); + int currentHour = startTime.atZone(KST).getHour(); + + // 새벽 시간대는 알림 미발송 + if (currentHour >= QUIET_HOURS_START && currentHour < QUIET_HOURS_END) { + log.info("[Learning Encouragement] Skipping quiet hours ({}:00 KST)", currentHour); + return; + } + + LocalDate today = LocalDate.now(KST); + log.info("[Learning Encouragement] Starting at {} (KST {}:00)", startTime, currentHour); + + int activeUserCount = 0; + int churnedUserCount = 0; + int usersMatchingTime = 0; + int usersWithoutCompletion = 0; + int usersWithTokens = 0; + int notificationsSent = 0; + int notificationsFailed = 0; + + try { + // 1. 활성 유저 알림 처리 + int[] activeResults = processActiveUsers(today, currentHour); + activeUserCount = activeResults[0]; + usersMatchingTime += activeResults[1]; + usersWithoutCompletion += activeResults[2]; + usersWithTokens += activeResults[3]; + notificationsSent += activeResults[4]; + notificationsFailed += activeResults[5]; + + // 2. 이탈 유저 복귀 알림 처리 (Day 1-4) + int[] churnedResults = processChurnedUsers(today, currentHour, startTime); + churnedUserCount = churnedResults[0]; + usersMatchingTime += churnedResults[1]; + usersWithoutCompletion += churnedResults[2]; + usersWithTokens += churnedResults[3]; + notificationsSent += churnedResults[4]; + notificationsFailed += churnedResults[5]; + + Instant endTime = Instant.now(); + long durationMillis = Duration.between(startTime, endTime).toMillis(); + + log.info( + "[Learning Encouragement] Completed. Active: {}, Churned: {}, Matching Time: {}, " + + "Without Completion: {}, With Tokens: {}, Sent: {}, Failed: {}, Duration: {}ms", + activeUserCount, churnedUserCount, usersMatchingTime, usersWithoutCompletion, usersWithTokens, + notificationsSent, notificationsFailed, durationMillis); + + } + catch (Exception e) { + log.error( + "[Learning Encouragement] Critical error. " + + "Active: {}, Churned: {}, Matching Time: {}, Without Completion: {}, With Tokens: {}, " + + "Sent: {}, Failed: {}", + activeUserCount, churnedUserCount, usersMatchingTime, usersWithoutCompletion, usersWithTokens, + notificationsSent, notificationsFailed, e); + } + } + + /** + * 활성 유저 알림 처리 + * @return [candidateUsers, usersMatchingTime, usersWithoutCompletion, + * usersWithTokens, notificationsSent, notificationsFailed] + */ + private int[] processActiveUsers(LocalDate today, int currentHour) { + int candidateUsers = 0; + int usersMatchingTime = 0; + int usersWithoutCompletion = 0; + int usersWithTokens = 0; + int notificationsSent = 0; + int notificationsFailed = 0; + + try { + // 1. 활성 유저 조회 (currentStreak > 0) + List activeUsers = userStudyReportRepository.findByCurrentStreakGreaterThan(0); + candidateUsers = activeUsers.size(); + + log.debug("[Learning Encouragement - Active] Found {} active users", candidateUsers); + + // 2. 평소 학습 시간이 현재 시각과 일치하는 사용자 필터링 + for (UserStudyReport report : activeUsers) { + String userId = report.getUserId(); + + // 2-1. 평소 학습 시간 확인 (DB에서 조회) + Optional usualStudyHour = studyTimeAnalysisService.getPreferredStudyHour(userId); + if (usualStudyHour.isEmpty() || usualStudyHour.get() != currentHour) { + continue; + } + usersMatchingTime++; + + // 2-2. 오늘 학습 완료 여부 확인 + boolean hasCompletedToday = dailyCompletionRepository.existsByUserIdAndCompletionDate(userId, today); + if (hasCompletedToday) { + continue; + } + usersWithoutCompletion++; + + // 2-3. FCM 토큰 조회 + List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); + if (tokens.isEmpty()) { + log.debug("[Learning Encouragement] No active FCM tokens for user: {}", userId); + continue; + } + usersWithTokens++; + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + // 2-4. 언어 결정 + LanguageCode languageCode = determineLanguageFromTokens(tokens); + + // 2-5. 학습 권장 메시지 생성 + StreakReminderMessage.Message message = StreakReminderMessage.LEARNING_ENCOURAGEMENT + .getRandomMessage(languageCode); + + FcmMessageRequest messageRequest = FcmMessageRequest.builder() + .title(message.getTitle()) + .body(message.getBodyFormat()) // 스트릭 수 포맷팅 불필요 + .type(NOTIFICATION_TYPE) + .campaignId(CAMPAIGN_ID) + .action("open_app") + .build(); + + // 2-6. 알림 전송 + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); + notificationsSent++; + log.debug("[Learning Encouragement - Active] Sent to user: {} (lang: {}, hour: {})", userId, + languageCode, currentHour); + } + else { + BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); + + for (int i = 0; i < response.getResponses().size(); i++) { + if (response.getResponses().get(i).isSuccessful()) { + notificationsSent++; + } + else { + notificationsFailed++; + String failedToken = fcmTokens.get(i); + log.warn("[Learning Encouragement] Failed to send to user: {}, token error: {}", userId, + response.getResponses().get(i).getException().getMessage()); + fcmTokenService.deactivateToken(failedToken); + } + } + + log.debug("[Learning Encouragement] Multicast to user: {} - Success: {}, Failed: {}", userId, + response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + notificationsFailed += fcmTokens.size(); + log.error("[Learning Encouragement - Active] Failed to send notification to user: {}", userId, e); + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + fcmTokens.forEach(fcmTokenService::deactivateToken); + } + } + } + } + catch (Exception e) { + log.error("[Learning Encouragement - Active] Error processing active users", e); + } + + return new int[] { candidateUsers, usersMatchingTime, usersWithoutCompletion, usersWithTokens, + notificationsSent, notificationsFailed }; + } + + /** + * 이탈 유저 복귀 알림 처리 (Day 1-4) + * @return [candidateUsers, usersMatchingTime, usersWithoutCompletion, + * usersWithTokens, notificationsSent, notificationsFailed] + */ + private int[] processChurnedUsers(LocalDate today, int currentHour, Instant now) { + int candidateUsers = 0; + int usersMatchingTime = 0; + int usersWithoutCompletion = 0; + int usersWithTokens = 0; + int notificationsSent = 0; + int notificationsFailed = 0; + + try { + // Day 1-4 시간 윈도우에서 이탈 유저 조회 + List allChurnedCandidates = new ArrayList<>(); + + // Day 1: 23-24시간 전 + allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 23, 24)); + + // Day 2: 47-48시간 전 + allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 47, 48)); + + // Day 3: 71-72시간 전 + allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 71, 72)); + + // Day 4: 95-96시간 전 + allChurnedCandidates.addAll(getChurnedUsersInTimeWindow(now, 95, 96)); + + candidateUsers = allChurnedCandidates.size(); + + log.debug("[Learning Encouragement - Churned] Found {} churned users across all time windows", + candidateUsers); + + if (allChurnedCandidates.isEmpty()) { + return new int[] { 0, 0, 0, 0, 0, 0 }; + } + + // 각 이탈 유저에 대해 처리 + for (UserStudyReport report : allChurnedCandidates) { + String userId = report.getUserId(); + + // 1. 평소 학습 시간 확인 (DB에서 조회) + Optional usualStudyHour = studyTimeAnalysisService.getPreferredStudyHour(userId); + if (usualStudyHour.isEmpty() || usualStudyHour.get() != currentHour) { + continue; + } + usersMatchingTime++; + + // 2. 오늘 학습 완료 여부 확인 (복귀했으면 스킵) + boolean hasCompletedToday = dailyCompletionRepository.existsByUserIdAndCompletionDate(userId, today); + if (hasCompletedToday) { + continue; + } + usersWithoutCompletion++; + + // 3. FCM 토큰 조회 + List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); + if (tokens.isEmpty()) { + log.debug("[Learning Encouragement - Churned] No active FCM tokens for user: {}", userId); + continue; + } + usersWithTokens++; + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + // 4. 언어 결정 + LanguageCode languageCode = determineLanguageFromTokens(tokens); + + // 5. 메시지 타입 결정 (Day 1-4에 따라) + StreakReminderMessage messageType = determineChurnedUserMessageType(report, now); + StreakReminderMessage.Message message = messageType.getRandomMessage(languageCode); + + // 스트릭이 0이므로 표시할 때는 이전 스트릭 사용 (없으면 1) + int displayStreak = report.getLongestStreak() > 0 ? report.getLongestStreak() : 1; + String body = message.getBodyFormat().contains("%d") + ? String.format(message.getBodyFormat(), displayStreak) : message.getBodyFormat(); + + FcmMessageRequest messageRequest = FcmMessageRequest.builder() + .title(message.getTitle()) + .body(body) + .type(NOTIFICATION_TYPE) + .campaignId(CAMPAIGN_ID + "_churned") + .action("open_app") + .build(); + + // 6. 알림 전송 + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); + notificationsSent++; + log.debug("[Learning Encouragement - Churned] Sent to user: {} (lang: {}, type: {})", userId, + languageCode, messageType); + } + else { + BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); + + for (int i = 0; i < response.getResponses().size(); i++) { + if (response.getResponses().get(i).isSuccessful()) { + notificationsSent++; + } + else { + notificationsFailed++; + String failedToken = fcmTokens.get(i); + log.warn( + "[Learning Encouragement - Churned] Failed to send to user: {}, token error: {}", + userId, response.getResponses().get(i).getException().getMessage()); + fcmTokenService.deactivateToken(failedToken); + } + } + + log.debug("[Learning Encouragement - Churned] Multicast to user: {} - Success: {}, Failed: {}", + userId, response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + notificationsFailed += fcmTokens.size(); + log.error("[Learning Encouragement - Churned] Failed to send notification to user: {}", userId, e); + if (e instanceof com.linglevel.api.fcm.exception.FcmException) { + fcmTokens.forEach(fcmTokenService::deactivateToken); + } + } + } + } + catch (Exception e) { + log.error("[Learning Encouragement - Churned] Error processing churned users", e); + } + + return new int[] { candidateUsers, usersMatchingTime, usersWithoutCompletion, usersWithTokens, + notificationsSent, notificationsFailed }; + } + + /** + * 시간 윈도우 내의 이탈 유저 조회 + */ + private List getChurnedUsersInTimeWindow(Instant now, int hoursAgoStart, int hoursAgoEnd) { + Instant windowEnd = now.minus(Duration.ofHours(hoursAgoEnd)); + Instant windowStart = now.minus(Duration.ofHours(hoursAgoStart)); + + return userStudyReportRepository.findChurnedUsersInTimeWindow(windowStart, windowEnd); + } + + /** + * 이탈 유저의 마지막 학습 시간을 기반으로 메시지 타입 결정 + */ + private StreakReminderMessage determineChurnedUserMessageType(UserStudyReport report, Instant now) { + Instant lastLearning = report.getLastLearningTimestamp(); + if (lastLearning == null) { + return StreakReminderMessage.STREAK_LOST_DAY1; + } + + long hoursSinceLastLearning = Duration.between(lastLearning, now).toHours(); + + // Day 4 (95-96h) + if (hoursSinceLastLearning >= 95 && hoursSinceLastLearning < 96) { + return StreakReminderMessage.STREAK_LOST_DAY4; + } + + // Day 3 (71-72h) + if (hoursSinceLastLearning >= 71 && hoursSinceLastLearning < 72) { + return StreakReminderMessage.STREAK_LOST_DAY3; + } + + // Day 2 (47-48h) + if (hoursSinceLastLearning >= 47 && hoursSinceLastLearning < 48) { + return StreakReminderMessage.STREAK_LOST_DAY2; + } + + // Day 1 (23-24h) + return StreakReminderMessage.STREAK_LOST_DAY1; + } + + /** + * FcmToken 리스트에서 사용자의 선호 언어를 결정합니다. + */ + private LanguageCode determineLanguageFromTokens(List tokens) { + if (tokens.isEmpty()) { + return LanguageCode.EN; + } + + CountryCode countryCode = tokens.get(0).getCountryCode(); + return convertCountryCodeToLanguageCode(countryCode); + } + + /** + * CountryCode를 LanguageCode로 변환합니다. + */ + private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { + if (countryCode == null) { + return LanguageCode.EN; + } + + switch (countryCode) { + case KR: + return LanguageCode.KO; + case JP: + return LanguageCode.JA; + case US: + default: + return LanguageCode.EN; + } + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/streak/scheduler/PreferredStudyHourUpdateScheduler.java b/src/main/java/com/linglevel/api/streak/scheduler/PreferredStudyHourUpdateScheduler.java index 67d4fae1..7f13e1df 100644 --- a/src/main/java/com/linglevel/api/streak/scheduler/PreferredStudyHourUpdateScheduler.java +++ b/src/main/java/com/linglevel/api/streak/scheduler/PreferredStudyHourUpdateScheduler.java @@ -13,71 +13,74 @@ import java.util.List; /** - * 사용자의 선호 학습 시간(preferredStudyHour)을 주기적으로 재계산하는 스케줄러 - * 매일 새벽 3시에 실행하여 모든 활성 사용자의 학습 패턴을 업데이트합니다. + * 사용자의 선호 학습 시간(preferredStudyHour)을 주기적으로 재계산하는 스케줄러 매일 새벽 3시에 실행하여 모든 활성 사용자의 학습 패턴을 + * 업데이트합니다. */ @Component @RequiredArgsConstructor @Slf4j public class PreferredStudyHourUpdateScheduler { - private final UserStudyReportRepository userStudyReportRepository; - private final StudyTimeAnalysisService studyTimeAnalysisService; - - private static final int BATCH_SIZE = 100; // 한 번에 처리할 사용자 수 - - /** - * 매일 새벽 3시에 실행: 모든 활성 사용자의 선호 학습 시간 재계산 - */ - @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") - public void updatePreferredStudyHours() { - Instant startTime = Instant.now(); - log.info("[Preferred Study Hour Update] Starting batch update"); - - int totalUsers = 0; - int updatedUsers = 0; - int failedUsers = 0; - - try { - // 활성 사용자 조회 (currentStreak > 0) - List activeUsers = userStudyReportRepository.findByCurrentStreakGreaterThan(0); - totalUsers = activeUsers.size(); - - log.info("[Preferred Study Hour Update] Found {} active users to update", totalUsers); - - // 배치로 처리 - for (int i = 0; i < activeUsers.size(); i += BATCH_SIZE) { - int end = Math.min(i + BATCH_SIZE, activeUsers.size()); - List batch = activeUsers.subList(i, end); - - for (UserStudyReport report : batch) { - try { - String userId = report.getUserId(); - - // 선호 학습 시간 재계산 및 저장 - studyTimeAnalysisService.calculateAndSavePreferredStudyHour(userId); - updatedUsers++; - - if (updatedUsers % 100 == 0) { - log.info("[Preferred Study Hour Update] Progress: {}/{} users updated", - updatedUsers, totalUsers); - } - } catch (Exception e) { - failedUsers++; - log.warn("[Preferred Study Hour Update] Failed to update user: {}", - report.getUserId(), e); - } - } - } - - Instant endTime = Instant.now(); - - log.info("[Preferred Study Hour Update] Completed. Total: {}, Updated: {}, Failed: {}, Duration: {}ms", - totalUsers, updatedUsers, failedUsers, Duration.between(startTime, endTime).toMillis()); - - } catch (Exception e) { - log.error("[Preferred Study Hour Update] Critical error. Total: {}, Updated: {}, Failed: {}", - totalUsers, updatedUsers, failedUsers, e); - } - } + private final UserStudyReportRepository userStudyReportRepository; + + private final StudyTimeAnalysisService studyTimeAnalysisService; + + private static final int BATCH_SIZE = 100; // 한 번에 처리할 사용자 수 + + /** + * 매일 새벽 3시에 실행: 모든 활성 사용자의 선호 학습 시간 재계산 + */ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void updatePreferredStudyHours() { + Instant startTime = Instant.now(); + log.info("[Preferred Study Hour Update] Starting batch update"); + + int totalUsers = 0; + int updatedUsers = 0; + int failedUsers = 0; + + try { + // 활성 사용자 조회 (currentStreak > 0) + List activeUsers = userStudyReportRepository.findByCurrentStreakGreaterThan(0); + totalUsers = activeUsers.size(); + + log.info("[Preferred Study Hour Update] Found {} active users to update", totalUsers); + + // 배치로 처리 + for (int i = 0; i < activeUsers.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, activeUsers.size()); + List batch = activeUsers.subList(i, end); + + for (UserStudyReport report : batch) { + try { + String userId = report.getUserId(); + + // 선호 학습 시간 재계산 및 저장 + studyTimeAnalysisService.calculateAndSavePreferredStudyHour(userId); + updatedUsers++; + + if (updatedUsers % 100 == 0) { + log.info("[Preferred Study Hour Update] Progress: {}/{} users updated", updatedUsers, + totalUsers); + } + } + catch (Exception e) { + failedUsers++; + log.warn("[Preferred Study Hour Update] Failed to update user: {}", report.getUserId(), e); + } + } + } + + Instant endTime = Instant.now(); + + log.info("[Preferred Study Hour Update] Completed. Total: {}, Updated: {}, Failed: {}, Duration: {}ms", + totalUsers, updatedUsers, failedUsers, Duration.between(startTime, endTime).toMillis()); + + } + catch (Exception e) { + log.error("[Preferred Study Hour Update] Critical error. Total: {}, Updated: {}, Failed: {}", totalUsers, + updatedUsers, failedUsers, e); + } + } + } diff --git a/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java b/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java index 0bacbdeb..b0c93f0c 100644 --- a/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java +++ b/src/main/java/com/linglevel/api/streak/scheduler/StreakProtectionScheduler.java @@ -24,199 +24,186 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -/** 스트릭 보호 알림 스케줄러 매일 밤 9시에 실행하여 스트릭이 깨지지 않도록 알림을 전송합니다. - 조건: currentStreak > 0 && 오늘 학습 미완료 */ +/** + * 스트릭 보호 알림 스케줄러 매일 밤 9시에 실행하여 스트릭이 깨지지 않도록 알림을 전송합니다. - 조건: currentStreak > 0 && 오늘 학습 + * 미완료 + */ @Component @RequiredArgsConstructor @Slf4j public class StreakProtectionScheduler { - private final UserStudyReportRepository userStudyReportRepository; - private final DailyCompletionRepository dailyCompletionRepository; - private final FreezeTransactionRepository freezeTransactionRepository; - private final FcmTokenRepository fcmTokenRepository; - private final FcmMessagingService fcmMessagingService; - private final com.linglevel.api.fcm.service.FcmTokenService fcmTokenService; - - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final String NOTIFICATION_TYPE = "streak_protection"; - private static final String CAMPAIGN_ID = "streak_protection"; - private static final int BATCH_SIZE = 500; - - /** 매일 밤 9시에 실행: 스트릭 보호 알림 전송 */ - @Scheduled(cron = "0 0 21 * * *", zone = "Asia/Seoul") - public void sendStreakProtectionNotifications() { - Instant startTime = Instant.now(); - LocalDate today = LocalDate.now(KST); - - log.info( - "[Streak Protection] Starting notification batch at 21:00 KST for date: {}", today); - - int candidateUsers = 0; - int usersWithoutCompletion = 0; - int usersWithTokens = 0; - int notificationsSent = 0; - int notificationsFailed = 0; - - try { - // 1. 현재 스트릭이 있는 모든 활성 사용자 조회 (currentStreak > 0) - List activeUsers = - userStudyReportRepository.findByCurrentStreakGreaterThan(0); - candidateUsers = activeUsers.size(); - - log.info("[Streak Protection] Found {} active users with streak > 0", candidateUsers); - - // 2. 오늘 학습 완료하지 않은 사용자 필터링 및 알림 전송 - for (UserStudyReport report : activeUsers) { - String userId = report.getUserId(); - - // 2-1. 오늘 학습 완료 여부 확인 - boolean hasCompletedToday = - dailyCompletionRepository.existsByUserIdAndCompletionDate(userId, today); - if (hasCompletedToday) { - continue; // 이미 학습 완료한 사용자는 스킵 - } - usersWithoutCompletion++; - - // 2-2. FCM 토큰 조회 - List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); - if (tokens.isEmpty()) { - log.debug("[Streak Protection] No active FCM tokens for user: {}", userId); - continue; - } - usersWithTokens++; - - List fcmTokens = - tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); - - // 2-3. 언어 결정 - LanguageCode languageCode = determineLanguageFromTokens(tokens); - - // 2-4. 어제 프리즈 사용 여부 확인 - boolean usedFreezeYesterday = checkIfFreezeUsedYesterday(userId, today); - - // 2-5. 메시지 타입 결정 (프리즈 사용 여부에 따라) - StreakReminderMessage messageType = - usedFreezeYesterday - ? StreakReminderMessage.STREAK_SAVED_BY_FREEZE - : StreakReminderMessage.STREAK_PROTECTION; - - StreakReminderMessage.Message message = messageType.getRandomMessage(languageCode); - String title = String.format(message.getTitle(), report.getCurrentStreak()); - String body = String.format(message.getBodyFormat(), report.getCurrentStreak()); - - FcmMessageRequest messageRequest = - FcmMessageRequest.builder() - .title(title) - .body(body) - .type(NOTIFICATION_TYPE) - .campaignId(CAMPAIGN_ID) - .action("open_app") - .build(); - - // 2-5. 알림 전송 - try { - if (fcmTokens.size() == 1) { - fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); - notificationsSent++; - log.debug( - "[Streak Protection] Sent to user: {} (streak: {}, lang: {}, type: {})", - userId, - report.getCurrentStreak(), - languageCode, - messageType); - } else { - BatchResponse response = - fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); - - for (int i = 0; i < response.getResponses().size(); i++) { - if (response.getResponses().get(i).isSuccessful()) { - notificationsSent++; - } else { - notificationsFailed++; - String failedToken = fcmTokens.get(i); - log.warn( - "[Streak Protection] Failed to send to user: {}, token error: {}", - userId, - response.getResponses().get(i).getException().getMessage()); - fcmTokenService.deactivateToken(failedToken); - } - } - - log.debug( - "[Streak Protection] Multicast to user: {} - Success: {}, Failed: {}", - userId, - response.getSuccessCount(), - response.getFailureCount()); - } - } catch (Exception e) { - notificationsFailed++; - log.warn( - "[Streak Protection] Failed to send notification to user: {}", - userId, - e); - } - } - - long durationMillis = Duration.between(startTime, Instant.now()).toMillis(); - - log.info( - "[Streak Protection] Completed. Candidates: {}, Without completion: {}, With tokens: {}, " - + "Sent: {}, Failed: {}, Duration: {}ms", - candidateUsers, - usersWithoutCompletion, - usersWithTokens, - notificationsSent, - notificationsFailed, - durationMillis); - - } catch (Exception e) { - log.error( - "[Streak Protection] Critical error. Candidates: {}, Without completion: {}, Sent: {}, Failed: {}", - candidateUsers, - usersWithoutCompletion, - notificationsSent, - notificationsFailed, - e); - } - } - - /** 어제 프리즈가 사용되었는지 확인합니다. 어제 날짜(00:00 ~ 23:59)에 amount가 -1인 트랜잭션이 있으면 프리즈 사용됨 */ - private boolean checkIfFreezeUsedYesterday(String userId, LocalDate today) { - LocalDate yesterday = today.minusDays(1); - Instant yesterdayStart = yesterday.atStartOfDay(KST).toInstant(); - Instant yesterdayEnd = today.atStartOfDay(KST).toInstant(); - - List transactions = - freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - userId, -1, yesterdayStart, yesterdayEnd); - - return !transactions.isEmpty(); - } - - /** FcmToken 리스트에서 사용자의 선호 언어를 결정합니다. */ - private LanguageCode determineLanguageFromTokens(List tokens) { - if (tokens.isEmpty()) { - return LanguageCode.EN; - } - - CountryCode countryCode = tokens.get(0).getCountryCode(); - return convertCountryCodeToLanguageCode(countryCode); - } - - /** CountryCode를 LanguageCode로 변환합니다. */ - private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { - if (countryCode == null) { - return LanguageCode.EN; - } - - switch (countryCode) { - case KR: - return LanguageCode.KO; - case JP: - return LanguageCode.JA; - case US: - default: - return LanguageCode.EN; - } - } + private final UserStudyReportRepository userStudyReportRepository; + + private final DailyCompletionRepository dailyCompletionRepository; + + private final FreezeTransactionRepository freezeTransactionRepository; + + private final FcmTokenRepository fcmTokenRepository; + + private final FcmMessagingService fcmMessagingService; + + private final com.linglevel.api.fcm.service.FcmTokenService fcmTokenService; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final String NOTIFICATION_TYPE = "streak_protection"; + + private static final String CAMPAIGN_ID = "streak_protection"; + + private static final int BATCH_SIZE = 500; + + /** 매일 밤 9시에 실행: 스트릭 보호 알림 전송 */ + @Scheduled(cron = "0 0 21 * * *", zone = "Asia/Seoul") + public void sendStreakProtectionNotifications() { + Instant startTime = Instant.now(); + LocalDate today = LocalDate.now(KST); + + log.info("[Streak Protection] Starting notification batch at 21:00 KST for date: {}", today); + + int candidateUsers = 0; + int usersWithoutCompletion = 0; + int usersWithTokens = 0; + int notificationsSent = 0; + int notificationsFailed = 0; + + try { + // 1. 현재 스트릭이 있는 모든 활성 사용자 조회 (currentStreak > 0) + List activeUsers = userStudyReportRepository.findByCurrentStreakGreaterThan(0); + candidateUsers = activeUsers.size(); + + log.info("[Streak Protection] Found {} active users with streak > 0", candidateUsers); + + // 2. 오늘 학습 완료하지 않은 사용자 필터링 및 알림 전송 + for (UserStudyReport report : activeUsers) { + String userId = report.getUserId(); + + // 2-1. 오늘 학습 완료 여부 확인 + boolean hasCompletedToday = dailyCompletionRepository.existsByUserIdAndCompletionDate(userId, today); + if (hasCompletedToday) { + continue; // 이미 학습 완료한 사용자는 스킵 + } + usersWithoutCompletion++; + + // 2-2. FCM 토큰 조회 + List tokens = fcmTokenRepository.findByUserIdAndIsActive(userId, true); + if (tokens.isEmpty()) { + log.debug("[Streak Protection] No active FCM tokens for user: {}", userId); + continue; + } + usersWithTokens++; + + List fcmTokens = tokens.stream().map(FcmToken::getFcmToken).collect(Collectors.toList()); + + // 2-3. 언어 결정 + LanguageCode languageCode = determineLanguageFromTokens(tokens); + + // 2-4. 어제 프리즈 사용 여부 확인 + boolean usedFreezeYesterday = checkIfFreezeUsedYesterday(userId, today); + + // 2-5. 메시지 타입 결정 (프리즈 사용 여부에 따라) + StreakReminderMessage messageType = usedFreezeYesterday ? StreakReminderMessage.STREAK_SAVED_BY_FREEZE + : StreakReminderMessage.STREAK_PROTECTION; + + StreakReminderMessage.Message message = messageType.getRandomMessage(languageCode); + String title = String.format(message.getTitle(), report.getCurrentStreak()); + String body = String.format(message.getBodyFormat(), report.getCurrentStreak()); + + FcmMessageRequest messageRequest = FcmMessageRequest.builder() + .title(title) + .body(body) + .type(NOTIFICATION_TYPE) + .campaignId(CAMPAIGN_ID) + .action("open_app") + .build(); + + // 2-5. 알림 전송 + try { + if (fcmTokens.size() == 1) { + fcmMessagingService.sendMessage(fcmTokens.get(0), messageRequest); + notificationsSent++; + log.debug("[Streak Protection] Sent to user: {} (streak: {}, lang: {}, type: {})", userId, + report.getCurrentStreak(), languageCode, messageType); + } + else { + BatchResponse response = fcmMessagingService.sendMulticastMessage(fcmTokens, messageRequest); + + for (int i = 0; i < response.getResponses().size(); i++) { + if (response.getResponses().get(i).isSuccessful()) { + notificationsSent++; + } + else { + notificationsFailed++; + String failedToken = fcmTokens.get(i); + log.warn("[Streak Protection] Failed to send to user: {}, token error: {}", userId, + response.getResponses().get(i).getException().getMessage()); + fcmTokenService.deactivateToken(failedToken); + } + } + + log.debug("[Streak Protection] Multicast to user: {} - Success: {}, Failed: {}", userId, + response.getSuccessCount(), response.getFailureCount()); + } + } + catch (Exception e) { + notificationsFailed++; + log.warn("[Streak Protection] Failed to send notification to user: {}", userId, e); + } + } + + long durationMillis = Duration.between(startTime, Instant.now()).toMillis(); + + log.info( + "[Streak Protection] Completed. Candidates: {}, Without completion: {}, With tokens: {}, " + + "Sent: {}, Failed: {}, Duration: {}ms", + candidateUsers, usersWithoutCompletion, usersWithTokens, notificationsSent, notificationsFailed, + durationMillis); + + } + catch (Exception e) { + log.error( + "[Streak Protection] Critical error. Candidates: {}, Without completion: {}, Sent: {}, Failed: {}", + candidateUsers, usersWithoutCompletion, notificationsSent, notificationsFailed, e); + } + } + + /** 어제 프리즈가 사용되었는지 확인합니다. 어제 날짜(00:00 ~ 23:59)에 amount가 -1인 트랜잭션이 있으면 프리즈 사용됨 */ + private boolean checkIfFreezeUsedYesterday(String userId, LocalDate today) { + LocalDate yesterday = today.minusDays(1); + Instant yesterdayStart = yesterday.atStartOfDay(KST).toInstant(); + Instant yesterdayEnd = today.atStartOfDay(KST).toInstant(); + + List transactions = freezeTransactionRepository + .findByUserIdAndAmountAndCreatedAtBetween(userId, -1, yesterdayStart, yesterdayEnd); + + return !transactions.isEmpty(); + } + + /** FcmToken 리스트에서 사용자의 선호 언어를 결정합니다. */ + private LanguageCode determineLanguageFromTokens(List tokens) { + if (tokens.isEmpty()) { + return LanguageCode.EN; + } + + CountryCode countryCode = tokens.get(0).getCountryCode(); + return convertCountryCodeToLanguageCode(countryCode); + } + + /** CountryCode를 LanguageCode로 변환합니다. */ + private LanguageCode convertCountryCodeToLanguageCode(CountryCode countryCode) { + if (countryCode == null) { + return LanguageCode.EN; + } + + switch (countryCode) { + case KR: + return LanguageCode.KO; + case JP: + return LanguageCode.JA; + case US: + default: + return LanguageCode.EN; + } + } + } diff --git a/src/main/java/com/linglevel/api/streak/service/ReadingSessionService.java b/src/main/java/com/linglevel/api/streak/service/ReadingSessionService.java index fcc68b12..5d0366db 100644 --- a/src/main/java/com/linglevel/api/streak/service/ReadingSessionService.java +++ b/src/main/java/com/linglevel/api/streak/service/ReadingSessionService.java @@ -17,96 +17,100 @@ @Slf4j public class ReadingSessionService { - private final RedisTemplate redisTemplate; - private static final String READING_SESSION_KEY_PREFIX = "user:"; - private static final String READING_SESSION_KEY_SUFFIX = ":reading_session"; - private static final Duration READING_SESSION_TTL = Duration.ofHours(6); - private static final Duration MIN_READING_DURATION = Duration.ofSeconds(5); - - public void startReadingSession(String userId, ContentType contentType, String contentId) { - String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; - ReadingSession existingSession = getReadingSession(userId); - - // 같은 작품에 대한 세션이 이미 존재하면 started 시간을 갱신하지 않음 - if (existingSession != null - && existingSession.getContentType().equals(contentType) - && existingSession.getContentId().equals(contentId)) { - // TTL만 갱신 - redisTemplate.expire(key, READING_SESSION_TTL); - return; - } - - // 다른 작품이거나 세션이 없으면 새로운 세션 생성 - ReadingSession session = ReadingSession.builder() - .contentType(contentType) - .contentId(contentId) - .startedAtMillis(Instant.now().toEpochMilli()) - .build(); - - redisTemplate.opsForValue().set(key, session, READING_SESSION_TTL); - log.info("Reading session started - userId: {}, content: {}/{}, redisKey: {}, startedAt: {}", - userId, contentType.getCode(), contentId, key, Instant.ofEpochMilli(session.getStartedAtMillis())); - } - - public ReadingSession getReadingSession(String userId) { - String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; - return (ReadingSession) redisTemplate.opsForValue().get(key); - } - - public ReadingSession getReadingSessionOrThrow(String userId) { - ReadingSession session = getReadingSession(userId); - if (session == null) { - throw new StreakException(StreakErrorCode.READING_SESSION_NOT_FOUND); - } - return session; - } - - public void deleteReadingSession(String userId) { - String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; - redisTemplate.delete(key); - log.info("Reading session deleted - userId: {}, redisKey: {}", userId, key); - } - - public long getReadingSessionSeconds(String userId, ContentType contentType, String contentId) { - ReadingSession session = getReadingSession(userId); - if (session == null) { - return 0; - } - - // 세션의 contentType과 contentId가 일치하지 않으면 0 반환 - if (!session.getContentType().equals(contentType) || !session.getContentId().equals(contentId)) { - return 0; - } - - Instant startedAt = Instant.ofEpochMilli(session.getStartedAtMillis()); - return Duration.between(startedAt, Instant.now()).getSeconds(); - } - - public boolean isReadingSessionValid(String userId, ContentType contentType, String contentId) { - String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; - ReadingSession session = getReadingSession(userId); - if (session == null) { - log.info("No reading session found - userId: {}, redisKey: {}, expected content: {}/{}", - userId, key, contentType.getCode(), contentId); - return false; - } - - if (!session.getContentType().equals(contentType) || !session.getContentId().equals(contentId)) { - log.info("Reading session content mismatch - userId: {}, expected: {}/{}, actual: {}/{}", - userId, contentType.getCode(), contentId, session.getContentType().getCode(), session.getContentId()); - return false; - } - - Instant startedAt = Instant.ofEpochMilli(session.getStartedAtMillis()); - Duration readingDuration = Duration.between(startedAt, Instant.now()); - if (readingDuration.compareTo(MIN_READING_DURATION) < 0) { - log.info("Reading duration too short - userId: {}, duration: {} seconds (minimum: 30)", - userId, readingDuration.getSeconds()); - return false; - } - - log.info("Reading session validated successfully - userId: {}, content: {}/{}, duration: {} seconds", - userId, contentType.getCode(), contentId, readingDuration.getSeconds()); - return true; - } + private final RedisTemplate redisTemplate; + + private static final String READING_SESSION_KEY_PREFIX = "user:"; + + private static final String READING_SESSION_KEY_SUFFIX = ":reading_session"; + + private static final Duration READING_SESSION_TTL = Duration.ofHours(6); + + private static final Duration MIN_READING_DURATION = Duration.ofSeconds(5); + + public void startReadingSession(String userId, ContentType contentType, String contentId) { + String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; + ReadingSession existingSession = getReadingSession(userId); + + // 같은 작품에 대한 세션이 이미 존재하면 started 시간을 갱신하지 않음 + if (existingSession != null && existingSession.getContentType().equals(contentType) + && existingSession.getContentId().equals(contentId)) { + // TTL만 갱신 + redisTemplate.expire(key, READING_SESSION_TTL); + return; + } + + // 다른 작품이거나 세션이 없으면 새로운 세션 생성 + ReadingSession session = ReadingSession.builder() + .contentType(contentType) + .contentId(contentId) + .startedAtMillis(Instant.now().toEpochMilli()) + .build(); + + redisTemplate.opsForValue().set(key, session, READING_SESSION_TTL); + log.info("Reading session started - userId: {}, content: {}/{}, redisKey: {}, startedAt: {}", userId, + contentType.getCode(), contentId, key, Instant.ofEpochMilli(session.getStartedAtMillis())); + } + + public ReadingSession getReadingSession(String userId) { + String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; + return (ReadingSession) redisTemplate.opsForValue().get(key); + } + + public ReadingSession getReadingSessionOrThrow(String userId) { + ReadingSession session = getReadingSession(userId); + if (session == null) { + throw new StreakException(StreakErrorCode.READING_SESSION_NOT_FOUND); + } + return session; + } + + public void deleteReadingSession(String userId) { + String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; + redisTemplate.delete(key); + log.info("Reading session deleted - userId: {}, redisKey: {}", userId, key); + } + + public long getReadingSessionSeconds(String userId, ContentType contentType, String contentId) { + ReadingSession session = getReadingSession(userId); + if (session == null) { + return 0; + } + + // 세션의 contentType과 contentId가 일치하지 않으면 0 반환 + if (!session.getContentType().equals(contentType) || !session.getContentId().equals(contentId)) { + return 0; + } + + Instant startedAt = Instant.ofEpochMilli(session.getStartedAtMillis()); + return Duration.between(startedAt, Instant.now()).getSeconds(); + } + + public boolean isReadingSessionValid(String userId, ContentType contentType, String contentId) { + String key = READING_SESSION_KEY_PREFIX + userId + READING_SESSION_KEY_SUFFIX; + ReadingSession session = getReadingSession(userId); + if (session == null) { + log.info("No reading session found - userId: {}, redisKey: {}, expected content: {}/{}", userId, key, + contentType.getCode(), contentId); + return false; + } + + if (!session.getContentType().equals(contentType) || !session.getContentId().equals(contentId)) { + log.info("Reading session content mismatch - userId: {}, expected: {}/{}, actual: {}/{}", userId, + contentType.getCode(), contentId, session.getContentType().getCode(), session.getContentId()); + return false; + } + + Instant startedAt = Instant.ofEpochMilli(session.getStartedAtMillis()); + Duration readingDuration = Duration.between(startedAt, Instant.now()); + if (readingDuration.compareTo(MIN_READING_DURATION) < 0) { + log.info("Reading duration too short - userId: {}, duration: {} seconds (minimum: 30)", userId, + readingDuration.getSeconds()); + return false; + } + + log.info("Reading session validated successfully - userId: {}, content: {}/{}, duration: {} seconds", userId, + contentType.getCode(), contentId, readingDuration.getSeconds()); + return true; + } + } diff --git a/src/main/java/com/linglevel/api/streak/service/StreakService.java b/src/main/java/com/linglevel/api/streak/service/StreakService.java index 735621c4..4d9729e3 100644 --- a/src/main/java/com/linglevel/api/streak/service/StreakService.java +++ b/src/main/java/com/linglevel/api/streak/service/StreakService.java @@ -29,1126 +29,1130 @@ @Slf4j public class StreakService { - private static final int FREEZE_REWARD_CYCLE = 5; - private static final int MAX_FREEZE_COUNT = 2; - private static final int FIRST_TICKET_REWARD_DAY = 7; - private static final int TICKET_REWARD_CYCLE = 15; - private static final int TICKET_REWARD_AMOUNT = 1; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); - - private final UserStudyReportRepository userStudyReportRepository; - private final DailyCompletionRepository dailyCompletionRepository; - private final TicketService ticketService; - private final FreezeTransactionRepository freezeTransactionRepository; - private final TicketTransactionRepository ticketTransactionRepository; - private final ReadingSessionService readingSessionService; - - @Transactional - public StreakResponse getStreakInfo(String userId, LanguageCode languageCode) { - UserStudyReport report = userStudyReportRepository.findByUserId(userId) - .orElseGet(() -> { - UserStudyReport newReport = createNewUserStudyReport(userId); - userStudyReportRepository.save(newReport); - log.info("Created new UserStudyReport for user: {}", userId); - return newReport; - }); - - LocalDate today = getKstToday(); - StreakStatus todayStatus = calculateTodayStatus(userId, today); - StreakStatus yesterdayStatus = calculateTodayStatus(userId, today.minusDays(1)); - long totalStudyDays = dailyCompletionRepository.countByUserId(userId); - long totalContentsRead = report.getCompletedContentIds() != null ? report.getCompletedContentIds().size() : 0; - - // Calculate expected rewards for today (if not completed yet) - RewardInfo expectedRewards = null; - if (todayStatus != StreakStatus.COMPLETED) { - int expectedStreak = report.getCurrentStreak() + 1; - expectedRewards = calculateExpectedRewards(expectedStreak); - } - - return StreakResponse.builder() - .currentStreak(report.getCurrentStreak()) - .todayStatus(todayStatus) - .yesterdayStatus(yesterdayStatus) - .longestStreak(report.getLongestStreak()) - .streakStartDate(report.getStreakStartDate()) - .totalStudyDays(totalStudyDays) - .totalContentsRead(totalContentsRead) - .availableFreezes(report.getAvailableFreezes()) - .totalReadingTimeSeconds(report.getTotalReadingTimeSeconds()) - .percentile(calculatePercentile(report)) - .encouragementMessage(getEncouragementMessage(report.getCurrentStreak(), todayStatus, languageCode)) - .expectedRewards(expectedRewards) - .build(); - } - - @Transactional - public boolean updateStreak(String userId, ContentType contentType, String contentId) { - LocalDate today = getKstToday(); - - // 유효성 검사: 오늘 이미 스트릭 완료했는지만 확인 - if (hasCompletedStreakToday(userId, today)) { - return false; - } - - UserStudyReport report = userStudyReportRepository.findByUserId(userId) - .orElseGet(() -> createNewUserStudyReport(userId)); - - if (report.getLastCompletionDate() == null) { - report.setCurrentStreak(1); - report.setLongestStreak(1); - report.setStreakStartDate(today); - report.setLastCompletionDate(today); - } - - long daysBetween = ChronoUnit.DAYS.between(report.getLastCompletionDate(), today); - - if (daysBetween == 1) { - // 연속 완료 → 스트릭 증가 - report.setCurrentStreak(report.getCurrentStreak() + 1); - } else if (daysBetween > 1) { - boolean streakWasReset = processMissedDays(report, today); - - if (streakWasReset) { - // 스트릭이 리셋됨 -> 오늘부터 다시 시작 - report.setCurrentStreak(1); - report.setStreakStartDate(today); - } else { - // 프리즈로 스트릭 유지됨 또는 이미 배치 처리됨 -> 오늘 완료로 스트릭 증가 - if (report.getCurrentStreak() > 0) { - report.setCurrentStreak(report.getCurrentStreak() + 1); - } else { - // 배치에서 이미 리셋됨 -> 새로 시작 - report.setCurrentStreak(1); - report.setStreakStartDate(today); - } - } - } - - // 최장 기록 갱신 - if (report.getCurrentStreak() > report.getLongestStreak()) { - report.setLongestStreak(report.getCurrentStreak()); - } - - // 보상 지급 확인 및 적용 - checkAndGrantRewards(report); - - report.setLastCompletionDate(today); - report.setLastLearningTimestamp(Instant.now()); - report.setUpdatedAt(Instant.now()); - userStudyReportRepository.save(report); - - log.info("Streak updated for user: {}. Current streak: {}", userId, report.getCurrentStreak()); - return true; - } - - private void checkAndGrantRewards(UserStudyReport report) { - int currentStreak = report.getCurrentStreak(); - String userId = report.getUserId(); - - grantFreezeIfEligible(report, currentStreak, userId); - grantTicketIfEligible(currentStreak, userId); - } - - private void grantFreezeIfEligible(UserStudyReport report, int currentStreak, String userId) { - boolean isEligible = currentStreak > 0 - && currentStreak % FREEZE_REWARD_CYCLE == 0 - && report.getAvailableFreezes() < MAX_FREEZE_COUNT; - - if (!isEligible) { - return; - } - - report.setAvailableFreezes(report.getAvailableFreezes() + 1); - - FreezeTransaction freezeTransaction = FreezeTransaction.builder() - .userId(userId) - .amount(1) - .description("Reward for " + currentStreak + "-day streak") - .createdAt(Instant.now()) - .build(); - freezeTransactionRepository.save(freezeTransaction); - - log.info("Granted 1 freeze to user {} for {} day streak. User now has {} freezes.", - userId, currentStreak, report.getAvailableFreezes()); - } - - private void grantTicketIfEligible(int currentStreak, String userId) { - if (!shouldGrantTicket(currentStreak)) { - return; - } - - String description = "Reward for " + currentStreak + "-day streak"; - ticketService.grantTicket(userId, TICKET_REWARD_AMOUNT, description); - - log.info("Granted {} ticket to user {} for {} day streak.", - TICKET_REWARD_AMOUNT, userId, currentStreak); - } - - private boolean shouldGrantTicket(int streakCount) { - if (streakCount == FIRST_TICKET_REWARD_DAY) { - return true; - } - return streakCount >= TICKET_REWARD_CYCLE && (streakCount - TICKET_REWARD_CYCLE) % TICKET_REWARD_CYCLE == 0; - } - - public boolean hasCompletedStreakToday(String userId, LocalDate today) { - return dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, today) - .map(completion -> completion.getStreakStatus() == StreakStatus.COMPLETED) - .orElse(false); - } - - private UserStudyReport createNewUserStudyReport(String userId) { - UserStudyReport report = new UserStudyReport(); - report.setUserId(userId); - report.setCreatedAt(Instant.now()); - return report; - } - - private LocalDate getKstToday() { - return LocalDate.now(KST_ZONE); - } - - private double calculatePercentile(UserStudyReport report) { - if (report.getCurrentStreak() == 0) { - return 0.0; - } - - long totalUsers = userStudyReportRepository.count(); - if (totalUsers <= 1) { - return 100.0; - } - - // Calculate "top X%" - users with higher or equal streak - long usersWithHigherOrEqualStreak = userStudyReportRepository.countByCurrentStreakGreaterThanEqual(report.getCurrentStreak()); - - double percentile = ((double) usersWithHigherOrEqualStreak / totalUsers) * 100; - - // 소수점 첫째 자리까지 반올림 - return Math.round(percentile * 10.0) / 10.0; - } - - private EncouragementMessage getEncouragementMessage(int currentStreak, StreakStatus todayStatus, LanguageCode languageCode) { - if (todayStatus != StreakStatus.COMPLETED) { - return EncouragementMessage.builder().build(); - } - - // Default to EN if null - if (languageCode == null) { - languageCode = LanguageCode.EN; - } - - // Check if current streak is a milestone day - var milestone = StreakMilestone.fromDay(currentStreak); - - if (milestone.isPresent()) { - // Milestone day - show celebration message - StreakMilestone streakMilestone = milestone.get(); - return EncouragementMessage.builder() - .title(streakMilestone.getTitle(languageCode)) - .body(null) - .translation(streakMilestone.getMessage(languageCode)) - .build(); - } else { - // Non-milestone day - show random inspirational quote - InspirationQuote quote = InspirationQuote.random(); - return EncouragementMessage.builder() - .title(null) - .body(quote.getOriginal()) - .translation(quote.getTranslation(languageCode)) - .build(); - } - } - - private StreakStatus calculateTodayStatus(String userId, LocalDate date) { - DailyCompletion completion = dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, date) - .map(this::ensureStreakStatus) // Lazy 업데이트 - .orElse(null); - - if (completion != null && completion.getStreakStatus() != null) { - return completion.getStreakStatus(); - } - - return StreakStatus.MISSED; - } - - @Transactional(readOnly = true) - public Page getFreezeTransactions(String userId, int page, int limit) { - PageRequest pageRequest = PageRequest.of(page - 1, limit); - Page transactions = freezeTransactionRepository.findByUserIdOrderByCreatedAtDesc(userId, pageRequest); - return transactions.map(this::toFreezeTransactionResponse); - } - - private FreezeTransactionResponse toFreezeTransactionResponse(FreezeTransaction transaction) { - return FreezeTransactionResponse.builder() - .id(transaction.getId()) - .amount(transaction.getAmount()) - .description(transaction.getDescription()) - .createdAt(transaction.getCreatedAt()) - .build(); - } - - @Transactional - public void addStudyTime(String userId, long studyTimeSeconds) { - UserStudyReport report = userStudyReportRepository.findByUserId(userId) - .orElseGet(() -> { - UserStudyReport newReport = createNewUserStudyReport(userId); - userStudyReportRepository.save(newReport); - return newReport; - }); - - report.setTotalReadingTimeSeconds(report.getTotalReadingTimeSeconds() + studyTimeSeconds); - userStudyReportRepository.save(report); - } - - @Transactional - public void addCompletedContent(String userId, ContentType contentType, String contentId, boolean streakUpdated) { - LocalDate today = getKstToday(); - - UserStudyReport report = userStudyReportRepository.findByUserId(userId) - .orElseGet(() -> createNewUserStudyReport(userId)); - - // completedContentIds 초기화 - if (report.getCompletedContentIds() == null) { - report.setCompletedContentIds(new HashSet<>()); - } - - // DailyCompletion 업데이트 - DailyCompletion.CompletedContent completedContent = DailyCompletion.CompletedContent.builder() - .type(contentType) - .contentId(contentId) - .completedAt(Instant.now()) - .build(); - - DailyCompletion dailyCompletion = dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, today) - .orElse(DailyCompletion.builder() - .userId(userId) - .completionDate(today) - .firstCompletionCount(0) - .totalCompletionCount(0) - .streakCount(report.getCurrentStreak()) - .streakStatus(StreakStatus.MISSED) - .createdAt(Instant.now()) - .build() - ); - - if (dailyCompletion.getCompletedContents() == null) { - dailyCompletion.setCompletedContents(new ArrayList<>()); - } - - dailyCompletion.getCompletedContents().add(completedContent); - dailyCompletion.setTotalCompletionCount(dailyCompletion.getTotalCompletionCount() + 1); - - if (!report.getCompletedContentIds().contains(contentId)) { - report.getCompletedContentIds().add(contentId); - dailyCompletion.setFirstCompletionCount(dailyCompletion.getFirstCompletionCount() + 1); - } - - if (streakUpdated) { - dailyCompletion.setStreakStatus(StreakStatus.COMPLETED); - } - - userStudyReportRepository.save(report); - dailyCompletionRepository.save(dailyCompletion); - } - - /** - * 여러 날 누락 처리 - * - * @param report UserStudyReport - * @param today 오늘 날짜 - * @return 스트릭이 리셋되었는지 여부 - */ - @Transactional - public boolean processMissedDays(UserStudyReport report, LocalDate today) { - if (report.getLastCompletionDate() == null) { - log.warn("Cannot process missed days: lastCompletionDate is null for user {}", report.getUserId()); - return false; - } - - long daysSinceLastCompletion = ChronoUnit.DAYS.between(report.getLastCompletionDate(), today); - - if (daysSinceLastCompletion <= 1) { - return false; - } - - int daysMissed = (int) daysSinceLastCompletion - 1; - log.warn("User {} missed {} days. Processing gap.", report.getUserId(), daysMissed); - - int consumed = 0; - - for (int i = 1; i <= daysMissed; i++) { - LocalDate missedDate = report.getLastCompletionDate().plusDays(i); - - if (wasFreezeProcessedForDate(report.getUserId(), missedDate)) { - continue; - } - - if (consumed < report.getAvailableFreezes()) { - consumeFreezeForDate(report, missedDate); - consumed++; - } else { - resetStreak(report, consumed); - return true; - } - } - - report.setAvailableFreezes(report.getAvailableFreezes() - consumed); - log.info("Consumed {} freezes for user {}. Streak maintained at {}.", - consumed, report.getUserId(), report.getCurrentStreak()); - return false; - } - - private void resetStreak(UserStudyReport report, int freezesConsumed) { - int previousStreak = report.getCurrentStreak(); - report.setCurrentStreak(0); - report.setLastCompletionDate(null); - report.setStreakStartDate(null); - report.setAvailableFreezes(0); - - log.warn("Insufficient freezes for user {}. Streak reset from {} to 0. Consumed {} freezes.", - report.getUserId(), previousStreak, freezesConsumed); - } - - private boolean wasFreezeProcessedForDate(String userId, LocalDate date) { - return dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, date) - .map(completion -> completion.getStreakStatus() == StreakStatus.FREEZE_USED) - .orElse(false); - } - - private boolean hasFreezeTransaction(String userId, LocalDate date) { - Instant dayStart = date.atStartOfDay(KST_ZONE).toInstant(); - Instant dayEnd = date.plusDays(1).atStartOfDay(KST_ZONE).toInstant(); - - return freezeTransactionRepository.existsByUserIdAndAmountAndCreatedAtBetween( - userId, -1, dayStart, dayEnd - ); - } - - @Transactional - protected DailyCompletion ensureStreakStatus(DailyCompletion completion) { - if (completion == null || completion.getStreakStatus() != null) { - return completion; - } - - StreakStatus status; - - if (completion.getTotalCompletionCount() != null && completion.getTotalCompletionCount() > 0) { - status = StreakStatus.COMPLETED; - } - else if (completion.getStreakCount() != null && completion.getStreakCount() > 0) { - boolean hasFreeze = hasFreezeTransaction(completion.getUserId(), completion.getCompletionDate()); - status = hasFreeze ? StreakStatus.FREEZE_USED : StreakStatus.COMPLETED; - } - else { - status = StreakStatus.MISSED; - } - - completion.setStreakStatus(status); - dailyCompletionRepository.save(completion); - - log.debug("Lazy updated streakStatus for user {} on {}: {}", - completion.getUserId(), completion.getCompletionDate(), status); - - return completion; - } - - private void consumeFreezeForDate(UserStudyReport report, LocalDate missedDate) { - FreezeTransaction transaction = FreezeTransaction.builder() - .userId(report.getUserId()) - .amount(-1) - .description("Auto-consumed for missed day: " + missedDate) - .createdAt(Instant.now()) - .build(); - freezeTransactionRepository.save(transaction); - - // 프리즈 사용 시에도 DailyCompletion 생성 (streakStatus=FREEZE_USED로 구분) - DailyCompletion freezeCompletion = DailyCompletion.builder() - .userId(report.getUserId()) - .completionDate(missedDate) - .firstCompletionCount(0) - .totalCompletionCount(0) - .completedContents(new ArrayList<>()) - .streakCount(report.getCurrentStreak()) - .streakStatus(StreakStatus.FREEZE_USED) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(freezeCompletion); - - log.debug("Created freeze consumption transaction and DailyCompletion for user {} on date {} with streak count {}", - report.getUserId(), missedDate, report.getCurrentStreak()); - } - - @Transactional(readOnly = true) - public CalendarResponse getCalendar(String userId, int year, int month) { - LocalDate today = getKstToday(); - LocalDate firstDay = LocalDate.of(year, month, 1); - LocalDate lastDay = firstDay.withDayOfMonth(firstDay.lengthOfMonth()); - - CalendarViewData viewData = prepareCalendarViewData(userId, firstDay, lastDay); - - List days = new ArrayList<>(); - for (LocalDate date = firstDay; !date.isAfter(lastDay); date = date.plusDays(1)) { - days.add(buildCalendarDay(date, today, viewData)); - } - - return CalendarResponse.builder() - .year(year) - .month(month) - .today(today.getDayOfMonth()) - .currentStreak(viewData.getReport().getCurrentStreak()) - .days(days) - .build(); - } - - @Transactional(readOnly = true) - public WeekStreakResponse getThisWeekStreak(String userId) { - LocalDate today = getKstToday(); - LocalDate sunday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)); - LocalDate saturday = sunday.plusDays(6); - - CalendarViewData viewData = prepareCalendarViewData(userId, sunday, saturday); - - List weekDays = new ArrayList<>(); - for (LocalDate date = sunday; !date.isAfter(saturday); date = date.plusDays(1)) { - weekDays.add(buildWeekDay(date, today, viewData)); - } - - return WeekStreakResponse.builder() - .currentStreak(viewData.getReport().getCurrentStreak()) - .freezeCount(viewData.getReport().getAvailableFreezes()) - .weekDays(weekDays) - .build(); - } - - private CalendarViewData prepareCalendarViewData(String userId, LocalDate startDate, LocalDate endDate) { - UserStudyReport report = userStudyReportRepository.findByUserId(userId) - .orElseGet(() -> createNewUserStudyReport(userId)); - - List completions = dailyCompletionRepository - .findByUserIdAndCompletionDateBetween(userId, startDate, endDate); - - completions = completions.stream() - .map(this::ensureStreakStatus) - .toList(); - - Map completionMap = completions.stream() - .collect(Collectors.toMap(DailyCompletion::getCompletionDate, c -> c)); - - Instant startInstant = startDate.atStartOfDay(KST_ZONE).toInstant(); - Instant endInstant = endDate.plusDays(1).atStartOfDay(KST_ZONE).toInstant(); - - List freezeRewardTxs = freezeTransactionRepository - .findByUserIdAndAmountAndCreatedAtBetween(userId, 1, startInstant, endInstant); - Map freezeRewardsMap = freezeRewardTxs.stream() - .collect(Collectors.groupingBy( - t -> t.getCreatedAt().atZone(KST_ZONE).toLocalDate(), - Collectors.summingInt(FreezeTransaction::getAmount) - )); - - LocalDateTime startDateTime = startDate.atStartOfDay(KST_ZONE).toLocalDateTime(); - LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay(KST_ZONE).toLocalDateTime(); - - List ticketRewardTxs = ticketTransactionRepository - .findByUserIdAndAmountAndCreatedAtBetween(userId, TICKET_REWARD_AMOUNT, startDateTime, endDateTime); - Map ticketRewardsMap = ticketRewardTxs.stream() - .collect(Collectors.groupingBy( - t -> t.getCreatedAt().atZone(ZoneId.systemDefault()).withZoneSameInstant(KST_ZONE).toLocalDate(), - Collectors.summingInt(TicketTransaction::getAmount) - )); - - // null인 streakCount를 가진 날짜들을 채우기 - backfillMissingStreakCountsInRange(userId, startDate, endDate, completionMap); - - return new CalendarViewData(report, completionMap, freezeRewardsMap, ticketRewardsMap); - } - - @Transactional - protected void backfillMissingStreakCountsInRange( - String userId, - LocalDate startDate, - LocalDate endDate, - Map completionMap) { - - List allUpdates = new ArrayList<>(); - Set processedDates = new HashSet<>(); - - for (LocalDate currentDate = startDate; !currentDate.isAfter(endDate); currentDate = currentDate.plusDays(1)) { - if (processedDates.contains(currentDate)) { - continue; - } - - DailyCompletion completion = completionMap.get(currentDate); - boolean hasStreakActivity = completion != null && completion.getStreakStatus() != null; - boolean needsBackfill = hasStreakActivity && completion.getStreakCount() == null; - - if (!needsBackfill) { - continue; - } - - log.info("Found missing streakCount for user {} on date {}. Starting backfill.", userId, currentDate); - - LocalDate streakStartDate = findStreakStartDate(userId, currentDate, completionMap); - - Map extendedCompletionMap = loadExtendedCompletions( - userId, streakStartDate, endDate); - - List updates = calculateAndCollectUpdates( - streakStartDate, endDate, extendedCompletionMap, userId, processedDates); - - allUpdates.addAll(updates); - } - - saveUpdatesAndUpdateMap(allUpdates, completionMap, userId); - } - - private Map loadExtendedCompletions( - String userId, - LocalDate streakStartDate, - LocalDate endDate) { - - List extendedCompletions = dailyCompletionRepository - .findByUserIdAndCompletionDateBetween(userId, streakStartDate, endDate); - - return extendedCompletions.stream() - .collect(Collectors.toMap(DailyCompletion::getCompletionDate, c -> c)); - } - - private List calculateAndCollectUpdates( - LocalDate startDate, - LocalDate endDate, - Map completionMap, - String userId, - Set processedDates) { - - List toUpdate = new ArrayList<>(); - int streakCount = 0; - LocalDate currentDate = startDate; - - while (!currentDate.isAfter(endDate)) { - DailyCompletion completion = completionMap.get(currentDate); - boolean hasStreakActivity = completion != null && completion.getStreakStatus() != null; - - if (hasStreakActivity) { - // COMPLETED 상태일 때만 증가, FREEZE_USED는 유지만 - if (completion.getStreakStatus() == StreakStatus.COMPLETED) { - streakCount++; - } - - if (completion.getStreakCount() == null) { - completion.setStreakCount(streakCount); - toUpdate.add(completion); - log.debug("Scheduled streakCount update for user {} on date {} to {}", userId, currentDate, streakCount); - } - - processedDates.add(currentDate); - currentDate = currentDate.plusDays(1); - } else { - break; - } - } - - return toUpdate; - } - - private void saveUpdatesAndUpdateMap( - List toUpdate, - Map completionMap, - String userId) { - - if (toUpdate.isEmpty()) { - return; - } - - dailyCompletionRepository.saveAll(toUpdate); - log.info("Backfilled {} streakCounts for user {}", toUpdate.size(), userId); - - toUpdate.forEach(c -> completionMap.put(c.getCompletionDate(), c)); - } - - private LocalDate findStreakStartDate( - String userId, - LocalDate fromDate, - Map completionMap) { - - LocalDate currentDate = fromDate.minusDays(1); - LocalDate searchLimit = fromDate.minusDays(1000); // 안전장치: 최대 1000일 전까지만 - - while (!currentDate.isBefore(searchLimit)) { - DailyCompletion completion = completionMap.get(currentDate); - boolean hasStreakActivity = completion != null && completion.getStreakStatus() != null; - - if (!hasStreakActivity) { - completion = dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, currentDate) - .map(this::ensureStreakStatus) - .orElse(null); - hasStreakActivity = completion != null && completion.getStreakStatus() != null; - } - - if (!hasStreakActivity) { - return currentDate.plusDays(1); - } - - currentDate = currentDate.minusDays(1); - } - - log.warn("Streak search went back to limit {} for user {}", searchLimit, userId); - return searchLimit; - } - - private CalendarDayInfo calculateCalendarDayInfo(LocalDate date, LocalDate today, CalendarViewData viewData) { - boolean isFuture = date.isAfter(today); - DailyCompletion completion = viewData.getCompletionMap().get(date); - - // Determine StreakStatus - StreakStatus status; - if (isFuture) { - status = StreakStatus.FUTURE; - } else if (completion != null && completion.getStreakStatus() != null) { - status = completion.getStreakStatus(); - } else { - status = StreakStatus.MISSED; - } - - // 저장된 streakCount 가져오기 - Integer streakCount = 0; - if (completion != null && completion.getStreakCount() != null) { - streakCount = completion.getStreakCount(); - } - - RewardInfo rewards = null; - RewardInfo expectedRewards = null; - - if (!isFuture) { - rewards = createRewardInfo( - viewData.getTicketRewardsMap().getOrDefault(date, 0), - viewData.getFreezeRewardsMap().getOrDefault(date, 0) - ); - } - - if (!date.isBefore(today)) { - UserStudyReport report = viewData.getReport(); - boolean isTodayCompleted = viewData.getCompletionMap().containsKey(today); - - int baseStreak = report.getCurrentStreak(); - if (isTodayCompleted) { - baseStreak--; - } - if (baseStreak < 0) { - baseStreak = 0; - } - - int daysFromToday = (int) ChronoUnit.DAYS.between(today, date); - int expectedStreak = baseStreak + 1 + daysFromToday; - - expectedRewards = calculateExpectedRewards(expectedStreak); - } - - return new CalendarDayInfo(status, streakCount, rewards, expectedRewards); - } - - private CalendarDayResponse buildCalendarDay(LocalDate date, LocalDate today, CalendarViewData viewData) { - CalendarDayInfo dayInfo = calculateCalendarDayInfo(date, today, viewData); - DailyCompletion completion = viewData.getCompletionMap().get(date); - - Integer firstCompletionCount = (completion != null) ? completion.getFirstCompletionCount() : 0; - Integer totalCompletionCount = (completion != null) ? completion.getTotalCompletionCount() : 0; - - return CalendarDayResponse.builder() - .date(date) - .dayOfMonth(date.getDayOfMonth()) - .isToday(date.equals(today)) - .status(dayInfo.status) - .streakCount(dayInfo.streakCount) - .firstCompletionCount(firstCompletionCount) - .totalCompletionCount(totalCompletionCount) - .rewards(dayInfo.rewards) - .expectedRewards(dayInfo.expectedRewards) - .build(); - } - - private WeekDayResponse buildWeekDay(LocalDate date, LocalDate today, CalendarViewData viewData) { - CalendarDayInfo dayInfo = calculateCalendarDayInfo(date, today, viewData); - - return WeekDayResponse.builder() - .dayOfWeek(date.getDayOfWeek().name()) - .date(date) - .isToday(date.equals(today)) - .status(dayInfo.status) - .rewards(dayInfo.rewards) - .expectedRewards(dayInfo.expectedRewards) - .build(); - } - - private RewardInfo calculateExpectedRewards(int streakCount) { - int freezes = 0; - int tickets = 0; - - if (streakCount > 0 && streakCount % FREEZE_REWARD_CYCLE == 0) { - freezes = 1; - } - - if (shouldGrantTicket(streakCount)) { - tickets = TICKET_REWARD_AMOUNT; - } - - return createRewardInfo(tickets, freezes); - } - - private RewardInfo createRewardInfo(int tickets, int freezes) { - return RewardInfo.builder() - .tickets(tickets) - .freezes(freezes) - .build(); - } - - @Value - private static class CalendarViewData { - UserStudyReport report; - Map completionMap; - Map freezeRewardsMap; - Map ticketRewardsMap; - } - - @Value - private static class CalendarDayInfo { - StreakStatus status; - Integer streakCount; - RewardInfo rewards; - RewardInfo expectedRewards; - } - - @Transactional - public UserStudyReport recalculateUserStudyReport(String userId) { - LocalDate today = getKstToday(); - - // UserStudyReport 가져오기 (없으면 새로 생성) - UserStudyReport report = userStudyReportRepository.findByUserId(userId) - .orElseGet(() -> createNewUserStudyReport(userId)); - - // 모든 DailyCompletion 가져오기 (날짜 순으로 정렬) - List allCompletions = dailyCompletionRepository - .findByUserIdOrderByCompletionDateAsc(userId); - - if (allCompletions.isEmpty()) { - // 완료 기록이 없으면 모든 값 초기화 - report.setCurrentStreak(0); - report.setLongestStreak(0); - report.setLastCompletionDate(null); - report.setStreakStartDate(null); - report.setUpdatedAt(Instant.now()); - return userStudyReportRepository.save(report); - } - - // 스트릭 재계산 - int currentStreak = 0; - int longestStreak = 0; - LocalDate streakStartDate = null; - LocalDate lastCompletionDate = null; - LocalDate previousDate = null; - - for (DailyCompletion completion : allCompletions) { - LocalDate date = completion.getCompletionDate(); - StreakStatus status = completion.getStreakStatus(); - - // 미래 날짜나 상태가 없는 경우 스킵 - if (date.isAfter(today) || status == null) { - continue; - } - - if (status == StreakStatus.COMPLETED) { - if (previousDate == null) { - // 첫 완료일 - currentStreak = 1; - streakStartDate = date; - } else { - long daysBetween = ChronoUnit.DAYS.between(previousDate, date); - - if (daysBetween == 1) { - // 연속 완료 - currentStreak++; - } else { - // 연속성 끊김 - 새로운 스트릭 시작 - currentStreak = 1; - streakStartDate = date; - } - } - - lastCompletionDate = date; - previousDate = date; - - // 최장 스트릭 갱신 - if (currentStreak > longestStreak) { - longestStreak = currentStreak; - } - } else if (status == StreakStatus.FREEZE_USED) { - // 프리즈 사용 - 스트릭 유지, 카운트 증가 없음 - previousDate = date; - } else if (status == StreakStatus.MISSED) { - // 놓침 - 스트릭 끊김 - currentStreak = 0; - streakStartDate = null; - previousDate = null; - } - } - - // 오늘 날짜와의 연속성 확인 - if (lastCompletionDate != null && !lastCompletionDate.equals(today)) { - long daysSinceLastCompletion = ChronoUnit.DAYS.between(lastCompletionDate, today); - if (daysSinceLastCompletion > 1) { - // 오늘까지의 연속성이 끊김 - 스트릭 리셋 - currentStreak = 0; - streakStartDate = null; - } - } - - // UserStudyReport 업데이트 - report.setCurrentStreak(currentStreak); - report.setLongestStreak(longestStreak); - report.setLastCompletionDate(lastCompletionDate); - report.setStreakStartDate(streakStartDate); - report.setUpdatedAt(Instant.now()); - - log.info("Recalculated UserStudyReport for user {}. Current streak: {}, Longest streak: {}", - userId, currentStreak, longestStreak); - - return userStudyReportRepository.save(report); - } - - @Transactional - public void recoverStreak(String userId, LocalDate startDate, LocalDate endDate) { - LocalDate today = getKstToday(); - - // 복구 범위 검증 - if (startDate.isAfter(endDate)) { - throw new IllegalArgumentException("startDate must be before or equal to endDate"); - } - if (endDate.isAfter(today)) { - endDate = today; - } - - // 복구 전 프리즈 개수 저장 - UserStudyReport beforeReport = userStudyReportRepository.findByUserId(userId).orElse(null); - int freezesBeforeRecovery = beforeReport != null && beforeReport.getAvailableFreezes() != null - ? beforeReport.getAvailableFreezes() : 0; - - // 1. 복구 처리 및 프리즈 보상 수집 - List completionsToSave = new ArrayList<>(); - List freezeTransactions = new ArrayList<>(); - int earnedFreezes = 0; - - // 복구 범위 처리 (streakCount는 나중에 재계산) - for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - Optional existingOpt = dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, date); - - if (existingOpt.isPresent()) { - DailyCompletion existing = existingOpt.get(); - - if (existing.getStreakStatus() == StreakStatus.FREEZE_USED) { - // 프리즈 보상 - FreezeTransaction rewardTx = FreezeTransaction.builder() - .userId(userId) - .amount(1) - .description("Streak recovery compensation for " + date) - .createdAt(Instant.now()) - .build(); - freezeTransactions.add(rewardTx); - earnedFreezes++; - - log.info("Recovered FREEZE_USED to COMPLETED for user {} on {}. Rewarded 1 freeze.", - userId, date); - } - - // streakCount는 나중에 재계산할 것이므로 null로 설정 - existing.setStreakStatus(StreakStatus.COMPLETED); - existing.setStreakCount(null); - completionsToSave.add(existing); - - } else { - // 레코드 없음 (MISSED) → 새로 생성 - DailyCompletion newCompletion = DailyCompletion.builder() - .userId(userId) - .completionDate(date) - .streakStatus(StreakStatus.COMPLETED) - .streakCount(null) // 나중에 재계산 - .firstCompletionCount(0) - .totalCompletionCount(0) - .completedContents(new ArrayList<>()) - .createdAt(Instant.now()) - .build(); - completionsToSave.add(newCompletion); - - log.info("Created new COMPLETED record for user {} on {}.", userId, date); - } - } - - // 2. 복구 범위 이후 날짜들 처리 (프리즈 자동 사용) - LocalDate currentDate = endDate.plusDays(1); - int availableFreezes = earnedFreezes; - - while (currentDate.isBefore(today)) { // 오늘은 제외 - Optional existingOpt = dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, currentDate); - - if (existingOpt.isPresent()) { - DailyCompletion existing = existingOpt.get(); - - if (existing.getStreakStatus() == StreakStatus.COMPLETED) { - // 이미 완료됨 - 그대로 유지 (streakCount는 재계산) - existing.setStreakCount(null); - completionsToSave.add(existing); - currentDate = currentDate.plusDays(1); - } else if (existing.getStreakStatus() == StreakStatus.FREEZE_USED) { - // 이미 프리즈 사용됨 - 유지 - existing.setStreakCount(null); - completionsToSave.add(existing); - currentDate = currentDate.plusDays(1); - } else { - // MISSED - 프리즈로 커버 시도 - if (availableFreezes > 0) { - existing.setStreakStatus(StreakStatus.FREEZE_USED); - existing.setStreakCount(null); - completionsToSave.add(existing); - - // 프리즈 사용 - FreezeTransaction usageTx = FreezeTransaction.builder() - .userId(userId) - .amount(-1) - .description("Auto-consumed for recovery on " + currentDate) - .createdAt(Instant.now()) - .build(); - freezeTransactions.add(usageTx); - availableFreezes--; - - log.info("Auto-used freeze for user {} on {}. {} freezes remaining.", - userId, currentDate, availableFreezes); - - currentDate = currentDate.plusDays(1); - } else { - // 프리즈 없음 - 연결 중단 - log.info("No freeze available for user {} on {}. Stopping streak connection.", - userId, currentDate); - break; - } - } - } else { - // 레코드 없음 (MISSED) - 프리즈로 커버 시도 - if (availableFreezes > 0) { - DailyCompletion newFreezeCompletion = DailyCompletion.builder() - .userId(userId) - .completionDate(currentDate) - .streakStatus(StreakStatus.FREEZE_USED) - .streakCount(null) // 재계산 예정 - .firstCompletionCount(0) - .totalCompletionCount(0) - .completedContents(new ArrayList<>()) - .createdAt(Instant.now()) - .build(); - completionsToSave.add(newFreezeCompletion); - - // 프리즈 사용 - FreezeTransaction usageTx = FreezeTransaction.builder() - .userId(userId) - .amount(-1) - .description("Auto-consumed for recovery on " + currentDate) - .createdAt(Instant.now()) - .build(); - freezeTransactions.add(usageTx); - availableFreezes--; - - log.info("Auto-used freeze and created FREEZE_USED for user {} on {}. {} freezes remaining.", - userId, currentDate, availableFreezes); - - currentDate = currentDate.plusDays(1); - } else { - // 프리즈 없음 - 연결 중단 - log.info("No freeze available for user {} on {}. Stopping streak connection.", - userId, currentDate); - break; - } - } - } - - // 3. 데이터 저장 - if (!completionsToSave.isEmpty()) { - dailyCompletionRepository.saveAll(completionsToSave); - log.info("Saved {} DailyCompletion records for user {}", completionsToSave.size(), userId); - } - - if (!freezeTransactions.isEmpty()) { - freezeTransactionRepository.saveAll(freezeTransactions); - log.info("Saved {} FreezeTransaction records for user {}", freezeTransactions.size(), userId); - } - - // 4. 전체 streakCount 재계산 (startDate 기준) - recalculateAllStreakCounts(userId, startDate); - - // 5. UserStudyReport 재계산 및 프리즈 반영 - UserStudyReport finalReport = recalculateUserStudyReport(userId); - - // 최종 프리즈 = 복구 전 + 획득 - 사용 (최대 MAX_FREEZE_COUNT) - int finalFreezes = Math.min(MAX_FREEZE_COUNT, freezesBeforeRecovery + availableFreezes); - finalReport.setAvailableFreezes(finalFreezes); - userStudyReportRepository.save(finalReport); - - int usedFreezes = earnedFreezes - availableFreezes; - log.info("Streak recovery completed for user {} from {} to {}. Earned {} freezes, used {} freezes. Final freezes: {}", - userId, startDate, endDate, earnedFreezes, usedFreezes, finalFreezes); - } - - @Transactional - public void recalculateAllStreakCounts(String userId, LocalDate recoveryStartDate) { - // 복구 시작일 전날의 streakCount를 기준값으로 가져오기 - LocalDate dayBeforeStart = recoveryStartDate.minusDays(1); - int baseStreakCount = dailyCompletionRepository - .findByUserIdAndCompletionDate(userId, dayBeforeStart) - .map(DailyCompletion::getStreakCount) - .orElse(0); - - log.info("Starting recalculation from {} with base streakCount: {}", recoveryStartDate, baseStreakCount); - - // 복구 시작일부터 모든 DailyCompletion 가져오기 - List completionsToRecalculate = dailyCompletionRepository - .findByUserIdAndCompletionDateGreaterThanEqualOrderByCompletionDateAsc(userId, recoveryStartDate); - - if (completionsToRecalculate.isEmpty()) { - log.info("No completions found for user {} from {}. Skipping streakCount recalculation.", - userId, recoveryStartDate); - return; - } - - int streakCount = baseStreakCount; - LocalDate previousDate = dayBeforeStart; - List toUpdate = new ArrayList<>(); - - for (DailyCompletion completion : completionsToRecalculate) { - LocalDate currentDate = completion.getCompletionDate(); - StreakStatus status = completion.getStreakStatus(); - - // 날짜가 연속적이지 않으면 스트릭 리셋 - if (ChronoUnit.DAYS.between(previousDate, currentDate) > 1 || status == StreakStatus.MISSED) { - break; - } - - if (status == StreakStatus.COMPLETED) { - streakCount++; - } - - completion.setStreakCount(streakCount); - toUpdate.add(completion); - previousDate = currentDate; - } - - if (!toUpdate.isEmpty()) { - dailyCompletionRepository.saveAll(toUpdate); - log.info("Recalculated {} streakCounts for user {} starting from {} (base: {}). Final streakCount: {}", - toUpdate.size(), userId, recoveryStartDate, baseStreakCount, streakCount); - } - } + private static final int FREEZE_REWARD_CYCLE = 5; + + private static final int MAX_FREEZE_COUNT = 2; + + private static final int FIRST_TICKET_REWARD_DAY = 7; + + private static final int TICKET_REWARD_CYCLE = 15; + + private static final int TICKET_REWARD_AMOUNT = 1; + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private final UserStudyReportRepository userStudyReportRepository; + + private final DailyCompletionRepository dailyCompletionRepository; + + private final TicketService ticketService; + + private final FreezeTransactionRepository freezeTransactionRepository; + + private final TicketTransactionRepository ticketTransactionRepository; + + private final ReadingSessionService readingSessionService; + + @Transactional + public StreakResponse getStreakInfo(String userId, LanguageCode languageCode) { + UserStudyReport report = userStudyReportRepository.findByUserId(userId).orElseGet(() -> { + UserStudyReport newReport = createNewUserStudyReport(userId); + userStudyReportRepository.save(newReport); + log.info("Created new UserStudyReport for user: {}", userId); + return newReport; + }); + + LocalDate today = getKstToday(); + StreakStatus todayStatus = calculateTodayStatus(userId, today); + StreakStatus yesterdayStatus = calculateTodayStatus(userId, today.minusDays(1)); + long totalStudyDays = dailyCompletionRepository.countByUserId(userId); + long totalContentsRead = report.getCompletedContentIds() != null ? report.getCompletedContentIds().size() : 0; + + // Calculate expected rewards for today (if not completed yet) + RewardInfo expectedRewards = null; + if (todayStatus != StreakStatus.COMPLETED) { + int expectedStreak = report.getCurrentStreak() + 1; + expectedRewards = calculateExpectedRewards(expectedStreak); + } + + return StreakResponse.builder() + .currentStreak(report.getCurrentStreak()) + .todayStatus(todayStatus) + .yesterdayStatus(yesterdayStatus) + .longestStreak(report.getLongestStreak()) + .streakStartDate(report.getStreakStartDate()) + .totalStudyDays(totalStudyDays) + .totalContentsRead(totalContentsRead) + .availableFreezes(report.getAvailableFreezes()) + .totalReadingTimeSeconds(report.getTotalReadingTimeSeconds()) + .percentile(calculatePercentile(report)) + .encouragementMessage(getEncouragementMessage(report.getCurrentStreak(), todayStatus, languageCode)) + .expectedRewards(expectedRewards) + .build(); + } + + @Transactional + public boolean updateStreak(String userId, ContentType contentType, String contentId) { + LocalDate today = getKstToday(); + + // 유효성 검사: 오늘 이미 스트릭 완료했는지만 확인 + if (hasCompletedStreakToday(userId, today)) { + return false; + } + + UserStudyReport report = userStudyReportRepository.findByUserId(userId) + .orElseGet(() -> createNewUserStudyReport(userId)); + + if (report.getLastCompletionDate() == null) { + report.setCurrentStreak(1); + report.setLongestStreak(1); + report.setStreakStartDate(today); + report.setLastCompletionDate(today); + } + + long daysBetween = ChronoUnit.DAYS.between(report.getLastCompletionDate(), today); + + if (daysBetween == 1) { + // 연속 완료 → 스트릭 증가 + report.setCurrentStreak(report.getCurrentStreak() + 1); + } + else if (daysBetween > 1) { + boolean streakWasReset = processMissedDays(report, today); + + if (streakWasReset) { + // 스트릭이 리셋됨 -> 오늘부터 다시 시작 + report.setCurrentStreak(1); + report.setStreakStartDate(today); + } + else { + // 프리즈로 스트릭 유지됨 또는 이미 배치 처리됨 -> 오늘 완료로 스트릭 증가 + if (report.getCurrentStreak() > 0) { + report.setCurrentStreak(report.getCurrentStreak() + 1); + } + else { + // 배치에서 이미 리셋됨 -> 새로 시작 + report.setCurrentStreak(1); + report.setStreakStartDate(today); + } + } + } + + // 최장 기록 갱신 + if (report.getCurrentStreak() > report.getLongestStreak()) { + report.setLongestStreak(report.getCurrentStreak()); + } + + // 보상 지급 확인 및 적용 + checkAndGrantRewards(report); + + report.setLastCompletionDate(today); + report.setLastLearningTimestamp(Instant.now()); + report.setUpdatedAt(Instant.now()); + userStudyReportRepository.save(report); + + log.info("Streak updated for user: {}. Current streak: {}", userId, report.getCurrentStreak()); + return true; + } + + private void checkAndGrantRewards(UserStudyReport report) { + int currentStreak = report.getCurrentStreak(); + String userId = report.getUserId(); + + grantFreezeIfEligible(report, currentStreak, userId); + grantTicketIfEligible(currentStreak, userId); + } + + private void grantFreezeIfEligible(UserStudyReport report, int currentStreak, String userId) { + boolean isEligible = currentStreak > 0 && currentStreak % FREEZE_REWARD_CYCLE == 0 + && report.getAvailableFreezes() < MAX_FREEZE_COUNT; + + if (!isEligible) { + return; + } + + report.setAvailableFreezes(report.getAvailableFreezes() + 1); + + FreezeTransaction freezeTransaction = FreezeTransaction.builder() + .userId(userId) + .amount(1) + .description("Reward for " + currentStreak + "-day streak") + .createdAt(Instant.now()) + .build(); + freezeTransactionRepository.save(freezeTransaction); + + log.info("Granted 1 freeze to user {} for {} day streak. User now has {} freezes.", userId, currentStreak, + report.getAvailableFreezes()); + } + + private void grantTicketIfEligible(int currentStreak, String userId) { + if (!shouldGrantTicket(currentStreak)) { + return; + } + + String description = "Reward for " + currentStreak + "-day streak"; + ticketService.grantTicket(userId, TICKET_REWARD_AMOUNT, description); + + log.info("Granted {} ticket to user {} for {} day streak.", TICKET_REWARD_AMOUNT, userId, currentStreak); + } + + private boolean shouldGrantTicket(int streakCount) { + if (streakCount == FIRST_TICKET_REWARD_DAY) { + return true; + } + return streakCount >= TICKET_REWARD_CYCLE && (streakCount - TICKET_REWARD_CYCLE) % TICKET_REWARD_CYCLE == 0; + } + + public boolean hasCompletedStreakToday(String userId, LocalDate today) { + return dailyCompletionRepository.findByUserIdAndCompletionDate(userId, today) + .map(completion -> completion.getStreakStatus() == StreakStatus.COMPLETED) + .orElse(false); + } + + private UserStudyReport createNewUserStudyReport(String userId) { + UserStudyReport report = new UserStudyReport(); + report.setUserId(userId); + report.setCreatedAt(Instant.now()); + return report; + } + + private LocalDate getKstToday() { + return LocalDate.now(KST_ZONE); + } + + private double calculatePercentile(UserStudyReport report) { + if (report.getCurrentStreak() == 0) { + return 0.0; + } + + long totalUsers = userStudyReportRepository.count(); + if (totalUsers <= 1) { + return 100.0; + } + + // Calculate "top X%" - users with higher or equal streak + long usersWithHigherOrEqualStreak = userStudyReportRepository + .countByCurrentStreakGreaterThanEqual(report.getCurrentStreak()); + + double percentile = ((double) usersWithHigherOrEqualStreak / totalUsers) * 100; + + // 소수점 첫째 자리까지 반올림 + return Math.round(percentile * 10.0) / 10.0; + } + + private EncouragementMessage getEncouragementMessage(int currentStreak, StreakStatus todayStatus, + LanguageCode languageCode) { + if (todayStatus != StreakStatus.COMPLETED) { + return EncouragementMessage.builder().build(); + } + + // Default to EN if null + if (languageCode == null) { + languageCode = LanguageCode.EN; + } + + // Check if current streak is a milestone day + var milestone = StreakMilestone.fromDay(currentStreak); + + if (milestone.isPresent()) { + // Milestone day - show celebration message + StreakMilestone streakMilestone = milestone.get(); + return EncouragementMessage.builder() + .title(streakMilestone.getTitle(languageCode)) + .body(null) + .translation(streakMilestone.getMessage(languageCode)) + .build(); + } + else { + // Non-milestone day - show random inspirational quote + InspirationQuote quote = InspirationQuote.random(); + return EncouragementMessage.builder() + .title(null) + .body(quote.getOriginal()) + .translation(quote.getTranslation(languageCode)) + .build(); + } + } + + private StreakStatus calculateTodayStatus(String userId, LocalDate date) { + DailyCompletion completion = dailyCompletionRepository.findByUserIdAndCompletionDate(userId, date) + .map(this::ensureStreakStatus) // Lazy 업데이트 + .orElse(null); + + if (completion != null && completion.getStreakStatus() != null) { + return completion.getStreakStatus(); + } + + return StreakStatus.MISSED; + } + + @Transactional(readOnly = true) + public Page getFreezeTransactions(String userId, int page, int limit) { + PageRequest pageRequest = PageRequest.of(page - 1, limit); + Page transactions = freezeTransactionRepository.findByUserIdOrderByCreatedAtDesc(userId, + pageRequest); + return transactions.map(this::toFreezeTransactionResponse); + } + + private FreezeTransactionResponse toFreezeTransactionResponse(FreezeTransaction transaction) { + return FreezeTransactionResponse.builder() + .id(transaction.getId()) + .amount(transaction.getAmount()) + .description(transaction.getDescription()) + .createdAt(transaction.getCreatedAt()) + .build(); + } + + @Transactional + public void addStudyTime(String userId, long studyTimeSeconds) { + UserStudyReport report = userStudyReportRepository.findByUserId(userId).orElseGet(() -> { + UserStudyReport newReport = createNewUserStudyReport(userId); + userStudyReportRepository.save(newReport); + return newReport; + }); + + report.setTotalReadingTimeSeconds(report.getTotalReadingTimeSeconds() + studyTimeSeconds); + userStudyReportRepository.save(report); + } + + @Transactional + public void addCompletedContent(String userId, ContentType contentType, String contentId, boolean streakUpdated) { + LocalDate today = getKstToday(); + + UserStudyReport report = userStudyReportRepository.findByUserId(userId) + .orElseGet(() -> createNewUserStudyReport(userId)); + + // completedContentIds 초기화 + if (report.getCompletedContentIds() == null) { + report.setCompletedContentIds(new HashSet<>()); + } + + // DailyCompletion 업데이트 + DailyCompletion.CompletedContent completedContent = DailyCompletion.CompletedContent.builder() + .type(contentType) + .contentId(contentId) + .completedAt(Instant.now()) + .build(); + + DailyCompletion dailyCompletion = dailyCompletionRepository.findByUserIdAndCompletionDate(userId, today) + .orElse(DailyCompletion.builder() + .userId(userId) + .completionDate(today) + .firstCompletionCount(0) + .totalCompletionCount(0) + .streakCount(report.getCurrentStreak()) + .streakStatus(StreakStatus.MISSED) + .createdAt(Instant.now()) + .build()); + + if (dailyCompletion.getCompletedContents() == null) { + dailyCompletion.setCompletedContents(new ArrayList<>()); + } + + dailyCompletion.getCompletedContents().add(completedContent); + dailyCompletion.setTotalCompletionCount(dailyCompletion.getTotalCompletionCount() + 1); + + if (!report.getCompletedContentIds().contains(contentId)) { + report.getCompletedContentIds().add(contentId); + dailyCompletion.setFirstCompletionCount(dailyCompletion.getFirstCompletionCount() + 1); + } + + if (streakUpdated) { + dailyCompletion.setStreakStatus(StreakStatus.COMPLETED); + } + + userStudyReportRepository.save(report); + dailyCompletionRepository.save(dailyCompletion); + } + + /** + * 여러 날 누락 처리 + * @param report UserStudyReport + * @param today 오늘 날짜 + * @return 스트릭이 리셋되었는지 여부 + */ + @Transactional + public boolean processMissedDays(UserStudyReport report, LocalDate today) { + if (report.getLastCompletionDate() == null) { + log.warn("Cannot process missed days: lastCompletionDate is null for user {}", report.getUserId()); + return false; + } + + long daysSinceLastCompletion = ChronoUnit.DAYS.between(report.getLastCompletionDate(), today); + + if (daysSinceLastCompletion <= 1) { + return false; + } + + int daysMissed = (int) daysSinceLastCompletion - 1; + log.warn("User {} missed {} days. Processing gap.", report.getUserId(), daysMissed); + + int consumed = 0; + + for (int i = 1; i <= daysMissed; i++) { + LocalDate missedDate = report.getLastCompletionDate().plusDays(i); + + if (wasFreezeProcessedForDate(report.getUserId(), missedDate)) { + continue; + } + + if (consumed < report.getAvailableFreezes()) { + consumeFreezeForDate(report, missedDate); + consumed++; + } + else { + resetStreak(report, consumed); + return true; + } + } + + report.setAvailableFreezes(report.getAvailableFreezes() - consumed); + log.info("Consumed {} freezes for user {}. Streak maintained at {}.", consumed, report.getUserId(), + report.getCurrentStreak()); + return false; + } + + private void resetStreak(UserStudyReport report, int freezesConsumed) { + int previousStreak = report.getCurrentStreak(); + report.setCurrentStreak(0); + report.setLastCompletionDate(null); + report.setStreakStartDate(null); + report.setAvailableFreezes(0); + + log.warn("Insufficient freezes for user {}. Streak reset from {} to 0. Consumed {} freezes.", + report.getUserId(), previousStreak, freezesConsumed); + } + + private boolean wasFreezeProcessedForDate(String userId, LocalDate date) { + return dailyCompletionRepository.findByUserIdAndCompletionDate(userId, date) + .map(completion -> completion.getStreakStatus() == StreakStatus.FREEZE_USED) + .orElse(false); + } + + private boolean hasFreezeTransaction(String userId, LocalDate date) { + Instant dayStart = date.atStartOfDay(KST_ZONE).toInstant(); + Instant dayEnd = date.plusDays(1).atStartOfDay(KST_ZONE).toInstant(); + + return freezeTransactionRepository.existsByUserIdAndAmountAndCreatedAtBetween(userId, -1, dayStart, dayEnd); + } + + @Transactional + protected DailyCompletion ensureStreakStatus(DailyCompletion completion) { + if (completion == null || completion.getStreakStatus() != null) { + return completion; + } + + StreakStatus status; + + if (completion.getTotalCompletionCount() != null && completion.getTotalCompletionCount() > 0) { + status = StreakStatus.COMPLETED; + } + else if (completion.getStreakCount() != null && completion.getStreakCount() > 0) { + boolean hasFreeze = hasFreezeTransaction(completion.getUserId(), completion.getCompletionDate()); + status = hasFreeze ? StreakStatus.FREEZE_USED : StreakStatus.COMPLETED; + } + else { + status = StreakStatus.MISSED; + } + + completion.setStreakStatus(status); + dailyCompletionRepository.save(completion); + + log.debug("Lazy updated streakStatus for user {} on {}: {}", completion.getUserId(), + completion.getCompletionDate(), status); + + return completion; + } + + private void consumeFreezeForDate(UserStudyReport report, LocalDate missedDate) { + FreezeTransaction transaction = FreezeTransaction.builder() + .userId(report.getUserId()) + .amount(-1) + .description("Auto-consumed for missed day: " + missedDate) + .createdAt(Instant.now()) + .build(); + freezeTransactionRepository.save(transaction); + + // 프리즈 사용 시에도 DailyCompletion 생성 (streakStatus=FREEZE_USED로 구분) + DailyCompletion freezeCompletion = DailyCompletion.builder() + .userId(report.getUserId()) + .completionDate(missedDate) + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .streakCount(report.getCurrentStreak()) + .streakStatus(StreakStatus.FREEZE_USED) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(freezeCompletion); + + log.debug( + "Created freeze consumption transaction and DailyCompletion for user {} on date {} with streak count {}", + report.getUserId(), missedDate, report.getCurrentStreak()); + } + + @Transactional(readOnly = true) + public CalendarResponse getCalendar(String userId, int year, int month) { + LocalDate today = getKstToday(); + LocalDate firstDay = LocalDate.of(year, month, 1); + LocalDate lastDay = firstDay.withDayOfMonth(firstDay.lengthOfMonth()); + + CalendarViewData viewData = prepareCalendarViewData(userId, firstDay, lastDay); + + List days = new ArrayList<>(); + for (LocalDate date = firstDay; !date.isAfter(lastDay); date = date.plusDays(1)) { + days.add(buildCalendarDay(date, today, viewData)); + } + + return CalendarResponse.builder() + .year(year) + .month(month) + .today(today.getDayOfMonth()) + .currentStreak(viewData.getReport().getCurrentStreak()) + .days(days) + .build(); + } + + @Transactional(readOnly = true) + public WeekStreakResponse getThisWeekStreak(String userId) { + LocalDate today = getKstToday(); + LocalDate sunday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)); + LocalDate saturday = sunday.plusDays(6); + + CalendarViewData viewData = prepareCalendarViewData(userId, sunday, saturday); + + List weekDays = new ArrayList<>(); + for (LocalDate date = sunday; !date.isAfter(saturday); date = date.plusDays(1)) { + weekDays.add(buildWeekDay(date, today, viewData)); + } + + return WeekStreakResponse.builder() + .currentStreak(viewData.getReport().getCurrentStreak()) + .freezeCount(viewData.getReport().getAvailableFreezes()) + .weekDays(weekDays) + .build(); + } + + private CalendarViewData prepareCalendarViewData(String userId, LocalDate startDate, LocalDate endDate) { + UserStudyReport report = userStudyReportRepository.findByUserId(userId) + .orElseGet(() -> createNewUserStudyReport(userId)); + + List completions = dailyCompletionRepository.findByUserIdAndCompletionDateBetween(userId, + startDate, endDate); + + completions = completions.stream().map(this::ensureStreakStatus).toList(); + + Map completionMap = completions.stream() + .collect(Collectors.toMap(DailyCompletion::getCompletionDate, c -> c)); + + Instant startInstant = startDate.atStartOfDay(KST_ZONE).toInstant(); + Instant endInstant = endDate.plusDays(1).atStartOfDay(KST_ZONE).toInstant(); + + List freezeRewardTxs = freezeTransactionRepository + .findByUserIdAndAmountAndCreatedAtBetween(userId, 1, startInstant, endInstant); + Map freezeRewardsMap = freezeRewardTxs.stream() + .collect(Collectors.groupingBy(t -> t.getCreatedAt().atZone(KST_ZONE).toLocalDate(), + Collectors.summingInt(FreezeTransaction::getAmount))); + + LocalDateTime startDateTime = startDate.atStartOfDay(KST_ZONE).toLocalDateTime(); + LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay(KST_ZONE).toLocalDateTime(); + + List ticketRewardTxs = ticketTransactionRepository + .findByUserIdAndAmountAndCreatedAtBetween(userId, TICKET_REWARD_AMOUNT, startDateTime, endDateTime); + Map ticketRewardsMap = ticketRewardTxs.stream() + .collect(Collectors.groupingBy( + t -> t.getCreatedAt().atZone(ZoneId.systemDefault()).withZoneSameInstant(KST_ZONE).toLocalDate(), + Collectors.summingInt(TicketTransaction::getAmount))); + + // null인 streakCount를 가진 날짜들을 채우기 + backfillMissingStreakCountsInRange(userId, startDate, endDate, completionMap); + + return new CalendarViewData(report, completionMap, freezeRewardsMap, ticketRewardsMap); + } + + @Transactional + protected void backfillMissingStreakCountsInRange(String userId, LocalDate startDate, LocalDate endDate, + Map completionMap) { + + List allUpdates = new ArrayList<>(); + Set processedDates = new HashSet<>(); + + for (LocalDate currentDate = startDate; !currentDate.isAfter(endDate); currentDate = currentDate.plusDays(1)) { + if (processedDates.contains(currentDate)) { + continue; + } + + DailyCompletion completion = completionMap.get(currentDate); + boolean hasStreakActivity = completion != null && completion.getStreakStatus() != null; + boolean needsBackfill = hasStreakActivity && completion.getStreakCount() == null; + + if (!needsBackfill) { + continue; + } + + log.info("Found missing streakCount for user {} on date {}. Starting backfill.", userId, currentDate); + + LocalDate streakStartDate = findStreakStartDate(userId, currentDate, completionMap); + + Map extendedCompletionMap = loadExtendedCompletions(userId, streakStartDate, + endDate); + + List updates = calculateAndCollectUpdates(streakStartDate, endDate, extendedCompletionMap, + userId, processedDates); + + allUpdates.addAll(updates); + } + + saveUpdatesAndUpdateMap(allUpdates, completionMap, userId); + } + + private Map loadExtendedCompletions(String userId, LocalDate streakStartDate, + LocalDate endDate) { + + List extendedCompletions = dailyCompletionRepository + .findByUserIdAndCompletionDateBetween(userId, streakStartDate, endDate); + + return extendedCompletions.stream().collect(Collectors.toMap(DailyCompletion::getCompletionDate, c -> c)); + } + + private List calculateAndCollectUpdates(LocalDate startDate, LocalDate endDate, + Map completionMap, String userId, Set processedDates) { + + List toUpdate = new ArrayList<>(); + int streakCount = 0; + LocalDate currentDate = startDate; + + while (!currentDate.isAfter(endDate)) { + DailyCompletion completion = completionMap.get(currentDate); + boolean hasStreakActivity = completion != null && completion.getStreakStatus() != null; + + if (hasStreakActivity) { + // COMPLETED 상태일 때만 증가, FREEZE_USED는 유지만 + if (completion.getStreakStatus() == StreakStatus.COMPLETED) { + streakCount++; + } + + if (completion.getStreakCount() == null) { + completion.setStreakCount(streakCount); + toUpdate.add(completion); + log.debug("Scheduled streakCount update for user {} on date {} to {}", userId, currentDate, + streakCount); + } + + processedDates.add(currentDate); + currentDate = currentDate.plusDays(1); + } + else { + break; + } + } + + return toUpdate; + } + + private void saveUpdatesAndUpdateMap(List toUpdate, Map completionMap, + String userId) { + + if (toUpdate.isEmpty()) { + return; + } + + dailyCompletionRepository.saveAll(toUpdate); + log.info("Backfilled {} streakCounts for user {}", toUpdate.size(), userId); + + toUpdate.forEach(c -> completionMap.put(c.getCompletionDate(), c)); + } + + private LocalDate findStreakStartDate(String userId, LocalDate fromDate, + Map completionMap) { + + LocalDate currentDate = fromDate.minusDays(1); + LocalDate searchLimit = fromDate.minusDays(1000); // 안전장치: 최대 1000일 전까지만 + + while (!currentDate.isBefore(searchLimit)) { + DailyCompletion completion = completionMap.get(currentDate); + boolean hasStreakActivity = completion != null && completion.getStreakStatus() != null; + + if (!hasStreakActivity) { + completion = dailyCompletionRepository.findByUserIdAndCompletionDate(userId, currentDate) + .map(this::ensureStreakStatus) + .orElse(null); + hasStreakActivity = completion != null && completion.getStreakStatus() != null; + } + + if (!hasStreakActivity) { + return currentDate.plusDays(1); + } + + currentDate = currentDate.minusDays(1); + } + + log.warn("Streak search went back to limit {} for user {}", searchLimit, userId); + return searchLimit; + } + + private CalendarDayInfo calculateCalendarDayInfo(LocalDate date, LocalDate today, CalendarViewData viewData) { + boolean isFuture = date.isAfter(today); + DailyCompletion completion = viewData.getCompletionMap().get(date); + + // Determine StreakStatus + StreakStatus status; + if (isFuture) { + status = StreakStatus.FUTURE; + } + else if (completion != null && completion.getStreakStatus() != null) { + status = completion.getStreakStatus(); + } + else { + status = StreakStatus.MISSED; + } + + // 저장된 streakCount 가져오기 + Integer streakCount = 0; + if (completion != null && completion.getStreakCount() != null) { + streakCount = completion.getStreakCount(); + } + + RewardInfo rewards = null; + RewardInfo expectedRewards = null; + + if (!isFuture) { + rewards = createRewardInfo(viewData.getTicketRewardsMap().getOrDefault(date, 0), + viewData.getFreezeRewardsMap().getOrDefault(date, 0)); + } + + if (!date.isBefore(today)) { + UserStudyReport report = viewData.getReport(); + boolean isTodayCompleted = viewData.getCompletionMap().containsKey(today); + + int baseStreak = report.getCurrentStreak(); + if (isTodayCompleted) { + baseStreak--; + } + if (baseStreak < 0) { + baseStreak = 0; + } + + int daysFromToday = (int) ChronoUnit.DAYS.between(today, date); + int expectedStreak = baseStreak + 1 + daysFromToday; + + expectedRewards = calculateExpectedRewards(expectedStreak); + } + + return new CalendarDayInfo(status, streakCount, rewards, expectedRewards); + } + + private CalendarDayResponse buildCalendarDay(LocalDate date, LocalDate today, CalendarViewData viewData) { + CalendarDayInfo dayInfo = calculateCalendarDayInfo(date, today, viewData); + DailyCompletion completion = viewData.getCompletionMap().get(date); + + Integer firstCompletionCount = (completion != null) ? completion.getFirstCompletionCount() : 0; + Integer totalCompletionCount = (completion != null) ? completion.getTotalCompletionCount() : 0; + + return CalendarDayResponse.builder() + .date(date) + .dayOfMonth(date.getDayOfMonth()) + .isToday(date.equals(today)) + .status(dayInfo.status) + .streakCount(dayInfo.streakCount) + .firstCompletionCount(firstCompletionCount) + .totalCompletionCount(totalCompletionCount) + .rewards(dayInfo.rewards) + .expectedRewards(dayInfo.expectedRewards) + .build(); + } + + private WeekDayResponse buildWeekDay(LocalDate date, LocalDate today, CalendarViewData viewData) { + CalendarDayInfo dayInfo = calculateCalendarDayInfo(date, today, viewData); + + return WeekDayResponse.builder() + .dayOfWeek(date.getDayOfWeek().name()) + .date(date) + .isToday(date.equals(today)) + .status(dayInfo.status) + .rewards(dayInfo.rewards) + .expectedRewards(dayInfo.expectedRewards) + .build(); + } + + private RewardInfo calculateExpectedRewards(int streakCount) { + int freezes = 0; + int tickets = 0; + + if (streakCount > 0 && streakCount % FREEZE_REWARD_CYCLE == 0) { + freezes = 1; + } + + if (shouldGrantTicket(streakCount)) { + tickets = TICKET_REWARD_AMOUNT; + } + + return createRewardInfo(tickets, freezes); + } + + private RewardInfo createRewardInfo(int tickets, int freezes) { + return RewardInfo.builder().tickets(tickets).freezes(freezes).build(); + } + + @Value + private static class CalendarViewData { + + UserStudyReport report; + + Map completionMap; + + Map freezeRewardsMap; + + Map ticketRewardsMap; + + } + + @Value + private static class CalendarDayInfo { + + StreakStatus status; + + Integer streakCount; + + RewardInfo rewards; + + RewardInfo expectedRewards; + + } + + @Transactional + public UserStudyReport recalculateUserStudyReport(String userId) { + LocalDate today = getKstToday(); + + // UserStudyReport 가져오기 (없으면 새로 생성) + UserStudyReport report = userStudyReportRepository.findByUserId(userId) + .orElseGet(() -> createNewUserStudyReport(userId)); + + // 모든 DailyCompletion 가져오기 (날짜 순으로 정렬) + List allCompletions = dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(userId); + + if (allCompletions.isEmpty()) { + // 완료 기록이 없으면 모든 값 초기화 + report.setCurrentStreak(0); + report.setLongestStreak(0); + report.setLastCompletionDate(null); + report.setStreakStartDate(null); + report.setUpdatedAt(Instant.now()); + return userStudyReportRepository.save(report); + } + + // 스트릭 재계산 + int currentStreak = 0; + int longestStreak = 0; + LocalDate streakStartDate = null; + LocalDate lastCompletionDate = null; + LocalDate previousDate = null; + + for (DailyCompletion completion : allCompletions) { + LocalDate date = completion.getCompletionDate(); + StreakStatus status = completion.getStreakStatus(); + + // 미래 날짜나 상태가 없는 경우 스킵 + if (date.isAfter(today) || status == null) { + continue; + } + + if (status == StreakStatus.COMPLETED) { + if (previousDate == null) { + // 첫 완료일 + currentStreak = 1; + streakStartDate = date; + } + else { + long daysBetween = ChronoUnit.DAYS.between(previousDate, date); + + if (daysBetween == 1) { + // 연속 완료 + currentStreak++; + } + else { + // 연속성 끊김 - 새로운 스트릭 시작 + currentStreak = 1; + streakStartDate = date; + } + } + + lastCompletionDate = date; + previousDate = date; + + // 최장 스트릭 갱신 + if (currentStreak > longestStreak) { + longestStreak = currentStreak; + } + } + else if (status == StreakStatus.FREEZE_USED) { + // 프리즈 사용 - 스트릭 유지, 카운트 증가 없음 + previousDate = date; + } + else if (status == StreakStatus.MISSED) { + // 놓침 - 스트릭 끊김 + currentStreak = 0; + streakStartDate = null; + previousDate = null; + } + } + + // 오늘 날짜와의 연속성 확인 + if (lastCompletionDate != null && !lastCompletionDate.equals(today)) { + long daysSinceLastCompletion = ChronoUnit.DAYS.between(lastCompletionDate, today); + if (daysSinceLastCompletion > 1) { + // 오늘까지의 연속성이 끊김 - 스트릭 리셋 + currentStreak = 0; + streakStartDate = null; + } + } + + // UserStudyReport 업데이트 + report.setCurrentStreak(currentStreak); + report.setLongestStreak(longestStreak); + report.setLastCompletionDate(lastCompletionDate); + report.setStreakStartDate(streakStartDate); + report.setUpdatedAt(Instant.now()); + + log.info("Recalculated UserStudyReport for user {}. Current streak: {}, Longest streak: {}", userId, + currentStreak, longestStreak); + + return userStudyReportRepository.save(report); + } + + @Transactional + public void recoverStreak(String userId, LocalDate startDate, LocalDate endDate) { + LocalDate today = getKstToday(); + + // 복구 범위 검증 + if (startDate.isAfter(endDate)) { + throw new IllegalArgumentException("startDate must be before or equal to endDate"); + } + if (endDate.isAfter(today)) { + endDate = today; + } + + // 복구 전 프리즈 개수 저장 + UserStudyReport beforeReport = userStudyReportRepository.findByUserId(userId).orElse(null); + int freezesBeforeRecovery = beforeReport != null && beforeReport.getAvailableFreezes() != null + ? beforeReport.getAvailableFreezes() : 0; + + // 1. 복구 처리 및 프리즈 보상 수집 + List completionsToSave = new ArrayList<>(); + List freezeTransactions = new ArrayList<>(); + int earnedFreezes = 0; + + // 복구 범위 처리 (streakCount는 나중에 재계산) + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + Optional existingOpt = dailyCompletionRepository.findByUserIdAndCompletionDate(userId, + date); + + if (existingOpt.isPresent()) { + DailyCompletion existing = existingOpt.get(); + + if (existing.getStreakStatus() == StreakStatus.FREEZE_USED) { + // 프리즈 보상 + FreezeTransaction rewardTx = FreezeTransaction.builder() + .userId(userId) + .amount(1) + .description("Streak recovery compensation for " + date) + .createdAt(Instant.now()) + .build(); + freezeTransactions.add(rewardTx); + earnedFreezes++; + + log.info("Recovered FREEZE_USED to COMPLETED for user {} on {}. Rewarded 1 freeze.", userId, date); + } + + // streakCount는 나중에 재계산할 것이므로 null로 설정 + existing.setStreakStatus(StreakStatus.COMPLETED); + existing.setStreakCount(null); + completionsToSave.add(existing); + + } + else { + // 레코드 없음 (MISSED) → 새로 생성 + DailyCompletion newCompletion = DailyCompletion.builder() + .userId(userId) + .completionDate(date) + .streakStatus(StreakStatus.COMPLETED) + .streakCount(null) // 나중에 재계산 + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + completionsToSave.add(newCompletion); + + log.info("Created new COMPLETED record for user {} on {}.", userId, date); + } + } + + // 2. 복구 범위 이후 날짜들 처리 (프리즈 자동 사용) + LocalDate currentDate = endDate.plusDays(1); + int availableFreezes = earnedFreezes; + + while (currentDate.isBefore(today)) { // 오늘은 제외 + Optional existingOpt = dailyCompletionRepository.findByUserIdAndCompletionDate(userId, + currentDate); + + if (existingOpt.isPresent()) { + DailyCompletion existing = existingOpt.get(); + + if (existing.getStreakStatus() == StreakStatus.COMPLETED) { + // 이미 완료됨 - 그대로 유지 (streakCount는 재계산) + existing.setStreakCount(null); + completionsToSave.add(existing); + currentDate = currentDate.plusDays(1); + } + else if (existing.getStreakStatus() == StreakStatus.FREEZE_USED) { + // 이미 프리즈 사용됨 - 유지 + existing.setStreakCount(null); + completionsToSave.add(existing); + currentDate = currentDate.plusDays(1); + } + else { + // MISSED - 프리즈로 커버 시도 + if (availableFreezes > 0) { + existing.setStreakStatus(StreakStatus.FREEZE_USED); + existing.setStreakCount(null); + completionsToSave.add(existing); + + // 프리즈 사용 + FreezeTransaction usageTx = FreezeTransaction.builder() + .userId(userId) + .amount(-1) + .description("Auto-consumed for recovery on " + currentDate) + .createdAt(Instant.now()) + .build(); + freezeTransactions.add(usageTx); + availableFreezes--; + + log.info("Auto-used freeze for user {} on {}. {} freezes remaining.", userId, currentDate, + availableFreezes); + + currentDate = currentDate.plusDays(1); + } + else { + // 프리즈 없음 - 연결 중단 + log.info("No freeze available for user {} on {}. Stopping streak connection.", userId, + currentDate); + break; + } + } + } + else { + // 레코드 없음 (MISSED) - 프리즈로 커버 시도 + if (availableFreezes > 0) { + DailyCompletion newFreezeCompletion = DailyCompletion.builder() + .userId(userId) + .completionDate(currentDate) + .streakStatus(StreakStatus.FREEZE_USED) + .streakCount(null) // 재계산 예정 + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + completionsToSave.add(newFreezeCompletion); + + // 프리즈 사용 + FreezeTransaction usageTx = FreezeTransaction.builder() + .userId(userId) + .amount(-1) + .description("Auto-consumed for recovery on " + currentDate) + .createdAt(Instant.now()) + .build(); + freezeTransactions.add(usageTx); + availableFreezes--; + + log.info("Auto-used freeze and created FREEZE_USED for user {} on {}. {} freezes remaining.", + userId, currentDate, availableFreezes); + + currentDate = currentDate.plusDays(1); + } + else { + // 프리즈 없음 - 연결 중단 + log.info("No freeze available for user {} on {}. Stopping streak connection.", userId, currentDate); + break; + } + } + } + + // 3. 데이터 저장 + if (!completionsToSave.isEmpty()) { + dailyCompletionRepository.saveAll(completionsToSave); + log.info("Saved {} DailyCompletion records for user {}", completionsToSave.size(), userId); + } + + if (!freezeTransactions.isEmpty()) { + freezeTransactionRepository.saveAll(freezeTransactions); + log.info("Saved {} FreezeTransaction records for user {}", freezeTransactions.size(), userId); + } + + // 4. 전체 streakCount 재계산 (startDate 기준) + recalculateAllStreakCounts(userId, startDate); + + // 5. UserStudyReport 재계산 및 프리즈 반영 + UserStudyReport finalReport = recalculateUserStudyReport(userId); + + // 최종 프리즈 = 복구 전 + 획득 - 사용 (최대 MAX_FREEZE_COUNT) + int finalFreezes = Math.min(MAX_FREEZE_COUNT, freezesBeforeRecovery + availableFreezes); + finalReport.setAvailableFreezes(finalFreezes); + userStudyReportRepository.save(finalReport); + + int usedFreezes = earnedFreezes - availableFreezes; + log.info( + "Streak recovery completed for user {} from {} to {}. Earned {} freezes, used {} freezes. Final freezes: {}", + userId, startDate, endDate, earnedFreezes, usedFreezes, finalFreezes); + } + + @Transactional + public void recalculateAllStreakCounts(String userId, LocalDate recoveryStartDate) { + // 복구 시작일 전날의 streakCount를 기준값으로 가져오기 + LocalDate dayBeforeStart = recoveryStartDate.minusDays(1); + int baseStreakCount = dailyCompletionRepository.findByUserIdAndCompletionDate(userId, dayBeforeStart) + .map(DailyCompletion::getStreakCount) + .orElse(0); + + log.info("Starting recalculation from {} with base streakCount: {}", recoveryStartDate, baseStreakCount); + + // 복구 시작일부터 모든 DailyCompletion 가져오기 + List completionsToRecalculate = dailyCompletionRepository + .findByUserIdAndCompletionDateGreaterThanEqualOrderByCompletionDateAsc(userId, recoveryStartDate); + + if (completionsToRecalculate.isEmpty()) { + log.info("No completions found for user {} from {}. Skipping streakCount recalculation.", userId, + recoveryStartDate); + return; + } + + int streakCount = baseStreakCount; + LocalDate previousDate = dayBeforeStart; + List toUpdate = new ArrayList<>(); + + for (DailyCompletion completion : completionsToRecalculate) { + LocalDate currentDate = completion.getCompletionDate(); + StreakStatus status = completion.getStreakStatus(); + + // 날짜가 연속적이지 않으면 스트릭 리셋 + if (ChronoUnit.DAYS.between(previousDate, currentDate) > 1 || status == StreakStatus.MISSED) { + break; + } + + if (status == StreakStatus.COMPLETED) { + streakCount++; + } + + completion.setStreakCount(streakCount); + toUpdate.add(completion); + previousDate = currentDate; + } + + if (!toUpdate.isEmpty()) { + dailyCompletionRepository.saveAll(toUpdate); + log.info("Recalculated {} streakCounts for user {} starting from {} (base: {}). Final streakCount: {}", + toUpdate.size(), userId, recoveryStartDate, baseStreakCount, streakCount); + } + } + } diff --git a/src/main/java/com/linglevel/api/streak/service/StudyTimeAnalysisService.java b/src/main/java/com/linglevel/api/streak/service/StudyTimeAnalysisService.java index 7310026c..87850d16 100644 --- a/src/main/java/com/linglevel/api/streak/service/StudyTimeAnalysisService.java +++ b/src/main/java/com/linglevel/api/streak/service/StudyTimeAnalysisService.java @@ -26,117 +26,117 @@ @Slf4j public class StudyTimeAnalysisService { - private final DailyCompletionRepository dailyCompletionRepository; - private final UserStudyReportRepository userStudyReportRepository; - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private static final int ANALYSIS_DAYS = 7; // 최근 7일 분석 - - /** - * DB에 저장된 사용자의 선호 학습 시간을 반환합니다. - * 값이 없으면 즉시 계산하여 저장합니다. - */ - public Optional getPreferredStudyHour(String userId) { - Optional reportOpt = userStudyReportRepository.findByUserId(userId); - - if (reportOpt.isEmpty()) { - return Optional.empty(); - } - - UserStudyReport report = reportOpt.get(); - - // DB에 저장된 값이 있으면 반환 - if (report.getPreferredStudyHour() != null) { - return Optional.of(report.getPreferredStudyHour()); - } - - // 값이 없으면 즉시 계산하여 저장 - return calculateAndSavePreferredStudyHour(userId); - } - - /** - * 사용자의 선호 학습 시간을 계산하고 DB에 저장합니다. - * PreferredStudyHourUpdateScheduler에서 주기적으로 호출됩니다. - */ - public Optional calculateAndSavePreferredStudyHour(String userId) { - Optional reportOpt = userStudyReportRepository.findByUserId(userId); - - if (reportOpt.isEmpty()) { - return Optional.empty(); - } - - // 최근 7일 학습 데이터 기반 계산 - Optional calculatedHour = calculateMostFrequentStudyHour(userId); - - if (calculatedHour.isPresent()) { - UserStudyReport report = reportOpt.get(); - report.setPreferredStudyHour(calculatedHour.get()); - report.setPreferredStudyHourUpdatedAt(Instant.now()); - report.setUpdatedAt(Instant.now()); - userStudyReportRepository.save(report); - - log.debug("[StudyTimeAnalysis] Calculated and saved preferred hour for user: {} - {}:00", - userId, calculatedHour.get()); - } - - return calculatedHour; - } - - /** - * 최근 7일 학습 데이터를 기반으로 가장 빈번한 학습 시간을 계산합니다. - */ - private Optional calculateMostFrequentStudyHour(String userId) { - LocalDate startDate = LocalDate.now(KST).minusDays(ANALYSIS_DAYS); - List recentCompletions = dailyCompletionRepository - .findByUserIdAndCompletionDateAfter(userId, startDate); - - if (recentCompletions.isEmpty()) { - log.debug("[StudyTimeAnalysis] No recent completions found for user: {}", userId); - return Optional.empty(); - } - - // 시간대별 학습 빈도 계산 - Map hourFrequency = new HashMap<>(); - - for (DailyCompletion completion : recentCompletions) { - if (completion.getCompletedContents() == null) { - continue; - } - - for (DailyCompletion.CompletedContent content : completion.getCompletedContents()) { - if (content.getCompletedAt() == null) { - continue; - } - - ZonedDateTime completedTime = content.getCompletedAt().atZone(KST); - int hour = completedTime.getHour(); - hourFrequency.put(hour, hourFrequency.getOrDefault(hour, 0) + 1); - } - } - - if (hourFrequency.isEmpty()) { - log.debug("[StudyTimeAnalysis] No valid completion timestamps for user: {}", userId); - return Optional.empty(); - } - - // 가장 빈번한 시간대 찾기 - int mostFrequentHour = hourFrequency.entrySet().stream() - .max(Map.Entry.comparingByValue()) - .map(Map.Entry::getKey) - .orElse(-1); - - if (mostFrequentHour == -1) { - return Optional.empty(); - } - - int totalCompletions = hourFrequency.values().stream().mapToInt(Integer::intValue).sum(); - int frequencyCount = hourFrequency.get(mostFrequentHour); - - log.debug("[StudyTimeAnalysis] User: {} | Most frequent hour: {}:00 ({}% of {} completions in last {} days)", - userId, mostFrequentHour, - (frequencyCount * 100 / totalCompletions), - totalCompletions, - ANALYSIS_DAYS); - - return Optional.of(mostFrequentHour); - } + private final DailyCompletionRepository dailyCompletionRepository; + + private final UserStudyReportRepository userStudyReportRepository; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final int ANALYSIS_DAYS = 7; // 최근 7일 분석 + + /** + * DB에 저장된 사용자의 선호 학습 시간을 반환합니다. 값이 없으면 즉시 계산하여 저장합니다. + */ + public Optional getPreferredStudyHour(String userId) { + Optional reportOpt = userStudyReportRepository.findByUserId(userId); + + if (reportOpt.isEmpty()) { + return Optional.empty(); + } + + UserStudyReport report = reportOpt.get(); + + // DB에 저장된 값이 있으면 반환 + if (report.getPreferredStudyHour() != null) { + return Optional.of(report.getPreferredStudyHour()); + } + + // 값이 없으면 즉시 계산하여 저장 + return calculateAndSavePreferredStudyHour(userId); + } + + /** + * 사용자의 선호 학습 시간을 계산하고 DB에 저장합니다. PreferredStudyHourUpdateScheduler에서 주기적으로 호출됩니다. + */ + public Optional calculateAndSavePreferredStudyHour(String userId) { + Optional reportOpt = userStudyReportRepository.findByUserId(userId); + + if (reportOpt.isEmpty()) { + return Optional.empty(); + } + + // 최근 7일 학습 데이터 기반 계산 + Optional calculatedHour = calculateMostFrequentStudyHour(userId); + + if (calculatedHour.isPresent()) { + UserStudyReport report = reportOpt.get(); + report.setPreferredStudyHour(calculatedHour.get()); + report.setPreferredStudyHourUpdatedAt(Instant.now()); + report.setUpdatedAt(Instant.now()); + userStudyReportRepository.save(report); + + log.debug("[StudyTimeAnalysis] Calculated and saved preferred hour for user: {} - {}:00", userId, + calculatedHour.get()); + } + + return calculatedHour; + } + + /** + * 최근 7일 학습 데이터를 기반으로 가장 빈번한 학습 시간을 계산합니다. + */ + private Optional calculateMostFrequentStudyHour(String userId) { + LocalDate startDate = LocalDate.now(KST).minusDays(ANALYSIS_DAYS); + List recentCompletions = dailyCompletionRepository.findByUserIdAndCompletionDateAfter(userId, + startDate); + + if (recentCompletions.isEmpty()) { + log.debug("[StudyTimeAnalysis] No recent completions found for user: {}", userId); + return Optional.empty(); + } + + // 시간대별 학습 빈도 계산 + Map hourFrequency = new HashMap<>(); + + for (DailyCompletion completion : recentCompletions) { + if (completion.getCompletedContents() == null) { + continue; + } + + for (DailyCompletion.CompletedContent content : completion.getCompletedContents()) { + if (content.getCompletedAt() == null) { + continue; + } + + ZonedDateTime completedTime = content.getCompletedAt().atZone(KST); + int hour = completedTime.getHour(); + hourFrequency.put(hour, hourFrequency.getOrDefault(hour, 0) + 1); + } + } + + if (hourFrequency.isEmpty()) { + log.debug("[StudyTimeAnalysis] No valid completion timestamps for user: {}", userId); + return Optional.empty(); + } + + // 가장 빈번한 시간대 찾기 + int mostFrequentHour = hourFrequency.entrySet() + .stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(-1); + + if (mostFrequentHour == -1) { + return Optional.empty(); + } + + int totalCompletions = hourFrequency.values().stream().mapToInt(Integer::intValue).sum(); + int frequencyCount = hourFrequency.get(mostFrequentHour); + + log.debug("[StudyTimeAnalysis] User: {} | Most frequent hour: {}:00 ({}% of {} completions in last {} days)", + userId, mostFrequentHour, (frequencyCount * 100 / totalCompletions), totalCompletions, ANALYSIS_DAYS); + + return Optional.of(mostFrequentHour); + } + } diff --git a/src/main/java/com/linglevel/api/suggestion/controller/SuggestionsController.java b/src/main/java/com/linglevel/api/suggestion/controller/SuggestionsController.java index 3b5cd4d8..aed416a8 100644 --- a/src/main/java/com/linglevel/api/suggestion/controller/SuggestionsController.java +++ b/src/main/java/com/linglevel/api/suggestion/controller/SuggestionsController.java @@ -25,18 +25,19 @@ @Tag(name = "Suggestions", description = "고객 건의 관련 API") public class SuggestionsController { - private final SuggestionsService suggestionsService; + private final SuggestionsService suggestionsService; + + @Operation(summary = "고객 건의 제출", description = "고객의 건의사항을 제출받습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "건의 제출 성공", + content = @Content(schema = @Schema(implementation = SuggestionResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PostMapping + public ResponseEntity submitSuggestion(@RequestBody SuggestionRequest request, + @AuthenticationPrincipal JwtClaims claims) { + SuggestionResponse response = suggestionsService.saveSuggestion(request, claims.getId()); + return ResponseEntity.ok(response); + } - @Operation(summary = "고객 건의 제출", description = "고객의 건의사항을 제출받습니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "건의 제출 성공", - content = @Content(schema = @Schema(implementation = SuggestionResponse.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PostMapping - public ResponseEntity submitSuggestion(@RequestBody SuggestionRequest request, @AuthenticationPrincipal JwtClaims claims) { - SuggestionResponse response = suggestionsService.saveSuggestion(request, claims.getId()); - return ResponseEntity.ok(response); - } } diff --git a/src/main/java/com/linglevel/api/suggestion/dto/SuggestionRequest.java b/src/main/java/com/linglevel/api/suggestion/dto/SuggestionRequest.java index 5f252019..66214d13 100644 --- a/src/main/java/com/linglevel/api/suggestion/dto/SuggestionRequest.java +++ b/src/main/java/com/linglevel/api/suggestion/dto/SuggestionRequest.java @@ -7,12 +7,14 @@ @Getter @NoArgsConstructor public class SuggestionRequest { - @Schema(description = "건의자 이메일", example = "user@example.com") - private String email = "익명"; - @Schema(description = "건의 태그 (쉼표로 구분)", example = "bug,ui,feature") - private String tags = "태그없음"; + @Schema(description = "건의자 이메일", example = "user@example.com") + private String email = "익명"; + + @Schema(description = "건의 태그 (쉼표로 구분)", example = "bug,ui,feature") + private String tags = "태그없음"; + + @Schema(description = "건의 내용", example = "이런이런 기능이 추가되었으면 좋겠습니다.", required = true) + private String content; - @Schema(description = "건의 내용", example = "이런이런 기능이 추가되었으면 좋겠습니다.", required = true) - private String content; } diff --git a/src/main/java/com/linglevel/api/suggestion/dto/SuggestionResponse.java b/src/main/java/com/linglevel/api/suggestion/dto/SuggestionResponse.java index 4bc1db82..eaa96a22 100644 --- a/src/main/java/com/linglevel/api/suggestion/dto/SuggestionResponse.java +++ b/src/main/java/com/linglevel/api/suggestion/dto/SuggestionResponse.java @@ -9,6 +9,8 @@ @NoArgsConstructor @AllArgsConstructor public class SuggestionResponse { - @Schema(description = "응답 메시지", example = "Suggestion submitted successfully.") - private String message; + + @Schema(description = "응답 메시지", example = "Suggestion submitted successfully.") + private String message; + } diff --git a/src/main/java/com/linglevel/api/suggestion/service/SuggestionsService.java b/src/main/java/com/linglevel/api/suggestion/service/SuggestionsService.java index e35acecd..1d6183a2 100644 --- a/src/main/java/com/linglevel/api/suggestion/service/SuggestionsService.java +++ b/src/main/java/com/linglevel/api/suggestion/service/SuggestionsService.java @@ -15,42 +15,45 @@ @Service public class SuggestionsService { - private final String webhookUrl; - private final RestTemplate restTemplate; - private final UserRepository userRepository; - - public SuggestionsService(@Value("${discord.webhook.suggestion.url}") String webhookUrl, - RestTemplate restTemplate, - UserRepository userRepository) { - this.webhookUrl = webhookUrl; - this.restTemplate = restTemplate; - this.userRepository = userRepository; - } - - public SuggestionResponse saveSuggestion(SuggestionRequest request, String userId) { - User user = userRepository.findById(userId).orElse(null); - - String userInfo; - if (user != null) { - String accountEmail = user.getEmail() != null ? user.getEmail() : "계정 이메일 없음"; - String inputEmail = request.getEmail() != null && !request.getEmail().equals("익명") ? request.getEmail() : "입력 이메일 없음"; - userInfo = user.getId() + " | " + accountEmail; - } else { - userInfo = "사용자 정보 없음"; - } - - String message = "**" + request.getTags() + "**(" + request.getEmail() + ")\n" - + "> " + userInfo + "\n" - + "```" + request.getContent() + "```"; - - DiscordWebhookRequest discordRequest = new DiscordWebhookRequest(message); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity requestEntity = new HttpEntity<>(discordRequest, headers); - - restTemplate.postForEntity(webhookUrl, requestEntity, String.class); - - return new SuggestionResponse("Suggestion submitted successfully."); - } + private final String webhookUrl; + + private final RestTemplate restTemplate; + + private final UserRepository userRepository; + + public SuggestionsService(@Value("${discord.webhook.suggestion.url}") String webhookUrl, RestTemplate restTemplate, + UserRepository userRepository) { + this.webhookUrl = webhookUrl; + this.restTemplate = restTemplate; + this.userRepository = userRepository; + } + + public SuggestionResponse saveSuggestion(SuggestionRequest request, String userId) { + User user = userRepository.findById(userId).orElse(null); + + String userInfo; + if (user != null) { + String accountEmail = user.getEmail() != null ? user.getEmail() : "계정 이메일 없음"; + String inputEmail = request.getEmail() != null && !request.getEmail().equals("익명") ? request.getEmail() + : "입력 이메일 없음"; + userInfo = user.getId() + " | " + accountEmail; + } + else { + userInfo = "사용자 정보 없음"; + } + + String message = "**" + request.getTags() + "**(" + request.getEmail() + ")\n" + "> " + userInfo + "\n" + "```" + + request.getContent() + "```"; + + DiscordWebhookRequest discordRequest = new DiscordWebhookRequest(message); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(discordRequest, headers); + + restTemplate.postForEntity(webhookUrl, requestEntity, String.class); + + return new SuggestionResponse("Suggestion submitted successfully."); + } + } diff --git a/src/main/java/com/linglevel/api/user/controller/UsersController.java b/src/main/java/com/linglevel/api/user/controller/UsersController.java index 31056996..1d1d4061 100644 --- a/src/main/java/com/linglevel/api/user/controller/UsersController.java +++ b/src/main/java/com/linglevel/api/user/controller/UsersController.java @@ -23,28 +23,28 @@ @Slf4j @Tag(name = "Users", description = "사용자 관련 API") public class UsersController { - - private final UsersService usersService; - @DeleteMapping("/me") - @Operation(summary = "사용자 계정 삭제", description = "현재 인증된 사용자의 계정을 삭제합니다. JWT 토큰을 통해 사용자를 식별하며, 관련된 모든 사용자 데이터가 삭제됩니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "계정 삭제 성공", - content = @Content(schema = @Schema(implementation = MessageResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - public ResponseEntity deleteUser(@AuthenticationPrincipal JwtClaims claims) { - usersService.deleteUser(claims.getId()); - return ResponseEntity.ok(new MessageResponse("User account deleted successfully.")); - } + private final UsersService usersService; - @ExceptionHandler(UsersException.class) - public ResponseEntity handleUsersException(UsersException e) { - log.error("Users Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } -} \ No newline at end of file + @DeleteMapping("/me") + @Operation(summary = "사용자 계정 삭제", + description = "현재 인증된 사용자의 계정을 삭제합니다. JWT 토큰을 통해 사용자를 식별하며, 관련된 모든 사용자 데이터가 삭제됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "계정 삭제 성공", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + public ResponseEntity deleteUser(@AuthenticationPrincipal JwtClaims claims) { + usersService.deleteUser(claims.getId()); + return ResponseEntity.ok(new MessageResponse("User account deleted successfully.")); + } + + @ExceptionHandler(UsersException.class) + public ResponseEntity handleUsersException(UsersException e) { + log.error("Users Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/entity/User.java b/src/main/java/com/linglevel/api/user/entity/User.java index 2d50f267..a38d36f8 100644 --- a/src/main/java/com/linglevel/api/user/entity/User.java +++ b/src/main/java/com/linglevel/api/user/entity/User.java @@ -15,30 +15,30 @@ @AllArgsConstructor @Document(collection = "user") public class User { - - @Id - private String id; - @NotNull - @Indexed(unique = true) - private String username; + @Id + private String id; - private String password; + @NotNull + @Indexed(unique = true) + private String username; - private String email; + private String password; - private String displayName; + private String email; - private String provider; + private String displayName; - private String profileImageUrl; + private String provider; - private UserRole role; + private String profileImageUrl; - private Boolean deleted; + private UserRole role; - private LocalDateTime createdAt; + private Boolean deleted; - private LocalDateTime deletedAt; + private LocalDateTime createdAt; + + private LocalDateTime deletedAt; } diff --git a/src/main/java/com/linglevel/api/user/entity/UserRole.java b/src/main/java/com/linglevel/api/user/entity/UserRole.java index a7665f91..d4cd2bdf 100644 --- a/src/main/java/com/linglevel/api/user/entity/UserRole.java +++ b/src/main/java/com/linglevel/api/user/entity/UserRole.java @@ -1,11 +1,12 @@ package com.linglevel.api.user.entity; public enum UserRole { - ADMIN, - USER; - // for spring security GrantedAuthority - public String getSecurityRole() { - return "ROLE_" + this.name(); - } + ADMIN, USER; + + // for spring security GrantedAuthority + public String getSecurityRole() { + return "ROLE_" + this.name(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/exception/UsersErrorCode.java b/src/main/java/com/linglevel/api/user/exception/UsersErrorCode.java index 5eded981..cf4bb9b3 100644 --- a/src/main/java/com/linglevel/api/user/exception/UsersErrorCode.java +++ b/src/main/java/com/linglevel/api/user/exception/UsersErrorCode.java @@ -7,11 +7,14 @@ @Getter @AllArgsConstructor public enum UsersErrorCode { - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found."), - PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "No reading progress found for this user."), - INVALID_USER_DATA(HttpStatus.BAD_REQUEST, "Invalid user data."), - USER_ACCOUNT_DELETED(HttpStatus.UNAUTHORIZED, "User account is deleted."); - - private final HttpStatus status; - private final String message; -} \ No newline at end of file + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found."), + PROGRESS_NOT_FOUND(HttpStatus.NOT_FOUND, "No reading progress found for this user."), + INVALID_USER_DATA(HttpStatus.BAD_REQUEST, "Invalid user data."), + USER_ACCOUNT_DELETED(HttpStatus.UNAUTHORIZED, "User account is deleted."); + + private final HttpStatus status; + + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/exception/UsersException.java b/src/main/java/com/linglevel/api/user/exception/UsersException.java index f28330e3..2386bb1f 100644 --- a/src/main/java/com/linglevel/api/user/exception/UsersException.java +++ b/src/main/java/com/linglevel/api/user/exception/UsersException.java @@ -5,10 +5,12 @@ @Getter public class UsersException extends RuntimeException { - private final HttpStatus status; - public UsersException(UsersErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } -} \ No newline at end of file + private final HttpStatus status; + + public UsersException(UsersErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/repository/UserRepository.java b/src/main/java/com/linglevel/api/user/repository/UserRepository.java index a3085c31..a10894c9 100644 --- a/src/main/java/com/linglevel/api/user/repository/UserRepository.java +++ b/src/main/java/com/linglevel/api/user/repository/UserRepository.java @@ -6,5 +6,7 @@ import java.util.Optional; public interface UserRepository extends MongoRepository { - Optional findByUsername(String username); + + Optional findByUsername(String username); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/service/UsersService.java b/src/main/java/com/linglevel/api/user/service/UsersService.java index 7e473ee0..daa6902d 100644 --- a/src/main/java/com/linglevel/api/user/service/UsersService.java +++ b/src/main/java/com/linglevel/api/user/service/UsersService.java @@ -17,26 +17,28 @@ @Slf4j public class UsersService { - private final UserRepository userRepository; - private final FcmTokenService fcmTokenService; - - @Transactional - public void deleteUser(String userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UsersException(UsersErrorCode.USER_NOT_FOUND)); - - if (user.getDeleted()) { - throw new UsersException(UsersErrorCode.USER_NOT_FOUND); - } - - user.setDeleted(true); - user.setDeletedAt(LocalDateTime.now()); - String originalUsername = user.getUsername(); - user.setUsername("deleted_" + user.getDeletedAt() + "_" + originalUsername); - - userRepository.save(user); - fcmTokenService.deactivateAllTokens(userId); - - log.info("User deleted successfully (username: {}, FCM tokens deactivated)", originalUsername); - } + private final UserRepository userRepository; + + private final FcmTokenService fcmTokenService; + + @Transactional + public void deleteUser(String userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UsersException(UsersErrorCode.USER_NOT_FOUND)); + + if (user.getDeleted()) { + throw new UsersException(UsersErrorCode.USER_NOT_FOUND); + } + + user.setDeleted(true); + user.setDeletedAt(LocalDateTime.now()); + String originalUsername = user.getUsername(); + user.setUsername("deleted_" + user.getDeletedAt() + "_" + originalUsername); + + userRepository.save(user); + fcmTokenService.deactivateAllTokens(userId); + + log.info("User deleted successfully (username: {}, FCM tokens deactivated)", originalUsername); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/controller/TicketsController.java b/src/main/java/com/linglevel/api/user/ticket/controller/TicketsController.java index f0380989..96e886d8 100644 --- a/src/main/java/com/linglevel/api/user/ticket/controller/TicketsController.java +++ b/src/main/java/com/linglevel/api/user/ticket/controller/TicketsController.java @@ -29,50 +29,38 @@ @Tag(name = "Tickets", description = "티켓 관련 API") public class TicketsController { - private final TicketService ticketService; + private final TicketService ticketService; - @GetMapping("/balance") - @Operation(summary = "티켓 잔고 조회", description = "사용자의 현재 티켓 잔고를 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "잔고 조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - public ResponseEntity getTicketBalance(@AuthenticationPrincipal JwtClaims claims) { - TicketBalanceResponse response = ticketService.getTicketBalance(claims.getId()); + @GetMapping("/balance") + @Operation(summary = "티켓 잔고 조회", description = "사용자의 현재 티켓 잔고를 조회합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "잔고 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + public ResponseEntity getTicketBalance(@AuthenticationPrincipal JwtClaims claims) { + TicketBalanceResponse response = ticketService.getTicketBalance(claims.getId()); - return ResponseEntity.ok(response); - } + return ResponseEntity.ok(response); + } - @GetMapping("/transactions") - @Operation(summary = "티켓 거래 내역 조회", description = "사용자의 티켓 거래 내역을 조회합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "거래 내역 조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - public ResponseEntity> getTicketTransactions( - @ParameterObject @Valid @ModelAttribute GetTicketTransactionsRequest request, - @AuthenticationPrincipal JwtClaims claims) { - var transactions = ticketService.getTicketTransactions( - claims.getId(), - request.getPage(), - request.getLimit() - ); + @GetMapping("/transactions") + @Operation(summary = "티켓 거래 내역 조회", description = "사용자의 티켓 거래 내역을 조회합니다.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "거래 내역 조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + public ResponseEntity> getTicketTransactions( + @ParameterObject @Valid @ModelAttribute GetTicketTransactionsRequest request, + @AuthenticationPrincipal JwtClaims claims) { + var transactions = ticketService.getTicketTransactions(claims.getId(), request.getPage(), request.getLimit()); - PageResponse response = new PageResponse<>( - transactions.getContent(), - transactions - ); + PageResponse response = new PageResponse<>(transactions.getContent(), transactions); - return ResponseEntity.ok(response); - } + return ResponseEntity.ok(response); + } + @ExceptionHandler(TicketException.class) + public ResponseEntity handleTicketException(TicketException e) { + log.error("Ticket Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(TicketException.class) - public ResponseEntity handleTicketException(TicketException e) { - log.error("Ticket Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/dto/GetTicketTransactionsRequest.java b/src/main/java/com/linglevel/api/user/ticket/dto/GetTicketTransactionsRequest.java index 69d4c638..e4728d26 100644 --- a/src/main/java/com/linglevel/api/user/ticket/dto/GetTicketTransactionsRequest.java +++ b/src/main/java/com/linglevel/api/user/ticket/dto/GetTicketTransactionsRequest.java @@ -7,13 +7,14 @@ @Data public class GetTicketTransactionsRequest { - - @Parameter(description = "페이지 번호", example = "1") - @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") - private Integer page = 1; - - @Parameter(description = "페이지 당 항목 수", example = "10") - @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") - @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") - private Integer limit = 10; + + @Parameter(description = "페이지 번호", example = "1") + @Min(value = 1, message = "페이지 번호는 1 이상이어야 합니다.") + private Integer page = 1; + + @Parameter(description = "페이지 당 항목 수", example = "10") + @Min(value = 1, message = "페이지 당 항목 수는 1 이상이어야 합니다.") + @Max(value = 200, message = "페이지 당 항목 수는 200 이하여야 합니다.") + private Integer limit = 10; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/dto/TicketBalanceResponse.java b/src/main/java/com/linglevel/api/user/ticket/dto/TicketBalanceResponse.java index 910b8d1e..762756f4 100644 --- a/src/main/java/com/linglevel/api/user/ticket/dto/TicketBalanceResponse.java +++ b/src/main/java/com/linglevel/api/user/ticket/dto/TicketBalanceResponse.java @@ -14,10 +14,11 @@ @AllArgsConstructor @Schema(description = "티켓 잔고 응답") public class TicketBalanceResponse { - - @Schema(description = "보유 티켓 수", example = "5") - private Integer balance; - - @Schema(description = "마지막 업데이트 시간", example = "2024-01-15T10:30:00") - private LocalDateTime updatedAt; + + @Schema(description = "보유 티켓 수", example = "5") + private Integer balance; + + @Schema(description = "마지막 업데이트 시간", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/dto/TicketTransactionResponse.java b/src/main/java/com/linglevel/api/user/ticket/dto/TicketTransactionResponse.java index 1c27ff8e..1e3ee8de 100644 --- a/src/main/java/com/linglevel/api/user/ticket/dto/TicketTransactionResponse.java +++ b/src/main/java/com/linglevel/api/user/ticket/dto/TicketTransactionResponse.java @@ -14,16 +14,17 @@ @AllArgsConstructor @Schema(description = "티켓 거래 내역 응답") public class TicketTransactionResponse { - - @Schema(description = "거래 ID", example = "60d0fe4f5311236168a109ca") - private String id; - - @Schema(description = "티켓 변화량 (양수: 획득, 음수: 사용)", example = "-1") - private Integer amount; - - @Schema(description = "거래 설명", example = "콘텐츠 생성 (My Custom Article)") - private String description; - - @Schema(description = "거래 생성 시간", example = "2024-01-15T10:30:00") - private LocalDateTime createdAt; + + @Schema(description = "거래 ID", example = "60d0fe4f5311236168a109ca") + private String id; + + @Schema(description = "티켓 변화량 (양수: 획득, 음수: 사용)", example = "-1") + private Integer amount; + + @Schema(description = "거래 설명", example = "콘텐츠 생성 (My Custom Article)") + private String description; + + @Schema(description = "거래 생성 시간", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/entity/TicketTransaction.java b/src/main/java/com/linglevel/api/user/ticket/entity/TicketTransaction.java index fca06657..48af0dca 100644 --- a/src/main/java/com/linglevel/api/user/ticket/entity/TicketTransaction.java +++ b/src/main/java/com/linglevel/api/user/ticket/entity/TicketTransaction.java @@ -16,20 +16,21 @@ @Document(collection = "ticketTransactions") @CompoundIndex(name = "userId_createdAt", def = "{'userId': 1, 'createdAt': -1}") public class TicketTransaction { - - @Id - private String id; - - private String userId; - - private Integer amount; // 양수: 획득, 음수: 사용 - - private String description; - - private TransactionStatus status; - - private String reservationId; // 예약 그룹 ID - - @CreatedDate - private LocalDateTime createdAt; + + @Id + private String id; + + private String userId; + + private Integer amount; // 양수: 획득, 음수: 사용 + + private String description; + + private TransactionStatus status; + + private String reservationId; // 예약 그룹 ID + + @CreatedDate + private LocalDateTime createdAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/entity/TransactionStatus.java b/src/main/java/com/linglevel/api/user/ticket/entity/TransactionStatus.java index 6573a32a..ec6f0943 100644 --- a/src/main/java/com/linglevel/api/user/ticket/entity/TransactionStatus.java +++ b/src/main/java/com/linglevel/api/user/ticket/entity/TransactionStatus.java @@ -1,17 +1,17 @@ package com.linglevel.api.user.ticket.entity; public enum TransactionStatus { - CONFIRMED("CONFIRMED"), - RESERVED("RESERVED"), - CANCELLED("CANCELLED"); - - private final String code; - - TransactionStatus(String code) { - this.code = code; - } - - public String getCode() { - return code; - } + + CONFIRMED("CONFIRMED"), RESERVED("RESERVED"), CANCELLED("CANCELLED"); + + private final String code; + + TransactionStatus(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/entity/UserTicket.java b/src/main/java/com/linglevel/api/user/ticket/entity/UserTicket.java index 043977b5..cb18e007 100644 --- a/src/main/java/com/linglevel/api/user/ticket/entity/UserTicket.java +++ b/src/main/java/com/linglevel/api/user/ticket/entity/UserTicket.java @@ -17,22 +17,23 @@ @AllArgsConstructor @Document(collection = "userTickets") public class UserTicket { - - @Id - private String id; - - @Indexed(unique = true) - private String userId; - - @Builder.Default - private Integer balance = 0; - - @Version - private Long version; - - @CreatedDate - private LocalDateTime createdAt; - - @LastModifiedDate - private LocalDateTime updatedAt; + + @Id + private String id; + + @Indexed(unique = true) + private String userId; + + @Builder.Default + private Integer balance = 0; + + @Version + private Long version; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/exception/TicketErrorCode.java b/src/main/java/com/linglevel/api/user/ticket/exception/TicketErrorCode.java index 9a4e2564..e4462cce 100644 --- a/src/main/java/com/linglevel/api/user/ticket/exception/TicketErrorCode.java +++ b/src/main/java/com/linglevel/api/user/ticket/exception/TicketErrorCode.java @@ -7,11 +7,14 @@ @Getter @RequiredArgsConstructor public enum TicketErrorCode { - INSUFFICIENT_BALANCE(HttpStatus.BAD_REQUEST, "Insufficient ticket balance."), - TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "Ticket not found."), - INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "Invalid ticket amount."), - RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "Ticket reservation not found."); - private final HttpStatus status; - private final String message; + INSUFFICIENT_BALANCE(HttpStatus.BAD_REQUEST, "Insufficient ticket balance."), + TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "Ticket not found."), + INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "Invalid ticket amount."), + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "Ticket reservation not found."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/exception/TicketException.java b/src/main/java/com/linglevel/api/user/ticket/exception/TicketException.java index af1ae44d..cc0dd2aa 100644 --- a/src/main/java/com/linglevel/api/user/ticket/exception/TicketException.java +++ b/src/main/java/com/linglevel/api/user/ticket/exception/TicketException.java @@ -5,10 +5,12 @@ @Getter public class TicketException extends RuntimeException { - private final HttpStatus status; - public TicketException(TicketErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public TicketException(TicketErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java b/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java index c4a7be3a..c2ec4bc2 100644 --- a/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java +++ b/src/main/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepository.java @@ -12,11 +12,14 @@ public interface TicketTransactionRepository extends MongoRepository { - Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); - Page findByUserIdAndStatusOrderByCreatedAtDesc(String userId, TransactionStatus status, Pageable pageable); + Page findByUserIdAndStatusOrderByCreatedAtDesc(String userId, TransactionStatus status, + Pageable pageable); - Optional findByReservationIdAndStatus(String reservationId, TransactionStatus status); + Optional findByReservationIdAndStatus(String reservationId, TransactionStatus status); + + List findByUserIdAndAmountAndCreatedAtBetween(String userId, Integer amount, + LocalDateTime startDateTime, LocalDateTime endDateTime); - List findByUserIdAndAmountAndCreatedAtBetween(String userId, Integer amount, LocalDateTime startDateTime, LocalDateTime endDateTime); } diff --git a/src/main/java/com/linglevel/api/user/ticket/repository/UserTicketRepository.java b/src/main/java/com/linglevel/api/user/ticket/repository/UserTicketRepository.java index 3d61f3a6..f93ae51b 100644 --- a/src/main/java/com/linglevel/api/user/ticket/repository/UserTicketRepository.java +++ b/src/main/java/com/linglevel/api/user/ticket/repository/UserTicketRepository.java @@ -6,5 +6,7 @@ import java.util.Optional; public interface UserTicketRepository extends MongoRepository { - Optional findByUserId(String userId); + + Optional findByUserId(String userId); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/user/ticket/service/TicketService.java b/src/main/java/com/linglevel/api/user/ticket/service/TicketService.java index f973cc6f..538b2a00 100644 --- a/src/main/java/com/linglevel/api/user/ticket/service/TicketService.java +++ b/src/main/java/com/linglevel/api/user/ticket/service/TicketService.java @@ -17,180 +17,179 @@ import java.util.UUID; - @Service @RequiredArgsConstructor public class TicketService { - - private final UserTicketRepository userTicketRepository; - private final TicketTransactionRepository ticketTransactionRepository; - - public TicketBalanceResponse getTicketBalance(String userId) { - UserTicket userTicket = getOrCreateUserTicket(userId); - return TicketBalanceResponse.builder() - .balance(userTicket.getBalance()) - .updatedAt(userTicket.getUpdatedAt()) - .build(); - } - - public Page getTicketTransactions(String userId, int page, int limit) { - // 지갑이 없으면 생성 (잔고 조회와 동일한 동작) - getOrCreateUserTicket(userId); - - PageRequest pageRequest = PageRequest.of(page - 1, limit); - Page transactions = ticketTransactionRepository - .findByUserIdAndStatusOrderByCreatedAtDesc(userId, TransactionStatus.CONFIRMED, pageRequest); - - return transactions.map(this::toTicketTransactionResponse); - } - - @Transactional - public String reserveTicket(String userId, int amount, String description) { - UserTicket userTicket = getOrCreateUserTicket(userId); - - // 잔고 확인 - if (userTicket.getBalance() < amount) { - throw new TicketException(TicketErrorCode.INSUFFICIENT_BALANCE); - } - - String reservationId = UUID.randomUUID().toString(); - - // 티켓 차감 (예약 상태) - userTicket.setBalance(userTicket.getBalance() - amount); - userTicketRepository.save(userTicket); - - // 예약 거래 내역 기록 - TicketTransaction transaction = TicketTransaction.builder() - .userId(userId) - .amount(-amount) - .description(description) - .status(TransactionStatus.RESERVED) - .reservationId(reservationId) - .build(); - ticketTransactionRepository.save(transaction); - - return reservationId; - } - - @Transactional - public void confirmReservation(String reservationId) { - TicketTransaction transaction = ticketTransactionRepository - .findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED) - .orElseThrow(() -> new TicketException(TicketErrorCode.RESERVATION_NOT_FOUND)); - - // 예약 상태를 확정으로 변경 - transaction.setStatus(TransactionStatus.CONFIRMED); - ticketTransactionRepository.save(transaction); - } - - @Transactional - public void cancelReservation(String reservationId) { - TicketTransaction transaction = ticketTransactionRepository - .findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED) - .orElseThrow(() -> new TicketException(TicketErrorCode.RESERVATION_NOT_FOUND)); - - // 티켓 복구 - UserTicket userTicket = getOrCreateUserTicket(transaction.getUserId()); - userTicket.setBalance(userTicket.getBalance() + Math.abs(transaction.getAmount())); - userTicketRepository.save(userTicket); - - // 예약 상태를 취소로 변경 - transaction.setStatus(TransactionStatus.CANCELLED); - ticketTransactionRepository.save(transaction); - } - - /** - * 티켓을 사용합니다. (내부 로직에서만 사용 - 즉시 확정) - * @param userId 사용자 ID - * @param amount 사용할 티켓 수 - * @param description 사용 내역 설명 - * @return 남은 티켓 잔고 - */ - @Transactional - public int spendTicket(String userId, int amount, String description) { - UserTicket userTicket = getOrCreateUserTicket(userId); - - // 잔고 확인 - if (userTicket.getBalance() < amount) { - throw new TicketException(TicketErrorCode.INSUFFICIENT_BALANCE); - } - - // 티켓 차감 - userTicket.setBalance(userTicket.getBalance() - amount); - userTicketRepository.save(userTicket); - - // 거래 내역 기록 - TicketTransaction transaction = TicketTransaction.builder() - .userId(userId) - .amount(-amount) // 음수로 저장 - .description(description) - .status(TransactionStatus.CONFIRMED) - .build(); - ticketTransactionRepository.save(transaction); - - return userTicket.getBalance(); - } - - /** - * 티켓을 지급합니다. (관리자 또는 시스템에서 사용) - * @param userId 사용자 ID - * @param amount 지급할 티켓 수 - * @param description 지급 사유 - * @return 지급 후 티켓 잔고 - */ - @Transactional - public int grantTicket(String userId, int amount, String description) { - UserTicket userTicket = getOrCreateUserTicket(userId); - - // 티켓 지급 - userTicket.setBalance(userTicket.getBalance() + amount); - userTicketRepository.save(userTicket); - - // 거래 내역 기록 - TicketTransaction transaction = TicketTransaction.builder() - .userId(userId) - .amount(amount) // 양수로 저장 - .description(description) - .status(TransactionStatus.CONFIRMED) - .build(); - ticketTransactionRepository.save(transaction); - - return userTicket.getBalance(); - } - - private UserTicket getOrCreateUserTicket(String userId) { - return userTicketRepository.findByUserId(userId) - .orElseGet(() -> createDefaultUserTicket(userId)); - } - - /** - * 기본 사용자 티켓을 생성합니다 - * 🎁 이벤트: 최초 지갑 생성 시 10개 티켓 지급 - */ - private UserTicket createDefaultUserTicket(String userId) { - UserTicket userTicket = UserTicket.builder() - .userId(userId) - .balance(10) // 🎁 이벤트: 최초 10개 티켓 지급 - .build(); - UserTicket savedUserTicket = userTicketRepository.save(userTicket); - - TicketTransaction welcomeTransaction = TicketTransaction.builder() - .userId(userId) - .amount(10) - .description("Welcome bonus for new user") - .status(TransactionStatus.CONFIRMED) - .build(); - ticketTransactionRepository.save(welcomeTransaction); - - return savedUserTicket; - } - - private TicketTransactionResponse toTicketTransactionResponse(TicketTransaction transaction) { - return TicketTransactionResponse.builder() - .id(transaction.getId()) - .amount(transaction.getAmount()) - .description(transaction.getDescription()) - .createdAt(transaction.getCreatedAt()) - .build(); - } + + private final UserTicketRepository userTicketRepository; + + private final TicketTransactionRepository ticketTransactionRepository; + + public TicketBalanceResponse getTicketBalance(String userId) { + UserTicket userTicket = getOrCreateUserTicket(userId); + return TicketBalanceResponse.builder() + .balance(userTicket.getBalance()) + .updatedAt(userTicket.getUpdatedAt()) + .build(); + } + + public Page getTicketTransactions(String userId, int page, int limit) { + // 지갑이 없으면 생성 (잔고 조회와 동일한 동작) + getOrCreateUserTicket(userId); + + PageRequest pageRequest = PageRequest.of(page - 1, limit); + Page transactions = ticketTransactionRepository + .findByUserIdAndStatusOrderByCreatedAtDesc(userId, TransactionStatus.CONFIRMED, pageRequest); + + return transactions.map(this::toTicketTransactionResponse); + } + + @Transactional + public String reserveTicket(String userId, int amount, String description) { + UserTicket userTicket = getOrCreateUserTicket(userId); + + // 잔고 확인 + if (userTicket.getBalance() < amount) { + throw new TicketException(TicketErrorCode.INSUFFICIENT_BALANCE); + } + + String reservationId = UUID.randomUUID().toString(); + + // 티켓 차감 (예약 상태) + userTicket.setBalance(userTicket.getBalance() - amount); + userTicketRepository.save(userTicket); + + // 예약 거래 내역 기록 + TicketTransaction transaction = TicketTransaction.builder() + .userId(userId) + .amount(-amount) + .description(description) + .status(TransactionStatus.RESERVED) + .reservationId(reservationId) + .build(); + ticketTransactionRepository.save(transaction); + + return reservationId; + } + + @Transactional + public void confirmReservation(String reservationId) { + TicketTransaction transaction = ticketTransactionRepository + .findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED) + .orElseThrow(() -> new TicketException(TicketErrorCode.RESERVATION_NOT_FOUND)); + + // 예약 상태를 확정으로 변경 + transaction.setStatus(TransactionStatus.CONFIRMED); + ticketTransactionRepository.save(transaction); + } + + @Transactional + public void cancelReservation(String reservationId) { + TicketTransaction transaction = ticketTransactionRepository + .findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED) + .orElseThrow(() -> new TicketException(TicketErrorCode.RESERVATION_NOT_FOUND)); + + // 티켓 복구 + UserTicket userTicket = getOrCreateUserTicket(transaction.getUserId()); + userTicket.setBalance(userTicket.getBalance() + Math.abs(transaction.getAmount())); + userTicketRepository.save(userTicket); + + // 예약 상태를 취소로 변경 + transaction.setStatus(TransactionStatus.CANCELLED); + ticketTransactionRepository.save(transaction); + } + + /** + * 티켓을 사용합니다. (내부 로직에서만 사용 - 즉시 확정) + * @param userId 사용자 ID + * @param amount 사용할 티켓 수 + * @param description 사용 내역 설명 + * @return 남은 티켓 잔고 + */ + @Transactional + public int spendTicket(String userId, int amount, String description) { + UserTicket userTicket = getOrCreateUserTicket(userId); + + // 잔고 확인 + if (userTicket.getBalance() < amount) { + throw new TicketException(TicketErrorCode.INSUFFICIENT_BALANCE); + } + + // 티켓 차감 + userTicket.setBalance(userTicket.getBalance() - amount); + userTicketRepository.save(userTicket); + + // 거래 내역 기록 + TicketTransaction transaction = TicketTransaction.builder() + .userId(userId) + .amount(-amount) // 음수로 저장 + .description(description) + .status(TransactionStatus.CONFIRMED) + .build(); + ticketTransactionRepository.save(transaction); + + return userTicket.getBalance(); + } + + /** + * 티켓을 지급합니다. (관리자 또는 시스템에서 사용) + * @param userId 사용자 ID + * @param amount 지급할 티켓 수 + * @param description 지급 사유 + * @return 지급 후 티켓 잔고 + */ + @Transactional + public int grantTicket(String userId, int amount, String description) { + UserTicket userTicket = getOrCreateUserTicket(userId); + + // 티켓 지급 + userTicket.setBalance(userTicket.getBalance() + amount); + userTicketRepository.save(userTicket); + + // 거래 내역 기록 + TicketTransaction transaction = TicketTransaction.builder() + .userId(userId) + .amount(amount) // 양수로 저장 + .description(description) + .status(TransactionStatus.CONFIRMED) + .build(); + ticketTransactionRepository.save(transaction); + + return userTicket.getBalance(); + } + + private UserTicket getOrCreateUserTicket(String userId) { + return userTicketRepository.findByUserId(userId).orElseGet(() -> createDefaultUserTicket(userId)); + } + + /** + * 기본 사용자 티켓을 생성합니다 🎁 이벤트: 최초 지갑 생성 시 10개 티켓 지급 + */ + private UserTicket createDefaultUserTicket(String userId) { + UserTicket userTicket = UserTicket.builder() + .userId(userId) + .balance(10) // 🎁 이벤트: 최초 10개 티켓 지급 + .build(); + UserTicket savedUserTicket = userTicketRepository.save(userTicket); + + TicketTransaction welcomeTransaction = TicketTransaction.builder() + .userId(userId) + .amount(10) + .description("Welcome bonus for new user") + .status(TransactionStatus.CONFIRMED) + .build(); + ticketTransactionRepository.save(welcomeTransaction); + + return savedUserTicket; + } + + private TicketTransactionResponse toTicketTransactionResponse(TicketTransaction transaction) { + return TicketTransactionResponse.builder() + .id(transaction.getId()) + .amount(transaction.getAmount()) + .description(transaction.getDescription()) + .createdAt(transaction.getCreatedAt()) + .build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/controller/VersionController.java b/src/main/java/com/linglevel/api/version/controller/VersionController.java index 13cada88..66adcfdd 100644 --- a/src/main/java/com/linglevel/api/version/controller/VersionController.java +++ b/src/main/java/com/linglevel/api/version/controller/VersionController.java @@ -24,25 +24,23 @@ @Slf4j @Tag(name = "App Version", description = "앱 버전 관리 API") public class VersionController { - - private final VersionService versionService; - @Operation(summary = "앱 버전 정보 조회", description = "클라이언트에서 사용할 앱의 최신 버전과 최소 요구 버전을 조회합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "버전 정보를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping - public ResponseEntity getVersion() { - VersionResponse response = versionService.getVersion(); - return ResponseEntity.ok(response); - } + private final VersionService versionService; + + @Operation(summary = "앱 버전 정보 조회", description = "클라이언트에서 사용할 앱의 최신 버전과 최소 요구 버전을 조회합니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "버전 정보를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping + public ResponseEntity getVersion() { + VersionResponse response = versionService.getVersion(); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(VersionException.class) + public ResponseEntity handleVersionException(VersionException e) { + log.info("Version Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(VersionException.class) - public ResponseEntity handleVersionException(VersionException e) { - log.info("Version Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/dto/VersionResponse.java b/src/main/java/com/linglevel/api/version/dto/VersionResponse.java index 79648327..cbb77f56 100644 --- a/src/main/java/com/linglevel/api/version/dto/VersionResponse.java +++ b/src/main/java/com/linglevel/api/version/dto/VersionResponse.java @@ -12,9 +12,11 @@ @AllArgsConstructor @Schema(description = "앱 버전 정보 응답") public class VersionResponse { - @Schema(description = "최신 버전", example = "1.2.3") - private String latestVersion; - - @Schema(description = "최소 요구 버전", example = "1.1.0") - private String minimumVersion; + + @Schema(description = "최신 버전", example = "1.2.3") + private String latestVersion; + + @Schema(description = "최소 요구 버전", example = "1.1.0") + private String minimumVersion; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/dto/VersionUpdateRequest.java b/src/main/java/com/linglevel/api/version/dto/VersionUpdateRequest.java index 9ba7dc63..901cc3d9 100644 --- a/src/main/java/com/linglevel/api/version/dto/VersionUpdateRequest.java +++ b/src/main/java/com/linglevel/api/version/dto/VersionUpdateRequest.java @@ -10,9 +10,11 @@ @AllArgsConstructor @Schema(description = "앱 버전 업데이트 요청") public class VersionUpdateRequest { - @Schema(description = "최신 버전", example = "1.2.3") - private String latestVersion; - - @Schema(description = "최소 요구 버전", example = "1.1.0") - private String minimumVersion; + + @Schema(description = "최신 버전", example = "1.2.3") + private String latestVersion; + + @Schema(description = "최소 요구 버전", example = "1.1.0") + private String minimumVersion; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/dto/VersionUpdateResponse.java b/src/main/java/com/linglevel/api/version/dto/VersionUpdateResponse.java index 458281dc..0b700ca4 100644 --- a/src/main/java/com/linglevel/api/version/dto/VersionUpdateResponse.java +++ b/src/main/java/com/linglevel/api/version/dto/VersionUpdateResponse.java @@ -14,12 +14,14 @@ @AllArgsConstructor @Schema(description = "앱 버전 업데이트 응답") public class VersionUpdateResponse { - @Schema(description = "최신 버전", example = "1.2.3") - private String latestVersion; - - @Schema(description = "최소 요구 버전", example = "1.1.0") - private String minimumVersion; - - @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00") - private LocalDateTime updatedAt; + + @Schema(description = "최신 버전", example = "1.2.3") + private String latestVersion; + + @Schema(description = "최소 요구 버전", example = "1.1.0") + private String minimumVersion; + + @Schema(description = "업데이트 일시", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/entity/AppVersion.java b/src/main/java/com/linglevel/api/version/entity/AppVersion.java index 23b85109..f318b761 100644 --- a/src/main/java/com/linglevel/api/version/entity/AppVersion.java +++ b/src/main/java/com/linglevel/api/version/entity/AppVersion.java @@ -12,12 +12,14 @@ @AllArgsConstructor @Document(collection = "appVersion") public class AppVersion { - @Id - private String id; - - private String latestVersion; - - private String minimumVersion; - - private LocalDateTime updatedAt; + + @Id + private String id; + + private String latestVersion; + + private String minimumVersion; + + private LocalDateTime updatedAt; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/exception/VersionErrorCode.java b/src/main/java/com/linglevel/api/version/exception/VersionErrorCode.java index 3b107629..b87037f7 100644 --- a/src/main/java/com/linglevel/api/version/exception/VersionErrorCode.java +++ b/src/main/java/com/linglevel/api/version/exception/VersionErrorCode.java @@ -7,9 +7,12 @@ @Getter @AllArgsConstructor public enum VersionErrorCode { - VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "Version information not found."), - VERSION_FIELD_REQUIRED(HttpStatus.BAD_REQUEST, "At least one version field (latestVersion or minimumVersion) must be provided."); - - private final HttpStatus status; - private final String message; + + VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "Version information not found."), VERSION_FIELD_REQUIRED( + HttpStatus.BAD_REQUEST, "At least one version field (latestVersion or minimumVersion) must be provided."); + + private final HttpStatus status; + + private final String message; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/exception/VersionException.java b/src/main/java/com/linglevel/api/version/exception/VersionException.java index 28d3e567..b40805e0 100644 --- a/src/main/java/com/linglevel/api/version/exception/VersionException.java +++ b/src/main/java/com/linglevel/api/version/exception/VersionException.java @@ -5,10 +5,12 @@ @Getter public class VersionException extends RuntimeException { - private final HttpStatus status; - public VersionException(VersionErrorCode errorCode) { - super(errorCode.getMessage()); - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + public VersionException(VersionErrorCode errorCode) { + super(errorCode.getMessage()); + this.status = errorCode.getStatus(); + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/repository/AppVersionRepository.java b/src/main/java/com/linglevel/api/version/repository/AppVersionRepository.java index 730e6578..37b4ba96 100644 --- a/src/main/java/com/linglevel/api/version/repository/AppVersionRepository.java +++ b/src/main/java/com/linglevel/api/version/repository/AppVersionRepository.java @@ -6,5 +6,7 @@ import java.util.Optional; public interface AppVersionRepository extends MongoRepository { - Optional findTopByOrderByUpdatedAtDesc(); + + Optional findTopByOrderByUpdatedAtDesc(); + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/version/service/VersionService.java b/src/main/java/com/linglevel/api/version/service/VersionService.java index 79cff5f7..ed0b9200 100644 --- a/src/main/java/com/linglevel/api/version/service/VersionService.java +++ b/src/main/java/com/linglevel/api/version/service/VersionService.java @@ -19,49 +19,51 @@ @RequiredArgsConstructor @Slf4j public class VersionService { - - private final AppVersionRepository appVersionRepository; - public VersionResponse getVersion() { - AppVersion appVersion = appVersionRepository.findTopByOrderByUpdatedAtDesc() - .orElseThrow(() -> new VersionException(VersionErrorCode.VERSION_NOT_FOUND)); - - return VersionResponse.builder() - .latestVersion(appVersion.getLatestVersion()) - .minimumVersion(appVersion.getMinimumVersion()) - .build(); - } + private final AppVersionRepository appVersionRepository; - @Transactional - public VersionUpdateResponse updateVersion(VersionUpdateRequest request) { - if (request.getLatestVersion() == null && request.getMinimumVersion() == null) { - throw new VersionException(VersionErrorCode.VERSION_FIELD_REQUIRED); - } + public VersionResponse getVersion() { + AppVersion appVersion = appVersionRepository.findTopByOrderByUpdatedAtDesc() + .orElseThrow(() -> new VersionException(VersionErrorCode.VERSION_NOT_FOUND)); - Optional existingVersion = appVersionRepository.findTopByOrderByUpdatedAtDesc(); - AppVersion appVersion; + return VersionResponse.builder() + .latestVersion(appVersion.getLatestVersion()) + .minimumVersion(appVersion.getMinimumVersion()) + .build(); + } - if (existingVersion.isPresent()) { - appVersion = existingVersion.get(); - if (request.getLatestVersion() != null) { - appVersion.setLatestVersion(request.getLatestVersion()); - } - if (request.getMinimumVersion() != null) { - appVersion.setMinimumVersion(request.getMinimumVersion()); - } - } else { - appVersion = new AppVersion(); - appVersion.setLatestVersion(request.getLatestVersion() != null ? request.getLatestVersion() : "1.0.0"); - appVersion.setMinimumVersion(request.getMinimumVersion() != null ? request.getMinimumVersion() : "1.0.0"); - } + @Transactional + public VersionUpdateResponse updateVersion(VersionUpdateRequest request) { + if (request.getLatestVersion() == null && request.getMinimumVersion() == null) { + throw new VersionException(VersionErrorCode.VERSION_FIELD_REQUIRED); + } - appVersion.setUpdatedAt(LocalDateTime.now()); - AppVersion savedVersion = appVersionRepository.save(appVersion); + Optional existingVersion = appVersionRepository.findTopByOrderByUpdatedAtDesc(); + AppVersion appVersion; + + if (existingVersion.isPresent()) { + appVersion = existingVersion.get(); + if (request.getLatestVersion() != null) { + appVersion.setLatestVersion(request.getLatestVersion()); + } + if (request.getMinimumVersion() != null) { + appVersion.setMinimumVersion(request.getMinimumVersion()); + } + } + else { + appVersion = new AppVersion(); + appVersion.setLatestVersion(request.getLatestVersion() != null ? request.getLatestVersion() : "1.0.0"); + appVersion.setMinimumVersion(request.getMinimumVersion() != null ? request.getMinimumVersion() : "1.0.0"); + } + + appVersion.setUpdatedAt(LocalDateTime.now()); + AppVersion savedVersion = appVersionRepository.save(appVersion); + + return VersionUpdateResponse.builder() + .latestVersion(savedVersion.getLatestVersion()) + .minimumVersion(savedVersion.getMinimumVersion()) + .updatedAt(savedVersion.getUpdatedAt()) + .build(); + } - return VersionUpdateResponse.builder() - .latestVersion(savedVersion.getLatestVersion()) - .minimumVersion(savedVersion.getMinimumVersion()) - .updatedAt(savedVersion.getUpdatedAt()) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/config/WordSingleFlightProperties.java b/src/main/java/com/linglevel/api/word/config/WordSingleFlightProperties.java index 56b4c4bd..9a38475a 100644 --- a/src/main/java/com/linglevel/api/word/config/WordSingleFlightProperties.java +++ b/src/main/java/com/linglevel/api/word/config/WordSingleFlightProperties.java @@ -11,9 +11,10 @@ @ConfigurationProperties(prefix = "word.single-flight") public class WordSingleFlightProperties { - private boolean enabled = true; + private boolean enabled = true; - private long waitTimeoutMs = 5_000; + private long waitTimeoutMs = 5_000; + + private String resultSchemaVersion = "v2"; - private String resultSchemaVersion = "v2"; } diff --git a/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java b/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java index 18f4da1d..43d60dec 100644 --- a/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java +++ b/src/main/java/com/linglevel/api/word/controller/WordsAdminController.java @@ -33,174 +33,145 @@ @SecurityRequirement(name = "adminApiKey") public class WordsAdminController { - private final WordService wordService; - private final Oxford3000Service oxford3000Service; - - @Operation( - summary = "단어 강제 재분석", - description = """ - 관리자 전용: 단어를 AI로 강제 재분석합니다. - - **사용 사례:** - 1. Homograph 대응 (overwrite=false): 'saw'가 'see'의 과거형으로만 저장된 경우, '톱' 의미를 추가 - 2. 품질 개선 (overwrite=true, deleteVariants=false): 기존 Variant 관계 유지하면서 Word 재생성 - 3. 완전 초기화 (overwrite=true, deleteVariants=true): Variant + Word 모두 삭제 후 완전 재생성 - - **파라미터:** - - overwrite=false (기본): 기존 데이터 유지 + 새로운 의미 추가 - - overwrite=true, deleteVariants=false: Variant 유지 + Word 재생성 (homograph 추가 가능) - - overwrite=true, deleteVariants=true: Variant + Word 모두 삭제 후 완전 재생성 - """ - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "재분석 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @RateLimit(capacity = 5, refillMinutes = 10, keyType = KeyType.IP) - @PostMapping("/{word}/force-analyze") - public ResponseEntity forceAnalyzeWord( - @Parameter(description = "재분석할 단어", example = "saw") - @PathVariable String word, - @Parameter(description = "번역 대상 언어", example = "KO") - @RequestParam(defaultValue = "KO") LanguageCode targetLanguage, - @Parameter(description = "true: 기존 데이터 삭제 후 재생성, false: 기존 데이터 유지 + 새로운 의미 추가") - @RequestParam(defaultValue = "false") boolean overwrite, - @Parameter(description = "true: Variant도 함께 삭제 (완전 초기화), false: Variant 유지 (기본값)") - @RequestParam(defaultValue = "false") boolean deleteVariants) { - - log.info("Admin force-analyze: word='{}', targetLanguage={}, overwrite={}, deleteVariants={}", - word, targetLanguage, overwrite, deleteVariants); - - WordSearchResponse response = wordService.forceReanalyzeWord(word, targetLanguage, overwrite, deleteVariants); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "Oxford 3000 단어 초기화", - description = """ - 관리자 전용: Oxford 3000 필수 단어를 일괄 생성/업데이트합니다. - - **동작 방식:** - - 이미 존재하는 단어: 재사용하고 isEssential=true로 업데이트 - - 존재하지 않는 단어: AI로 분석하여 새로 생성 - - **주의사항:** - - 이 작업은 매우 오래 걸릴 수 있습니다 (3000개 단어 × AI 호출) - - 비동기 처리를 권장하지만, 현재는 동기 처리로 구현되어 있습니다 - - Rate limit을 고려하여 천천히 호출하세요 - - **파라미터:** - - targetLanguage: 번역 대상 언어 (예: KO, JA) - """ - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "초기화 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @RateLimit(capacity = 1, refillMinutes = 60, keyType = KeyType.IP) - @PostMapping("/essential/initialize-oxford3000") - public ResponseEntity initializeOxford3000( - @Parameter(description = "번역 대상 언어", example = "KO") - @RequestParam(defaultValue = "KO") LanguageCode targetLanguage) { - - log.info("Admin initialize-oxford3000: targetLanguage={}", targetLanguage); - - Oxford3000InitResponse response = oxford3000Service.initializeOxford3000(targetLanguage); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "필수 단어 통계 조회", - description = """ - 관리자 전용: 필수 단어(isEssential=true)의 통계 정보를 조회합니다. - - **응답 정보:** - - totalEssentialWords: 전체 필수 단어 수 - - countByTargetLanguage: 번역 대상 언어별 필수 단어 수 - - countBySourceLanguage: 원본 언어별 필수 단어 수 - """ - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/essential/stats") - public ResponseEntity getEssentialWordsStats() { - log.info("Admin get essential words stats"); - - EssentialWordsStatsResponse response = oxford3000Service.getEssentialWordsStats(); - return ResponseEntity.ok(response); - } - - @Operation( - summary = "등록된 필수 단어 목록 조회", - description = """ - 관리자 전용: 이미 등록된 필수 단어(isEssential=true)의 word 문자열 목록을 반환합니다. - - **사용 사례:** - - Python 스크립트에서 중복 등록 방지를 위해 사용 - - 특정 언어로 필터링 가능 (targetLanguage 파라미터 사용) - - **파라미터:** - - targetLanguage (선택): 특정 번역 대상 언어로 필터링 (예: KO) - - 파라미터 없으면 모든 언어의 필수 단어 반환 - """ - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @GetMapping("/essential/words") - public ResponseEntity> getEssentialWords( - @Parameter(description = "번역 대상 언어 (선택)", example = "KO") - @RequestParam(required = false) LanguageCode targetLanguage) { - - log.info("Admin get essential words: targetLanguage={}", targetLanguage); - - List words = oxford3000Service.getEssentialWordsList(targetLanguage); - return ResponseEntity.ok(words); - } - - @Operation( - summary = "단어의 필수 여부 설정", - description = """ - 관리자 전용: 특정 단어의 isEssential 플래그를 수동으로 설정/해제합니다. - - **사용 사례:** - 1. 실수로 필수 단어로 표시된 단어를 일반 단어로 변경 - 2. 커스텀 필수 단어 추가 (Oxford 3000 외의 단어) - """ - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "업데이트 성공"), - @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @PatchMapping("/{wordId}/essential") - public ResponseEntity updateEssentialStatus( - @Parameter(description = "단어 ID", example = "507f1f77bcf86cd799439011") - @PathVariable String wordId, - @Parameter(description = "필수 단어 여부", example = "true") - @RequestParam boolean isEssential) { - - log.info("Admin update essential status: wordId={}, isEssential={}", wordId, isEssential); - - oxford3000Service.updateEssentialStatus(wordId, isEssential); - return ResponseEntity.ok().build(); - } - - @ExceptionHandler(WordsException.class) - public ResponseEntity handleWordsException(WordsException e) { - log.info("Words Admin Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } + private final WordService wordService; + + private final Oxford3000Service oxford3000Service; + + @Operation(summary = "단어 강제 재분석", description = """ + 관리자 전용: 단어를 AI로 강제 재분석합니다. + + **사용 사례:** + 1. Homograph 대응 (overwrite=false): 'saw'가 'see'의 과거형으로만 저장된 경우, '톱' 의미를 추가 + 2. 품질 개선 (overwrite=true, deleteVariants=false): 기존 Variant 관계 유지하면서 Word 재생성 + 3. 완전 초기화 (overwrite=true, deleteVariants=true): Variant + Word 모두 삭제 후 완전 재생성 + + **파라미터:** + - overwrite=false (기본): 기존 데이터 유지 + 새로운 의미 추가 + - overwrite=true, deleteVariants=false: Variant 유지 + Word 재생성 (homograph 추가 가능) + - overwrite=true, deleteVariants=true: Variant + Word 모두 삭제 후 완전 재생성 + """) + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "재분석 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @RateLimit(capacity = 5, refillMinutes = 10, keyType = KeyType.IP) + @PostMapping("/{word}/force-analyze") + public ResponseEntity forceAnalyzeWord( + @Parameter(description = "재분석할 단어", example = "saw") @PathVariable String word, + @Parameter(description = "번역 대상 언어", + example = "KO") @RequestParam(defaultValue = "KO") LanguageCode targetLanguage, + @Parameter(description = "true: 기존 데이터 삭제 후 재생성, false: 기존 데이터 유지 + 새로운 의미 추가") @RequestParam( + defaultValue = "false") boolean overwrite, + @Parameter(description = "true: Variant도 함께 삭제 (완전 초기화), false: Variant 유지 (기본값)") @RequestParam( + defaultValue = "false") boolean deleteVariants) { + + log.info("Admin force-analyze: word='{}', targetLanguage={}, overwrite={}, deleteVariants={}", word, + targetLanguage, overwrite, deleteVariants); + + WordSearchResponse response = wordService.forceReanalyzeWord(word, targetLanguage, overwrite, deleteVariants); + return ResponseEntity.ok(response); + } + + @Operation(summary = "Oxford 3000 단어 초기화", description = """ + 관리자 전용: Oxford 3000 필수 단어를 일괄 생성/업데이트합니다. + + **동작 방식:** + - 이미 존재하는 단어: 재사용하고 isEssential=true로 업데이트 + - 존재하지 않는 단어: AI로 분석하여 새로 생성 + + **주의사항:** + - 이 작업은 매우 오래 걸릴 수 있습니다 (3000개 단어 × AI 호출) + - 비동기 처리를 권장하지만, 현재는 동기 처리로 구현되어 있습니다 + - Rate limit을 고려하여 천천히 호출하세요 + + **파라미터:** + - targetLanguage: 번역 대상 언어 (예: KO, JA) + """) + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "초기화 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @RateLimit(capacity = 1, refillMinutes = 60, keyType = KeyType.IP) + @PostMapping("/essential/initialize-oxford3000") + public ResponseEntity initializeOxford3000(@Parameter(description = "번역 대상 언어", + example = "KO") @RequestParam(defaultValue = "KO") LanguageCode targetLanguage) { + + log.info("Admin initialize-oxford3000: targetLanguage={}", targetLanguage); + + Oxford3000InitResponse response = oxford3000Service.initializeOxford3000(targetLanguage); + return ResponseEntity.ok(response); + } + + @Operation(summary = "필수 단어 통계 조회", description = """ + 관리자 전용: 필수 단어(isEssential=true)의 통계 정보를 조회합니다. + + **응답 정보:** + - totalEssentialWords: 전체 필수 단어 수 + - countByTargetLanguage: 번역 대상 언어별 필수 단어 수 + - countBySourceLanguage: 원본 언어별 필수 단어 수 + """) + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/essential/stats") + public ResponseEntity getEssentialWordsStats() { + log.info("Admin get essential words stats"); + + EssentialWordsStatsResponse response = oxford3000Service.getEssentialWordsStats(); + return ResponseEntity.ok(response); + } + + @Operation(summary = "등록된 필수 단어 목록 조회", description = """ + 관리자 전용: 이미 등록된 필수 단어(isEssential=true)의 word 문자열 목록을 반환합니다. + + **사용 사례:** + - Python 스크립트에서 중복 등록 방지를 위해 사용 + - 특정 언어로 필터링 가능 (targetLanguage 파라미터 사용) + + **파라미터:** + - targetLanguage (선택): 특정 번역 대상 언어로 필터링 (예: KO) + - 파라미터 없으면 모든 언어의 필수 단어 반환 + """) + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @GetMapping("/essential/words") + public ResponseEntity> getEssentialWords(@Parameter(description = "번역 대상 언어 (선택)", + example = "KO") @RequestParam(required = false) LanguageCode targetLanguage) { + + log.info("Admin get essential words: targetLanguage={}", targetLanguage); + + List words = oxford3000Service.getEssentialWordsList(targetLanguage); + return ResponseEntity.ok(words); + } + + @Operation(summary = "단어의 필수 여부 설정", description = """ + 관리자 전용: 특정 단어의 isEssential 플래그를 수동으로 설정/해제합니다. + + **사용 사례:** + 1. 실수로 필수 단어로 표시된 단어를 일반 단어로 변경 + 2. 커스텀 필수 단어 추가 (Oxford 3000 외의 단어) + """) + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "업데이트 성공"), + @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 (관리자 권한 필요)", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @PatchMapping("/{wordId}/essential") + public ResponseEntity updateEssentialStatus( + @Parameter(description = "단어 ID", example = "507f1f77bcf86cd799439011") @PathVariable String wordId, + @Parameter(description = "필수 단어 여부", example = "true") @RequestParam boolean isEssential) { + + log.info("Admin update essential status: wordId={}, isEssential={}", wordId, isEssential); + + oxford3000Service.updateEssentialStatus(wordId, isEssential); + return ResponseEntity.ok().build(); + } + + @ExceptionHandler(WordsException.class) + public ResponseEntity handleWordsException(WordsException e) { + log.info("Words Admin Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } + } diff --git a/src/main/java/com/linglevel/api/word/controller/WordsController.java b/src/main/java/com/linglevel/api/word/controller/WordsController.java index 05b44901..ebc218e0 100644 --- a/src/main/java/com/linglevel/api/word/controller/WordsController.java +++ b/src/main/java/com/linglevel/api/word/controller/WordsController.java @@ -40,34 +40,35 @@ @Tag(name = "Words", description = "단어 관련 API") public class WordsController { - private final WordService wordService; - private final WordValidator wordValidator; + private final WordService wordService; - @Operation(summary = "단일 단어 조회", description = "특정 단어의 상세 정보를 조회합니다. Homograph인 경우 여러 결과를 반환합니다. 현재 사용자의 북마크 상태도 함께 반환됩니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), - @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) - }) - @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) - @GetMapping("/{word}") - public ResponseEntity getWord( - @Parameter(description = "조회할 단어", example = "saw") - @PathVariable String word, - @ParameterObject @Valid @ModelAttribute WordSearchRequest request, - @AuthenticationPrincipal JwtClaims claims) { - if (request.getTargetLanguage() == LanguageCode.EN) throw new WordsException(WordsErrorCode.SAME_SOURCE_TARGET_LANGUAGE); - String validatedWord = wordValidator.validateAndPreprocess(word); - WordSearchResponse response = wordService.getOrCreateWords(claims.getId(), validatedWord, request.getTargetLanguage()); - return ResponseEntity.ok(response); - } + private final WordValidator wordValidator; + + @Operation(summary = "단일 단어 조회", + description = "특정 단어의 상세 정보를 조회합니다. Homograph인 경우 여러 결과를 반환합니다. 현재 사용자의 북마크 상태도 함께 반환됩니다.") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "404", description = "단어를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class))) }) + @RateLimit(capacity = 30, refillMinutes = 1, keyType = KeyType.USER) + @GetMapping("/{word}") + public ResponseEntity getWord( + @Parameter(description = "조회할 단어", example = "saw") @PathVariable String word, + @ParameterObject @Valid @ModelAttribute WordSearchRequest request, + @AuthenticationPrincipal JwtClaims claims) { + if (request.getTargetLanguage() == LanguageCode.EN) + throw new WordsException(WordsErrorCode.SAME_SOURCE_TARGET_LANGUAGE); + String validatedWord = wordValidator.validateAndPreprocess(word); + WordSearchResponse response = wordService.getOrCreateWords(claims.getId(), validatedWord, + request.getTargetLanguage()); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(WordsException.class) + public ResponseEntity handleWordsException(WordsException e) { + log.info("Words Exception: {}", e.getMessage()); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e)); + } - @ExceptionHandler(WordsException.class) - public ResponseEntity handleWordsException(WordsException e) { - log.info("Words Exception: {}", e.getMessage()); - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e)); - } } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/dto/EssentialWordsStatsResponse.java b/src/main/java/com/linglevel/api/word/dto/EssentialWordsStatsResponse.java index dbb5f65d..7abfac06 100644 --- a/src/main/java/com/linglevel/api/word/dto/EssentialWordsStatsResponse.java +++ b/src/main/java/com/linglevel/api/word/dto/EssentialWordsStatsResponse.java @@ -16,12 +16,13 @@ @Schema(description = "필수 단어 통계 응답") public class EssentialWordsStatsResponse { - @Schema(description = "총 필수 단어 수", example = "3000") - private Long totalEssentialWords; + @Schema(description = "총 필수 단어 수", example = "3000") + private Long totalEssentialWords; - @Schema(description = "언어별 필수 단어 수") - private Map countByTargetLanguage; + @Schema(description = "언어별 필수 단어 수") + private Map countByTargetLanguage; + + @Schema(description = "원본 언어별 필수 단어 수") + private Map countBySourceLanguage; - @Schema(description = "원본 언어별 필수 단어 수") - private Map countBySourceLanguage; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/dto/Meaning.java b/src/main/java/com/linglevel/api/word/dto/Meaning.java index b9c569c0..0bef9784 100644 --- a/src/main/java/com/linglevel/api/word/dto/Meaning.java +++ b/src/main/java/com/linglevel/api/word/dto/Meaning.java @@ -14,19 +14,21 @@ @AllArgsConstructor @Schema(description = "단어의 의미 (품사별)") public class Meaning { - @NotNull(message = "품사는 필수입니다") - @Schema(description = "품사", example = "VERB") - private PartOfSpeech partOfSpeech; - @NotBlank(message = "의미는 필수입니다") - @Schema(description = "의미", example = "보다, 시각적으로 인지하다") - private String meaning; + @NotNull(message = "품사는 필수입니다") + @Schema(description = "품사", example = "VERB") + private PartOfSpeech partOfSpeech; - @NotBlank(message = "예문은 필수입니다") - @Schema(description = "예문", example = "I see him at the store every day.") - private String example; + @NotBlank(message = "의미는 필수입니다") + @Schema(description = "의미", example = "보다, 시각적으로 인지하다") + private String meaning; + + @NotBlank(message = "예문은 필수입니다") + @Schema(description = "예문", example = "I see him at the store every day.") + private String example; + + @NotBlank(message = "예문 번역은 필수입니다") + @Schema(description = "예문 번역", example = "나는 매일 가게에서 그를 봅니다.") + private String exampleTranslation; - @NotBlank(message = "예문 번역은 필수입니다") - @Schema(description = "예문 번역", example = "나는 매일 가게에서 그를 봅니다.") - private String exampleTranslation; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/dto/Oxford3000InitResponse.java b/src/main/java/com/linglevel/api/word/dto/Oxford3000InitResponse.java index 6dc13a18..8c689e39 100644 --- a/src/main/java/com/linglevel/api/word/dto/Oxford3000InitResponse.java +++ b/src/main/java/com/linglevel/api/word/dto/Oxford3000InitResponse.java @@ -15,30 +15,31 @@ @Schema(description = "Oxford 3000 초기화 응답") public class Oxford3000InitResponse { - @Schema(description = "초기화 시작 시간") - private LocalDateTime startedAt; + @Schema(description = "초기화 시작 시간") + private LocalDateTime startedAt; - @Schema(description = "초기화 완료 시간") - private LocalDateTime completedAt; + @Schema(description = "초기화 완료 시간") + private LocalDateTime completedAt; - @Schema(description = "총 단어 수", example = "3417") - private Integer totalWords; + @Schema(description = "총 단어 수", example = "3417") + private Integer totalWords; - @Schema(description = "성공한 단어 수", example = "3400") - private Integer successCount; + @Schema(description = "성공한 단어 수", example = "3400") + private Integer successCount; - @Schema(description = "실패한 단어 수", example = "17") - private Integer failureCount; + @Schema(description = "실패한 단어 수", example = "17") + private Integer failureCount; - @Schema(description = "이미 존재하던 단어 수 (업데이트만 수행)", example = "1500") - private Integer alreadyExistCount; + @Schema(description = "이미 존재하던 단어 수 (업데이트만 수행)", example = "1500") + private Integer alreadyExistCount; - @Schema(description = "새로 생성된 단어 수", example = "1900") - private Integer newlyCreatedCount; + @Schema(description = "새로 생성된 단어 수", example = "1900") + private Integer newlyCreatedCount; - @Schema(description = "실패한 단어 리스트") - private java.util.List failedWords; + @Schema(description = "실패한 단어 리스트") + private java.util.List failedWords; + + @Schema(description = "처리 상태 메시지") + private String message; - @Schema(description = "처리 상태 메시지") - private String message; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/dto/PartOfSpeech.java b/src/main/java/com/linglevel/api/word/dto/PartOfSpeech.java index 3ccfba47..65628be7 100644 --- a/src/main/java/com/linglevel/api/word/dto/PartOfSpeech.java +++ b/src/main/java/com/linglevel/api/word/dto/PartOfSpeech.java @@ -4,54 +4,50 @@ import com.fasterxml.jackson.annotation.JsonValue; public enum PartOfSpeech { - NOUN("noun", "명사"), - VERB("verb", "동사"), - ADJECTIVE("adjective", "형용사"), - ADVERB("adverb", "부사"), - PRONOUN("pronoun", "대명사"), - PREPOSITION("preposition", "전치사"), - CONJUNCTION("conjunction", "접속사"), - INTERJECTION("interjection", "감탄사"), - DETERMINER("determiner", "한정사"), - ARTICLE("article", "관사"), - NUMERAL("numeral", "수사"); - - private final String value; - private final String korean; - - PartOfSpeech(String value, String korean) { - this.value = value; - this.korean = korean; - } - - @JsonValue - public String getValue() { - return value; - } - - public String getKorean() { - return korean; - } - - @JsonCreator - public static PartOfSpeech fromString(String value) { - if (value == null) { - return null; - } - - String normalizedValue = value.trim().toLowerCase(); - for (PartOfSpeech pos : PartOfSpeech.values()) { - if (pos.value.equals(normalizedValue)) { - return pos; - } - } - - // AI가 잘못된 값을 반환한 경우 null 반환 (후처리에서 필터링됨) - return null; - } - - @Override - public String toString() { - return value; - } + + NOUN("noun", "명사"), VERB("verb", "동사"), ADJECTIVE("adjective", "형용사"), ADVERB("adverb", "부사"), + PRONOUN("pronoun", "대명사"), PREPOSITION("preposition", "전치사"), CONJUNCTION("conjunction", "접속사"), + INTERJECTION("interjection", "감탄사"), DETERMINER("determiner", "한정사"), ARTICLE("article", "관사"), + NUMERAL("numeral", "수사"); + + private final String value; + + private final String korean; + + PartOfSpeech(String value, String korean) { + this.value = value; + this.korean = korean; + } + + @JsonValue + public String getValue() { + return value; + } + + public String getKorean() { + return korean; + } + + @JsonCreator + public static PartOfSpeech fromString(String value) { + if (value == null) { + return null; + } + + String normalizedValue = value.trim().toLowerCase(); + for (PartOfSpeech pos : PartOfSpeech.values()) { + if (pos.value.equals(normalizedValue)) { + return pos; + } + } + + // AI가 잘못된 값을 반환한 경우 null 반환 (후처리에서 필터링됨) + return null; + } + + @Override + public String toString() { + return value; + } + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/dto/RelatedForms.java b/src/main/java/com/linglevel/api/word/dto/RelatedForms.java index 28f9298f..3309a4e9 100644 --- a/src/main/java/com/linglevel/api/word/dto/RelatedForms.java +++ b/src/main/java/com/linglevel/api/word/dto/RelatedForms.java @@ -12,63 +12,71 @@ @AllArgsConstructor @Schema(description = "관련 변형 형태들") public class RelatedForms { - @Schema(description = "동사 활용형") - private Conjugations conjugations; - - @Schema(description = "형용사/부사 비교급") - private Comparatives comparatives; - - @Schema(description = "명사 복수형") - private Plural plural; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "동사 활용형") - public static class Conjugations { - @Schema(description = "현재형") - private String present; - - @Schema(description = "과거형") - private String past; - - @Schema(description = "과거분사") - private String pastParticiple; - - @Schema(description = "현재분사") - private String presentParticiple; - - @Schema(description = "3인칭 단수") - private String thirdPerson; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "형용사/부사 비교급") - public static class Comparatives { - @Schema(description = "원급") - private String positive; - - @Schema(description = "비교급") - private String comparative; - - @Schema(description = "최상급") - private String superlative; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @Schema(description = "명사 복수형") - public static class Plural { - @Schema(description = "단수형") - private String singular; - - @Schema(description = "복수형") - private String plural; - } + + @Schema(description = "동사 활용형") + private Conjugations conjugations; + + @Schema(description = "형용사/부사 비교급") + private Comparatives comparatives; + + @Schema(description = "명사 복수형") + private Plural plural; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "동사 활용형") + public static class Conjugations { + + @Schema(description = "현재형") + private String present; + + @Schema(description = "과거형") + private String past; + + @Schema(description = "과거분사") + private String pastParticiple; + + @Schema(description = "현재분사") + private String presentParticiple; + + @Schema(description = "3인칭 단수") + private String thirdPerson; + + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "형용사/부사 비교급") + public static class Comparatives { + + @Schema(description = "원급") + private String positive; + + @Schema(description = "비교급") + private String comparative; + + @Schema(description = "최상급") + private String superlative; + + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "명사 복수형") + public static class Plural { + + @Schema(description = "단수형") + private String singular; + + @Schema(description = "복수형") + private String plural; + + } + } diff --git a/src/main/java/com/linglevel/api/word/dto/VariantType.java b/src/main/java/com/linglevel/api/word/dto/VariantType.java index efb46da6..aa18f5ee 100644 --- a/src/main/java/com/linglevel/api/word/dto/VariantType.java +++ b/src/main/java/com/linglevel/api/word/dto/VariantType.java @@ -3,31 +3,32 @@ import com.fasterxml.jackson.annotation.JsonCreator; public enum VariantType { - ORIGINAL_FORM, // 원형 - PAST_TENSE, // 과거형 - PAST_PARTICIPLE, // 과거분사 - PRESENT_PARTICIPLE, // 현재분사 - THIRD_PERSON, // 3인칭 단수 - COMPARATIVE, // 비교급 - SUPERLATIVE, // 최상급 - PLURAL, // 복수형 - UNDEFINED; // 정의되지 않은 변형 (소유격 등) - @JsonCreator - public static VariantType fromString(String value) { - if (value == null) { - return null; - } + ORIGINAL_FORM, // 원형 + PAST_TENSE, // 과거형 + PAST_PARTICIPLE, // 과거분사 + PRESENT_PARTICIPLE, // 현재분사 + THIRD_PERSON, // 3인칭 단수 + COMPARATIVE, // 비교급 + SUPERLATIVE, // 최상급 + PLURAL, // 복수형 + UNDEFINED; // 정의되지 않은 변형 (소유격 등) - String normalizedValue = value.trim().toUpperCase(); - for (VariantType vt : VariantType.values()) { - if (vt.name().equals(normalizedValue)) { - return vt; - } - } + @JsonCreator + public static VariantType fromString(String value) { + if (value == null) { + return null; + } - // AI가 잘못된 값을 반환한 경우 null 반환 (후처리에서 필터링됨) - return null; - } -} + String normalizedValue = value.trim().toUpperCase(); + for (VariantType vt : VariantType.values()) { + if (vt.name().equals(normalizedValue)) { + return vt; + } + } + + // AI가 잘못된 값을 반환한 경우 null 반환 (후처리에서 필터링됨) + return null; + } +} diff --git a/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java b/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java index 3cb1ec6e..986cefb6 100644 --- a/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java +++ b/src/main/java/com/linglevel/api/word/dto/WordAnalysisResult.java @@ -22,37 +22,39 @@ @NoArgsConstructor @AllArgsConstructor public class WordAnalysisResult { - @NotBlank(message = "originalForm은 필수입니다") - @JsonProperty("originalForm") - private String originalForm; - @NotEmpty(message = "variantTypes는 최소 1개 이상이어야 합니다") - @JsonProperty("variantTypes") - private List variantTypes; + @NotBlank(message = "originalForm은 필수입니다") + @JsonProperty("originalForm") + private String originalForm; - @NotNull(message = "sourceLanguageCode는 필수입니다") - @JsonProperty("sourceLanguageCode") - private LanguageCode sourceLanguageCode; + @NotEmpty(message = "variantTypes는 최소 1개 이상이어야 합니다") + @JsonProperty("variantTypes") + private List variantTypes; - @NotNull(message = "targetLanguageCode는 필수입니다") - @JsonProperty("targetLanguageCode") - private LanguageCode targetLanguageCode; + @NotNull(message = "sourceLanguageCode는 필수입니다") + @JsonProperty("sourceLanguageCode") + private LanguageCode sourceLanguageCode; - @Size(max = 3, message = "summary는 최대 3개까지 가능합니다") - @JsonProperty("summary") - private List summary; + @NotNull(message = "targetLanguageCode는 필수입니다") + @JsonProperty("targetLanguageCode") + private LanguageCode targetLanguageCode; - @Size(max = 15, message = "meanings는 최대 15개까지 가능합니다") - @Valid - @JsonProperty("meanings") - private List meanings; + @Size(max = 3, message = "summary는 최대 3개까지 가능합니다") + @JsonProperty("summary") + private List summary; - @JsonProperty("conjugations") - private RelatedForms.Conjugations conjugations; + @Size(max = 15, message = "meanings는 최대 15개까지 가능합니다") + @Valid + @JsonProperty("meanings") + private List meanings; - @JsonProperty("comparatives") - private RelatedForms.Comparatives comparatives; + @JsonProperty("conjugations") + private RelatedForms.Conjugations conjugations; + + @JsonProperty("comparatives") + private RelatedForms.Comparatives comparatives; + + @JsonProperty("plural") + private RelatedForms.Plural plural; - @JsonProperty("plural") - private RelatedForms.Plural plural; } diff --git a/src/main/java/com/linglevel/api/word/dto/WordResponse.java b/src/main/java/com/linglevel/api/word/dto/WordResponse.java index a367fffd..16be1c33 100644 --- a/src/main/java/com/linglevel/api/word/dto/WordResponse.java +++ b/src/main/java/com/linglevel/api/word/dto/WordResponse.java @@ -15,33 +15,35 @@ @AllArgsConstructor @Schema(description = "단어 응답") public class WordResponse { - @Schema(description = "단어 ID", example = "60d0fe4f5311236168a109ca") - private String id; - @Schema(description = "원형", example = "see") - private String originalForm; + @Schema(description = "단어 ID", example = "60d0fe4f5311236168a109ca") + private String id; - @Schema(description = "변형 형태 타입 (배열)") - private List variantTypes; + @Schema(description = "원형", example = "see") + private String originalForm; - @Schema(description = "원본 언어 코드", example = "en") - private LanguageCode sourceLanguageCode; + @Schema(description = "변형 형태 타입 (배열)") + private List variantTypes; - @Schema(description = "번역 대상 언어 코드", example = "ko") - private LanguageCode targetLanguageCode; + @Schema(description = "원본 언어 코드", example = "en") + private LanguageCode sourceLanguageCode; - @Schema(description = "자주 쓰이는 뜻 3개 요약", example = "[\"보다\", \"알다\", \"이해하다\"]") - private List summary; + @Schema(description = "번역 대상 언어 코드", example = "ko") + private LanguageCode targetLanguageCode; - @Schema(description = "품사별 의미 목록") - private List meanings; + @Schema(description = "자주 쓰이는 뜻 3개 요약", example = "[\"보다\", \"알다\", \"이해하다\"]") + private List summary; - @Schema(description = "관련 변형 형태들") - private RelatedForms relatedForms; + @Schema(description = "품사별 의미 목록") + private List meanings; - @Schema(description = "현재 사용자가 해당 단어를 북마크했는지 여부", example = "true") - private Boolean bookmarked; + @Schema(description = "관련 변형 형태들") + private RelatedForms relatedForms; + + @Schema(description = "현재 사용자가 해당 단어를 북마크했는지 여부", example = "true") + private Boolean bookmarked; + + @Schema(description = "필수 단어 여부 (예: Oxford 3000)", example = "true") + private Boolean isEssential; - @Schema(description = "필수 단어 여부 (예: Oxford 3000)", example = "true") - private Boolean isEssential; } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/dto/WordSearchRequest.java b/src/main/java/com/linglevel/api/word/dto/WordSearchRequest.java index ee755688..e0fd4846 100644 --- a/src/main/java/com/linglevel/api/word/dto/WordSearchRequest.java +++ b/src/main/java/com/linglevel/api/word/dto/WordSearchRequest.java @@ -18,7 +18,8 @@ @Schema(description = "단어 검색 요청") public class WordSearchRequest { - @NotNull(message = "번역 대상 언어는 필수입니다") - @Schema(description = "번역 대상 언어 코드", example = "KO", required = true) - private LanguageCode targetLanguage; + @NotNull(message = "번역 대상 언어는 필수입니다") + @Schema(description = "번역 대상 언어 코드", example = "KO", required = true) + private LanguageCode targetLanguage; + } diff --git a/src/main/java/com/linglevel/api/word/dto/WordSearchResponse.java b/src/main/java/com/linglevel/api/word/dto/WordSearchResponse.java index c7efbe52..3d8de1d7 100644 --- a/src/main/java/com/linglevel/api/word/dto/WordSearchResponse.java +++ b/src/main/java/com/linglevel/api/word/dto/WordSearchResponse.java @@ -14,9 +14,11 @@ @AllArgsConstructor @Schema(description = "단어 검색 응답") public class WordSearchResponse { - @Schema(description = "사용자가 검색한 단어", example = "saw") - private String searchedWord; - @Schema(description = "검색 결과 목록 (일반 단어는 1개, Homograph는 2개 이상)") - private List results; + @Schema(description = "사용자가 검색한 단어", example = "saw") + private String searchedWord; + + @Schema(description = "검색 결과 목록 (일반 단어는 1개, Homograph는 2개 이상)") + private List results; + } \ No newline at end of file diff --git a/src/main/java/com/linglevel/api/word/entity/InvalidWord.java b/src/main/java/com/linglevel/api/word/entity/InvalidWord.java index cbdb29a9..9de7cb18 100644 --- a/src/main/java/com/linglevel/api/word/entity/InvalidWord.java +++ b/src/main/java/com/linglevel/api/word/entity/InvalidWord.java @@ -14,14 +14,16 @@ @AllArgsConstructor @Document(collection = "invalidWords") public class InvalidWord { - @Id - private String id; - @Indexed(unique = true) - private String word; + @Id + private String id; - private LocalDateTime attemptedAt; + @Indexed(unique = true) + private String word; + + private LocalDateTime attemptedAt; + + @Builder.Default + private Integer attemptCount = 1; - @Builder.Default - private Integer attemptCount = 1; } diff --git a/src/main/java/com/linglevel/api/word/entity/Word.java b/src/main/java/com/linglevel/api/word/entity/Word.java index f0ee8848..28b2bdfa 100644 --- a/src/main/java/com/linglevel/api/word/entity/Word.java +++ b/src/main/java/com/linglevel/api/word/entity/Word.java @@ -12,11 +12,9 @@ import java.util.List; /** - * 단어 엔티티 (원형 단어만 저장) - * 변형 형태는 WordVariant에 별도 저장 + * 단어 엔티티 (원형 단어만 저장) 변형 형태는 WordVariant에 별도 저장 * - * 같은 단어도 언어 쌍별로 여러 개 저장 가능 - * 예: "run" EN->KO, "run" EN->JA는 별도 문서 + * 같은 단어도 언어 쌍별로 여러 개 저장 가능 예: "run" EN->KO, "run" EN->JA는 별도 문서 */ @Getter @Setter @@ -24,49 +22,45 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "words") -@CompoundIndexes({ - @CompoundIndex( - name = "word_target_source_language_unique_idx", - def = "{'word': 1, 'targetLanguageCode': 1, 'sourceLanguageCode': 1}", - unique = true - ) -}) +@CompoundIndexes({ @CompoundIndex(name = "word_target_source_language_unique_idx", + def = "{'word': 1, 'targetLanguageCode': 1, 'sourceLanguageCode': 1}", unique = true) }) public class Word { - @Id - private String id; - /** - * 원형 단어 (예: "pretty", "see", "run") - * 복합 unique index의 일부 (word + targetLanguageCode + sourceLanguageCode) - */ - private String word; + @Id + private String id; - /** - * 원본 언어 코드 - */ - private LanguageCode sourceLanguageCode; + /** + * 원형 단어 (예: "pretty", "see", "run") 복합 unique index의 일부 (word + targetLanguageCode + + * sourceLanguageCode) + */ + private String word; - /** - * 번역 대상 언어 코드 - */ - private LanguageCode targetLanguageCode; + /** + * 원본 언어 코드 + */ + private LanguageCode sourceLanguageCode; - /** - * 자주 쓰이는 뜻 3개 요약 (대상 언어) - */ - private List summary; + /** + * 번역 대상 언어 코드 + */ + private LanguageCode targetLanguageCode; - /** - * 품사별 의미 목록 - * AI로부터 받은 Meaning을 그대로 저장 - */ - private List meanings; + /** + * 자주 쓰이는 뜻 3개 요약 (대상 언어) + */ + private List summary; - /** - * 관련 변형 형태들 (동사 활용형, 비교급, 복수형 등) - */ - private RelatedForms relatedForms; + /** + * 품사별 의미 목록 AI로부터 받은 Meaning을 그대로 저장 + */ + private List meanings; + + /** + * 관련 변형 형태들 (동사 활용형, 비교급, 복수형 등) + */ + private RelatedForms relatedForms; + + @Builder.Default + private Boolean isEssential = false; - @Builder.Default - private Boolean isEssential = false; } diff --git a/src/main/java/com/linglevel/api/word/entity/WordVariant.java b/src/main/java/com/linglevel/api/word/entity/WordVariant.java index b84babd8..cdfb2717 100644 --- a/src/main/java/com/linglevel/api/word/entity/WordVariant.java +++ b/src/main/java/com/linglevel/api/word/entity/WordVariant.java @@ -16,12 +16,14 @@ @Document(collection = "word_variants") @CompoundIndex(name = "word_original_idx", def = "{'word': 1, 'originalForm': 1}", unique = true) public class WordVariant { - @Id - private String id; - private String word; + @Id + private String id; - private String originalForm; + private String word; + + private String originalForm; + + private List variantTypes; - private List variantTypes; } diff --git a/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java b/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java index 172562d4..ad8b8c24 100644 --- a/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java +++ b/src/main/java/com/linglevel/api/word/exception/WordsErrorCode.java @@ -7,16 +7,20 @@ @Getter @AllArgsConstructor public enum WordsErrorCode { - WORD_NOT_FOUND(HttpStatus.NOT_FOUND, "Word not found."), - WORD_IS_MEANINGLESS(HttpStatus.BAD_REQUEST, "The word is meaningless."), - WORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "Word already exists."), - WORD_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "Word not found with id."), - WORD_ANALYSIS_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis is temporarily delayed. Please try again."), - WORD_ANALYSIS_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis failed. Please try again."), - INVALID_WORD_FORMAT(HttpStatus.BAD_REQUEST, "Word contains invalid characters (spaces, tabs, newlines, or special characters are not allowed)."), - WORD_TOO_LONG(HttpStatus.BAD_REQUEST, "Word is too long (maximum 50 characters)."), - SAME_SOURCE_TARGET_LANGUAGE(HttpStatus.BAD_REQUEST, "Source and target languages cannot be the same."); - private final HttpStatus status; - private final String message; + WORD_NOT_FOUND(HttpStatus.NOT_FOUND, "Word not found."), + WORD_IS_MEANINGLESS(HttpStatus.BAD_REQUEST, "The word is meaningless."), + WORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "Word already exists."), + WORD_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "Word not found with id."), + WORD_ANALYSIS_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis is temporarily delayed. Please try again."), + WORD_ANALYSIS_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "Word analysis failed. Please try again."), + INVALID_WORD_FORMAT(HttpStatus.BAD_REQUEST, + "Word contains invalid characters (spaces, tabs, newlines, or special characters are not allowed)."), + WORD_TOO_LONG(HttpStatus.BAD_REQUEST, "Word is too long (maximum 50 characters)."), + SAME_SOURCE_TARGET_LANGUAGE(HttpStatus.BAD_REQUEST, "Source and target languages cannot be the same."); + + private final HttpStatus status; + + private final String message; + } diff --git a/src/main/java/com/linglevel/api/word/exception/WordsException.java b/src/main/java/com/linglevel/api/word/exception/WordsException.java index f322abf0..98d117a0 100644 --- a/src/main/java/com/linglevel/api/word/exception/WordsException.java +++ b/src/main/java/com/linglevel/api/word/exception/WordsException.java @@ -5,18 +5,21 @@ @Getter public class WordsException extends RuntimeException { - private final HttpStatus status; - private final WordsErrorCode errorCode; - public WordsException(WordsErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - this.status = errorCode.getStatus(); - } + private final HttpStatus status; + + private final WordsErrorCode errorCode; + + public WordsException(WordsErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.status = errorCode.getStatus(); + } + + public WordsException(WordsErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + this.status = errorCode.getStatus(); + } - public WordsException(WordsErrorCode errorCode, Throwable cause) { - super(errorCode.getMessage(), cause); - this.errorCode = errorCode; - this.status = errorCode.getStatus(); - } } diff --git a/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java b/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java index 64f31d61..ac77bacb 100644 --- a/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java +++ b/src/main/java/com/linglevel/api/word/repository/InvalidWordRepository.java @@ -9,5 +9,6 @@ @Repository public interface InvalidWordRepository extends MongoRepository { - Optional findByWord(String word); + Optional findByWord(String word); + } diff --git a/src/main/java/com/linglevel/api/word/repository/WordRepository.java b/src/main/java/com/linglevel/api/word/repository/WordRepository.java index 3038f581..ad241d3c 100644 --- a/src/main/java/com/linglevel/api/word/repository/WordRepository.java +++ b/src/main/java/com/linglevel/api/word/repository/WordRepository.java @@ -13,40 +13,35 @@ @Repository public interface WordRepository extends MongoRepository { - /** - * 단어와 언어 쌍으로 검색 - * 같은 단어도 언어 쌍별로 다른 Word 문서가 존재할 수 있음 - */ - Optional findByWordAndSourceLanguageCodeAndTargetLanguageCode( - String word, - LanguageCode sourceLanguageCode, - LanguageCode targetLanguageCode - ); - - /** - * 단어와 target 언어로 검색 (sourceLanguageCode 무시) - * 대부분의 경우 특정 단어는 하나의 source 언어만 가짐 (예: "run"은 항상 EN) - */ - Optional findByWordAndTargetLanguageCode( - String word, - LanguageCode targetLanguageCode - ); - - @Query("{'word': {$regex: ?0, $options: 'i'}}") - Page findByWordContainingIgnoreCase(String word, Pageable pageable); - - /** - * isEssential 필드로 필터링 - */ - List findAllByIsEssential(Boolean isEssential); - - /** - * isEssential 필드로 개수 조회 - */ - long countByIsEssential(Boolean isEssential); - - /** - * 필수 단어 중 특정 target 언어로 필터링 - */ - List findAllByIsEssentialAndTargetLanguageCode(Boolean isEssential, LanguageCode targetLanguageCode); + + /** + * 단어와 언어 쌍으로 검색 같은 단어도 언어 쌍별로 다른 Word 문서가 존재할 수 있음 + */ + Optional findByWordAndSourceLanguageCodeAndTargetLanguageCode(String word, LanguageCode sourceLanguageCode, + LanguageCode targetLanguageCode); + + /** + * 단어와 target 언어로 검색 (sourceLanguageCode 무시) 대부분의 경우 특정 단어는 하나의 source 언어만 가짐 (예: + * "run"은 항상 EN) + */ + Optional findByWordAndTargetLanguageCode(String word, LanguageCode targetLanguageCode); + + @Query("{'word': {$regex: ?0, $options: 'i'}}") + Page findByWordContainingIgnoreCase(String word, Pageable pageable); + + /** + * isEssential 필드로 필터링 + */ + List findAllByIsEssential(Boolean isEssential); + + /** + * isEssential 필드로 개수 조회 + */ + long countByIsEssential(Boolean isEssential); + + /** + * 필수 단어 중 특정 target 언어로 필터링 + */ + List findAllByIsEssentialAndTargetLanguageCode(Boolean isEssential, LanguageCode targetLanguageCode); + } diff --git a/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java b/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java index 99f2ef23..f8119fb5 100644 --- a/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java +++ b/src/main/java/com/linglevel/api/word/repository/WordVariantRepository.java @@ -9,11 +9,13 @@ @Repository public interface WordVariantRepository extends MongoRepository { - List findAllByWord(String word); - List findByWordIn(List words); + List findAllByWord(String word); - Optional findByWordAndOriginalForm(String word, String originalForm); + List findByWordIn(List words); + + Optional findByWordAndOriginalForm(String word, String originalForm); + + List findAllByOriginalForm(String originalForm); - List findAllByOriginalForm(String originalForm); } diff --git a/src/main/java/com/linglevel/api/word/service/Oxford3000Service.java b/src/main/java/com/linglevel/api/word/service/Oxford3000Service.java index 2a2f02d2..83d6c190 100644 --- a/src/main/java/com/linglevel/api/word/service/Oxford3000Service.java +++ b/src/main/java/com/linglevel/api/word/service/Oxford3000Service.java @@ -29,288 +29,287 @@ @Slf4j public class Oxford3000Service { - private final WordRepository wordRepository; - private final WordService wordService; - private final com.linglevel.api.word.validator.WordValidator wordValidator; - private final com.linglevel.api.word.repository.WordVariantRepository wordVariantRepository; - - private static final String OXFORD3000_CSV_PATH = "data/oxford3000_final_cleaned.csv"; - private static final int MAX_RETRY_ATTEMPTS = 3; - - /** - * Oxford 3000 단어를 초기화합니다. - * - * @param targetLanguage 번역 대상 언어 - * @return Oxford3000InitResponse - */ - @Transactional - public Oxford3000InitResponse initializeOxford3000(LanguageCode targetLanguage) { - LocalDateTime startedAt = LocalDateTime.now(); - log.info("============================================================"); - log.info("Starting Oxford 3000 initialization"); - log.info("Target Language: {}", targetLanguage); - log.info("============================================================"); - - // 1. CSV 파일에서 단어 읽기 - List csvWords = readOxford3000Words(); - log.info("Step 1: Loaded {} words from Oxford 3000 CSV", csvWords.size()); - - // 2. 이미 등록된 essential 단어 목록 조회 (대소문자 무시) - List existingEssentialWords = getEssentialWordsList(targetLanguage); - log.info("Step 2: Found {} already registered essential words", existingEssentialWords.size()); - - // 3. 차집합 계산 (등록이 필요한 단어만 필터링) - List wordsToProcess = csvWords.stream() - .filter(word -> !existingEssentialWords.contains(word.toLowerCase())) - .collect(Collectors.toList()); - - int skippedCount = csvWords.size() - wordsToProcess.size(); - log.info("Step 3: {} words already registered, {} words to process", - skippedCount, wordsToProcess.size()); - - log.info("============================================================"); - log.info("Processing {} words...", wordsToProcess.size()); - log.info("============================================================"); - - // 4. 통계 정보 - int successCount = 0; - int failureCount = 0; - int newlyCreatedCount = 0; - List failedWords = new ArrayList<>(); - - // 5. 각 단어 처리 (최대 3번 재시도) - for (int i = 0; i < wordsToProcess.size(); i++) { - String word = wordsToProcess.get(i); - boolean processed = false; - - for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { - try { - boolean existed = processWord(word, targetLanguage); - successCount++; - - if (!existed) { - newlyCreatedCount++; - } - - processed = true; - - // 진행 상황 로깅 (10개마다, 또는 100개마다) - if ((wordsToProcess.size() <= 50 && (i + 1) % 10 == 0) || - (wordsToProcess.size() > 50 && (i + 1) % 100 == 0)) { - log.info("Progress: {}/{} words processed ({} succeeded, {} failed)", - i + 1, wordsToProcess.size(), successCount, failureCount); - } - - break; // 성공하면 재시도 중단 - - } catch (Exception e) { - if (attempt < MAX_RETRY_ATTEMPTS) { - log.warn("Failed to process word '{}' (attempt {}/{}): {}. Retrying...", - word, attempt, MAX_RETRY_ATTEMPTS, e.getMessage()); - } else { - log.error("Failed to process word '{}' after {} attempts: {}", - word, MAX_RETRY_ATTEMPTS, e.getMessage(), e); - } - } - } - - // 모든 재시도 실패 - if (!processed) { - failureCount++; - failedWords.add(word); - } - } - - LocalDateTime completedAt = LocalDateTime.now(); - long durationSeconds = java.time.Duration.between(startedAt, completedAt).getSeconds(); - - log.info("============================================================"); - log.info("Oxford 3000 initialization completed!"); - log.info("Duration: {} seconds ({} minutes)", durationSeconds, durationSeconds / 60); - log.info("Total CSV words: {}", csvWords.size()); - log.info("Already registered (skipped): {}", skippedCount); - log.info("Attempted to process: {}", wordsToProcess.size()); - log.info("Successfully processed: {}", successCount); - log.info("Newly created: {}", newlyCreatedCount); - log.info("Failed: {}", failureCount); - if (!failedWords.isEmpty()) { - log.error("Failed words: {}", String.join(", ", failedWords)); - } - log.info("============================================================"); - - return Oxford3000InitResponse.builder() - .startedAt(startedAt) - .completedAt(completedAt) - .totalWords(wordsToProcess.size()) - .successCount(successCount) - .failureCount(failureCount) - .alreadyExistCount(skippedCount) - .newlyCreatedCount(newlyCreatedCount) - .failedWords(failedWords) - .message(String.format("Processed %d words (%d skipped, %d succeeded, %d failed)", - wordsToProcess.size(), skippedCount, successCount, failureCount)) - .build(); - } - - /** - * 개별 단어 처리 - * - * @param word 처리할 단어 - * @param targetLanguage 번역 대상 언어 - * @return true: 이미 존재했던 단어, false: 새로 생성된 단어 - */ - @Transactional - public boolean processWord(String word, LanguageCode targetLanguage) { - String validatedWord = wordValidator.validateAndPreprocess(word); - log.debug("Word validated and preprocessed: '{}' -> '{}'", word, validatedWord); - - // 1. WordVariant에서 원형 찾기 - List variants = - wordVariantRepository.findAllByWord(validatedWord); - String originalForm; - - if (!variants.isEmpty()) { - originalForm = variants.get(0).getOriginalForm(); - log.info("Found variant '{}' -> original form '{}'", validatedWord, originalForm); - } else { - originalForm = validatedWord; - } - - // 2. targetLanguage와 일치하는 Word가 있는지 확인 - Optional existingWord = wordRepository.findByWordAndTargetLanguageCode( - originalForm, targetLanguage); - - Word wordToUpdate; - boolean existed = existingWord.isPresent(); - - if (existed) { - // 이미 존재하면 재사용 - wordToUpdate = existingWord.get(); - log.info("Word '{}' already exists for target language {}, reusing", - originalForm, targetLanguage); - } else { - // 없으면 AI로 생성 - wordService.forceReanalyzeWord(originalForm, targetLanguage, false); - - // 생성된 결과 조회 - wordToUpdate = wordRepository.findByWordAndTargetLanguageCode( - originalForm, targetLanguage) - .orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); - } - - // 3. isEssential=true 설정 - if (!Boolean.TRUE.equals(wordToUpdate.getIsEssential())) { - wordToUpdate.setIsEssential(true); - wordRepository.save(wordToUpdate); - log.info("Set isEssential=true for Oxford 3000 word: {}", wordToUpdate.getWord()); - } - - return existed; - } - - /** - * CSV 파일에서 Oxford 3000 단어 목록 읽기 - */ - private List readOxford3000Words() { - try { - ClassPathResource resource = new ClassPathResource(OXFORD3000_CSV_PATH); - - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { - - return reader.lines() - .skip(1) // 헤더 스킵 ("word") - .map(String::trim) - .filter(line -> !line.isEmpty()) - .collect(Collectors.toList()); - } - - } catch (Exception e) { - log.error("Failed to read Oxford 3000 CSV file: {}", e.getMessage(), e); - throw new WordsException(WordsErrorCode.WORD_NOT_FOUND); - } - } - - /** - * 필수 단어 통계 조회 - */ - public EssentialWordsStatsResponse getEssentialWordsStats() { - // 전체 필수 단어 수 - long totalEssential = wordRepository.countByIsEssential(true); - - // 언어별 통계 (간단한 구현 - 실제로는 aggregation 사용 권장) - List allEssentialWords = wordRepository.findAllByIsEssential(true); - - Map countByTarget = allEssentialWords.stream() - .collect(Collectors.groupingBy(Word::getTargetLanguageCode, Collectors.counting())); - - Map countBySource = allEssentialWords.stream() - .collect(Collectors.groupingBy(Word::getSourceLanguageCode, Collectors.counting())); - - return EssentialWordsStatsResponse.builder() - .totalEssentialWords(totalEssential) - .countByTargetLanguage(countByTarget) - .countBySourceLanguage(countBySource) - .build(); - } - - /** - * 등록된 필수 단어 목록 조회 (word 문자열만, 변형 형태 포함) - * - * @param targetLanguage 번역 대상 언어 (null이면 전체) - * @return 필수 단어 문자열 목록 (원형 + 변형 형태, 중복 제거) - */ - public List getEssentialWordsList(LanguageCode targetLanguage) { - List essentialWords; - - if (targetLanguage != null) { - essentialWords = wordRepository.findAllByIsEssentialAndTargetLanguageCode(true, targetLanguage); - log.info("Found {} essential words for target language: {}", essentialWords.size(), targetLanguage); - } else { - essentialWords = wordRepository.findAllByIsEssential(true); - log.info("Found {} total essential words (all languages)", essentialWords.size()); - } - - // 원형들 추출 - List originalForms = essentialWords.stream() - .map(Word::getWord) - .map(String::toLowerCase) - .distinct() - .collect(Collectors.toList()); - - // 각 원형의 변형 형태들도 가져오기 - List allFormsIncludingVariants = new ArrayList<>(originalForms); - - for (String originalForm : originalForms) { - List variants = - wordVariantRepository.findAllByOriginalForm(originalForm); - variants.stream() - .map(WordVariant::getWord) - .map(String::toLowerCase) - .forEach(allFormsIncludingVariants::add); - } - - List result = allFormsIncludingVariants.stream() - .distinct() - .sorted() - .collect(Collectors.toList()); - - log.info("Returning {} unique essential word strings (including {} variants)", - result.size(), result.size() - originalForms.size()); - return result; - } - - /** - * 특정 단어의 isEssential 상태 업데이트 - */ - @Transactional - public void updateEssentialStatus(String wordId, boolean isEssential) { - Word word = wordRepository.findById(wordId) - .orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); - - word.setIsEssential(isEssential); - wordRepository.save(word); - - log.info("Updated word '{}' isEssential={}", word.getWord(), isEssential); - } + private final WordRepository wordRepository; + + private final WordService wordService; + + private final com.linglevel.api.word.validator.WordValidator wordValidator; + + private final com.linglevel.api.word.repository.WordVariantRepository wordVariantRepository; + + private static final String OXFORD3000_CSV_PATH = "data/oxford3000_final_cleaned.csv"; + + private static final int MAX_RETRY_ATTEMPTS = 3; + + /** + * Oxford 3000 단어를 초기화합니다. + * @param targetLanguage 번역 대상 언어 + * @return Oxford3000InitResponse + */ + @Transactional + public Oxford3000InitResponse initializeOxford3000(LanguageCode targetLanguage) { + LocalDateTime startedAt = LocalDateTime.now(); + log.info("============================================================"); + log.info("Starting Oxford 3000 initialization"); + log.info("Target Language: {}", targetLanguage); + log.info("============================================================"); + + // 1. CSV 파일에서 단어 읽기 + List csvWords = readOxford3000Words(); + log.info("Step 1: Loaded {} words from Oxford 3000 CSV", csvWords.size()); + + // 2. 이미 등록된 essential 단어 목록 조회 (대소문자 무시) + List existingEssentialWords = getEssentialWordsList(targetLanguage); + log.info("Step 2: Found {} already registered essential words", existingEssentialWords.size()); + + // 3. 차집합 계산 (등록이 필요한 단어만 필터링) + List wordsToProcess = csvWords.stream() + .filter(word -> !existingEssentialWords.contains(word.toLowerCase())) + .collect(Collectors.toList()); + + int skippedCount = csvWords.size() - wordsToProcess.size(); + log.info("Step 3: {} words already registered, {} words to process", skippedCount, wordsToProcess.size()); + + log.info("============================================================"); + log.info("Processing {} words...", wordsToProcess.size()); + log.info("============================================================"); + + // 4. 통계 정보 + int successCount = 0; + int failureCount = 0; + int newlyCreatedCount = 0; + List failedWords = new ArrayList<>(); + + // 5. 각 단어 처리 (최대 3번 재시도) + for (int i = 0; i < wordsToProcess.size(); i++) { + String word = wordsToProcess.get(i); + boolean processed = false; + + for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { + try { + boolean existed = processWord(word, targetLanguage); + successCount++; + + if (!existed) { + newlyCreatedCount++; + } + + processed = true; + + // 진행 상황 로깅 (10개마다, 또는 100개마다) + if ((wordsToProcess.size() <= 50 && (i + 1) % 10 == 0) + || (wordsToProcess.size() > 50 && (i + 1) % 100 == 0)) { + log.info("Progress: {}/{} words processed ({} succeeded, {} failed)", i + 1, + wordsToProcess.size(), successCount, failureCount); + } + + break; // 성공하면 재시도 중단 + + } + catch (Exception e) { + if (attempt < MAX_RETRY_ATTEMPTS) { + log.warn("Failed to process word '{}' (attempt {}/{}): {}. Retrying...", word, attempt, + MAX_RETRY_ATTEMPTS, e.getMessage()); + } + else { + log.error("Failed to process word '{}' after {} attempts: {}", word, MAX_RETRY_ATTEMPTS, + e.getMessage(), e); + } + } + } + + // 모든 재시도 실패 + if (!processed) { + failureCount++; + failedWords.add(word); + } + } + + LocalDateTime completedAt = LocalDateTime.now(); + long durationSeconds = java.time.Duration.between(startedAt, completedAt).getSeconds(); + + log.info("============================================================"); + log.info("Oxford 3000 initialization completed!"); + log.info("Duration: {} seconds ({} minutes)", durationSeconds, durationSeconds / 60); + log.info("Total CSV words: {}", csvWords.size()); + log.info("Already registered (skipped): {}", skippedCount); + log.info("Attempted to process: {}", wordsToProcess.size()); + log.info("Successfully processed: {}", successCount); + log.info("Newly created: {}", newlyCreatedCount); + log.info("Failed: {}", failureCount); + if (!failedWords.isEmpty()) { + log.error("Failed words: {}", String.join(", ", failedWords)); + } + log.info("============================================================"); + + return Oxford3000InitResponse.builder() + .startedAt(startedAt) + .completedAt(completedAt) + .totalWords(wordsToProcess.size()) + .successCount(successCount) + .failureCount(failureCount) + .alreadyExistCount(skippedCount) + .newlyCreatedCount(newlyCreatedCount) + .failedWords(failedWords) + .message(String.format("Processed %d words (%d skipped, %d succeeded, %d failed)", wordsToProcess.size(), + skippedCount, successCount, failureCount)) + .build(); + } + + /** + * 개별 단어 처리 + * @param word 처리할 단어 + * @param targetLanguage 번역 대상 언어 + * @return true: 이미 존재했던 단어, false: 새로 생성된 단어 + */ + @Transactional + public boolean processWord(String word, LanguageCode targetLanguage) { + String validatedWord = wordValidator.validateAndPreprocess(word); + log.debug("Word validated and preprocessed: '{}' -> '{}'", word, validatedWord); + + // 1. WordVariant에서 원형 찾기 + List variants = wordVariantRepository.findAllByWord(validatedWord); + String originalForm; + + if (!variants.isEmpty()) { + originalForm = variants.get(0).getOriginalForm(); + log.info("Found variant '{}' -> original form '{}'", validatedWord, originalForm); + } + else { + originalForm = validatedWord; + } + + // 2. targetLanguage와 일치하는 Word가 있는지 확인 + Optional existingWord = wordRepository.findByWordAndTargetLanguageCode(originalForm, targetLanguage); + + Word wordToUpdate; + boolean existed = existingWord.isPresent(); + + if (existed) { + // 이미 존재하면 재사용 + wordToUpdate = existingWord.get(); + log.info("Word '{}' already exists for target language {}, reusing", originalForm, targetLanguage); + } + else { + // 없으면 AI로 생성 + wordService.forceReanalyzeWord(originalForm, targetLanguage, false); + + // 생성된 결과 조회 + wordToUpdate = wordRepository.findByWordAndTargetLanguageCode(originalForm, targetLanguage) + .orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); + } + + // 3. isEssential=true 설정 + if (!Boolean.TRUE.equals(wordToUpdate.getIsEssential())) { + wordToUpdate.setIsEssential(true); + wordRepository.save(wordToUpdate); + log.info("Set isEssential=true for Oxford 3000 word: {}", wordToUpdate.getWord()); + } + + return existed; + } + + /** + * CSV 파일에서 Oxford 3000 단어 목록 읽기 + */ + private List readOxford3000Words() { + try { + ClassPathResource resource = new ClassPathResource(OXFORD3000_CSV_PATH); + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + + return reader.lines() + .skip(1) // 헤더 스킵 ("word") + .map(String::trim) + .filter(line -> !line.isEmpty()) + .collect(Collectors.toList()); + } + + } + catch (Exception e) { + log.error("Failed to read Oxford 3000 CSV file: {}", e.getMessage(), e); + throw new WordsException(WordsErrorCode.WORD_NOT_FOUND); + } + } + + /** + * 필수 단어 통계 조회 + */ + public EssentialWordsStatsResponse getEssentialWordsStats() { + // 전체 필수 단어 수 + long totalEssential = wordRepository.countByIsEssential(true); + + // 언어별 통계 (간단한 구현 - 실제로는 aggregation 사용 권장) + List allEssentialWords = wordRepository.findAllByIsEssential(true); + + Map countByTarget = allEssentialWords.stream() + .collect(Collectors.groupingBy(Word::getTargetLanguageCode, Collectors.counting())); + + Map countBySource = allEssentialWords.stream() + .collect(Collectors.groupingBy(Word::getSourceLanguageCode, Collectors.counting())); + + return EssentialWordsStatsResponse.builder() + .totalEssentialWords(totalEssential) + .countByTargetLanguage(countByTarget) + .countBySourceLanguage(countBySource) + .build(); + } + + /** + * 등록된 필수 단어 목록 조회 (word 문자열만, 변형 형태 포함) + * @param targetLanguage 번역 대상 언어 (null이면 전체) + * @return 필수 단어 문자열 목록 (원형 + 변형 형태, 중복 제거) + */ + public List getEssentialWordsList(LanguageCode targetLanguage) { + List essentialWords; + + if (targetLanguage != null) { + essentialWords = wordRepository.findAllByIsEssentialAndTargetLanguageCode(true, targetLanguage); + log.info("Found {} essential words for target language: {}", essentialWords.size(), targetLanguage); + } + else { + essentialWords = wordRepository.findAllByIsEssential(true); + log.info("Found {} total essential words (all languages)", essentialWords.size()); + } + + // 원형들 추출 + List originalForms = essentialWords.stream() + .map(Word::getWord) + .map(String::toLowerCase) + .distinct() + .collect(Collectors.toList()); + + // 각 원형의 변형 형태들도 가져오기 + List allFormsIncludingVariants = new ArrayList<>(originalForms); + + for (String originalForm : originalForms) { + List variants = wordVariantRepository.findAllByOriginalForm(originalForm); + variants.stream() + .map(WordVariant::getWord) + .map(String::toLowerCase) + .forEach(allFormsIncludingVariants::add); + } + + List result = allFormsIncludingVariants.stream().distinct().sorted().collect(Collectors.toList()); + + log.info("Returning {} unique essential word strings (including {} variants)", result.size(), + result.size() - originalForms.size()); + return result; + } + + /** + * 특정 단어의 isEssential 상태 업데이트 + */ + @Transactional + public void updateEssentialStatus(String wordId, boolean isEssential) { + Word word = wordRepository.findById(wordId) + .orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); + + word.setIsEssential(isEssential); + wordRepository.save(word); + + log.info("Updated word '{}' isEssential={}", word.getWord(), isEssential); + } + } diff --git a/src/main/java/com/linglevel/api/word/service/WordAiService.java b/src/main/java/com/linglevel/api/word/service/WordAiService.java index c5fa1c4e..14f1d6bc 100644 --- a/src/main/java/com/linglevel/api/word/service/WordAiService.java +++ b/src/main/java/com/linglevel/api/word/service/WordAiService.java @@ -23,385 +23,380 @@ @Slf4j public class WordAiService { - private final ChatModel chatModel; - private final Validator validator; - - public WordAiService(ChatModel chatModel) { - this.chatModel = chatModel; - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - this.validator = factory.getValidator(); - } - - private static final String PROMPT_TEMPLATE = """ - Word: {word} | Target: {targetLanguage} - - **CRITICAL: If '{word}' is nonsensical/gibberish/typo, return []** - **CRITICAL: ALL fields (summary, meaning, example, exampleTranslation) MUST have meaningful content. NEVER leave empty strings.** - **If you cannot provide meaningful content, return [] instead.** - - HOMOGRAPH CHECK: Same spelling, multiple DISTINCT ORIGINS or DIFFERENT ORIGINAL FORMS? - (e.g., "saw"=past of "see" + noun "saw"톱, "left"=past of "leave" + adj "left"왼쪽, "rose"=past of "rise" + noun "rose"장미) - **CRITICAL**: If input word IS the original form (e.g., "run", "book"), return SINGLE entry with variantTypes=[ORIGINAL_FORM] - - "run" → Single entry: originalForm="run", variantTypes=[ORIGINAL_FORM] (DO NOT split into ORIGINAL_FORM and PAST_PARTICIPLE) - - "books" → Single entry: originalForm="book", variantTypes=[PLURAL, THIRD_PERSON] - → YES (different origins): Return array with separate entries | NO: Single-element array - - **CRITICAL MERGING RULE:** - - If input word has SAME originalForm but DIFFERENT variantTypes, MERGE into ONE ENTRY with multiple variantTypes - - Example "books": - Single entry: originalForm="book", variantTypes=[PLURAL, THIRD_PERSON], meanings include BOTH noun meanings AND verb meanings - - Each meaning should specify its partOfSpeech clearly - - STRUCTURE: - **CRITICAL: Only variantTypes describes the INPUT word. Everything else (summary, meanings, examples) describes the ORIGINAL FORM.** - - 1. sourceLanguageCode/targetLanguageCode: "EN", "KO", etc. - 2. originalForm: Base form (verbs→infinitive, adj→positive, nouns→singular) - **CRITICAL: For adverbs ending in "-ly":** - - The adverb itself IS the original form - - Do NOT remove "-ly" to get the base adjective - - "carefully" → originalForm="carefully" (NOT "careful") - - "absolutely" → originalForm="absolutely" (NOT "absolute") - 3. variantTypes: **ARRAY** of relationships between INPUT word and originalForm - variantTypes = ONLY morphological relationship (변형 관계만!) - ✅ VALID VALUES: ORIGINAL_FORM, PAST_TENSE, PAST_PARTICIPLE, PRESENT_PARTICIPLE, THIRD_PERSON, COMPARATIVE, SUPERLATIVE, PLURAL, UNDEFINED - - **CRITICAL: Special cases** - - Pronouns (them, him, whom, etc.): variantTypes=[ORIGINAL_FORM], partOfSpeech="pronoun" - - Past participles used as adjectives (confused, interested, etc.): variantTypes=[PAST_PARTICIPLE], add BOTH verb and adjective meanings - - Words without inflection (adverbs, prepositions, etc.): variantTypes=[ORIGINAL_FORM] - - 4. partOfSpeech = Grammatical category (품사) - - Goes INSIDE meanings array (meanings 배열 안에!) - ✅ VALID VALUES: verb, noun, adjective, adverb, pronoun, preposition, conjunction, interjection, determiner, article, numeral - - - If input="ran" and originalForm="run", then variantTypes=[PAST_TENSE] - - If input="books" and originalForm="book", then variantTypes=[PLURAL, THIRD_PERSON] (both noun plural AND verb 3rd person) - 5. summary: Max 3 common translations of the ORIGINAL FORM - - Input "ran" → summary of "run": ["달리다","운영하다"] - - Input "prettiest" → summary of "pretty": ["예쁜","아름다운"] - 6. meanings: All meanings describe the ORIGINAL FORM (not the input word) - - Max 15 objects (common→rare, omit obscure ones) - - partOfSpeech: verb, noun, adjective, adverb, etc. - - meaning: Detailed explanation in target language - - example: **CRITICAL RULES:** - 1. ALWAYS use the ORIGINAL FORM in the example (NOT the input word!) - - If originalForm="book" (input was "books"), use "book" in example - - If originalForm="run" (input was "ran"), use "run" in example - 2. **PART OF SPEECH MUST MATCH**: The word in the example MUST be used as the specified partOfSpeech - - If partOfSpeech="noun", the word must function as a noun in the example - - If partOfSpeech="verb", the word must function as a verb in the example - - WRONG: partOfSpeech="noun" but example has "I need to book a flight" (book is verb here) - - CORRECT: partOfSpeech="noun" and example has "I love reading a book" (book is noun here) - 3. Grammar: Ensure grammatically correct sentences (e.g., "I/You/We/They run" ✓, "She runs" ✓) - 4. QUALITY: Natural, practical sentences used in real-life contexts - 5. CLARITY: Sentence must clearly demonstrate the word's meaning - 6. LENGTH: 5-12 words (not too short, not too long) - 7. AVOID: Generic phrases like "I need...", "This is...", "It is..." - be creative! - GOOD: "I love reading a good book." (book as noun, matches partOfSpeech) - GOOD: "We run a small bakery in downtown." (run as verb, matches partOfSpeech) - BAD: "I need to book a flight." (if partOfSpeech is noun - book is verb here!) - - exampleTranslation: Translation in target language - 7. conjugations: (verbs only) present, past, pastParticiple, presentParticiple, thirdPerson - 8. comparatives: (adj only) positive, comparative, superlative - 9. plural: (nouns only) singular, plural - - EXAMPLE - "saw" homograph: - [ - {{ - "originalForm": "see", - "variantTypes": ["PAST_TENSE"], - "sourceLanguageCode": "EN", - "targetLanguageCode": "KO", - "summary": ["보다", "알다"], - "meanings": [ - {{ - "partOfSpeech": "verb", - "meaning": "시각적으로 인지하다", - "example": "We see the mountains clearly from our window.", - "exampleTranslation": "우리는 창문에서 산이 선명하게 보입니다." - }} - ], - "conjugations": {{"present": "see", "past": "saw", "pastParticiple": "seen", "presentParticiple": "seeing", "thirdPerson": "sees"}}, - "comparatives": null, - "plural": null - }}, - {{ - "originalForm": "saw", - "variantTypes": ["ORIGINAL_FORM"], - "sourceLanguageCode": "EN", - "targetLanguageCode": "KO", - "summary": ["톱"], - "meanings": [ - {{ - "partOfSpeech": "noun", - "meaning": "톱 (자르는 도구)", - "example": "The carpenter used a saw to cut the wood.", - "exampleTranslation": "목수는 톱을 사용하여 나무를 잘랐습니다." - }} - ], - "conjugations": null, - "comparatives": null, - "plural": {{"singular": "saw", "plural": "saws"}} - }} - ] - - {format} - """; - - public List analyzeWord(String word, String targetLanguage) { - try { - BeanOutputConverter outputConverter = - new BeanOutputConverter<>(WordAnalysisResult[].class); - - String format = outputConverter.getFormat(); - - PromptTemplate promptTemplate = new PromptTemplate(PROMPT_TEMPLATE); - Prompt prompt = promptTemplate.create(Map.of( - "word", word, - "targetLanguage", targetLanguage, - "format", format - )); - - ChatResponse chatResponse = ChatClient.create(chatModel) - .prompt(prompt) - .call() - .chatResponse(); - - String response = chatResponse.getResult().getOutput().getText(); - - // 토큰 사용량 및 비용 로깅 - if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) { - var usage = chatResponse.getMetadata().getUsage(); - long inputTokens = usage.getPromptTokens(); - long outputTokens = usage.getGenerationTokens(); - long totalTokens = usage.getTotalTokens(); - - double inputCostUsd = (inputTokens / 1000.0) * 0.00017; - double outputCostUsd = (outputTokens / 1000.0) * 0.000085; - double totalCostUsd = inputCostUsd + outputCostUsd; - - // 환율: 1 USD = 1430 KRW - double totalCostKrw = totalCostUsd * 1430; - - log.info("📊 Token Usage for word '{}': Input={}, Output={}, Total={}", - word, inputTokens, outputTokens, totalTokens); - log.info("💰 Cost: ${} (₩{}) = Input: ${} + Output: ${}", - String.format("%.6f", totalCostUsd), - String.format("%.2f", totalCostKrw), - String.format("%.6f", inputCostUsd), - String.format("%.6f", outputCostUsd)); - } - - // 전체 응답은 debug 레벨로만 출력 (응답이 길어서 info 레벨에서는 제외) - log.debug("AI Response for word '{}' (target: {}): {}", word, targetLanguage, response); - - WordAnalysisResult[] results = outputConverter.convert(response); - - // Validation 수행 - for (WordAnalysisResult result : results) { - validateResult(result, word); - } - - // ENUM 필터링 - variantTypes와 partOfSpeech에서 유효하지 않은 값 제거 (AI 실수 방지) - results = filterInvalidEnumValues(results, word); - - // 같은 originalForm을 가진 결과를 병합 (AI가 잘못 분리한 경우 대비) - List mergedResults = mergeDuplicateOriginalForms(results, word); - - // 빈 결과 검증 - AI가 무의미한 단어라고 판단한 경우 - if (mergedResults.isEmpty()) { - log.info("AI returned empty result for '{}' (meaningless/gibberish word)", word); - throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); - } - - // 요약 정보 로깅 - String summary = mergedResults.stream() - .map(r -> r.getOriginalForm() + " (" + String.join(", ", r.getVariantTypes().stream() - .map(Enum::name).toArray(String[]::new)) + ")") - .collect(Collectors.joining(", ")); - log.info("✅ AI analysis completed for '{}': {} result(s) - {}", word, mergedResults.size(), summary); - - return mergedResults; - } catch (WordsException e) { - throw e; - } catch (Exception e) { - log.error("Failed to analyze word '{}' with AI (target: {})", word, targetLanguage, e); - throw new WordsException(WordsErrorCode.WORD_ANALYSIS_FAILED, e); - } - } - - /** - * AI 응답 결과의 유효성을 검증 - */ - private void validateResult(WordAnalysisResult result, String word) { - Set> violations = validator.validate(result); - - if (!violations.isEmpty()) { - String errors = violations.stream() - .map(v -> v.getPropertyPath() + ": " + v.getMessage()) - .collect(Collectors.joining(", ")); - - log.error("AI response validation failed for word '{}': {}", word, errors); - throw new IllegalArgumentException("Invalid AI response for word '" + word + "': " + errors); - } - - log.debug("AI response validation passed for word '{}'", word); - } - - /** - * 같은 originalForm을 가진 결과들을 하나로 병합 - * AI가 프롬프트를 무시하고 같은 원형에 대해 여러 항목을 반환한 경우 처리 - */ - private List mergeDuplicateOriginalForms(WordAnalysisResult[] results, String word) { - if (results == null || results.length == 0) { - return List.of(); - } - - // originalForm 기준으로 그룹화 - Map> groupedByOriginalForm = Arrays.stream(results) - .collect(Collectors.groupingBy(WordAnalysisResult::getOriginalForm)); - - List mergedList = new ArrayList<>(); - - for (Map.Entry> entry : groupedByOriginalForm.entrySet()) { - List group = entry.getValue(); - - if (group.size() == 1) { - // 중복 없음 - 그대로 추가 - mergedList.add(group.get(0)); - } else { - // 중복 발견 - 병합 필요 - log.warn("Merging {} duplicate entries for originalForm '{}' (input word: '{}')", - group.size(), entry.getKey(), word); - - WordAnalysisResult merged = mergeResults(group); - mergedList.add(merged); - } - } - - return mergedList; - } - - /** - * variantTypes와 partOfSpeech에서 유효하지 않은 값들을 필터링 - * AI가 실수로 ENUM에 없는 값을 넣은 경우 제거 - */ - private WordAnalysisResult[] filterInvalidEnumValues(WordAnalysisResult[] results, String word) { - if (results == null || results.length == 0) { - return results; - } - - List filteredResults = new ArrayList<>(); - - for (WordAnalysisResult result : results) { - List originalVariantTypes = result.getVariantTypes(); - - if (originalVariantTypes == null || originalVariantTypes.isEmpty()) { - log.warn("Empty variantTypes for word '{}' (originalForm: '{}')", word, result.getOriginalForm()); - continue; - } - - // 1. 유효한 VariantType만 필터링 - List validVariantTypes = originalVariantTypes.stream() - .filter(vt -> vt != null) - .collect(Collectors.toList()); - - if (validVariantTypes.isEmpty()) { - log.warn("All variantTypes were invalid for word '{}' (originalForm: '{}'), skipping this result", - word, result.getOriginalForm()); - continue; - } - - if (validVariantTypes.size() < originalVariantTypes.size()) { - log.warn("Filtered invalid variantTypes for word '{}' (originalForm: '{}'): {} -> {}", - word, result.getOriginalForm(), originalVariantTypes.size(), validVariantTypes.size()); - } - - // 2. 유효한 PartOfSpeech를 가진 meanings만 필터링 - List originalMeanings = result.getMeanings(); - List validMeanings = new ArrayList<>(); - - if (originalMeanings != null) { - int invalidCount = 0; - for (com.linglevel.api.word.dto.Meaning meaning : originalMeanings) { - if (meaning.getPartOfSpeech() != null) { - validMeanings.add(meaning); - } else { - invalidCount++; - } - } - - if (invalidCount > 0) { - log.warn("Filtered {} invalid partOfSpeech(es) for word '{}' (originalForm: '{}'): {} -> {}", - invalidCount, word, result.getOriginalForm(), originalMeanings.size(), validMeanings.size()); - } - } - - if (validMeanings.isEmpty()) { - log.warn("All meanings have invalid partOfSpeech for word '{}' (originalForm: '{}'), skipping this result", - word, result.getOriginalForm()); - continue; - } - - // 필터링된 variantTypes와 meanings로 새 결과 생성 - WordAnalysisResult filteredResult = WordAnalysisResult.builder() - .sourceLanguageCode(result.getSourceLanguageCode()) - .targetLanguageCode(result.getTargetLanguageCode()) - .originalForm(result.getOriginalForm()) - .variantTypes(validVariantTypes) - .summary(result.getSummary()) - .meanings(validMeanings) - .conjugations(result.getConjugations()) - .comparatives(result.getComparatives()) - .plural(result.getPlural()) - .build(); - - filteredResults.add(filteredResult); - } - - return filteredResults.toArray(new WordAnalysisResult[0]); - } - - /** - * 같은 originalForm을 가진 여러 결과를 하나로 병합 - */ - private WordAnalysisResult mergeResults(List results) { - if (results.isEmpty()) { - throw new IllegalArgumentException("Cannot merge empty results"); - } - - WordAnalysisResult first = results.get(0); - - // variantTypes 병합 (중복 제거) - List mergedVariantTypes = results.stream() - .flatMap(r -> r.getVariantTypes().stream()) - .distinct() - .collect(Collectors.toList()); - - // meanings 병합 (중복 제거 - partOfSpeech와 meaning이 같은 것은 제외) - List mergedMeanings = results.stream() - .flatMap(r -> r.getMeanings().stream()) - .collect(Collectors.toMap( - m -> m.getPartOfSpeech() + ":" + m.getMeaning(), - m -> m, - (existing, replacement) -> existing - )) - .values() - .stream() - .collect(Collectors.toList()); - - // 첫 번째 결과를 기반으로 병합된 결과 생성 - return WordAnalysisResult.builder() - .sourceLanguageCode(first.getSourceLanguageCode()) - .targetLanguageCode(first.getTargetLanguageCode()) - .originalForm(first.getOriginalForm()) - .variantTypes(mergedVariantTypes) - .summary(first.getSummary()) // 첫 번째 것 사용 - .meanings(mergedMeanings) - .conjugations(first.getConjugations()) // 첫 번째 것 사용 - .comparatives(first.getComparatives()) // 첫 번째 것 사용 - .plural(first.getPlural()) // 첫 번째 것 사용 - .build(); - } + private final ChatModel chatModel; + + private final Validator validator; + + public WordAiService(ChatModel chatModel) { + this.chatModel = chatModel; + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + this.validator = factory.getValidator(); + } + + private static final String PROMPT_TEMPLATE = """ + Word: {word} | Target: {targetLanguage} + + **CRITICAL: If '{word}' is nonsensical/gibberish/typo, return []** + **CRITICAL: ALL fields (summary, meaning, example, exampleTranslation) MUST have meaningful content. NEVER leave empty strings.** + **If you cannot provide meaningful content, return [] instead.** + + HOMOGRAPH CHECK: Same spelling, multiple DISTINCT ORIGINS or DIFFERENT ORIGINAL FORMS? + (e.g., "saw"=past of "see" + noun "saw"톱, "left"=past of "leave" + adj "left"왼쪽, "rose"=past of "rise" + noun "rose"장미) + **CRITICAL**: If input word IS the original form (e.g., "run", "book"), return SINGLE entry with variantTypes=[ORIGINAL_FORM] + - "run" → Single entry: originalForm="run", variantTypes=[ORIGINAL_FORM] (DO NOT split into ORIGINAL_FORM and PAST_PARTICIPLE) + - "books" → Single entry: originalForm="book", variantTypes=[PLURAL, THIRD_PERSON] + → YES (different origins): Return array with separate entries | NO: Single-element array + + **CRITICAL MERGING RULE:** + - If input word has SAME originalForm but DIFFERENT variantTypes, MERGE into ONE ENTRY with multiple variantTypes + - Example "books": + Single entry: originalForm="book", variantTypes=[PLURAL, THIRD_PERSON], meanings include BOTH noun meanings AND verb meanings + - Each meaning should specify its partOfSpeech clearly + + STRUCTURE: + **CRITICAL: Only variantTypes describes the INPUT word. Everything else (summary, meanings, examples) describes the ORIGINAL FORM.** + + 1. sourceLanguageCode/targetLanguageCode: "EN", "KO", etc. + 2. originalForm: Base form (verbs→infinitive, adj→positive, nouns→singular) + **CRITICAL: For adverbs ending in "-ly":** + - The adverb itself IS the original form + - Do NOT remove "-ly" to get the base adjective + - "carefully" → originalForm="carefully" (NOT "careful") + - "absolutely" → originalForm="absolutely" (NOT "absolute") + 3. variantTypes: **ARRAY** of relationships between INPUT word and originalForm + variantTypes = ONLY morphological relationship (변형 관계만!) + ✅ VALID VALUES: ORIGINAL_FORM, PAST_TENSE, PAST_PARTICIPLE, PRESENT_PARTICIPLE, THIRD_PERSON, COMPARATIVE, SUPERLATIVE, PLURAL, UNDEFINED + + **CRITICAL: Special cases** + - Pronouns (them, him, whom, etc.): variantTypes=[ORIGINAL_FORM], partOfSpeech="pronoun" + - Past participles used as adjectives (confused, interested, etc.): variantTypes=[PAST_PARTICIPLE], add BOTH verb and adjective meanings + - Words without inflection (adverbs, prepositions, etc.): variantTypes=[ORIGINAL_FORM] + + 4. partOfSpeech = Grammatical category (품사) + - Goes INSIDE meanings array (meanings 배열 안에!) + ✅ VALID VALUES: verb, noun, adjective, adverb, pronoun, preposition, conjunction, interjection, determiner, article, numeral + + - If input="ran" and originalForm="run", then variantTypes=[PAST_TENSE] + - If input="books" and originalForm="book", then variantTypes=[PLURAL, THIRD_PERSON] (both noun plural AND verb 3rd person) + 5. summary: Max 3 common translations of the ORIGINAL FORM + - Input "ran" → summary of "run": ["달리다","운영하다"] + - Input "prettiest" → summary of "pretty": ["예쁜","아름다운"] + 6. meanings: All meanings describe the ORIGINAL FORM (not the input word) + - Max 15 objects (common→rare, omit obscure ones) + - partOfSpeech: verb, noun, adjective, adverb, etc. + - meaning: Detailed explanation in target language + - example: **CRITICAL RULES:** + 1. ALWAYS use the ORIGINAL FORM in the example (NOT the input word!) + - If originalForm="book" (input was "books"), use "book" in example + - If originalForm="run" (input was "ran"), use "run" in example + 2. **PART OF SPEECH MUST MATCH**: The word in the example MUST be used as the specified partOfSpeech + - If partOfSpeech="noun", the word must function as a noun in the example + - If partOfSpeech="verb", the word must function as a verb in the example + - WRONG: partOfSpeech="noun" but example has "I need to book a flight" (book is verb here) + - CORRECT: partOfSpeech="noun" and example has "I love reading a book" (book is noun here) + 3. Grammar: Ensure grammatically correct sentences (e.g., "I/You/We/They run" ✓, "She runs" ✓) + 4. QUALITY: Natural, practical sentences used in real-life contexts + 5. CLARITY: Sentence must clearly demonstrate the word's meaning + 6. LENGTH: 5-12 words (not too short, not too long) + 7. AVOID: Generic phrases like "I need...", "This is...", "It is..." - be creative! + GOOD: "I love reading a good book." (book as noun, matches partOfSpeech) + GOOD: "We run a small bakery in downtown." (run as verb, matches partOfSpeech) + BAD: "I need to book a flight." (if partOfSpeech is noun - book is verb here!) + - exampleTranslation: Translation in target language + 7. conjugations: (verbs only) present, past, pastParticiple, presentParticiple, thirdPerson + 8. comparatives: (adj only) positive, comparative, superlative + 9. plural: (nouns only) singular, plural + + EXAMPLE - "saw" homograph: + [ + {{ + "originalForm": "see", + "variantTypes": ["PAST_TENSE"], + "sourceLanguageCode": "EN", + "targetLanguageCode": "KO", + "summary": ["보다", "알다"], + "meanings": [ + {{ + "partOfSpeech": "verb", + "meaning": "시각적으로 인지하다", + "example": "We see the mountains clearly from our window.", + "exampleTranslation": "우리는 창문에서 산이 선명하게 보입니다." + }} + ], + "conjugations": {{"present": "see", "past": "saw", "pastParticiple": "seen", "presentParticiple": "seeing", "thirdPerson": "sees"}}, + "comparatives": null, + "plural": null + }}, + {{ + "originalForm": "saw", + "variantTypes": ["ORIGINAL_FORM"], + "sourceLanguageCode": "EN", + "targetLanguageCode": "KO", + "summary": ["톱"], + "meanings": [ + {{ + "partOfSpeech": "noun", + "meaning": "톱 (자르는 도구)", + "example": "The carpenter used a saw to cut the wood.", + "exampleTranslation": "목수는 톱을 사용하여 나무를 잘랐습니다." + }} + ], + "conjugations": null, + "comparatives": null, + "plural": {{"singular": "saw", "plural": "saws"}} + }} + ] + + {format} + """; + + public List analyzeWord(String word, String targetLanguage) { + try { + BeanOutputConverter outputConverter = new BeanOutputConverter<>( + WordAnalysisResult[].class); + + String format = outputConverter.getFormat(); + + PromptTemplate promptTemplate = new PromptTemplate(PROMPT_TEMPLATE); + Prompt prompt = promptTemplate + .create(Map.of("word", word, "targetLanguage", targetLanguage, "format", format)); + + ChatResponse chatResponse = ChatClient.create(chatModel).prompt(prompt).call().chatResponse(); + + String response = chatResponse.getResult().getOutput().getText(); + + // 토큰 사용량 및 비용 로깅 + if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) { + var usage = chatResponse.getMetadata().getUsage(); + long inputTokens = usage.getPromptTokens(); + long outputTokens = usage.getGenerationTokens(); + long totalTokens = usage.getTotalTokens(); + + double inputCostUsd = (inputTokens / 1000.0) * 0.00017; + double outputCostUsd = (outputTokens / 1000.0) * 0.000085; + double totalCostUsd = inputCostUsd + outputCostUsd; + + // 환율: 1 USD = 1430 KRW + double totalCostKrw = totalCostUsd * 1430; + + log.info("📊 Token Usage for word '{}': Input={}, Output={}, Total={}", word, inputTokens, outputTokens, + totalTokens); + log.info("💰 Cost: ${} (₩{}) = Input: ${} + Output: ${}", String.format("%.6f", totalCostUsd), + String.format("%.2f", totalCostKrw), String.format("%.6f", inputCostUsd), + String.format("%.6f", outputCostUsd)); + } + + // 전체 응답은 debug 레벨로만 출력 (응답이 길어서 info 레벨에서는 제외) + log.debug("AI Response for word '{}' (target: {}): {}", word, targetLanguage, response); + + WordAnalysisResult[] results = outputConverter.convert(response); + + // Validation 수행 + for (WordAnalysisResult result : results) { + validateResult(result, word); + } + + // ENUM 필터링 - variantTypes와 partOfSpeech에서 유효하지 않은 값 제거 (AI 실수 방지) + results = filterInvalidEnumValues(results, word); + + // 같은 originalForm을 가진 결과를 병합 (AI가 잘못 분리한 경우 대비) + List mergedResults = mergeDuplicateOriginalForms(results, word); + + // 빈 결과 검증 - AI가 무의미한 단어라고 판단한 경우 + if (mergedResults.isEmpty()) { + log.info("AI returned empty result for '{}' (meaningless/gibberish word)", word); + throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); + } + + // 요약 정보 로깅 + String summary = mergedResults.stream() + .map(r -> r.getOriginalForm() + " (" + + String.join(", ", r.getVariantTypes().stream().map(Enum::name).toArray(String[]::new)) + ")") + .collect(Collectors.joining(", ")); + log.info("✅ AI analysis completed for '{}': {} result(s) - {}", word, mergedResults.size(), summary); + + return mergedResults; + } + catch (WordsException e) { + throw e; + } + catch (Exception e) { + log.error("Failed to analyze word '{}' with AI (target: {})", word, targetLanguage, e); + throw new WordsException(WordsErrorCode.WORD_ANALYSIS_FAILED, e); + } + } + + /** + * AI 응답 결과의 유효성을 검증 + */ + private void validateResult(WordAnalysisResult result, String word) { + Set> violations = validator.validate(result); + + if (!violations.isEmpty()) { + String errors = violations.stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .collect(Collectors.joining(", ")); + + log.error("AI response validation failed for word '{}': {}", word, errors); + throw new IllegalArgumentException("Invalid AI response for word '" + word + "': " + errors); + } + + log.debug("AI response validation passed for word '{}'", word); + } + + /** + * 같은 originalForm을 가진 결과들을 하나로 병합 AI가 프롬프트를 무시하고 같은 원형에 대해 여러 항목을 반환한 경우 처리 + */ + private List mergeDuplicateOriginalForms(WordAnalysisResult[] results, String word) { + if (results == null || results.length == 0) { + return List.of(); + } + + // originalForm 기준으로 그룹화 + Map> groupedByOriginalForm = Arrays.stream(results) + .collect(Collectors.groupingBy(WordAnalysisResult::getOriginalForm)); + + List mergedList = new ArrayList<>(); + + for (Map.Entry> entry : groupedByOriginalForm.entrySet()) { + List group = entry.getValue(); + + if (group.size() == 1) { + // 중복 없음 - 그대로 추가 + mergedList.add(group.get(0)); + } + else { + // 중복 발견 - 병합 필요 + log.warn("Merging {} duplicate entries for originalForm '{}' (input word: '{}')", group.size(), + entry.getKey(), word); + + WordAnalysisResult merged = mergeResults(group); + mergedList.add(merged); + } + } + + return mergedList; + } + + /** + * variantTypes와 partOfSpeech에서 유효하지 않은 값들을 필터링 AI가 실수로 ENUM에 없는 값을 넣은 경우 제거 + */ + private WordAnalysisResult[] filterInvalidEnumValues(WordAnalysisResult[] results, String word) { + if (results == null || results.length == 0) { + return results; + } + + List filteredResults = new ArrayList<>(); + + for (WordAnalysisResult result : results) { + List originalVariantTypes = result.getVariantTypes(); + + if (originalVariantTypes == null || originalVariantTypes.isEmpty()) { + log.warn("Empty variantTypes for word '{}' (originalForm: '{}')", word, result.getOriginalForm()); + continue; + } + + // 1. 유효한 VariantType만 필터링 + List validVariantTypes = originalVariantTypes.stream() + .filter(vt -> vt != null) + .collect(Collectors.toList()); + + if (validVariantTypes.isEmpty()) { + log.warn("All variantTypes were invalid for word '{}' (originalForm: '{}'), skipping this result", word, + result.getOriginalForm()); + continue; + } + + if (validVariantTypes.size() < originalVariantTypes.size()) { + log.warn("Filtered invalid variantTypes for word '{}' (originalForm: '{}'): {} -> {}", word, + result.getOriginalForm(), originalVariantTypes.size(), validVariantTypes.size()); + } + + // 2. 유효한 PartOfSpeech를 가진 meanings만 필터링 + List originalMeanings = result.getMeanings(); + List validMeanings = new ArrayList<>(); + + if (originalMeanings != null) { + int invalidCount = 0; + for (com.linglevel.api.word.dto.Meaning meaning : originalMeanings) { + if (meaning.getPartOfSpeech() != null) { + validMeanings.add(meaning); + } + else { + invalidCount++; + } + } + + if (invalidCount > 0) { + log.warn("Filtered {} invalid partOfSpeech(es) for word '{}' (originalForm: '{}'): {} -> {}", + invalidCount, word, result.getOriginalForm(), originalMeanings.size(), + validMeanings.size()); + } + } + + if (validMeanings.isEmpty()) { + log.warn( + "All meanings have invalid partOfSpeech for word '{}' (originalForm: '{}'), skipping this result", + word, result.getOriginalForm()); + continue; + } + + // 필터링된 variantTypes와 meanings로 새 결과 생성 + WordAnalysisResult filteredResult = WordAnalysisResult.builder() + .sourceLanguageCode(result.getSourceLanguageCode()) + .targetLanguageCode(result.getTargetLanguageCode()) + .originalForm(result.getOriginalForm()) + .variantTypes(validVariantTypes) + .summary(result.getSummary()) + .meanings(validMeanings) + .conjugations(result.getConjugations()) + .comparatives(result.getComparatives()) + .plural(result.getPlural()) + .build(); + + filteredResults.add(filteredResult); + } + + return filteredResults.toArray(new WordAnalysisResult[0]); + } + + /** + * 같은 originalForm을 가진 여러 결과를 하나로 병합 + */ + private WordAnalysisResult mergeResults(List results) { + if (results.isEmpty()) { + throw new IllegalArgumentException("Cannot merge empty results"); + } + + WordAnalysisResult first = results.get(0); + + // variantTypes 병합 (중복 제거) + List mergedVariantTypes = results.stream() + .flatMap(r -> r.getVariantTypes().stream()) + .distinct() + .collect(Collectors.toList()); + + // meanings 병합 (중복 제거 - partOfSpeech와 meaning이 같은 것은 제외) + List mergedMeanings = results.stream() + .flatMap(r -> r.getMeanings().stream()) + .collect(Collectors.toMap(m -> m.getPartOfSpeech() + ":" + m.getMeaning(), m -> m, + (existing, replacement) -> existing)) + .values() + .stream() + .collect(Collectors.toList()); + + // 첫 번째 결과를 기반으로 병합된 결과 생성 + return WordAnalysisResult.builder() + .sourceLanguageCode(first.getSourceLanguageCode()) + .targetLanguageCode(first.getTargetLanguageCode()) + .originalForm(first.getOriginalForm()) + .variantTypes(mergedVariantTypes) + .summary(first.getSummary()) // 첫 번째 것 사용 + .meanings(mergedMeanings) + .conjugations(first.getConjugations()) // 첫 번째 것 사용 + .comparatives(first.getComparatives()) // 첫 번째 것 사용 + .plural(first.getPlural()) // 첫 번째 것 사용 + .build(); + } + } diff --git a/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java b/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java index 744edcfe..ed38558a 100644 --- a/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java +++ b/src/main/java/com/linglevel/api/word/service/WordPersistenceService.java @@ -26,243 +26,225 @@ @Slf4j public class WordPersistenceService { - private final WordRepository wordRepository; - private final WordVariantRepository wordVariantRepository; - private final InvalidWordRepository invalidWordRepository; - - @Transactional - public List saveAnalysisResults( - String word, - List analysisResults, - Optional cachedInvalidWord - ) { - List savedVariants = new ArrayList<>(); - for (WordAnalysisResult analysisResult : analysisResults) { - WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); - savedVariants.add(savedVariant); - } - - cachedInvalidWord.ifPresent(invalidWord -> { - invalidWordRepository.delete(invalidWord); - log.info("Removed word '{}' from invalid word cache after successful AI analysis (was attempt {}/3)", - word, invalidWord.getAttemptCount()); - }); - - return savedVariants; - } - - @Transactional - public List forceSaveAnalysisResults( - String word, - LanguageCode targetLanguage, - boolean overwrite, - boolean deleteVariants, - List analysisResults - ) { - if (overwrite) { - deleteExistingWords(word, targetLanguage, deleteVariants); - } - - List savedVariants = new ArrayList<>(); - for (WordAnalysisResult analysisResult : analysisResults) { - WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); - savedVariants.add(savedVariant); - } - - return savedVariants; - } - - @Transactional - public Word saveWord(WordAnalysisResult analysisResult) { - Word newWord = convertAnalysisResultToWord(analysisResult); - return wordRepository.save(newWord); - } - - private WordVariant saveWordFromAnalysis(String word, WordAnalysisResult analysisResult) { - String originalForm = analysisResult.getOriginalForm(); - LanguageCode sourceLanguageCode = analysisResult.getSourceLanguageCode(); - LanguageCode targetLanguageCode = analysisResult.getTargetLanguageCode(); - - wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode( - originalForm, sourceLanguageCode, targetLanguageCode - ).orElseGet(() -> { - Word newWord = convertAnalysisResultToWord(analysisResult); - Word savedWord = wordRepository.save(newWord); - log.info("Saved new word: {} ({} -> {})", originalForm, sourceLanguageCode, targetLanguageCode); - - saveWordVariants(savedWord); - - return savedWord; - }); - - Optional existingVariant = wordVariantRepository.findByWordAndOriginalForm(word, originalForm); - if (existingVariant.isPresent()) { - log.info("Variant already exists: {} -> {}", word, originalForm); - return existingVariant.get(); - } - - List variantTypes = analysisResult.getVariantTypes() != null && !analysisResult.getVariantTypes().isEmpty() - ? analysisResult.getVariantTypes() - : List.of(VariantType.ORIGINAL_FORM); - - WordVariant inputVariant = createVariant(word, originalForm, variantTypes); - wordVariantRepository.save(inputVariant); - log.info("Saved input variant: {} -> {} ({})", word, originalForm, variantTypes); - - return inputVariant; - } - - private void saveWordVariants(Word word) { - List variants = new ArrayList<>(); - - RelatedForms relatedForms = word.getRelatedForms(); - if (relatedForms == null) { - return; - } - - if (relatedForms.getConjugations() != null) { - var conj = relatedForms.getConjugations(); - if (conj.getPast() != null && !conj.getPast().equals(word.getWord())) { - variants.add(createVariant(conj.getPast(), word.getWord(), List.of(VariantType.PAST_TENSE))); - } - if (conj.getPastParticiple() != null && !conj.getPastParticiple().equals(word.getWord())) { - variants.add(createVariant(conj.getPastParticiple(), word.getWord(), List.of(VariantType.PAST_PARTICIPLE))); - } - if (conj.getPresentParticiple() != null && !conj.getPresentParticiple().equals(word.getWord())) { - variants.add(createVariant(conj.getPresentParticiple(), word.getWord(), List.of(VariantType.PRESENT_PARTICIPLE))); - } - if (conj.getThirdPerson() != null && !conj.getThirdPerson().equals(word.getWord())) { - variants.add(createVariant(conj.getThirdPerson(), word.getWord(), List.of(VariantType.THIRD_PERSON))); - } - } - - if (relatedForms.getComparatives() != null) { - var comp = relatedForms.getComparatives(); - if (comp.getComparative() != null && !comp.getComparative().equals(word.getWord())) { - variants.add(createVariant(comp.getComparative(), word.getWord(), List.of(VariantType.COMPARATIVE))); - } - if (comp.getSuperlative() != null && !comp.getSuperlative().equals(word.getWord())) { - variants.add(createVariant(comp.getSuperlative(), word.getWord(), List.of(VariantType.SUPERLATIVE))); - } - } - - if (relatedForms.getPlural() != null) { - var plural = relatedForms.getPlural(); - if (plural.getPlural() != null && !plural.getPlural().equals(word.getWord())) { - variants.add(createVariant(plural.getPlural(), word.getWord(), List.of(VariantType.PLURAL))); - } - } - - if (!variants.isEmpty()) { - List uniqueVariants = variants.stream() - .collect(Collectors.toMap( - WordVariant::getWord, - variant -> variant, - (existing, replacement) -> { - List mergedTypes = new ArrayList<>(existing.getVariantTypes()); - replacement.getVariantTypes().forEach(type -> { - if (!mergedTypes.contains(type)) { - mergedTypes.add(type); - } - }); - existing.setVariantTypes(mergedTypes); - return existing; - } - )) - .values() - .stream() - .toList(); - - List variantWords = uniqueVariants.stream() - .map(WordVariant::getWord) - .collect(Collectors.toList()); - - List existingVariants = wordVariantRepository.findByWordIn(variantWords); - List existingWords = existingVariants.stream() - .map(WordVariant::getWord) - .toList(); - - List newVariants = uniqueVariants.stream() - .filter(variant -> !existingWords.contains(variant.getWord())) - .collect(Collectors.toList()); - - if (!newVariants.isEmpty()) { - wordVariantRepository.saveAll(newVariants); - newVariants.forEach(variant -> - log.info("Saved variant: {} -> {} ({})", variant.getWord(), variant.getOriginalForm(), variant.getVariantTypes()) - ); - } - } - } - - @Transactional - public void saveInvalidWord(String word) { - Optional existingInvalidWord = invalidWordRepository.findByWord(word); - - if (existingInvalidWord.isPresent()) { - InvalidWord invalidWord = existingInvalidWord.get(); - invalidWord.setAttemptCount(invalidWord.getAttemptCount() + 1); - invalidWordRepository.save(invalidWord); - log.info("Updated invalid word '{}' attempt count: {}", word, invalidWord.getAttemptCount()); - return; - } - - InvalidWord invalidWord = InvalidWord.builder() - .word(word) - .attemptedAt(LocalDateTime.now()) - .attemptCount(1) - .build(); - invalidWordRepository.save(invalidWord); - log.info("Cached invalid word '{}' permanently (attempt 1/3)", word); - } - - private void deleteExistingWords(String word, LanguageCode targetLanguage, boolean deleteVariants) { - List existingVariants = wordVariantRepository.findAllByWord(word); - if (existingVariants.isEmpty()) { - return; - } - - for (WordVariant variant : existingVariants) { - String originalForm = variant.getOriginalForm(); - wordRepository.findByWordAndTargetLanguageCode( - originalForm, targetLanguage - ).ifPresent(wordToDelete -> { - wordRepository.delete(wordToDelete); - log.info("Deleted Word: {} (targetLanguage={})", originalForm, targetLanguage); - }); - } - - if (deleteVariants) { - wordVariantRepository.deleteAll(existingVariants); - log.info("Deleted {} existing WordVariants for word '{}' (complete reset)", existingVariants.size(), word); - return; - } - - log.info("Kept {} existing WordVariants for word '{}' (only Word deleted)", existingVariants.size(), word); - } - - private Word convertAnalysisResultToWord(WordAnalysisResult result) { - RelatedForms relatedForms = RelatedForms.builder() - .conjugations(result.getConjugations()) - .comparatives(result.getComparatives()) - .plural(result.getPlural()) - .build(); - - return Word.builder() - .word(result.getOriginalForm()) - .sourceLanguageCode(result.getSourceLanguageCode()) - .targetLanguageCode(result.getTargetLanguageCode()) - .summary(result.getSummary()) - .meanings(result.getMeanings()) - .relatedForms(relatedForms) - .build(); - } - - private WordVariant createVariant(String variantWord, String originalForm, List types) { - return WordVariant.builder() - .word(variantWord) - .originalForm(originalForm) - .variantTypes(types) - .build(); - } + private final WordRepository wordRepository; + + private final WordVariantRepository wordVariantRepository; + + private final InvalidWordRepository invalidWordRepository; + + @Transactional + public List saveAnalysisResults(String word, List analysisResults, + Optional cachedInvalidWord) { + List savedVariants = new ArrayList<>(); + for (WordAnalysisResult analysisResult : analysisResults) { + WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); + savedVariants.add(savedVariant); + } + + cachedInvalidWord.ifPresent(invalidWord -> { + invalidWordRepository.delete(invalidWord); + log.info("Removed word '{}' from invalid word cache after successful AI analysis (was attempt {}/3)", word, + invalidWord.getAttemptCount()); + }); + + return savedVariants; + } + + @Transactional + public List forceSaveAnalysisResults(String word, LanguageCode targetLanguage, boolean overwrite, + boolean deleteVariants, List analysisResults) { + if (overwrite) { + deleteExistingWords(word, targetLanguage, deleteVariants); + } + + List savedVariants = new ArrayList<>(); + for (WordAnalysisResult analysisResult : analysisResults) { + WordVariant savedVariant = saveWordFromAnalysis(word, analysisResult); + savedVariants.add(savedVariant); + } + + return savedVariants; + } + + @Transactional + public Word saveWord(WordAnalysisResult analysisResult) { + Word newWord = convertAnalysisResultToWord(analysisResult); + return wordRepository.save(newWord); + } + + private WordVariant saveWordFromAnalysis(String word, WordAnalysisResult analysisResult) { + String originalForm = analysisResult.getOriginalForm(); + LanguageCode sourceLanguageCode = analysisResult.getSourceLanguageCode(); + LanguageCode targetLanguageCode = analysisResult.getTargetLanguageCode(); + + wordRepository + .findByWordAndSourceLanguageCodeAndTargetLanguageCode(originalForm, sourceLanguageCode, targetLanguageCode) + .orElseGet(() -> { + Word newWord = convertAnalysisResultToWord(analysisResult); + Word savedWord = wordRepository.save(newWord); + log.info("Saved new word: {} ({} -> {})", originalForm, sourceLanguageCode, targetLanguageCode); + + saveWordVariants(savedWord); + + return savedWord; + }); + + Optional existingVariant = wordVariantRepository.findByWordAndOriginalForm(word, originalForm); + if (existingVariant.isPresent()) { + log.info("Variant already exists: {} -> {}", word, originalForm); + return existingVariant.get(); + } + + List variantTypes = analysisResult.getVariantTypes() != null + && !analysisResult.getVariantTypes().isEmpty() ? analysisResult.getVariantTypes() + : List.of(VariantType.ORIGINAL_FORM); + + WordVariant inputVariant = createVariant(word, originalForm, variantTypes); + wordVariantRepository.save(inputVariant); + log.info("Saved input variant: {} -> {} ({})", word, originalForm, variantTypes); + + return inputVariant; + } + + private void saveWordVariants(Word word) { + List variants = new ArrayList<>(); + + RelatedForms relatedForms = word.getRelatedForms(); + if (relatedForms == null) { + return; + } + + if (relatedForms.getConjugations() != null) { + var conj = relatedForms.getConjugations(); + if (conj.getPast() != null && !conj.getPast().equals(word.getWord())) { + variants.add(createVariant(conj.getPast(), word.getWord(), List.of(VariantType.PAST_TENSE))); + } + if (conj.getPastParticiple() != null && !conj.getPastParticiple().equals(word.getWord())) { + variants + .add(createVariant(conj.getPastParticiple(), word.getWord(), List.of(VariantType.PAST_PARTICIPLE))); + } + if (conj.getPresentParticiple() != null && !conj.getPresentParticiple().equals(word.getWord())) { + variants.add(createVariant(conj.getPresentParticiple(), word.getWord(), + List.of(VariantType.PRESENT_PARTICIPLE))); + } + if (conj.getThirdPerson() != null && !conj.getThirdPerson().equals(word.getWord())) { + variants.add(createVariant(conj.getThirdPerson(), word.getWord(), List.of(VariantType.THIRD_PERSON))); + } + } + + if (relatedForms.getComparatives() != null) { + var comp = relatedForms.getComparatives(); + if (comp.getComparative() != null && !comp.getComparative().equals(word.getWord())) { + variants.add(createVariant(comp.getComparative(), word.getWord(), List.of(VariantType.COMPARATIVE))); + } + if (comp.getSuperlative() != null && !comp.getSuperlative().equals(word.getWord())) { + variants.add(createVariant(comp.getSuperlative(), word.getWord(), List.of(VariantType.SUPERLATIVE))); + } + } + + if (relatedForms.getPlural() != null) { + var plural = relatedForms.getPlural(); + if (plural.getPlural() != null && !plural.getPlural().equals(word.getWord())) { + variants.add(createVariant(plural.getPlural(), word.getWord(), List.of(VariantType.PLURAL))); + } + } + + if (!variants.isEmpty()) { + List uniqueVariants = variants.stream() + .collect(Collectors.toMap(WordVariant::getWord, variant -> variant, (existing, replacement) -> { + List mergedTypes = new ArrayList<>(existing.getVariantTypes()); + replacement.getVariantTypes().forEach(type -> { + if (!mergedTypes.contains(type)) { + mergedTypes.add(type); + } + }); + existing.setVariantTypes(mergedTypes); + return existing; + })) + .values() + .stream() + .toList(); + + List variantWords = uniqueVariants.stream().map(WordVariant::getWord).collect(Collectors.toList()); + + List existingVariants = wordVariantRepository.findByWordIn(variantWords); + List existingWords = existingVariants.stream().map(WordVariant::getWord).toList(); + + List newVariants = uniqueVariants.stream() + .filter(variant -> !existingWords.contains(variant.getWord())) + .collect(Collectors.toList()); + + if (!newVariants.isEmpty()) { + wordVariantRepository.saveAll(newVariants); + newVariants.forEach(variant -> log.info("Saved variant: {} -> {} ({})", variant.getWord(), + variant.getOriginalForm(), variant.getVariantTypes())); + } + } + } + + @Transactional + public void saveInvalidWord(String word) { + Optional existingInvalidWord = invalidWordRepository.findByWord(word); + + if (existingInvalidWord.isPresent()) { + InvalidWord invalidWord = existingInvalidWord.get(); + invalidWord.setAttemptCount(invalidWord.getAttemptCount() + 1); + invalidWordRepository.save(invalidWord); + log.info("Updated invalid word '{}' attempt count: {}", word, invalidWord.getAttemptCount()); + return; + } + + InvalidWord invalidWord = InvalidWord.builder() + .word(word) + .attemptedAt(LocalDateTime.now()) + .attemptCount(1) + .build(); + invalidWordRepository.save(invalidWord); + log.info("Cached invalid word '{}' permanently (attempt 1/3)", word); + } + + private void deleteExistingWords(String word, LanguageCode targetLanguage, boolean deleteVariants) { + List existingVariants = wordVariantRepository.findAllByWord(word); + if (existingVariants.isEmpty()) { + return; + } + + for (WordVariant variant : existingVariants) { + String originalForm = variant.getOriginalForm(); + wordRepository.findByWordAndTargetLanguageCode(originalForm, targetLanguage).ifPresent(wordToDelete -> { + wordRepository.delete(wordToDelete); + log.info("Deleted Word: {} (targetLanguage={})", originalForm, targetLanguage); + }); + } + + if (deleteVariants) { + wordVariantRepository.deleteAll(existingVariants); + log.info("Deleted {} existing WordVariants for word '{}' (complete reset)", existingVariants.size(), word); + return; + } + + log.info("Kept {} existing WordVariants for word '{}' (only Word deleted)", existingVariants.size(), word); + } + + private Word convertAnalysisResultToWord(WordAnalysisResult result) { + RelatedForms relatedForms = RelatedForms.builder() + .conjugations(result.getConjugations()) + .comparatives(result.getComparatives()) + .plural(result.getPlural()) + .build(); + + return Word.builder() + .word(result.getOriginalForm()) + .sourceLanguageCode(result.getSourceLanguageCode()) + .targetLanguageCode(result.getTargetLanguageCode()) + .summary(result.getSummary()) + .meanings(result.getMeanings()) + .relatedForms(relatedForms) + .build(); + } + + private WordVariant createVariant(String variantWord, String originalForm, List types) { + return WordVariant.builder().word(variantWord).originalForm(originalForm).variantTypes(types).build(); + } + } diff --git a/src/main/java/com/linglevel/api/word/service/WordService.java b/src/main/java/com/linglevel/api/word/service/WordService.java index 3a9235cb..4fbacf32 100644 --- a/src/main/java/com/linglevel/api/word/service/WordService.java +++ b/src/main/java/com/linglevel/api/word/service/WordService.java @@ -24,219 +24,184 @@ @Slf4j public class WordService { - private final WordRepository wordRepository; - private final WordBookmarkRepository wordBookmarkRepository; - private final WordVariantRepository wordVariantRepository; - private final InvalidWordRepository invalidWordRepository; - private final WordAiService wordAiService; - private final WordSingleFlightRedisCoordinator singleFlightCoordinator; - private final WordPersistenceService wordPersistenceService; - - public WordSearchResponse getOrCreateWords(String userId, String word, LanguageCode targetLanguage) { - List wordVariants = getOrCreateWordEntities(word, targetLanguage); - - // 각 원형에 대한 WordResponse 생성 - List results = new ArrayList<>(); - - for (WordVariant wordVariant : wordVariants) { - // 원형 단어를 targetLanguage로 번역된 것 가져오기 - Word originalWord = wordRepository.findByWordAndTargetLanguageCode( - wordVariant.getOriginalForm(), - targetLanguage - ).orElseGet(() -> { - // 해당 언어로 번역된 Word가 없으면 AI로 새로 생성 - log.info("Word '{}' not found for targetLanguage {}, creating new one...", - wordVariant.getOriginalForm(), targetLanguage); - - return singleFlightCoordinator.execute( - wordVariant.getOriginalForm(), - targetLanguage, - () -> { - List analysisResults = wordAiService.analyzeWord( - wordVariant.getOriginalForm(), - targetLanguage.getCode() - ); - return wordPersistenceService.saveWord(analysisResults.get(0)); - }, - () -> wordRepository.findByWordAndTargetLanguageCode( - wordVariant.getOriginalForm(), - targetLanguage - ) - ); - }); - - boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, wordVariant.getOriginalForm()); - - WordResponse response = convertToResponse( - originalWord, - isBookmarked, - wordVariant.getVariantTypes(), - wordVariant.getOriginalForm() - ); - - results.add(response); - } - - return WordSearchResponse.builder() - .searchedWord(word) - .results(results) - .build(); - } - - public List getOrCreateWordEntities(String word, LanguageCode targetLanguage) { - // 1. WordVariant에서 검색 (변형 형태인지 확인) - List existingVariants = wordVariantRepository.findAllByWord(word); - if (!existingVariants.isEmpty()) { - log.info("Found {} existing variants for word '{}'", existingVariants.size(), word); - return existingVariants; - } - - // 2. InvalidWord 캐시 확인 - 3회 유예 후 차단 - Optional cachedInvalidWord = invalidWordRepository.findByWord(word); - int invalidAttemptCountBeforeSingleFlight = cachedInvalidWord - .map(InvalidWord::getAttemptCount) - .orElse(0); - if (cachedInvalidWord.isPresent()) { - InvalidWord invalidWord = cachedInvalidWord.get(); - if (invalidWord.getAttemptCount() >= 3) { - log.info("Word '{}' permanently blocked after {} failed attempts", word, invalidWord.getAttemptCount()); - throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); - } - log.info("Word '{}' found in cache with {} attempts. Allowing retry (attempt {}/3)", - word, invalidWord.getAttemptCount(), invalidWord.getAttemptCount() + 1); - } - - // 3. DB에 없으면 AI 호출 (AI 분석 실패 시에만 InvalidWord로 캐싱) - log.info("Word '{}' not found in database. Calling AI to analyze...", word); - return singleFlightCoordinator.execute( - word, - targetLanguage, - () -> { - List analysisResults = analyzeWordAndUpdateInvalidCache( - word, - targetLanguage - ); - return wordPersistenceService.saveAnalysisResults(word, analysisResults, cachedInvalidWord); - }, - () -> findWordVariantsAfterSingleFlight(word, invalidAttemptCountBeforeSingleFlight) - ); - } - - private Optional> findWordVariantsAfterSingleFlight( - String word, - int invalidAttemptCountBeforeSingleFlight - ) { - List existingVariants = wordVariantRepository.findAllByWord(word); - if (!existingVariants.isEmpty()) { - return Optional.of(existingVariants); - } - - Optional currentInvalidWord = invalidWordRepository.findByWord(word); - if (currentInvalidWord.isPresent()) { - int currentAttemptCount = currentInvalidWord.get().getAttemptCount(); - - if (currentAttemptCount >= 3 || currentAttemptCount > invalidAttemptCountBeforeSingleFlight) { - throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); - } - } - - return Optional.empty(); - } - - private void cacheInvalidWordIfMeaningless(String word, WordsException e) { - if (e.getErrorCode() == WordsErrorCode.WORD_IS_MEANINGLESS) { - log.warn("AI classified word '{}' as meaningless. Updating invalid-word cache.", word, e); - wordPersistenceService.saveInvalidWord(word); - } - } - - private List analyzeWordAndUpdateInvalidCache( - String word, - LanguageCode targetLanguage - ) { - try { - return wordAiService.analyzeWord(word, targetLanguage.getCode()); - } catch (WordsException e) { - cacheInvalidWordIfMeaningless(word, e); - throw e; - } - } - - private WordResponse convertToResponse(Word word, boolean isBookmarked, List variantTypes, String originalForm) { - return WordResponse.builder() - .id(word.getId()) - .originalForm(originalForm) - .variantTypes(variantTypes) - .sourceLanguageCode(word.getSourceLanguageCode()) - .targetLanguageCode(word.getTargetLanguageCode()) - .summary(word.getSummary()) - .meanings(word.getMeanings()) // Meaning을 그대로 사용 - .relatedForms(word.getRelatedForms()) - .bookmarked(isBookmarked) - .isEssential(word.getIsEssential()) - .build(); - } - - /** - * 관리자 전용: 단어를 AI로 강제 재분석 - * - * @param word 재분석할 단어 - * @param targetLanguage 번역 대상 언어 - * @param overwrite true: 기존 데이터 삭제 후 재생성, false: 기존 유지 + 새로운 의미 추가 - */ - public void forceReanalyzeWord(String word, LanguageCode targetLanguage, boolean overwrite) { - forceReanalyzeWord(word, targetLanguage, overwrite, false); - } - - /** - * 관리자 전용: 단어를 AI로 강제 재분석 (Variant 삭제 옵션 포함) - * - * @param word 재분석할 단어 - * @param targetLanguage 번역 대상 언어 - * @param overwrite true: 기존 데이터 삭제 후 재생성, false: 기존 유지 + 새로운 의미 추가 - * @param deleteVariants true: Variant도 함께 삭제 (완전 초기화), false: Variant 유지 (기본값) - * @return WordSearchResponse - */ - public WordSearchResponse forceReanalyzeWord(String word, LanguageCode targetLanguage, boolean overwrite, boolean deleteVariants) { - log.info("Force re-analyzing word '{}' with targetLanguage={}, overwrite={}, deleteVariants={}", - word, targetLanguage, overwrite, deleteVariants); - - // AI로 재분석 - log.info("Calling AI to re-analyze word '{}'...", word); - List analysisResults = wordAiService.analyzeWord(word, targetLanguage.getCode()); - - // 분석 결과를 DB에 저장 (빈 결과는 WordAiService에서 예외 발생, overwrite=false면 중복 체크로 인해 새로운 것만 추가됨) - List savedVariants = wordPersistenceService.forceSaveAnalysisResults( - word, - targetLanguage, - overwrite, - deleteVariants, - analysisResults - ); - - log.info("Force re-analysis completed. Saved {} variants", savedVariants.size()); - - // 결과를 WordSearchResponse로 변환하여 반환 - // userId는 null로 전달 (어드민 API이므로 북마크 체크 불필요) - List results = new ArrayList<>(); - for (WordVariant wordVariant : savedVariants) { - Word originalWord = wordRepository.findByWordAndTargetLanguageCode( - wordVariant.getOriginalForm(), - targetLanguage - ).orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); - - WordResponse response = convertToResponse( - originalWord, - false, // 어드민 API이므로 북마크 체크하지 않음 - wordVariant.getVariantTypes(), - wordVariant.getOriginalForm() - ); - results.add(response); - } - - return WordSearchResponse.builder() - .searchedWord(word) - .results(results) - .build(); - } + private final WordRepository wordRepository; + + private final WordBookmarkRepository wordBookmarkRepository; + + private final WordVariantRepository wordVariantRepository; + + private final InvalidWordRepository invalidWordRepository; + + private final WordAiService wordAiService; + + private final WordSingleFlightRedisCoordinator singleFlightCoordinator; + + private final WordPersistenceService wordPersistenceService; + + public WordSearchResponse getOrCreateWords(String userId, String word, LanguageCode targetLanguage) { + List wordVariants = getOrCreateWordEntities(word, targetLanguage); + + // 각 원형에 대한 WordResponse 생성 + List results = new ArrayList<>(); + + for (WordVariant wordVariant : wordVariants) { + // 원형 단어를 targetLanguage로 번역된 것 가져오기 + Word originalWord = wordRepository + .findByWordAndTargetLanguageCode(wordVariant.getOriginalForm(), targetLanguage) + .orElseGet(() -> { + // 해당 언어로 번역된 Word가 없으면 AI로 새로 생성 + log.info("Word '{}' not found for targetLanguage {}, creating new one...", + wordVariant.getOriginalForm(), targetLanguage); + + return singleFlightCoordinator.execute(wordVariant.getOriginalForm(), targetLanguage, () -> { + List analysisResults = wordAiService + .analyzeWord(wordVariant.getOriginalForm(), targetLanguage.getCode()); + return wordPersistenceService.saveWord(analysisResults.get(0)); + }, () -> wordRepository.findByWordAndTargetLanguageCode(wordVariant.getOriginalForm(), + targetLanguage)); + }); + + boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, wordVariant.getOriginalForm()); + + WordResponse response = convertToResponse(originalWord, isBookmarked, wordVariant.getVariantTypes(), + wordVariant.getOriginalForm()); + + results.add(response); + } + + return WordSearchResponse.builder().searchedWord(word).results(results).build(); + } + + public List getOrCreateWordEntities(String word, LanguageCode targetLanguage) { + // 1. WordVariant에서 검색 (변형 형태인지 확인) + List existingVariants = wordVariantRepository.findAllByWord(word); + if (!existingVariants.isEmpty()) { + log.info("Found {} existing variants for word '{}'", existingVariants.size(), word); + return existingVariants; + } + + // 2. InvalidWord 캐시 확인 - 3회 유예 후 차단 + Optional cachedInvalidWord = invalidWordRepository.findByWord(word); + int invalidAttemptCountBeforeSingleFlight = cachedInvalidWord.map(InvalidWord::getAttemptCount).orElse(0); + if (cachedInvalidWord.isPresent()) { + InvalidWord invalidWord = cachedInvalidWord.get(); + if (invalidWord.getAttemptCount() >= 3) { + log.info("Word '{}' permanently blocked after {} failed attempts", word, invalidWord.getAttemptCount()); + throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); + } + log.info("Word '{}' found in cache with {} attempts. Allowing retry (attempt {}/3)", word, + invalidWord.getAttemptCount(), invalidWord.getAttemptCount() + 1); + } + + // 3. DB에 없으면 AI 호출 (AI 분석 실패 시에만 InvalidWord로 캐싱) + log.info("Word '{}' not found in database. Calling AI to analyze...", word); + return singleFlightCoordinator.execute(word, targetLanguage, () -> { + List analysisResults = analyzeWordAndUpdateInvalidCache(word, targetLanguage); + return wordPersistenceService.saveAnalysisResults(word, analysisResults, cachedInvalidWord); + }, () -> findWordVariantsAfterSingleFlight(word, invalidAttemptCountBeforeSingleFlight)); + } + + private Optional> findWordVariantsAfterSingleFlight(String word, + int invalidAttemptCountBeforeSingleFlight) { + List existingVariants = wordVariantRepository.findAllByWord(word); + if (!existingVariants.isEmpty()) { + return Optional.of(existingVariants); + } + + Optional currentInvalidWord = invalidWordRepository.findByWord(word); + if (currentInvalidWord.isPresent()) { + int currentAttemptCount = currentInvalidWord.get().getAttemptCount(); + + if (currentAttemptCount >= 3 || currentAttemptCount > invalidAttemptCountBeforeSingleFlight) { + throw new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS); + } + } + + return Optional.empty(); + } + + private void cacheInvalidWordIfMeaningless(String word, WordsException e) { + if (e.getErrorCode() == WordsErrorCode.WORD_IS_MEANINGLESS) { + log.warn("AI classified word '{}' as meaningless. Updating invalid-word cache.", word, e); + wordPersistenceService.saveInvalidWord(word); + } + } + + private List analyzeWordAndUpdateInvalidCache(String word, LanguageCode targetLanguage) { + try { + return wordAiService.analyzeWord(word, targetLanguage.getCode()); + } + catch (WordsException e) { + cacheInvalidWordIfMeaningless(word, e); + throw e; + } + } + + private WordResponse convertToResponse(Word word, boolean isBookmarked, List variantTypes, + String originalForm) { + return WordResponse.builder() + .id(word.getId()) + .originalForm(originalForm) + .variantTypes(variantTypes) + .sourceLanguageCode(word.getSourceLanguageCode()) + .targetLanguageCode(word.getTargetLanguageCode()) + .summary(word.getSummary()) + .meanings(word.getMeanings()) // Meaning을 그대로 사용 + .relatedForms(word.getRelatedForms()) + .bookmarked(isBookmarked) + .isEssential(word.getIsEssential()) + .build(); + } + + /** + * 관리자 전용: 단어를 AI로 강제 재분석 + * @param word 재분석할 단어 + * @param targetLanguage 번역 대상 언어 + * @param overwrite true: 기존 데이터 삭제 후 재생성, false: 기존 유지 + 새로운 의미 추가 + */ + public void forceReanalyzeWord(String word, LanguageCode targetLanguage, boolean overwrite) { + forceReanalyzeWord(word, targetLanguage, overwrite, false); + } + + /** + * 관리자 전용: 단어를 AI로 강제 재분석 (Variant 삭제 옵션 포함) + * @param word 재분석할 단어 + * @param targetLanguage 번역 대상 언어 + * @param overwrite true: 기존 데이터 삭제 후 재생성, false: 기존 유지 + 새로운 의미 추가 + * @param deleteVariants true: Variant도 함께 삭제 (완전 초기화), false: Variant 유지 (기본값) + * @return WordSearchResponse + */ + public WordSearchResponse forceReanalyzeWord(String word, LanguageCode targetLanguage, boolean overwrite, + boolean deleteVariants) { + log.info("Force re-analyzing word '{}' with targetLanguage={}, overwrite={}, deleteVariants={}", word, + targetLanguage, overwrite, deleteVariants); + + // AI로 재분석 + log.info("Calling AI to re-analyze word '{}'...", word); + List analysisResults = wordAiService.analyzeWord(word, targetLanguage.getCode()); + + // 분석 결과를 DB에 저장 (빈 결과는 WordAiService에서 예외 발생, overwrite=false면 중복 체크로 인해 새로운 것만 + // 추가됨) + List savedVariants = wordPersistenceService.forceSaveAnalysisResults(word, targetLanguage, + overwrite, deleteVariants, analysisResults); + + log.info("Force re-analysis completed. Saved {} variants", savedVariants.size()); + + // 결과를 WordSearchResponse로 변환하여 반환 + // userId는 null로 전달 (어드민 API이므로 북마크 체크 불필요) + List results = new ArrayList<>(); + for (WordVariant wordVariant : savedVariants) { + Word originalWord = wordRepository + .findByWordAndTargetLanguageCode(wordVariant.getOriginalForm(), targetLanguage) + .orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND)); + + WordResponse response = convertToResponse(originalWord, false, // 어드민 API이므로 + // 북마크 체크하지 않음 + wordVariant.getVariantTypes(), wordVariant.getOriginalForm()); + results.add(response); + } + + return WordSearchResponse.builder().searchedWord(word).results(results).build(); + } + } diff --git a/src/main/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinator.java b/src/main/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinator.java index 199dd121..b9c91115 100644 --- a/src/main/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinator.java +++ b/src/main/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinator.java @@ -39,252 +39,241 @@ @Slf4j public class WordSingleFlightRedisCoordinator { - private static final String LOCK_PREFIX = "sf:word:lock"; - private static final String DONE_PREFIX = "sf:word:done"; - private static final String DONE_PATTERN = DONE_PREFIX + ":*"; - - private final StringRedisTemplate stringRedisTemplate; - private final RedisMessageListenerContainer redisMessageListenerContainer; - private final RedissonClient redissonClient; - private final WordSingleFlightProperties properties; - - private final ConcurrentHashMap>> channelWaiters = new ConcurrentHashMap<>(); - - private final MessageListener doneListener = this::onDoneMessage; - - @PostConstruct - void initialize() { - redisMessageListenerContainer.addMessageListener(doneListener, new PatternTopic(DONE_PATTERN)); - } - - @PreDestroy - void shutdown() { - - } - - public T execute( - String word, - LanguageCode targetLanguage, - Supplier leaderAction, - Supplier> followerResultLookup - ) { - if (!properties.isEnabled()) { - return leaderAction.get(); - } - - KeySet keys = buildKeySet(word, targetLanguage); - RLock lock = createLock(keys.lockKey()); - boolean lockAcquired = tryAcquireLeaderLock(lock); - if (lockAcquired) { - return executeWithLeaderLock(keys, lock, leaderAction, followerResultLookup); - } - - return waitAsFollower(keys, lock, leaderAction, followerResultLookup); - } - - private T executeWithLeaderLock( - KeySet keys, - RLock lock, - Supplier leaderAction, - Supplier> followerResultLookup - ) { - Optional existing; - try { - existing = followerResultLookup.get(); - } catch (RuntimeException | Error e) { - releaseLock(lock, keys.lockKey()); - throw e; - } - - if (existing.isPresent()) { - releaseThenPublishDone(keys, lock); - return existing.get(); - } - - return executeAsLeader(keys, lock, leaderAction); - } - - private T executeAsLeader( - KeySet keys, - RLock lock, - Supplier leaderAction - ) { - T result; - try { - result = leaderAction.get(); - } catch (RuntimeException | Error e) { - completeLeaderAfterCompletion(keys, lock); - throw e; - } - - completeLeaderAfterCommit(keys, lock); - return result; - } - - private boolean tryAcquireLeaderLock(RLock lock) { - try { - // Use Redisson watchdog mode (no fixed lease time) to keep lock alive - // while leaderAction is still running, and release promptly on unlock. - return lock.tryLock(0, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted while acquiring single-flight lock", e); - } - } - - private T waitAsFollower( - KeySet keys, - RLock lock, - Supplier leaderAction, - Supplier> followerResultLookup - ) { - CompletableFuture signal = new CompletableFuture<>(); - registerWaiter(keys.channel(), signal); - - try { - boolean lockAcquiredAfterRegister = tryAcquireLeaderLock(lock); - if (lockAcquiredAfterRegister) { - return executeWithLeaderLock(keys, lock, leaderAction, followerResultLookup); - } - - signal.get(properties.getWaitTimeoutMs(), TimeUnit.MILLISECONDS); - } catch (TimeoutException e) { - log.warn("Single-flight wait timed out for key digest={}", keys.digest()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Single-flight wait interrupted for key digest=" + keys.digest(), e); - } catch (ExecutionException e) { - throw new RuntimeException("Single-flight wait failed for key digest=" + keys.digest(), e); - } finally { - unregisterWaiter(keys.channel(), signal); - } - - Optional finalResult = followerResultLookup.get(); - if (finalResult.isPresent()) { - return finalResult.get(); - } - - throw new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT); - } - - private void completeLeaderAfterCommit(KeySet keys, RLock lock) { - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - releaseThenPublishDone(keys, lock); - return; - } - - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCommit() { - releaseThenPublishDone(keys, lock); - } - - @Override - public void afterCompletion(int status) { - if (status != STATUS_COMMITTED) { - releaseLock(lock, keys.lockKey()); - } - } - }); - } - - private void completeLeaderAfterCompletion(KeySet keys, RLock lock) { - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - releaseThenPublishDone(keys, lock); - return; - } - - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCompletion(int status) { - releaseThenPublishDone(keys, lock); - } - }); - } - - private void releaseThenPublishDone(KeySet keys, RLock lock) { - releaseLock(lock, keys.lockKey()); - publishDone(keys.channel()); - } - - private void publishDone(String channel) { - stringRedisTemplate.convertAndSend(channel, "done"); - } - - private void releaseLock(RLock lock, String lockKey) { - try { - lock.unlock(); - } catch (IllegalMonitorStateException e) { - log.warn("Single-flight lock was not held at release time key={}", lockKey, e); - } catch (Exception e) { - log.warn("Failed to release single-flight lock key={}", lockKey, e); - } - } - - private RLock createLock(String lockKey) { - return redissonClient.getLock(lockKey); - } - - private void registerWaiter(String channel, CompletableFuture signal) { - channelWaiters.compute(channel, (key, waiters) -> { - CopyOnWriteArrayList> values = waiters == null - ? new CopyOnWriteArrayList<>() - : waiters; - values.add(signal); - return values; - }); - } - - private void unregisterWaiter(String channel, CompletableFuture signal) { - channelWaiters.computeIfPresent(channel, (key, waiters) -> { - waiters.remove(signal); - return waiters.isEmpty() ? null : waiters; - }); - } - - private void onDoneMessage(Message message, byte[] pattern) { - String channel = new String(message.getChannel(), StandardCharsets.UTF_8); - List> waiters = channelWaiters.remove(channel); - if (waiters == null || waiters.isEmpty()) { - return; - } - - for (CompletableFuture waiter : waiters) { - waiter.complete(null); - } - } - - private KeySet buildKeySet(String word, LanguageCode targetLanguage) { - String normalizedWord = word.trim().toLowerCase(Locale.ROOT); - String canonicalKey = String.join("|", - "word=" + normalizedWord, - "lang=" + targetLanguage.getCode(), - "resultSchema=" + properties.getResultSchemaVersion() - ); - - String digest = sha256(canonicalKey); - String suffix = properties.getResultSchemaVersion() + ":" + digest; - - return new KeySet( - LOCK_PREFIX + ":" + suffix, - DONE_PREFIX + ":" + suffix, - digest - ); - } - - private String sha256(String value) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(digest); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 is not available", e); - } - } - - private record KeySet( - String lockKey, - String channel, - String digest - ) { } + private static final String LOCK_PREFIX = "sf:word:lock"; + + private static final String DONE_PREFIX = "sf:word:done"; + + private static final String DONE_PATTERN = DONE_PREFIX + ":*"; + + private final StringRedisTemplate stringRedisTemplate; + + private final RedisMessageListenerContainer redisMessageListenerContainer; + + private final RedissonClient redissonClient; + + private final WordSingleFlightProperties properties; + + private final ConcurrentHashMap>> channelWaiters = new ConcurrentHashMap<>(); + + private final MessageListener doneListener = this::onDoneMessage; + + @PostConstruct + void initialize() { + redisMessageListenerContainer.addMessageListener(doneListener, new PatternTopic(DONE_PATTERN)); + } + + @PreDestroy + void shutdown() { + + } + + public T execute(String word, LanguageCode targetLanguage, Supplier leaderAction, + Supplier> followerResultLookup) { + if (!properties.isEnabled()) { + return leaderAction.get(); + } + + KeySet keys = buildKeySet(word, targetLanguage); + RLock lock = createLock(keys.lockKey()); + boolean lockAcquired = tryAcquireLeaderLock(lock); + if (lockAcquired) { + return executeWithLeaderLock(keys, lock, leaderAction, followerResultLookup); + } + + return waitAsFollower(keys, lock, leaderAction, followerResultLookup); + } + + private T executeWithLeaderLock(KeySet keys, RLock lock, Supplier leaderAction, + Supplier> followerResultLookup) { + Optional existing; + try { + existing = followerResultLookup.get(); + } + catch (RuntimeException | Error e) { + releaseLock(lock, keys.lockKey()); + throw e; + } + + if (existing.isPresent()) { + releaseThenPublishDone(keys, lock); + return existing.get(); + } + + return executeAsLeader(keys, lock, leaderAction); + } + + private T executeAsLeader(KeySet keys, RLock lock, Supplier leaderAction) { + T result; + try { + result = leaderAction.get(); + } + catch (RuntimeException | Error e) { + completeLeaderAfterCompletion(keys, lock); + throw e; + } + + completeLeaderAfterCommit(keys, lock); + return result; + } + + private boolean tryAcquireLeaderLock(RLock lock) { + try { + // Use Redisson watchdog mode (no fixed lease time) to keep lock alive + // while leaderAction is still running, and release promptly on unlock. + return lock.tryLock(0, TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while acquiring single-flight lock", e); + } + } + + private T waitAsFollower(KeySet keys, RLock lock, Supplier leaderAction, + Supplier> followerResultLookup) { + CompletableFuture signal = new CompletableFuture<>(); + registerWaiter(keys.channel(), signal); + + try { + boolean lockAcquiredAfterRegister = tryAcquireLeaderLock(lock); + if (lockAcquiredAfterRegister) { + return executeWithLeaderLock(keys, lock, leaderAction, followerResultLookup); + } + + signal.get(properties.getWaitTimeoutMs(), TimeUnit.MILLISECONDS); + } + catch (TimeoutException e) { + log.warn("Single-flight wait timed out for key digest={}", keys.digest()); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Single-flight wait interrupted for key digest=" + keys.digest(), e); + } + catch (ExecutionException e) { + throw new RuntimeException("Single-flight wait failed for key digest=" + keys.digest(), e); + } + finally { + unregisterWaiter(keys.channel(), signal); + } + + Optional finalResult = followerResultLookup.get(); + if (finalResult.isPresent()) { + return finalResult.get(); + } + + throw new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT); + } + + private void completeLeaderAfterCommit(KeySet keys, RLock lock) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + releaseThenPublishDone(keys, lock); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + releaseThenPublishDone(keys, lock); + } + + @Override + public void afterCompletion(int status) { + if (status != STATUS_COMMITTED) { + releaseLock(lock, keys.lockKey()); + } + } + }); + } + + private void completeLeaderAfterCompletion(KeySet keys, RLock lock) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + releaseThenPublishDone(keys, lock); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + releaseThenPublishDone(keys, lock); + } + }); + } + + private void releaseThenPublishDone(KeySet keys, RLock lock) { + releaseLock(lock, keys.lockKey()); + publishDone(keys.channel()); + } + + private void publishDone(String channel) { + stringRedisTemplate.convertAndSend(channel, "done"); + } + + private void releaseLock(RLock lock, String lockKey) { + try { + lock.unlock(); + } + catch (IllegalMonitorStateException e) { + log.warn("Single-flight lock was not held at release time key={}", lockKey, e); + } + catch (Exception e) { + log.warn("Failed to release single-flight lock key={}", lockKey, e); + } + } + + private RLock createLock(String lockKey) { + return redissonClient.getLock(lockKey); + } + + private void registerWaiter(String channel, CompletableFuture signal) { + channelWaiters.compute(channel, (key, waiters) -> { + CopyOnWriteArrayList> values = waiters == null ? new CopyOnWriteArrayList<>() + : waiters; + values.add(signal); + return values; + }); + } + + private void unregisterWaiter(String channel, CompletableFuture signal) { + channelWaiters.computeIfPresent(channel, (key, waiters) -> { + waiters.remove(signal); + return waiters.isEmpty() ? null : waiters; + }); + } + + private void onDoneMessage(Message message, byte[] pattern) { + String channel = new String(message.getChannel(), StandardCharsets.UTF_8); + List> waiters = channelWaiters.remove(channel); + if (waiters == null || waiters.isEmpty()) { + return; + } + + for (CompletableFuture waiter : waiters) { + waiter.complete(null); + } + } + + private KeySet buildKeySet(String word, LanguageCode targetLanguage) { + String normalizedWord = word.trim().toLowerCase(Locale.ROOT); + String canonicalKey = String.join("|", "word=" + normalizedWord, "lang=" + targetLanguage.getCode(), + "resultSchema=" + properties.getResultSchemaVersion()); + + String digest = sha256(canonicalKey); + String suffix = properties.getResultSchemaVersion() + ":" + digest; + + return new KeySet(LOCK_PREFIX + ":" + suffix, DONE_PREFIX + ":" + suffix, digest); + } + + private String sha256(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } + catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 is not available", e); + } + } + + private record KeySet(String lockKey, String channel, String digest) { + } + } diff --git a/src/main/java/com/linglevel/api/word/service/WordVariantService.java b/src/main/java/com/linglevel/api/word/service/WordVariantService.java index ff85628f..4fd58ff0 100644 --- a/src/main/java/com/linglevel/api/word/service/WordVariantService.java +++ b/src/main/java/com/linglevel/api/word/service/WordVariantService.java @@ -11,27 +11,23 @@ /** * WordVariant 전용 서비스 (언어 중립적) * - * 북마크, 원형 조회 등 번역이 필요없는 작업에 사용 - * - WordVariant는 "변형 → 원형" 매핑만 저장 (언어 정보 없음) - * - 예: "ran" → "run", "prettiest" → "pretty" + * 북마크, 원형 조회 등 번역이 필요없는 작업에 사용 - WordVariant는 "변형 → 원형" 매핑만 저장 (언어 정보 없음) - 예: "ran" → + * "run", "prettiest" → "pretty" */ @Service @RequiredArgsConstructor @Slf4j public class WordVariantService { - private final WordVariantRepository wordVariantRepository; + private final WordVariantRepository wordVariantRepository; + + /** + * 단어의 원형 후보들을 반환 (언어 중립적) + * @param word 검색할 단어 (변형 형태 가능) + * @return 원형 단어 후보 목록 + */ + public List getOriginalForms(String word) { + return wordVariantRepository.findAllByWord(word).stream().map(WordVariant::getOriginalForm).distinct().toList(); + } - /** - * 단어의 원형 후보들을 반환 (언어 중립적) - * - * @param word 검색할 단어 (변형 형태 가능) - * @return 원형 단어 후보 목록 - */ - public List getOriginalForms(String word) { - return wordVariantRepository.findAllByWord(word).stream() - .map(WordVariant::getOriginalForm) - .distinct() - .toList(); - } } diff --git a/src/main/java/com/linglevel/api/word/validator/WordValidator.java b/src/main/java/com/linglevel/api/word/validator/WordValidator.java index c6fea3c4..61e5eecd 100644 --- a/src/main/java/com/linglevel/api/word/validator/WordValidator.java +++ b/src/main/java/com/linglevel/api/word/validator/WordValidator.java @@ -7,76 +7,74 @@ @Component public class WordValidator { - private static final int MAX_WORD_LENGTH = 50; - - public String validateAndPreprocess(String word) { - if (word == null || word.isEmpty()) { - throw new WordsException(WordsErrorCode.INVALID_WORD_FORMAT); - } - - // 1. 앞뒤 특수문자 제거 - String trimmedWord = trimSpecialCharacters(word); - - if (trimmedWord.isEmpty()) { - throw new WordsException(WordsErrorCode.INVALID_WORD_FORMAT); - } - - // 2. 대문자를 소문자로 변환 - String processedWord = trimmedWord.toLowerCase(); - - // 검증 1: 단어 내부에 특수문자, 띄어쓰기, 엔터, 탭 확인 - if (containsInvalidCharacters(processedWord)) { - throw new WordsException(WordsErrorCode.INVALID_WORD_FORMAT); - } - - // 검증 2: 단어 길이 확인 - if (processedWord.length() > MAX_WORD_LENGTH) { - throw new WordsException(WordsErrorCode.WORD_TOO_LONG); - } - - return processedWord; - } - - /** - * 문자열 앞뒤의 특수문자를 제거합니다. - * 유효한 문자(문자 또는 숫자)만 남기고 앞뒤를 trim합니다. - */ - private String trimSpecialCharacters(String word) { - int start = 0; - int end = word.length(); - - // 앞쪽 특수문자 제거 - while (start < end && !isValidCharacter(word.charAt(start))) { - start++; - } - - // 뒤쪽 특수문자 제거 - while (end > start && !isValidCharacter(word.charAt(end - 1))) { - end--; - } - - return word.substring(start, end); - } - - /** - * 유효한 문자인지 확인합니다. - * 모든 언어의 문자(한글, 영어, 일본어, 중국어 등)와 숫자를 허용합니다. - */ - private boolean isValidCharacter(char c) { - return Character.isLetterOrDigit(c); - } - - /** - * 단어 내부에 허용되지 않는 문자가 있는지 확인합니다. - * 띄어쓰기, 엔터, 탭, 특수문자가 포함되어 있으면 true를 반환합니다. - */ - private boolean containsInvalidCharacters(String word) { - for (char c : word.toCharArray()) { - // 유효한 문자(문자 또는 숫자)가 아니면 invalid - if (!isValidCharacter(c)) { - return true; - } - } - return false; - } + private static final int MAX_WORD_LENGTH = 50; + + public String validateAndPreprocess(String word) { + if (word == null || word.isEmpty()) { + throw new WordsException(WordsErrorCode.INVALID_WORD_FORMAT); + } + + // 1. 앞뒤 특수문자 제거 + String trimmedWord = trimSpecialCharacters(word); + + if (trimmedWord.isEmpty()) { + throw new WordsException(WordsErrorCode.INVALID_WORD_FORMAT); + } + + // 2. 대문자를 소문자로 변환 + String processedWord = trimmedWord.toLowerCase(); + + // 검증 1: 단어 내부에 특수문자, 띄어쓰기, 엔터, 탭 확인 + if (containsInvalidCharacters(processedWord)) { + throw new WordsException(WordsErrorCode.INVALID_WORD_FORMAT); + } + + // 검증 2: 단어 길이 확인 + if (processedWord.length() > MAX_WORD_LENGTH) { + throw new WordsException(WordsErrorCode.WORD_TOO_LONG); + } + + return processedWord; + } + + /** + * 문자열 앞뒤의 특수문자를 제거합니다. 유효한 문자(문자 또는 숫자)만 남기고 앞뒤를 trim합니다. + */ + private String trimSpecialCharacters(String word) { + int start = 0; + int end = word.length(); + + // 앞쪽 특수문자 제거 + while (start < end && !isValidCharacter(word.charAt(start))) { + start++; + } + + // 뒤쪽 특수문자 제거 + while (end > start && !isValidCharacter(word.charAt(end - 1))) { + end--; + } + + return word.substring(start, end); + } + + /** + * 유효한 문자인지 확인합니다. 모든 언어의 문자(한글, 영어, 일본어, 중국어 등)와 숫자를 허용합니다. + */ + private boolean isValidCharacter(char c) { + return Character.isLetterOrDigit(c); + } + + /** + * 단어 내부에 허용되지 않는 문자가 있는지 확인합니다. 띄어쓰기, 엔터, 탭, 특수문자가 포함되어 있으면 true를 반환합니다. + */ + private boolean containsInvalidCharacters(String word) { + for (char c : word.toCharArray()) { + // 유효한 문자(문자 또는 숫자)가 아니면 invalid + if (!isValidCharacter(c)) { + return true; + } + } + return false; + } + } diff --git a/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java b/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java index e7314800..946d0f32 100644 --- a/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java +++ b/src/test/java/com/linglevel/api/bookmark/service/BookmarkServiceTest.java @@ -19,67 +19,68 @@ @ExtendWith(MockitoExtension.class) class BookmarkServiceTest { - @Mock - private WordBookmarkRepository wordBookmarkRepository; - - @Mock - private WordRepository wordRepository; - - @Mock - private WordVariantService wordVariantService; - - @Mock - private WordService wordService; - - @InjectMocks - private BookmarkService bookmarkService; - - @Test - @DisplayName("variant 원형 후보가 없어도 입력 단어 북마크가 있으면 삭제한다") - void removeWordBookmark_noVariantCandidate_deletesBookmarkByInputWord() { - // given - String userId = "user-1"; - String word = "run"; - when(wordVariantService.getOriginalForms(word)).thenReturn(List.of()); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(true); - - // when - bookmarkService.removeWordBookmark(userId, word); - - // then - verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, word); - } - - @Test - @DisplayName("variant 원형 후보 중 실제 북마크된 단어를 찾아 삭제한다") - void removeWordBookmark_variantCandidates_deletesExistingBookmarkedOriginalForm() { - // given - String userId = "user-1"; - String word = "ran"; - when(wordVariantService.getOriginalForms(word)).thenReturn(List.of("run")); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(false); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(true); - - // when - bookmarkService.removeWordBookmark(userId, word); - - // then - verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, "run"); - } - - @Test - @DisplayName("입력 단어와 variant 원형 후보가 모두 북마크되어 있으면 입력 단어를 우선 삭제한다") - void removeWordBookmark_exactBookmarkExists_deletesInputWordBeforeVariantCandidate() { - // given - String userId = "user-1"; - String word = "saw"; - when(wordVariantService.getOriginalForms(word)).thenReturn(List.of("see", "saw")); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(true); - - // when - bookmarkService.removeWordBookmark(userId, word); - - // then - verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, word); - } + @Mock + private WordBookmarkRepository wordBookmarkRepository; + + @Mock + private WordRepository wordRepository; + + @Mock + private WordVariantService wordVariantService; + + @Mock + private WordService wordService; + + @InjectMocks + private BookmarkService bookmarkService; + + @Test + @DisplayName("variant 원형 후보가 없어도 입력 단어 북마크가 있으면 삭제한다") + void removeWordBookmark_noVariantCandidate_deletesBookmarkByInputWord() { + // given + String userId = "user-1"; + String word = "run"; + when(wordVariantService.getOriginalForms(word)).thenReturn(List.of()); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(true); + + // when + bookmarkService.removeWordBookmark(userId, word); + + // then + verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, word); + } + + @Test + @DisplayName("variant 원형 후보 중 실제 북마크된 단어를 찾아 삭제한다") + void removeWordBookmark_variantCandidates_deletesExistingBookmarkedOriginalForm() { + // given + String userId = "user-1"; + String word = "ran"; + when(wordVariantService.getOriginalForms(word)).thenReturn(List.of("run")); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(false); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(true); + + // when + bookmarkService.removeWordBookmark(userId, word); + + // then + verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, "run"); + } + + @Test + @DisplayName("입력 단어와 variant 원형 후보가 모두 북마크되어 있으면 입력 단어를 우선 삭제한다") + void removeWordBookmark_exactBookmarkExists_deletesInputWordBeforeVariantCandidate() { + // given + String userId = "user-1"; + String word = "saw"; + when(wordVariantService.getOriginalForms(word)).thenReturn(List.of("see", "saw")); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, word)).thenReturn(true); + + // when + bookmarkService.removeWordBookmark(userId, word); + + // then + verify(wordBookmarkRepository).deleteByUserIdAndWord(userId, word); + } + } diff --git a/src/test/java/com/linglevel/api/common/AbstractDatabaseTest.java b/src/test/java/com/linglevel/api/common/AbstractDatabaseTest.java index b2ea6328..1c498310 100644 --- a/src/test/java/com/linglevel/api/common/AbstractDatabaseTest.java +++ b/src/test/java/com/linglevel/api/common/AbstractDatabaseTest.java @@ -10,30 +10,30 @@ * 모든 테스트가 하나의 MongoDB 컨테이너를 공유하여 안정성과 성능을 보장합니다. * * 사용법: - * @DataMongoTest - * class MyRepositoryTest extends AbstractDatabaseTest { - * // TestContainers 설정 불필요, 상속받음 - * } + * + * @DataMongoTest class MyRepositoryTest extends AbstractDatabaseTest { // TestContainers + * 설정 불필요, 상속받음 } */ public abstract class AbstractDatabaseTest { - private static MongoDBContainer mongoContainer; + private static MongoDBContainer mongoContainer; + + static { + mongoContainer = new MongoDBContainer("mongo:6.0").withReuse(true); + mongoContainer.start(); + } - static { - mongoContainer = new MongoDBContainer("mongo:6.0").withReuse(true); - mongoContainer.start(); - } + @DynamicPropertySource + static void configureDatabaseProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.mongodb.uri", mongoContainer::getReplicaSetUrl); - @DynamicPropertySource - static void configureDatabaseProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.mongodb.uri", mongoContainer::getReplicaSetUrl); + // 추가 데이터베이스 설정이 필요한 경우 여기서 일괄 관리 + // registry.add("spring.data.mongodb.database", () -> "test-db"); + } - // 추가 데이터베이스 설정이 필요한 경우 여기서 일괄 관리 - // registry.add("spring.data.mongodb.database", () -> "test-db"); - } + // 컨테이너 정리를 위한 메서드 (필요 시) + public static MongoDBContainer getMongoContainer() { + return mongoContainer; + } - // 컨테이너 정리를 위한 메서드 (필요 시) - public static MongoDBContainer getMongoContainer() { - return mongoContainer; - } } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/common/AbstractRedisTest.java b/src/test/java/com/linglevel/api/common/AbstractRedisTest.java index 8e8fe93b..af13fdcd 100644 --- a/src/test/java/com/linglevel/api/common/AbstractRedisTest.java +++ b/src/test/java/com/linglevel/api/common/AbstractRedisTest.java @@ -11,30 +11,29 @@ * 모든 테스트가 하나의 Redis 컨테이너를 공유하여 안정성과 성능을 보장합니다. * * 사용법: - * @SpringBootTest - * class MyRedisTest extends AbstractRedisTest { - * // TestContainers 설정 불필요, 상속받음 - * } + * + * @SpringBootTest class MyRedisTest extends AbstractRedisTest { // TestContainers 설정 불필요, + * 상속받음 } */ public abstract class AbstractRedisTest { - private static GenericContainer redisContainer; + private static GenericContainer redisContainer; + + static { + redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379) + .withReuse(true); + redisContainer.start(); + } - static { - redisContainer = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) - .withExposedPorts(6379) - .withReuse(true); - redisContainer.start(); - } + @DynamicPropertySource + static void configureRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379)); + } - @DynamicPropertySource - static void configureRedisProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.redis.host", redisContainer::getHost); - registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379)); - } + // 컨테이너 정리를 위한 메서드 (필요 시) + public static GenericContainer getRedisContainer() { + return redisContainer; + } - // 컨테이너 정리를 위한 메서드 (필요 시) - public static GenericContainer getRedisContainer() { - return redisContainer; - } } diff --git a/src/test/java/com/linglevel/api/common/filter/RateLimitFilterTest.java b/src/test/java/com/linglevel/api/common/filter/RateLimitFilterTest.java index 144f8025..ffdfdcb5 100644 --- a/src/test/java/com/linglevel/api/common/filter/RateLimitFilterTest.java +++ b/src/test/java/com/linglevel/api/common/filter/RateLimitFilterTest.java @@ -34,350 +34,357 @@ class RateLimitFilterTest extends AbstractRedisTest { - private RateLimitFilter rateLimitFilter; - private ProxyManager proxyManager; - private RedissonClient redissonClient; - private RateLimitResolver rateLimitResolver; - - private HttpServletRequest request; - private HttpServletResponse response; - private FilterChain filterChain; - - private StringWriter stringWriter; - private PrintWriter printWriter; - - @BeforeEach - void setUp() throws Exception { - // Redis 연결 설정 - GenericContainer redis = getRedisContainer(); - String host = redis.getHost(); - Integer port = redis.getMappedPort(6379); - - Config redissonConfig = new Config(); - redissonConfig.useSingleServer() - .setAddress("redis://" + host + ":" + port) - .setTimeout((int) Duration.ofSeconds(10).toMillis()); - Redisson redisson = (Redisson) Redisson.create(redissonConfig); - redissonClient = redisson; - - // ProxyManager 생성 - proxyManager = Bucket4jRedisson.casBasedBuilder(redisson.getCommandExecutor()).build(); - - // RateLimitProperties 설정 - RateLimitProperties properties = new RateLimitProperties(); - properties.setCapacity(100); - RateLimitProperties.Refill refill = new RateLimitProperties.Refill(); - RateLimitProperties.Refill.Duration duration = new RateLimitProperties.Refill.Duration(); - duration.setMinutes(1); - refill.setDuration(duration); - properties.setRefill(refill); - - // RateLimitResolver mock 생성 - rateLimitResolver = mock(RateLimitResolver.class); - when(rateLimitResolver.resolveRateLimit(any())).thenReturn(null); // No annotation (default) - - // RateLimitFilter 생성 - rateLimitFilter = new RateLimitFilter(proxyManager, properties, rateLimitResolver); - - // Redis 플러시 - redissonClient.getKeys().flushall(); - - // Clear SecurityContext before each test - SecurityContextHolder.clearContext(); - - request = mock(HttpServletRequest.class); - response = mock(HttpServletResponse.class); - filterChain = mock(FilterChain.class); - - stringWriter = new StringWriter(); - printWriter = new PrintWriter(stringWriter); - - when(request.getRemoteAddr()).thenReturn("127.0.0.1"); - when(response.getWriter()).thenReturn(printWriter); - } - - @AfterEach - void tearDown() { - if (redissonClient != null) { - redissonClient.shutdown(); - } - SecurityContextHolder.clearContext(); - } - - private JwtClaims createTestUser(String userId, String email, String displayName) { - return JwtClaims.builder() - .id(userId) - .username("google_" + userId) - .email(email) - .role(UserRole.USER) - .provider("google") - .displayName(displayName) - .issuedAt(new Date()) - .expiresAt(new Date(System.currentTimeMillis() + 3600000)) - .build(); - } - - /** - * 사용자를 인증된 상태로 설정 - */ - private void authenticateUser(JwtClaims claims) { - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - claims, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - @Test - void testAllowsRequestsWithinLimit() throws Exception { - // 처음 요청은 통과해야 함 - rateLimitFilter.doFilter(request, response, filterChain); - - verify(filterChain, times(1)).doFilter(request, response); - verify(response, never()).setStatus(429); - } - - @Test - void testBlocksRequestsExceedingLimit() throws Exception { - // 100번 요청 (limit까지) - for (int i = 0; i < 100; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // 101번째 요청은 차단되어야 함 - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - verify(response, atLeastOnce()).setContentType("application/json"); - - String responseBody = stringWriter.toString(); - assertTrue(responseBody.contains("Too many requests")); - } - - @Test - void testDifferentIpAddressesHaveSeparateLimits() throws Exception { - HttpServletRequest request2 = mock(HttpServletRequest.class); - when(request2.getRemoteAddr()).thenReturn("192.168.0.1"); - - // 첫 번째 IP로 100번 요청 - for (int i = 0; i < 100; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // 두 번째 IP로 요청 (통과해야 함) - rateLimitFilter.doFilter(request2, response, filterChain); - - verify(filterChain, times(101)).doFilter(any(), any()); - } - - @Test - void testXForwardedForHeader() throws Exception { - when(request.getHeader("X-Forwarded-For")).thenReturn("10.0.0.1, 10.0.0.2"); - - rateLimitFilter.doFilter(request, response, filterChain); - - verify(filterChain, times(1)).doFilter(request, response); - } - - @Test - void testAuthenticatedUserUsesUserId() throws Exception { - // Given: 인증된 사용자 설정 - JwtClaims user = createTestUser("user123", "user@example.com", "Test User"); - authenticateUser(user); - - // When: 100번 요청 - for (int i = 0; i < 100; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 101번째 요청은 차단되어야 함 - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - String responseBody = stringWriter.toString(); - assertTrue(responseBody.contains("Too many requests")); - } - - @Test - void testDifferentUsersHaveSeparateLimits() throws Exception { - // Given: 첫 번째 사용자 - JwtClaims user1 = createTestUser("user123", "user1@example.com", "User 1"); - authenticateUser(user1); - - // When: 첫 번째 사용자로 100번 요청 - for (int i = 0; i < 100; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Given: 두 번째 사용자로 변경 - JwtClaims user2 = createTestUser("user456", "user2@example.com", "User 2"); - authenticateUser(user2); - - // When: 두 번째 사용자로 요청 (통과해야 함) - rateLimitFilter.doFilter(request, response, filterChain); - - // Then: 첫 번째 사용자 100회 + 두 번째 사용자 1회 = 101회 통과 - verify(filterChain, times(101)).doFilter(any(), any()); - } - - @Test - void testSameUserDifferentIpUseSameLimit() throws Exception { - // Given: 인증된 사용자 - JwtClaims user = createTestUser("user123", "user@example.com", "Test User"); - authenticateUser(user); - - // When: 첫 번째 IP에서 50번 요청 - when(request.getRemoteAddr()).thenReturn("127.0.0.1"); - for (int i = 0; i < 50; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // When: 두 번째 IP에서 50번 요청 (같은 사용자) - when(request.getRemoteAddr()).thenReturn("192.168.0.1"); - for (int i = 0; i < 50; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 같은 사용자이므로 101번째 요청은 차단되어야 함 - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - } - - @Test - void testUnauthenticatedUserUsesIp() throws Exception { - // Given: 인증되지 않은 사용자 (SecurityContext가 비어있음) - SecurityContextHolder.clearContext(); - - // When: 100번 요청 - for (int i = 0; i < 100; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 101번째 요청은 차단되어야 함 - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - String responseBody = stringWriter.toString(); - assertTrue(responseBody.contains("Too many requests")); - } - - // ========== Annotation-based Rate Limiting Tests ========== - - @Test - void testCustomIpBasedRateLimitWithAnnotation() throws Exception { - // Given: Mock annotation with 5 requests per minute, IP-based - RateLimit annotation = mock(RateLimit.class); - when(annotation.capacity()).thenReturn(5); - when(annotation.refillMinutes()).thenReturn(1L); - when(annotation.keyType()).thenReturn(RateLimit.KeyType.IP); - when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); - - // When: Make 5 requests (should all succeed) - for (int i = 0; i < 5; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 6th request should be rate limited - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - String responseBody = stringWriter.toString(); - assertTrue(responseBody.contains("Too many requests")); - } - - @Test - void testCustomUserBasedRateLimitWithAnnotation() throws Exception { - // Given: Mock annotation with 10 requests per minute, USER-based - RateLimit annotation = mock(RateLimit.class); - when(annotation.capacity()).thenReturn(10); - when(annotation.refillMinutes()).thenReturn(1L); - when(annotation.keyType()).thenReturn(RateLimit.KeyType.USER); - when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); - - // Authenticate user - JwtClaims user = createTestUser("test-user-123", "test@example.com", "Test User"); - authenticateUser(user); - - // When: Make 10 requests (should all succeed) - for (int i = 0; i < 10; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 11th request should be rate limited - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - String responseBody = stringWriter.toString(); - assertTrue(responseBody.contains("Too many requests")); - } - - @Test - void testCustomAutoRateLimitWithAuthentication() throws Exception { - // Given: Mock annotation with 15 requests per minute, AUTO (with authenticated user) - RateLimit annotation = mock(RateLimit.class); - when(annotation.capacity()).thenReturn(15); - when(annotation.refillMinutes()).thenReturn(1L); - when(annotation.keyType()).thenReturn(RateLimit.KeyType.AUTO); - when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); - - // Authenticate user - JwtClaims user = createTestUser("auto-user-123", "auto@example.com", "Auto User"); - authenticateUser(user); - - // When: Make 15 requests (should all succeed) - for (int i = 0; i < 15; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 16th request should be rate limited - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - } - - @Test - void testAnnotationOverridesGlobalConfig() throws Exception { - // Given: Annotation with stricter limit (3 requests) than global (100 requests) - RateLimit annotation = mock(RateLimit.class); - when(annotation.capacity()).thenReturn(3); - when(annotation.refillMinutes()).thenReturn(1L); - when(annotation.keyType()).thenReturn(RateLimit.KeyType.IP); - when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); - - // When: Make 3 requests (should succeed) - for (int i = 0; i < 3; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 4th request should be rate limited (not 101st, proving annotation overrides global) - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - String responseBody = stringWriter.toString(); - assertTrue(responseBody.contains("Too many requests")); - } - - @Test - void testUserBasedKeyTypeWithoutAuthenticationFallsBackToIp() throws Exception { - // Given: USER key type but no authentication - RateLimit annotation = mock(RateLimit.class); - when(annotation.capacity()).thenReturn(5); - when(annotation.refillMinutes()).thenReturn(1L); - when(annotation.keyType()).thenReturn(RateLimit.KeyType.USER); - when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); - - SecurityContextHolder.clearContext(); // Ensure no authentication - - // When: Make 5 requests (should succeed using IP fallback) - for (int i = 0; i < 5; i++) { - rateLimitFilter.doFilter(request, response, filterChain); - } - - // Then: 6th request should be rate limited - rateLimitFilter.doFilter(request, response, filterChain); - - verify(response, atLeastOnce()).setStatus(429); - } + private RateLimitFilter rateLimitFilter; + + private ProxyManager proxyManager; + + private RedissonClient redissonClient; + + private RateLimitResolver rateLimitResolver; + + private HttpServletRequest request; + + private HttpServletResponse response; + + private FilterChain filterChain; + + private StringWriter stringWriter; + + private PrintWriter printWriter; + + @BeforeEach + void setUp() throws Exception { + // Redis 연결 설정 + GenericContainer redis = getRedisContainer(); + String host = redis.getHost(); + Integer port = redis.getMappedPort(6379); + + Config redissonConfig = new Config(); + redissonConfig.useSingleServer() + .setAddress("redis://" + host + ":" + port) + .setTimeout((int) Duration.ofSeconds(10).toMillis()); + Redisson redisson = (Redisson) Redisson.create(redissonConfig); + redissonClient = redisson; + + // ProxyManager 생성 + proxyManager = Bucket4jRedisson.casBasedBuilder(redisson.getCommandExecutor()).build(); + + // RateLimitProperties 설정 + RateLimitProperties properties = new RateLimitProperties(); + properties.setCapacity(100); + RateLimitProperties.Refill refill = new RateLimitProperties.Refill(); + RateLimitProperties.Refill.Duration duration = new RateLimitProperties.Refill.Duration(); + duration.setMinutes(1); + refill.setDuration(duration); + properties.setRefill(refill); + + // RateLimitResolver mock 생성 + rateLimitResolver = mock(RateLimitResolver.class); + when(rateLimitResolver.resolveRateLimit(any())).thenReturn(null); // No annotation + // (default) + + // RateLimitFilter 생성 + rateLimitFilter = new RateLimitFilter(proxyManager, properties, rateLimitResolver); + + // Redis 플러시 + redissonClient.getKeys().flushall(); + + // Clear SecurityContext before each test + SecurityContextHolder.clearContext(); + + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + filterChain = mock(FilterChain.class); + + stringWriter = new StringWriter(); + printWriter = new PrintWriter(stringWriter); + + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(response.getWriter()).thenReturn(printWriter); + } + + @AfterEach + void tearDown() { + if (redissonClient != null) { + redissonClient.shutdown(); + } + SecurityContextHolder.clearContext(); + } + + private JwtClaims createTestUser(String userId, String email, String displayName) { + return JwtClaims.builder() + .id(userId) + .username("google_" + userId) + .email(email) + .role(UserRole.USER) + .provider("google") + .displayName(displayName) + .issuedAt(new Date()) + .expiresAt(new Date(System.currentTimeMillis() + 3600000)) + .build(); + } + + /** + * 사용자를 인증된 상태로 설정 + */ + private void authenticateUser(JwtClaims claims) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @Test + void testAllowsRequestsWithinLimit() throws Exception { + // 처음 요청은 통과해야 함 + rateLimitFilter.doFilter(request, response, filterChain); + + verify(filterChain, times(1)).doFilter(request, response); + verify(response, never()).setStatus(429); + } + + @Test + void testBlocksRequestsExceedingLimit() throws Exception { + // 100번 요청 (limit까지) + for (int i = 0; i < 100; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // 101번째 요청은 차단되어야 함 + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + verify(response, atLeastOnce()).setContentType("application/json"); + + String responseBody = stringWriter.toString(); + assertTrue(responseBody.contains("Too many requests")); + } + + @Test + void testDifferentIpAddressesHaveSeparateLimits() throws Exception { + HttpServletRequest request2 = mock(HttpServletRequest.class); + when(request2.getRemoteAddr()).thenReturn("192.168.0.1"); + + // 첫 번째 IP로 100번 요청 + for (int i = 0; i < 100; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // 두 번째 IP로 요청 (통과해야 함) + rateLimitFilter.doFilter(request2, response, filterChain); + + verify(filterChain, times(101)).doFilter(any(), any()); + } + + @Test + void testXForwardedForHeader() throws Exception { + when(request.getHeader("X-Forwarded-For")).thenReturn("10.0.0.1, 10.0.0.2"); + + rateLimitFilter.doFilter(request, response, filterChain); + + verify(filterChain, times(1)).doFilter(request, response); + } + + @Test + void testAuthenticatedUserUsesUserId() throws Exception { + // Given: 인증된 사용자 설정 + JwtClaims user = createTestUser("user123", "user@example.com", "Test User"); + authenticateUser(user); + + // When: 100번 요청 + for (int i = 0; i < 100; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 101번째 요청은 차단되어야 함 + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + String responseBody = stringWriter.toString(); + assertTrue(responseBody.contains("Too many requests")); + } + + @Test + void testDifferentUsersHaveSeparateLimits() throws Exception { + // Given: 첫 번째 사용자 + JwtClaims user1 = createTestUser("user123", "user1@example.com", "User 1"); + authenticateUser(user1); + + // When: 첫 번째 사용자로 100번 요청 + for (int i = 0; i < 100; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Given: 두 번째 사용자로 변경 + JwtClaims user2 = createTestUser("user456", "user2@example.com", "User 2"); + authenticateUser(user2); + + // When: 두 번째 사용자로 요청 (통과해야 함) + rateLimitFilter.doFilter(request, response, filterChain); + + // Then: 첫 번째 사용자 100회 + 두 번째 사용자 1회 = 101회 통과 + verify(filterChain, times(101)).doFilter(any(), any()); + } + + @Test + void testSameUserDifferentIpUseSameLimit() throws Exception { + // Given: 인증된 사용자 + JwtClaims user = createTestUser("user123", "user@example.com", "Test User"); + authenticateUser(user); + + // When: 첫 번째 IP에서 50번 요청 + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + for (int i = 0; i < 50; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // When: 두 번째 IP에서 50번 요청 (같은 사용자) + when(request.getRemoteAddr()).thenReturn("192.168.0.1"); + for (int i = 0; i < 50; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 같은 사용자이므로 101번째 요청은 차단되어야 함 + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + } + + @Test + void testUnauthenticatedUserUsesIp() throws Exception { + // Given: 인증되지 않은 사용자 (SecurityContext가 비어있음) + SecurityContextHolder.clearContext(); + + // When: 100번 요청 + for (int i = 0; i < 100; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 101번째 요청은 차단되어야 함 + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + String responseBody = stringWriter.toString(); + assertTrue(responseBody.contains("Too many requests")); + } + + // ========== Annotation-based Rate Limiting Tests ========== + + @Test + void testCustomIpBasedRateLimitWithAnnotation() throws Exception { + // Given: Mock annotation with 5 requests per minute, IP-based + RateLimit annotation = mock(RateLimit.class); + when(annotation.capacity()).thenReturn(5); + when(annotation.refillMinutes()).thenReturn(1L); + when(annotation.keyType()).thenReturn(RateLimit.KeyType.IP); + when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); + + // When: Make 5 requests (should all succeed) + for (int i = 0; i < 5; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 6th request should be rate limited + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + String responseBody = stringWriter.toString(); + assertTrue(responseBody.contains("Too many requests")); + } + + @Test + void testCustomUserBasedRateLimitWithAnnotation() throws Exception { + // Given: Mock annotation with 10 requests per minute, USER-based + RateLimit annotation = mock(RateLimit.class); + when(annotation.capacity()).thenReturn(10); + when(annotation.refillMinutes()).thenReturn(1L); + when(annotation.keyType()).thenReturn(RateLimit.KeyType.USER); + when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); + + // Authenticate user + JwtClaims user = createTestUser("test-user-123", "test@example.com", "Test User"); + authenticateUser(user); + + // When: Make 10 requests (should all succeed) + for (int i = 0; i < 10; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 11th request should be rate limited + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + String responseBody = stringWriter.toString(); + assertTrue(responseBody.contains("Too many requests")); + } + + @Test + void testCustomAutoRateLimitWithAuthentication() throws Exception { + // Given: Mock annotation with 15 requests per minute, AUTO (with authenticated + // user) + RateLimit annotation = mock(RateLimit.class); + when(annotation.capacity()).thenReturn(15); + when(annotation.refillMinutes()).thenReturn(1L); + when(annotation.keyType()).thenReturn(RateLimit.KeyType.AUTO); + when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); + + // Authenticate user + JwtClaims user = createTestUser("auto-user-123", "auto@example.com", "Auto User"); + authenticateUser(user); + + // When: Make 15 requests (should all succeed) + for (int i = 0; i < 15; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 16th request should be rate limited + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + } + + @Test + void testAnnotationOverridesGlobalConfig() throws Exception { + // Given: Annotation with stricter limit (3 requests) than global (100 requests) + RateLimit annotation = mock(RateLimit.class); + when(annotation.capacity()).thenReturn(3); + when(annotation.refillMinutes()).thenReturn(1L); + when(annotation.keyType()).thenReturn(RateLimit.KeyType.IP); + when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); + + // When: Make 3 requests (should succeed) + for (int i = 0; i < 3; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 4th request should be rate limited (not 101st, proving annotation + // overrides global) + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + String responseBody = stringWriter.toString(); + assertTrue(responseBody.contains("Too many requests")); + } + + @Test + void testUserBasedKeyTypeWithoutAuthenticationFallsBackToIp() throws Exception { + // Given: USER key type but no authentication + RateLimit annotation = mock(RateLimit.class); + when(annotation.capacity()).thenReturn(5); + when(annotation.refillMinutes()).thenReturn(1L); + when(annotation.keyType()).thenReturn(RateLimit.KeyType.USER); + when(rateLimitResolver.resolveRateLimit(any())).thenReturn(annotation); + + SecurityContextHolder.clearContext(); // Ensure no authentication + + // When: Make 5 requests (should succeed using IP fallback) + for (int i = 0; i < 5; i++) { + rateLimitFilter.doFilter(request, response, filterChain); + } + + // Then: 6th request should be rate limited + rateLimitFilter.doFilter(request, response, filterChain); + + verify(response, atLeastOnce()).setStatus(429); + } + } diff --git a/src/test/java/com/linglevel/api/content/article/service/ArticleProgressServiceTest.java b/src/test/java/com/linglevel/api/content/article/service/ArticleProgressServiceTest.java index 15d78b27..ecea53b8 100644 --- a/src/test/java/com/linglevel/api/content/article/service/ArticleProgressServiceTest.java +++ b/src/test/java/com/linglevel/api/content/article/service/ArticleProgressServiceTest.java @@ -29,81 +29,83 @@ @ExtendWith(MockitoExtension.class) class ArticleProgressServiceTest { - @Mock - private ArticleService articleService; - - @Mock - private ArticleProgressRepository articleProgressRepository; - - @Mock - private ArticleChunkRepository articleChunkRepository; - - @Mock - private ArticleChunkService articleChunkService; - - @Mock - private ProgressCalculationService progressCalculationService; - - @Mock - private ReadingCompletionService readingCompletionService; - - @Mock - private StreakService streakService; - - @InjectMocks - private ArticleProgressService articleProgressService; - - @Captor - private ArgumentCaptor articleProgressCaptor; - - @Test - @DisplayName("오래된 Article 진행률 업데이트 시 V2 필드가 정상적으로 마이그레이션된다") - void updateProgress_shouldLazyMigrate_forOldData() { - // Given: 마이그레이션되지 않은(V2 필드가 null인) ArticleProgress 설정 - String userId = "test-user"; - String articleId = "test-article"; - String chunkId = "test-chunk"; - - // V2 필드가 null인 레거시 데이터 - ArticleProgress legacyProgress = new ArticleProgress(); - legacyProgress.setId("legacy-progress-id"); - legacyProgress.setUserId(userId); - legacyProgress.setArticleId(articleId); - // legacyProgress.normalizedProgress is null - // legacyProgress.currentDifficultyLevel is null - - ArticleChunk currentChunk = new ArticleChunk(); - currentChunk.setId(chunkId); - currentChunk.setArticleId(articleId); - currentChunk.setChunkNumber(10); - currentChunk.setDifficultyLevel(DifficultyLevel.B1); - - Article article = new Article(); - article.setId(articleId); - - ArticleProgressUpdateRequest request = new ArticleProgressUpdateRequest(); - request.setChunkId(chunkId); - - // Mocking - when(articleService.existsById(articleId)).thenReturn(true); - when(articleService.findById(articleId)).thenReturn(article); - when(articleProgressRepository.findByUserIdAndArticleId(userId, articleId)).thenReturn(Optional.of(legacyProgress)); - when(articleChunkService.findById(chunkId)).thenReturn(currentChunk); - when(articleChunkRepository.countByArticleIdAndDifficultyLevel(articleId, DifficultyLevel.B1)).thenReturn(100L); - when(progressCalculationService.calculateNormalizedProgress(10, 100L)).thenReturn(10.0); - - // When: 진행률 업데이트 호출 - articleProgressService.updateProgress(articleId, request, userId); - - // Then: V2 필드가 채워진 상태로 저장되는지 검증 - verify(articleProgressRepository).save(articleProgressCaptor.capture()); - ArticleProgress savedProgress = articleProgressCaptor.getValue(); - - assertThat(savedProgress.getId()).isEqualTo("legacy-progress-id"); - assertThat(savedProgress.getNormalizedProgress()).isNotNull(); - assertThat(savedProgress.getNormalizedProgress()).isEqualTo(10.0); - assertThat(savedProgress.getMaxNormalizedProgress()).isEqualTo(10.0); - assertThat(savedProgress.getCurrentDifficultyLevel()).isNotNull(); - assertThat(savedProgress.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.B1); - } + @Mock + private ArticleService articleService; + + @Mock + private ArticleProgressRepository articleProgressRepository; + + @Mock + private ArticleChunkRepository articleChunkRepository; + + @Mock + private ArticleChunkService articleChunkService; + + @Mock + private ProgressCalculationService progressCalculationService; + + @Mock + private ReadingCompletionService readingCompletionService; + + @Mock + private StreakService streakService; + + @InjectMocks + private ArticleProgressService articleProgressService; + + @Captor + private ArgumentCaptor articleProgressCaptor; + + @Test + @DisplayName("오래된 Article 진행률 업데이트 시 V2 필드가 정상적으로 마이그레이션된다") + void updateProgress_shouldLazyMigrate_forOldData() { + // Given: 마이그레이션되지 않은(V2 필드가 null인) ArticleProgress 설정 + String userId = "test-user"; + String articleId = "test-article"; + String chunkId = "test-chunk"; + + // V2 필드가 null인 레거시 데이터 + ArticleProgress legacyProgress = new ArticleProgress(); + legacyProgress.setId("legacy-progress-id"); + legacyProgress.setUserId(userId); + legacyProgress.setArticleId(articleId); + // legacyProgress.normalizedProgress is null + // legacyProgress.currentDifficultyLevel is null + + ArticleChunk currentChunk = new ArticleChunk(); + currentChunk.setId(chunkId); + currentChunk.setArticleId(articleId); + currentChunk.setChunkNumber(10); + currentChunk.setDifficultyLevel(DifficultyLevel.B1); + + Article article = new Article(); + article.setId(articleId); + + ArticleProgressUpdateRequest request = new ArticleProgressUpdateRequest(); + request.setChunkId(chunkId); + + // Mocking + when(articleService.existsById(articleId)).thenReturn(true); + when(articleService.findById(articleId)).thenReturn(article); + when(articleProgressRepository.findByUserIdAndArticleId(userId, articleId)) + .thenReturn(Optional.of(legacyProgress)); + when(articleChunkService.findById(chunkId)).thenReturn(currentChunk); + when(articleChunkRepository.countByArticleIdAndDifficultyLevel(articleId, DifficultyLevel.B1)).thenReturn(100L); + when(progressCalculationService.calculateNormalizedProgress(10, 100L)).thenReturn(10.0); + + // When: 진행률 업데이트 호출 + articleProgressService.updateProgress(articleId, request, userId); + + // Then: V2 필드가 채워진 상태로 저장되는지 검증 + verify(articleProgressRepository).save(articleProgressCaptor.capture()); + ArticleProgress savedProgress = articleProgressCaptor.getValue(); + + assertThat(savedProgress.getId()).isEqualTo("legacy-progress-id"); + assertThat(savedProgress.getNormalizedProgress()).isNotNull(); + assertThat(savedProgress.getNormalizedProgress()).isEqualTo(10.0); + assertThat(savedProgress.getMaxNormalizedProgress()).isEqualTo(10.0); + assertThat(savedProgress.getCurrentDifficultyLevel()).isNotNull(); + assertThat(savedProgress.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.B1); + } + } diff --git a/src/test/java/com/linglevel/api/content/article/service/ArticleServiceTest.java b/src/test/java/com/linglevel/api/content/article/service/ArticleServiceTest.java index a2579e50..6008e8ea 100644 --- a/src/test/java/com/linglevel/api/content/article/service/ArticleServiceTest.java +++ b/src/test/java/com/linglevel/api/content/article/service/ArticleServiceTest.java @@ -36,217 +36,211 @@ @ExtendWith(MockitoExtension.class) class ArticleServiceTest { - @Mock - private ArticleRepository articleRepository; - - @Mock - private ArticleProgressRepository articleProgressRepository; - - @Mock - private ArticleChunkRepository articleChunkRepository; - - @Mock - private ArticleChunkService articleChunkService; - - @Mock - private UserRepository userRepository; - - @InjectMocks - private ArticleService articleService; - - private User testUser; - - @BeforeEach - void setUp() { - // 테스트 유저 생성 - testUser = new User(); - testUser.setId("test-user-id"); - testUser.setUsername("testuser"); - testUser.setEmail("test@example.com"); - testUser.setRole(UserRole.USER); - testUser.setDeleted(false); - testUser.setCreatedAt(LocalDateTime.now()); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션 - IN_PROGRESS") - void testProgressFilterWithPagination_InProgress() { - // Given: IN_PROGRESS 필터로 1페이지(limit=5) 요청 - GetArticlesRequest request = new GetArticlesRequest(); - request.setProgress(ProgressStatus.IN_PROGRESS); - request.setPage(1); - request.setLimit(5); - - // Mock data: 5개의 진행 중인 아티클 - List
articles = createArticles(5, "Article", "Author", List.of("tag1")); - - // Mock Page 생성 (총 10개 중 5개) - Page
articlePage = - new org.springframework.data.domain.PageImpl<>(articles, PageRequest.of(0, 5), 10); - - when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(articlePage); - - // Mock ArticleProgress for in-progress articles - mockArticleProgress(articles, false); - - // When - PageResponse response = articleService.getArticles(request, testUser.getId()); - - // Then: 정확히 5개 반환, 총 10개 - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - assertThat(response.getTotalPages()).isEqualTo(2); - - // 모든 항목이 진행중이어야 함 - response.getData().forEach(article -> { - assertThat(article.getProgressPercentage()).isGreaterThan(0.0); - assertThat(article.getIsCompleted()).isFalse(); - }); - } - - @Test - @DisplayName("태그 필터링과 페이지네이션") - void testTagsFilterWithPagination() { - // Given: technology 태그로 필터링, 1페이지(limit=10) - GetArticlesRequest request = new GetArticlesRequest(); - request.setTags("technology"); - request.setPage(1); - request.setLimit(10); - - // Mock data: 10개의 technology 태그 아티클 - List
articles = createArticles(10, "Tech Article", "Author", List.of("technology")); - - Page
articlePage = - new org.springframework.data.domain.PageImpl<>(articles, PageRequest.of(0, 10), 15); - - when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(articlePage); - - // When - PageResponse response = articleService.getArticles(request, testUser.getId()); - - // Then: 정확히 10개 반환, 총 15개 - assertThat(response.getData()).hasSize(10); - assertThat(response.getTotalCount()).isEqualTo(15); - assertThat(response.getTotalPages()).isEqualTo(2); - - // 모든 아티클이 technology 태그를 가져야 함 - response.getData().forEach(article -> assertThat(article.getTags()).contains("technology")); - } - - @Test - @DisplayName("키워드 검색과 페이지네이션") - void testKeywordSearchWithPagination() { - // Given: "viking" 키워드로 검색, 1페이지(limit=10) - GetArticlesRequest request = new GetArticlesRequest(); - request.setKeyword("viking"); - request.setPage(1); - request.setLimit(10); - - // Mock data: 10개의 "viking" 포함 아티클 - List
articles = createArticles(10, "The Viking Story", "Author", List.of("tag1")); - - Page
articlePage = - new PageImpl<>(articles, PageRequest.of(0, 10), 12); - - when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(articlePage); - - // When - PageResponse response = articleService.getArticles(request, testUser.getId()); - - // Then: 정확히 10개 반환, 총 12개 - assertThat(response.getData()).hasSize(10); - assertThat(response.getTotalCount()).isEqualTo(12); - assertThat(response.getTotalPages()).isEqualTo(2); - - // 모든 아티클의 제목에 "viking"이 포함되어야 함 - response.getData().forEach(article -> assertThat(article.getTitle().toLowerCase()).contains("viking")); - } - - @Test - @DisplayName("복합 필터링 - 태그 + 진도 + 페이지네이션") - void testCombinedFiltersWithPagination() { - // Given: technology 태그 + IN_PROGRESS 필터, 1페이지(limit=5) - GetArticlesRequest request = new GetArticlesRequest(); - request.setTags("technology"); - request.setProgress(ProgressStatus.IN_PROGRESS); - request.setPage(1); - request.setLimit(5); - - // Mock data: 5개의 technology 태그 + 진행중 아티클 - List
articles = createArticles(5, "Tech Article", "Author", List.of("technology")); - - Page
articlePage = - new org.springframework.data.domain.PageImpl<>(articles, PageRequest.of(0, 5), 10); - - when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(articlePage); - - // Mock ArticleProgress for in-progress articles - mockArticleProgress(articles, false); - - // When - PageResponse response = articleService.getArticles(request, testUser.getId()); - - // Then: technology 태그 + 진행중인 아티클만 반환 - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(article -> { - assertThat(article.getTags()).contains("technology"); - assertThat(article.getProgressPercentage()).isGreaterThan(0.0); - assertThat(article.getIsCompleted()).isFalse(); - }); - } - - private List
createArticles(int count, String titlePrefix, String authorPrefix, List tags) { - List
articles = new java.util.ArrayList<>(); - for (int i = 1; i <= count; i++) { - articles.add(createArticle(titlePrefix + " " + i, authorPrefix + " " + i, tags)); - } - return articles; - } - - private Article createArticle(String title, String author, List tags) { - Article article = new Article(); - article.setId("article-" + title.hashCode()); - article.setTitle(title); - article.setAuthor(author); - article.setTags(tags); - article.setDifficultyLevel(DifficultyLevel.A1); - article.setReadingTime(60); - article.setAverageRating(4.5); - article.setReviewCount(100); - article.setViewCount(1000); - article.setCreatedAt(Instant.now()); - return article; - } - - private void mockArticleProgress(List
articles, boolean isCompleted) { - com.linglevel.api.content.article.entity.ArticleChunk mockChunk = new com.linglevel.api.content.article.entity.ArticleChunk(); - mockChunk.setId("test-chunk-id"); - mockChunk.setChunkNumber(50); - when(articleChunkService.findById(anyString())).thenReturn(mockChunk); - - when(articleChunkRepository.countByArticleIdAndDifficultyLevel(anyString(), any(DifficultyLevel.class))).thenReturn(100L); - - for (Article article : articles) { - ArticleProgress progress = createArticleProgress(testUser.getId(), article.getId(), isCompleted); - when(articleProgressRepository.findByUserIdAndArticleId(testUser.getId(), article.getId())) - .thenReturn(Optional.of(progress)); - } - } - - private ArticleProgress createArticleProgress(String userId, String articleId, boolean isCompleted) { - ArticleProgress progress = new ArticleProgress(); - progress.setUserId(userId); - progress.setArticleId(articleId); - progress.setChunkId("test-chunk-id"); - progress.setIsCompleted(isCompleted); - progress.setUpdatedAt(Instant.now()); - return progress; - } + @Mock + private ArticleRepository articleRepository; + + @Mock + private ArticleProgressRepository articleProgressRepository; + + @Mock + private ArticleChunkRepository articleChunkRepository; + + @Mock + private ArticleChunkService articleChunkService; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ArticleService articleService; + + private User testUser; + + @BeforeEach + void setUp() { + // 테스트 유저 생성 + testUser = new User(); + testUser.setId("test-user-id"); + testUser.setUsername("testuser"); + testUser.setEmail("test@example.com"); + testUser.setRole(UserRole.USER); + testUser.setDeleted(false); + testUser.setCreatedAt(LocalDateTime.now()); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션 - IN_PROGRESS") + void testProgressFilterWithPagination_InProgress() { + // Given: IN_PROGRESS 필터로 1페이지(limit=5) 요청 + GetArticlesRequest request = new GetArticlesRequest(); + request.setProgress(ProgressStatus.IN_PROGRESS); + request.setPage(1); + request.setLimit(5); + + // Mock data: 5개의 진행 중인 아티클 + List
articles = createArticles(5, "Article", "Author", List.of("tag1")); + + // Mock Page 생성 (총 10개 중 5개) + Page
articlePage = new org.springframework.data.domain.PageImpl<>(articles, PageRequest.of(0, 5), 10); + + when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())).thenReturn(articlePage); + + // Mock ArticleProgress for in-progress articles + mockArticleProgress(articles, false); + + // When + PageResponse response = articleService.getArticles(request, testUser.getId()); + + // Then: 정확히 5개 반환, 총 10개 + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + + // 모든 항목이 진행중이어야 함 + response.getData().forEach(article -> { + assertThat(article.getProgressPercentage()).isGreaterThan(0.0); + assertThat(article.getIsCompleted()).isFalse(); + }); + } + + @Test + @DisplayName("태그 필터링과 페이지네이션") + void testTagsFilterWithPagination() { + // Given: technology 태그로 필터링, 1페이지(limit=10) + GetArticlesRequest request = new GetArticlesRequest(); + request.setTags("technology"); + request.setPage(1); + request.setLimit(10); + + // Mock data: 10개의 technology 태그 아티클 + List
articles = createArticles(10, "Tech Article", "Author", List.of("technology")); + + Page
articlePage = new org.springframework.data.domain.PageImpl<>(articles, PageRequest.of(0, 10), 15); + + when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())).thenReturn(articlePage); + + // When + PageResponse response = articleService.getArticles(request, testUser.getId()); + + // Then: 정확히 10개 반환, 총 15개 + assertThat(response.getData()).hasSize(10); + assertThat(response.getTotalCount()).isEqualTo(15); + assertThat(response.getTotalPages()).isEqualTo(2); + + // 모든 아티클이 technology 태그를 가져야 함 + response.getData().forEach(article -> assertThat(article.getTags()).contains("technology")); + } + + @Test + @DisplayName("키워드 검색과 페이지네이션") + void testKeywordSearchWithPagination() { + // Given: "viking" 키워드로 검색, 1페이지(limit=10) + GetArticlesRequest request = new GetArticlesRequest(); + request.setKeyword("viking"); + request.setPage(1); + request.setLimit(10); + + // Mock data: 10개의 "viking" 포함 아티클 + List
articles = createArticles(10, "The Viking Story", "Author", List.of("tag1")); + + Page
articlePage = new PageImpl<>(articles, PageRequest.of(0, 10), 12); + + when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())).thenReturn(articlePage); + + // When + PageResponse response = articleService.getArticles(request, testUser.getId()); + + // Then: 정확히 10개 반환, 총 12개 + assertThat(response.getData()).hasSize(10); + assertThat(response.getTotalCount()).isEqualTo(12); + assertThat(response.getTotalPages()).isEqualTo(2); + + // 모든 아티클의 제목에 "viking"이 포함되어야 함 + response.getData().forEach(article -> assertThat(article.getTitle().toLowerCase()).contains("viking")); + } + + @Test + @DisplayName("복합 필터링 - 태그 + 진도 + 페이지네이션") + void testCombinedFiltersWithPagination() { + // Given: technology 태그 + IN_PROGRESS 필터, 1페이지(limit=5) + GetArticlesRequest request = new GetArticlesRequest(); + request.setTags("technology"); + request.setProgress(ProgressStatus.IN_PROGRESS); + request.setPage(1); + request.setLimit(5); + + // Mock data: 5개의 technology 태그 + 진행중 아티클 + List
articles = createArticles(5, "Tech Article", "Author", List.of("technology")); + + Page
articlePage = new org.springframework.data.domain.PageImpl<>(articles, PageRequest.of(0, 5), 10); + + when(articleRepository.findArticlesWithFilters(any(), eq(testUser.getId()), any())).thenReturn(articlePage); + + // Mock ArticleProgress for in-progress articles + mockArticleProgress(articles, false); + + // When + PageResponse response = articleService.getArticles(request, testUser.getId()); + + // Then: technology 태그 + 진행중인 아티클만 반환 + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(article -> { + assertThat(article.getTags()).contains("technology"); + assertThat(article.getProgressPercentage()).isGreaterThan(0.0); + assertThat(article.getIsCompleted()).isFalse(); + }); + } + + private List
createArticles(int count, String titlePrefix, String authorPrefix, List tags) { + List
articles = new java.util.ArrayList<>(); + for (int i = 1; i <= count; i++) { + articles.add(createArticle(titlePrefix + " " + i, authorPrefix + " " + i, tags)); + } + return articles; + } + + private Article createArticle(String title, String author, List tags) { + Article article = new Article(); + article.setId("article-" + title.hashCode()); + article.setTitle(title); + article.setAuthor(author); + article.setTags(tags); + article.setDifficultyLevel(DifficultyLevel.A1); + article.setReadingTime(60); + article.setAverageRating(4.5); + article.setReviewCount(100); + article.setViewCount(1000); + article.setCreatedAt(Instant.now()); + return article; + } + + private void mockArticleProgress(List
articles, boolean isCompleted) { + com.linglevel.api.content.article.entity.ArticleChunk mockChunk = new com.linglevel.api.content.article.entity.ArticleChunk(); + mockChunk.setId("test-chunk-id"); + mockChunk.setChunkNumber(50); + when(articleChunkService.findById(anyString())).thenReturn(mockChunk); + + when(articleChunkRepository.countByArticleIdAndDifficultyLevel(anyString(), any(DifficultyLevel.class))) + .thenReturn(100L); + + for (Article article : articles) { + ArticleProgress progress = createArticleProgress(testUser.getId(), article.getId(), isCompleted); + when(articleProgressRepository.findByUserIdAndArticleId(testUser.getId(), article.getId())) + .thenReturn(Optional.of(progress)); + } + } + + private ArticleProgress createArticleProgress(String userId, String articleId, boolean isCompleted) { + ArticleProgress progress = new ArticleProgress(); + progress.setUserId(userId); + progress.setArticleId(articleId); + progress.setChunkId("test-chunk-id"); + progress.setIsCompleted(isCompleted); + progress.setUpdatedAt(Instant.now()); + return progress; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/repository/BookRepositoryImplTest.java b/src/test/java/com/linglevel/api/content/book/repository/BookRepositoryImplTest.java index 313f6302..3ee9c981 100644 --- a/src/test/java/com/linglevel/api/content/book/repository/BookRepositoryImplTest.java +++ b/src/test/java/com/linglevel/api/content/book/repository/BookRepositoryImplTest.java @@ -27,172 +27,150 @@ @Import(BookRepositoryImpl.class) class BookRepositoryImplTest extends AbstractDatabaseTest { - @Autowired - private BookRepository bookRepository; + @Autowired + private BookRepository bookRepository; - @Autowired - private BookProgressRepository bookProgressRepository; + @Autowired + private BookProgressRepository bookProgressRepository; - @Autowired - private MongoTemplate mongoTemplate; + @Autowired + private MongoTemplate mongoTemplate; - private static final String USER_ID = "user-1"; + private static final String USER_ID = "user-1"; - @BeforeEach - void setUp() { - bookProgressRepository.deleteAll(); - bookRepository.deleteAll(); - - bookRepository.saveAll(List.of( - createBook("book-1", "Alpha", Instant.parse("2026-01-01T00:00:00Z")), - createBook("book-2", "Beta", Instant.parse("2026-01-02T00:00:00Z")), - createBook("book-3", "Gamma", Instant.parse("2026-01-03T00:00:00Z")) - )); + @BeforeEach + void setUp() { + bookProgressRepository.deleteAll(); + bookRepository.deleteAll(); - mongoTemplate.insert(createProgressDocument("book-2", false, 40.0), "bookProgress"); - mongoTemplate.insert(createProgressDocument("book-3", true, 100.0), "bookProgress"); - } + bookRepository.saveAll(List.of(createBook("book-1", "Alpha", Instant.parse("2026-01-01T00:00:00Z")), + createBook("book-2", "Beta", Instant.parse("2026-01-02T00:00:00Z")), + createBook("book-3", "Gamma", Instant.parse("2026-01-03T00:00:00Z")))); - @Test - @DisplayName("NOT_STARTED 필터는 시작하지 않은 책(문서 없음 또는 normalizedProgress 0)을 반환한다") - void findBooksWithFilters_returnsNotStartedBooks() { - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .build(); + mongoTemplate.insert(createProgressDocument("book-2", false, 40.0), "bookProgress"); + mongoTemplate.insert(createProgressDocument("book-3", true, 100.0), "bookProgress"); + } - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + @Test + @DisplayName("NOT_STARTED 필터는 시작하지 않은 책(문서 없음 또는 normalizedProgress 0)을 반환한다") + void findBooksWithFilters_returnsNotStartedBooks() { + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.NOT_STARTED).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-1"); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("IN_PROGRESS 필터는 완료되지 않았고 읽기를 시작한 책을 반환한다") + void findBooksWithFilters_returnsInProgressBooks() { + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.IN_PROGRESS).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-2"); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("normalizedProgress가 0이어도 부분 읽기면 IN_PROGRESS로 분류한다") + void findBooksWithFilters_includesPartialReadAsInProgress() { + bookProgressRepository.deleteAll(); + mongoTemplate.insert(createPartialInProgressDocument("book-1", 1, 2, 20.0), "bookProgress"); + + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.IN_PROGRESS).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-1"); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("COMPLETED 필터는 완료된 책만 반환한다") + void findBooksWithFilters_returnsCompletedBooks() { + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.COMPLETED).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-3"); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("조건에 맞는 progress가 없으면 빈 페이지를 반환한다") + void findBooksWithFilters_returnsEmptyPageWhenNoProgressMatch() { + bookProgressRepository.deleteAll(); + mongoTemplate.insert(createProgressDocument("book-1", false, 0.0), "bookProgress"); + + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.IN_PROGRESS).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + + @Test + @DisplayName("normalizedProgress가 0이고 미완료인 책은 NOT_STARTED로 분류한다") + void findBooksWithFilters_includesZeroProgressAsNotStarted() { + mongoTemplate.insert(createProgressDocument("book-1", false, 0.0), "bookProgress"); + + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.NOT_STARTED).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-1"); + assertThat(result.getTotalElements()).isEqualTo(1); + } + + @Test + @DisplayName("부분 읽기 데이터는 NOT_STARTED에서 제외한다") + void findBooksWithFilters_excludesPartialReadFromNotStarted() { + bookProgressRepository.deleteAll(); + mongoTemplate.insert(createPartialInProgressDocument("book-1", 1, 2, 20.0), "bookProgress"); + + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.NOT_STARTED).build(); + + Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-2", "book-3"); + assertThat(result.getTotalElements()).isEqualTo(2); + } + + private Pageable defaultPageable() { + return PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "createdAt")); + } + + private Book createBook(String id, String title, Instant createdAt) { + Book book = new Book(); + book.setId(id); + book.setTitle(title); + book.setAuthor("Author"); + book.setDifficultyLevel(DifficultyLevel.A1); + book.setChapterCount(10); + book.setCreatedAt(createdAt); + return book; + } + + private Document createProgressDocument(String bookId, boolean isCompleted, double normalizedProgress) { + return new Document("userId", USER_ID).append("bookId", bookId) + .append("isCompleted", isCompleted) + .append("normalizedProgress", normalizedProgress); + } + + private Document createPartialInProgressDocument(String bookId, int chapterNumber, int chunkNumber, + double progressPercentage) { + int encodedPosition = chapterNumber * 65536 + chunkNumber; + Document chapterProgress = new Document("chapterNumber", chapterNumber) + .append("progressPercentage", progressPercentage) + .append("isCompleted", false) + .append("completedAt", null); + + return createProgressDocument(bookId, false, 0.0).append("maxReadChapterNumber", chapterNumber) + .append("maxReadChunkNumber", encodedPosition) + .append("chapterProgresses", List.of(chapterProgress)); + } - assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-1"); - assertThat(result.getTotalElements()).isEqualTo(1); - } - - @Test - @DisplayName("IN_PROGRESS 필터는 완료되지 않았고 읽기를 시작한 책을 반환한다") - void findBooksWithFilters_returnsInProgressBooks() { - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .build(); - - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-2"); - assertThat(result.getTotalElements()).isEqualTo(1); - } - - @Test - @DisplayName("normalizedProgress가 0이어도 부분 읽기면 IN_PROGRESS로 분류한다") - void findBooksWithFilters_includesPartialReadAsInProgress() { - bookProgressRepository.deleteAll(); - mongoTemplate.insert(createPartialInProgressDocument("book-1", 1, 2, 20.0), "bookProgress"); - - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .build(); - - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-1"); - assertThat(result.getTotalElements()).isEqualTo(1); - } - - @Test - @DisplayName("COMPLETED 필터는 완료된 책만 반환한다") - void findBooksWithFilters_returnsCompletedBooks() { - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.COMPLETED) - .build(); - - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-3"); - assertThat(result.getTotalElements()).isEqualTo(1); - } - - @Test - @DisplayName("조건에 맞는 progress가 없으면 빈 페이지를 반환한다") - void findBooksWithFilters_returnsEmptyPageWhenNoProgressMatch() { - bookProgressRepository.deleteAll(); - mongoTemplate.insert(createProgressDocument("book-1", false, 0.0), "bookProgress"); - - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .build(); - - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isZero(); - } - - @Test - @DisplayName("normalizedProgress가 0이고 미완료인 책은 NOT_STARTED로 분류한다") - void findBooksWithFilters_includesZeroProgressAsNotStarted() { - mongoTemplate.insert(createProgressDocument("book-1", false, 0.0), "bookProgress"); - - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .build(); - - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).extracting(Book::getId).containsExactly("book-1"); - assertThat(result.getTotalElements()).isEqualTo(1); - } - - @Test - @DisplayName("부분 읽기 데이터는 NOT_STARTED에서 제외한다") - void findBooksWithFilters_excludesPartialReadFromNotStarted() { - bookProgressRepository.deleteAll(); - mongoTemplate.insert(createPartialInProgressDocument("book-1", 1, 2, 20.0), "bookProgress"); - - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .build(); - - Page result = bookRepository.findBooksWithFilters(request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).extracting(Book::getId) - .containsExactly("book-2", "book-3"); - assertThat(result.getTotalElements()).isEqualTo(2); - } - - private Pageable defaultPageable() { - return PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "createdAt")); - } - - private Book createBook(String id, String title, Instant createdAt) { - Book book = new Book(); - book.setId(id); - book.setTitle(title); - book.setAuthor("Author"); - book.setDifficultyLevel(DifficultyLevel.A1); - book.setChapterCount(10); - book.setCreatedAt(createdAt); - return book; - } - - private Document createProgressDocument(String bookId, boolean isCompleted, double normalizedProgress) { - return new Document("userId", USER_ID) - .append("bookId", bookId) - .append("isCompleted", isCompleted) - .append("normalizedProgress", normalizedProgress); - } - - private Document createPartialInProgressDocument( - String bookId, - int chapterNumber, - int chunkNumber, - double progressPercentage - ) { - int encodedPosition = chapterNumber * 65536 + chunkNumber; - Document chapterProgress = new Document("chapterNumber", chapterNumber) - .append("progressPercentage", progressPercentage) - .append("isCompleted", false) - .append("completedAt", null); - - return createProgressDocument(bookId, false, 0.0) - .append("maxReadChapterNumber", chapterNumber) - .append("maxReadChunkNumber", encodedPosition) - .append("chapterProgresses", List.of(chapterProgress)); - } } diff --git a/src/test/java/com/linglevel/api/content/book/repository/ChapterRepositoryImplTest.java b/src/test/java/com/linglevel/api/content/book/repository/ChapterRepositoryImplTest.java index 8237ed7b..46242719 100644 --- a/src/test/java/com/linglevel/api/content/book/repository/ChapterRepositoryImplTest.java +++ b/src/test/java/com/linglevel/api/content/book/repository/ChapterRepositoryImplTest.java @@ -24,117 +24,116 @@ @Import(ChapterRepositoryImpl.class) class ChapterRepositoryImplTest extends AbstractDatabaseTest { - @Autowired - private ChapterRepository chapterRepository; - - @Autowired - private BookProgressRepository bookProgressRepository; - - private static final String BOOK_ID = "book-1"; - private static final String USER_ID = "user-1"; - - @BeforeEach - void setUp() { - bookProgressRepository.deleteAll(); - chapterRepository.deleteAll(); - - chapterRepository.saveAll(List.of( - createChapter(1, "Chapter 1"), - createChapter(2, "Chapter 2"), - createChapter(3, "Chapter 3") - )); - } - - @Test - @DisplayName("진도 정보가 없으면 NOT_STARTED 필터는 모든 챕터를 반환한다") - void findChaptersWithFilters_returnsAllChaptersWhenNoProgress() { - GetChaptersRequest request = GetChaptersRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .build(); - - Page result = chapterRepository.findChaptersWithFilters(BOOK_ID, request, USER_ID, defaultPageable()); - - assertThat(result.getContent()).extracting(Chapter::getChapterNumber).containsExactly(1, 2, 3); - assertThat(result.getTotalElements()).isEqualTo(3); - } - - @Test - @DisplayName("V3 chapterProgresses 기준으로 IN_PROGRESS와 COMPLETED를 구분한다") - void findChaptersWithFilters_usesV3ChapterProgresses() { - BookProgress progress = new BookProgress(); - progress.setUserId(USER_ID); - progress.setBookId(BOOK_ID); - progress.setChapterProgresses(List.of( - BookProgress.ChapterProgressInfo.builder() - .chapterNumber(1) - .progressPercentage(100.0) - .isCompleted(true) - .build(), - BookProgress.ChapterProgressInfo.builder() - .chapterNumber(2) - .progressPercentage(50.0) - .isCompleted(false) - .build() - )); - bookProgressRepository.save(progress); - - GetChaptersRequest inProgressRequest = GetChaptersRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .build(); - GetChaptersRequest completedRequest = GetChaptersRequest.builder() - .progress(ProgressStatus.COMPLETED) - .build(); - GetChaptersRequest notStartedRequest = GetChaptersRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .build(); - - Page inProgress = chapterRepository.findChaptersWithFilters(BOOK_ID, inProgressRequest, USER_ID, defaultPageable()); - Page completed = chapterRepository.findChaptersWithFilters(BOOK_ID, completedRequest, USER_ID, defaultPageable()); - Page notStarted = chapterRepository.findChaptersWithFilters(BOOK_ID, notStartedRequest, USER_ID, defaultPageable()); - - assertThat(inProgress.getContent()).extracting(Chapter::getChapterNumber).containsExactly(2); - assertThat(completed.getContent()).extracting(Chapter::getChapterNumber).containsExactly(1); - assertThat(notStarted.getContent()).extracting(Chapter::getChapterNumber).containsExactly(3); - } - - @Test - @DisplayName("V3 데이터가 없으면 모든 챕터를 NOT_STARTED로 본다") - void findChaptersWithFilters_treatsMissingV3DataAsNotStarted() { - BookProgress progress = new BookProgress(); - progress.setUserId(USER_ID); - progress.setBookId(BOOK_ID); - progress.setCurrentReadChapterNumber(2); // legacy field only (ignored in V3-only filtering) - bookProgressRepository.save(progress); - - GetChaptersRequest completedRequest = GetChaptersRequest.builder() - .progress(ProgressStatus.COMPLETED) - .build(); - GetChaptersRequest inProgressRequest = GetChaptersRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .build(); - GetChaptersRequest notStartedRequest = GetChaptersRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .build(); - - Page completed = chapterRepository.findChaptersWithFilters(BOOK_ID, completedRequest, USER_ID, defaultPageable()); - Page inProgress = chapterRepository.findChaptersWithFilters(BOOK_ID, inProgressRequest, USER_ID, defaultPageable()); - Page notStarted = chapterRepository.findChaptersWithFilters(BOOK_ID, notStartedRequest, USER_ID, defaultPageable()); - - assertThat(completed.getContent()).isEmpty(); - assertThat(inProgress.getContent()).isEmpty(); - assertThat(notStarted.getContent()).extracting(Chapter::getChapterNumber).containsExactly(1, 2, 3); - } - - private Pageable defaultPageable() { - return PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "chapterNumber")); - } - - private Chapter createChapter(int chapterNumber, String title) { - Chapter chapter = new Chapter(); - chapter.setId("chapter-" + chapterNumber); - chapter.setBookId(BOOK_ID); - chapter.setChapterNumber(chapterNumber); - chapter.setTitle(title); - return chapter; - } + @Autowired + private ChapterRepository chapterRepository; + + @Autowired + private BookProgressRepository bookProgressRepository; + + private static final String BOOK_ID = "book-1"; + + private static final String USER_ID = "user-1"; + + @BeforeEach + void setUp() { + bookProgressRepository.deleteAll(); + chapterRepository.deleteAll(); + + chapterRepository.saveAll( + List.of(createChapter(1, "Chapter 1"), createChapter(2, "Chapter 2"), createChapter(3, "Chapter 3"))); + } + + @Test + @DisplayName("진도 정보가 없으면 NOT_STARTED 필터는 모든 챕터를 반환한다") + void findChaptersWithFilters_returnsAllChaptersWhenNoProgress() { + GetChaptersRequest request = GetChaptersRequest.builder().progress(ProgressStatus.NOT_STARTED).build(); + + Page result = chapterRepository.findChaptersWithFilters(BOOK_ID, request, USER_ID, defaultPageable()); + + assertThat(result.getContent()).extracting(Chapter::getChapterNumber).containsExactly(1, 2, 3); + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @Test + @DisplayName("V3 chapterProgresses 기준으로 IN_PROGRESS와 COMPLETED를 구분한다") + void findChaptersWithFilters_usesV3ChapterProgresses() { + BookProgress progress = new BookProgress(); + progress.setUserId(USER_ID); + progress.setBookId(BOOK_ID); + progress.setChapterProgresses(List.of( + BookProgress.ChapterProgressInfo.builder() + .chapterNumber(1) + .progressPercentage(100.0) + .isCompleted(true) + .build(), + BookProgress.ChapterProgressInfo.builder() + .chapterNumber(2) + .progressPercentage(50.0) + .isCompleted(false) + .build())); + bookProgressRepository.save(progress); + + GetChaptersRequest inProgressRequest = GetChaptersRequest.builder() + .progress(ProgressStatus.IN_PROGRESS) + .build(); + GetChaptersRequest completedRequest = GetChaptersRequest.builder().progress(ProgressStatus.COMPLETED).build(); + GetChaptersRequest notStartedRequest = GetChaptersRequest.builder() + .progress(ProgressStatus.NOT_STARTED) + .build(); + + Page inProgress = chapterRepository.findChaptersWithFilters(BOOK_ID, inProgressRequest, USER_ID, + defaultPageable()); + Page completed = chapterRepository.findChaptersWithFilters(BOOK_ID, completedRequest, USER_ID, + defaultPageable()); + Page notStarted = chapterRepository.findChaptersWithFilters(BOOK_ID, notStartedRequest, USER_ID, + defaultPageable()); + + assertThat(inProgress.getContent()).extracting(Chapter::getChapterNumber).containsExactly(2); + assertThat(completed.getContent()).extracting(Chapter::getChapterNumber).containsExactly(1); + assertThat(notStarted.getContent()).extracting(Chapter::getChapterNumber).containsExactly(3); + } + + @Test + @DisplayName("V3 데이터가 없으면 모든 챕터를 NOT_STARTED로 본다") + void findChaptersWithFilters_treatsMissingV3DataAsNotStarted() { + BookProgress progress = new BookProgress(); + progress.setUserId(USER_ID); + progress.setBookId(BOOK_ID); + progress.setCurrentReadChapterNumber(2); // legacy field only (ignored in V3-only + // filtering) + bookProgressRepository.save(progress); + + GetChaptersRequest completedRequest = GetChaptersRequest.builder().progress(ProgressStatus.COMPLETED).build(); + GetChaptersRequest inProgressRequest = GetChaptersRequest.builder() + .progress(ProgressStatus.IN_PROGRESS) + .build(); + GetChaptersRequest notStartedRequest = GetChaptersRequest.builder() + .progress(ProgressStatus.NOT_STARTED) + .build(); + + Page completed = chapterRepository.findChaptersWithFilters(BOOK_ID, completedRequest, USER_ID, + defaultPageable()); + Page inProgress = chapterRepository.findChaptersWithFilters(BOOK_ID, inProgressRequest, USER_ID, + defaultPageable()); + Page notStarted = chapterRepository.findChaptersWithFilters(BOOK_ID, notStartedRequest, USER_ID, + defaultPageable()); + + assertThat(completed.getContent()).isEmpty(); + assertThat(inProgress.getContent()).isEmpty(); + assertThat(notStarted.getContent()).extracting(Chapter::getChapterNumber).containsExactly(1, 2, 3); + } + + private Pageable defaultPageable() { + return PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "chapterNumber")); + } + + private Chapter createChapter(int chapterNumber, String title) { + Chapter chapter = new Chapter(); + chapter.setId("chapter-" + chapterNumber); + chapter.setBookId(BOOK_ID); + chapter.setChapterNumber(chapterNumber); + chapter.setTitle(title); + return chapter; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/BookImportServiceTest.java b/src/test/java/com/linglevel/api/content/book/service/BookImportServiceTest.java index 29783983..6ddfed4f 100644 --- a/src/test/java/com/linglevel/api/content/book/service/BookImportServiceTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/BookImportServiceTest.java @@ -29,248 +29,249 @@ @ExtendWith(MockitoExtension.class) class BookImportServiceTest { - @Mock - private ChapterRepository chapterRepository; - @Mock - private ChunkRepository chunkRepository; - - @Mock - private S3UrlService s3UrlService; - - @Mock - private BookPathStrategy bookPathStrategy; - - @InjectMocks - private BookImportService bookImportService; - - private BookImportData bookImportData; - private BookImportData.ChapterMetadata chapterMetadata; - - @BeforeEach - void setUp() { - bookImportData = new BookImportData(); - chapterMetadata = new BookImportData.ChapterMetadata(); - BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); - BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); - BookImportData.ChunkData textChunkData = new BookImportData.ChunkData(); - BookImportData.ChunkData imageChunkData = new BookImportData.ChunkData(); - - textChunkData.setChunkNum(1); - textChunkData.setChunkText("내용"); - textChunkData.setIsImage(false); - imageChunkData.setChunkNum(2); - imageChunkData.setChunkText("주소"); - imageChunkData.setIsImage(true); - imageChunkData.setDescription("이미지 설명"); - - chapterData.setChapterNum(1); - chapterData.setChunks(List.of(textChunkData, imageChunkData)); - - textLevelData.setTextLevel("a1"); - textLevelData.setChapters(List.of(chapterData)); - - chapterMetadata.setChapterNum(1); - chapterMetadata.setTitle("제목"); - chapterMetadata.setSummary("요약"); - - bookImportData.setChapterMetadata(List.of(chapterMetadata)); - bookImportData.setLeveledResults(List.of(textLevelData)); - } - - @Test - @DisplayName("chapter metadata를 Chapter 엔티티로 변환해 저장한다.") - void importChapters() { - // given - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); - - when(chapterRepository.saveAll(ArgumentMatchers.anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - - // when - List chapters = bookImportService.createChaptersFromMetadata(bookImportData, "bookId"); - - // then - verify(chapterRepository).saveAll(captor.capture()); - - List savedChapters = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); - - assertEquals(1, savedChapters.size()); - - Chapter savedChapter = savedChapters.get(0); - assertEquals("bookId", savedChapter.getBookId()); - assertEquals(1, savedChapter.getChapterNumber()); - assertEquals("제목", savedChapter.getTitle()); - assertEquals("요약", savedChapter.getDescription()); - assertEquals(0, savedChapter.getReadingTime()); - assertEquals(savedChapters, chapters); - } - - @Test - @DisplayName("leveled results를 텍스트와 이미지 Chunk 엔티티로 변환해 저장한다.") - void importChunks() { - // given - Chapter savedChapter = new Chapter(); - savedChapter.setId("chapter-1"); - List chapters = List.of(savedChapter); - - when(s3UrlService.buildImageUrl("bookId", "주소", bookPathStrategy)) - .thenReturn("https://cdn.example.com/image.png"); - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = - ArgumentCaptor.forClass((Class) Iterable.class); - - // when - bookImportService.createChunksFromLeveledResults(bookImportData, chapters, "bookId"); - - // then - verify(chunkRepository).saveAll(captor.capture()); - - List savedChunks = - StreamSupport.stream(captor.getValue().spliterator(), false).toList(); - - assertEquals(2, savedChunks.size()); - - Chunk textChunk = savedChunks.get(0); - assertEquals("chapter-1", textChunk.getChapterId()); - assertEquals(1, textChunk.getChunkNumber()); - assertEquals(DifficultyLevel.A1, textChunk.getDifficultyLevel()); - assertEquals(ChunkType.TEXT, textChunk.getType()); - assertEquals("내용", textChunk.getContent()); - assertNull(textChunk.getDescription()); - - Chunk imageChunk = savedChunks.get(1); - assertEquals("chapter-1", imageChunk.getChapterId()); - assertEquals(2, imageChunk.getChunkNumber()); - assertEquals(DifficultyLevel.A1, imageChunk.getDifficultyLevel()); - assertEquals(ChunkType.IMAGE, imageChunk.getType()); - assertEquals("https://cdn.example.com/image.png", imageChunk.getContent()); - assertEquals("이미지 설명", imageChunk.getDescription()); - - verify(s3UrlService).buildImageUrl("bookId", "주소", bookPathStrategy); - } - - @Test - @DisplayName("여러 chapter metadata가 주어지면 chapterNumber를 1부터 순차 증가시켜 저장한다.") - void importChapters_assignSequentialChapterNumbers() { - // given - BookImportData.ChapterMetadata secondMetadata = new BookImportData.ChapterMetadata(); - secondMetadata.setChapterNum(2); - secondMetadata.setTitle("두번째 제목"); - secondMetadata.setSummary("두번째 요약"); - bookImportData.setChapterMetadata(List.of(chapterMetadata, secondMetadata)); - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); - - when(chapterRepository.saveAll(ArgumentMatchers.anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - - // when - bookImportService.createChaptersFromMetadata(bookImportData, "bookId"); - - // then - verify(chapterRepository).saveAll(captor.capture()); - - List savedChapters = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); - - assertEquals(2, savedChapters.size()); - assertEquals(1, savedChapters.get(0).getChapterNumber()); - assertEquals("제목", savedChapters.get(0).getTitle()); - assertEquals(2, savedChapters.get(1).getChapterNumber()); - assertEquals("두번째 제목", savedChapters.get(1).getTitle()); - } - - @Test - @DisplayName("여러 챕터를 저장할 때 각 챕터의 chunkNumber는 1부터 다시 시작한다.") - void importChunks_resetsChunkNumberPerChapter() { - // given - Chapter firstChapter = new Chapter(); - firstChapter.setId("chapter-1"); - Chapter secondChapter = new Chapter(); - secondChapter.setId("chapter-2"); - List chapters = List.of(firstChapter, secondChapter); - - BookImportData.ChunkData firstTextChunk = createChunkData("첫 챕터 1", false, null); - BookImportData.ChunkData firstImageChunk = createChunkData("first.png", true, "첫 이미지"); - BookImportData.ChunkData secondTextChunk = createChunkData("둘째 챕터 1", false, null); - - BookImportData.ChapterData firstChapterData = createChapterData(List.of(firstTextChunk, firstImageChunk)); - BookImportData.ChapterData secondChapterData = createChapterData(List.of(secondTextChunk)); - - BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); - textLevelData.setTextLevel("a1"); - textLevelData.setChapters(List.of(firstChapterData, secondChapterData)); - bookImportData.setLeveledResults(List.of(textLevelData)); - - when(s3UrlService.buildImageUrl("bookId", "first.png", bookPathStrategy)) - .thenReturn("https://cdn.example.com/first.png"); - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); - - // when - bookImportService.createChunksFromLeveledResults(bookImportData, chapters, "bookId"); - - // then - verify(chunkRepository).saveAll(captor.capture()); - - List savedChunks = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); - - assertEquals(3, savedChunks.size()); - assertEquals("chapter-1", savedChunks.get(0).getChapterId()); - assertEquals(1, savedChunks.get(0).getChunkNumber()); - assertEquals("chapter-1", savedChunks.get(1).getChapterId()); - assertEquals(2, savedChunks.get(1).getChunkNumber()); - assertEquals("chapter-2", savedChunks.get(2).getChapterId()); - assertEquals(1, savedChunks.get(2).getChunkNumber()); - } - - @Test - @DisplayName("AI chapter 수가 savedChapters보다 적으면 남은 챕터는 건너뛴다.") - void importChunks_skipsRemainingSavedChaptersWhenAiChaptersAreShorter() { - // given - Chapter firstChapter = new Chapter(); - firstChapter.setId("chapter-1"); - Chapter secondChapter = new Chapter(); - secondChapter.setId("chapter-2"); - List chapters = List.of(firstChapter, secondChapter); - - BookImportData.ChunkData onlyChunk = createChunkData("첫 챕터만 저장", false, null); - BookImportData.ChapterData onlyChapterData = createChapterData(List.of(onlyChunk)); - - BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); - textLevelData.setTextLevel("a1"); - textLevelData.setChapters(List.of(onlyChapterData)); - bookImportData.setLeveledResults(List.of(textLevelData)); - - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); - - // when - bookImportService.createChunksFromLeveledResults(bookImportData, chapters, "bookId"); - - // then - verify(chunkRepository).saveAll(captor.capture()); - - List savedChunks = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); - - assertEquals(1, savedChunks.size()); - assertEquals("chapter-1", savedChunks.get(0).getChapterId()); - assertEquals("첫 챕터만 저장", savedChunks.get(0).getContent()); - } - - private BookImportData.ChunkData createChunkData(String chunkText, boolean isImage, String description) { - BookImportData.ChunkData chunkData = new BookImportData.ChunkData(); - chunkData.setChunkText(chunkText); - chunkData.setIsImage(isImage); - chunkData.setDescription(description); - return chunkData; - } - - private BookImportData.ChapterData createChapterData(List chunks) { - BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); - chapterData.setChunks(chunks); - return chapterData; - } + @Mock + private ChapterRepository chapterRepository; + + @Mock + private ChunkRepository chunkRepository; + + @Mock + private S3UrlService s3UrlService; + + @Mock + private BookPathStrategy bookPathStrategy; + + @InjectMocks + private BookImportService bookImportService; + + private BookImportData bookImportData; + + private BookImportData.ChapterMetadata chapterMetadata; + + @BeforeEach + void setUp() { + bookImportData = new BookImportData(); + chapterMetadata = new BookImportData.ChapterMetadata(); + BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); + BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); + BookImportData.ChunkData textChunkData = new BookImportData.ChunkData(); + BookImportData.ChunkData imageChunkData = new BookImportData.ChunkData(); + + textChunkData.setChunkNum(1); + textChunkData.setChunkText("내용"); + textChunkData.setIsImage(false); + imageChunkData.setChunkNum(2); + imageChunkData.setChunkText("주소"); + imageChunkData.setIsImage(true); + imageChunkData.setDescription("이미지 설명"); + + chapterData.setChapterNum(1); + chapterData.setChunks(List.of(textChunkData, imageChunkData)); + + textLevelData.setTextLevel("a1"); + textLevelData.setChapters(List.of(chapterData)); + + chapterMetadata.setChapterNum(1); + chapterMetadata.setTitle("제목"); + chapterMetadata.setSummary("요약"); + + bookImportData.setChapterMetadata(List.of(chapterMetadata)); + bookImportData.setLeveledResults(List.of(textLevelData)); + } + + @Test + @DisplayName("chapter metadata를 Chapter 엔티티로 변환해 저장한다.") + void importChapters() { + // given + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); + + when(chapterRepository.saveAll(ArgumentMatchers.anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + List chapters = bookImportService.createChaptersFromMetadata(bookImportData, "bookId"); + + // then + verify(chapterRepository).saveAll(captor.capture()); + + List savedChapters = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); + + assertEquals(1, savedChapters.size()); + + Chapter savedChapter = savedChapters.get(0); + assertEquals("bookId", savedChapter.getBookId()); + assertEquals(1, savedChapter.getChapterNumber()); + assertEquals("제목", savedChapter.getTitle()); + assertEquals("요약", savedChapter.getDescription()); + assertEquals(0, savedChapter.getReadingTime()); + assertEquals(savedChapters, chapters); + } + + @Test + @DisplayName("leveled results를 텍스트와 이미지 Chunk 엔티티로 변환해 저장한다.") + void importChunks() { + // given + Chapter savedChapter = new Chapter(); + savedChapter.setId("chapter-1"); + List chapters = List.of(savedChapter); + + when(s3UrlService.buildImageUrl("bookId", "주소", bookPathStrategy)) + .thenReturn("https://cdn.example.com/image.png"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); + + // when + bookImportService.createChunksFromLeveledResults(bookImportData, chapters, "bookId"); + + // then + verify(chunkRepository).saveAll(captor.capture()); + + List savedChunks = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); + + assertEquals(2, savedChunks.size()); + + Chunk textChunk = savedChunks.get(0); + assertEquals("chapter-1", textChunk.getChapterId()); + assertEquals(1, textChunk.getChunkNumber()); + assertEquals(DifficultyLevel.A1, textChunk.getDifficultyLevel()); + assertEquals(ChunkType.TEXT, textChunk.getType()); + assertEquals("내용", textChunk.getContent()); + assertNull(textChunk.getDescription()); + + Chunk imageChunk = savedChunks.get(1); + assertEquals("chapter-1", imageChunk.getChapterId()); + assertEquals(2, imageChunk.getChunkNumber()); + assertEquals(DifficultyLevel.A1, imageChunk.getDifficultyLevel()); + assertEquals(ChunkType.IMAGE, imageChunk.getType()); + assertEquals("https://cdn.example.com/image.png", imageChunk.getContent()); + assertEquals("이미지 설명", imageChunk.getDescription()); + + verify(s3UrlService).buildImageUrl("bookId", "주소", bookPathStrategy); + } + + @Test + @DisplayName("여러 chapter metadata가 주어지면 chapterNumber를 1부터 순차 증가시켜 저장한다.") + void importChapters_assignSequentialChapterNumbers() { + // given + BookImportData.ChapterMetadata secondMetadata = new BookImportData.ChapterMetadata(); + secondMetadata.setChapterNum(2); + secondMetadata.setTitle("두번째 제목"); + secondMetadata.setSummary("두번째 요약"); + bookImportData.setChapterMetadata(List.of(chapterMetadata, secondMetadata)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); + + when(chapterRepository.saveAll(ArgumentMatchers.anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + bookImportService.createChaptersFromMetadata(bookImportData, "bookId"); + + // then + verify(chapterRepository).saveAll(captor.capture()); + + List savedChapters = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); + + assertEquals(2, savedChapters.size()); + assertEquals(1, savedChapters.get(0).getChapterNumber()); + assertEquals("제목", savedChapters.get(0).getTitle()); + assertEquals(2, savedChapters.get(1).getChapterNumber()); + assertEquals("두번째 제목", savedChapters.get(1).getTitle()); + } + + @Test + @DisplayName("여러 챕터를 저장할 때 각 챕터의 chunkNumber는 1부터 다시 시작한다.") + void importChunks_resetsChunkNumberPerChapter() { + // given + Chapter firstChapter = new Chapter(); + firstChapter.setId("chapter-1"); + Chapter secondChapter = new Chapter(); + secondChapter.setId("chapter-2"); + List chapters = List.of(firstChapter, secondChapter); + + BookImportData.ChunkData firstTextChunk = createChunkData("첫 챕터 1", false, null); + BookImportData.ChunkData firstImageChunk = createChunkData("first.png", true, "첫 이미지"); + BookImportData.ChunkData secondTextChunk = createChunkData("둘째 챕터 1", false, null); + + BookImportData.ChapterData firstChapterData = createChapterData(List.of(firstTextChunk, firstImageChunk)); + BookImportData.ChapterData secondChapterData = createChapterData(List.of(secondTextChunk)); + + BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); + textLevelData.setTextLevel("a1"); + textLevelData.setChapters(List.of(firstChapterData, secondChapterData)); + bookImportData.setLeveledResults(List.of(textLevelData)); + + when(s3UrlService.buildImageUrl("bookId", "first.png", bookPathStrategy)) + .thenReturn("https://cdn.example.com/first.png"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); + + // when + bookImportService.createChunksFromLeveledResults(bookImportData, chapters, "bookId"); + + // then + verify(chunkRepository).saveAll(captor.capture()); + + List savedChunks = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); + + assertEquals(3, savedChunks.size()); + assertEquals("chapter-1", savedChunks.get(0).getChapterId()); + assertEquals(1, savedChunks.get(0).getChunkNumber()); + assertEquals("chapter-1", savedChunks.get(1).getChapterId()); + assertEquals(2, savedChunks.get(1).getChunkNumber()); + assertEquals("chapter-2", savedChunks.get(2).getChapterId()); + assertEquals(1, savedChunks.get(2).getChunkNumber()); + } + + @Test + @DisplayName("AI chapter 수가 savedChapters보다 적으면 남은 챕터는 건너뛴다.") + void importChunks_skipsRemainingSavedChaptersWhenAiChaptersAreShorter() { + // given + Chapter firstChapter = new Chapter(); + firstChapter.setId("chapter-1"); + Chapter secondChapter = new Chapter(); + secondChapter.setId("chapter-2"); + List chapters = List.of(firstChapter, secondChapter); + + BookImportData.ChunkData onlyChunk = createChunkData("첫 챕터만 저장", false, null); + BookImportData.ChapterData onlyChapterData = createChapterData(List.of(onlyChunk)); + + BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); + textLevelData.setTextLevel("a1"); + textLevelData.setChapters(List.of(onlyChapterData)); + bookImportData.setLeveledResults(List.of(textLevelData)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass((Class) Iterable.class); + + // when + bookImportService.createChunksFromLeveledResults(bookImportData, chapters, "bookId"); + + // then + verify(chunkRepository).saveAll(captor.capture()); + + List savedChunks = StreamSupport.stream(captor.getValue().spliterator(), false).toList(); + + assertEquals(1, savedChunks.size()); + assertEquals("chapter-1", savedChunks.get(0).getChapterId()); + assertEquals("첫 챕터만 저장", savedChunks.get(0).getContent()); + } + + private BookImportData.ChunkData createChunkData(String chunkText, boolean isImage, String description) { + BookImportData.ChunkData chunkData = new BookImportData.ChunkData(); + chunkData.setChunkText(chunkText); + chunkData.setIsImage(isImage); + chunkData.setDescription(description); + return chunkData; + } + + private BookImportData.ChapterData createChapterData(List chunks) { + BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); + chapterData.setChunks(chunks); + return chapterData; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/BookReadingTimeServiceTest.java b/src/test/java/com/linglevel/api/content/book/service/BookReadingTimeServiceTest.java index 35dde96c..2523740b 100644 --- a/src/test/java/com/linglevel/api/content/book/service/BookReadingTimeServiceTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/BookReadingTimeServiceTest.java @@ -30,124 +30,122 @@ @ExtendWith(MockitoExtension.class) class BookReadingTimeServiceTest { - @Mock - private BookRepository bookRepository; - - @Mock - private ChapterRepository chapterRepository; - - @Mock - private ReadingTimeService readingTimeService; - - @InjectMocks - private BookReadingTimeService bookReadingTimeService; - - @Test - @DisplayName("책이 없으면 BOOK_NOT_FOUND 예외를 던진다.") - void updateReadingTimes_throwsWhenBookNotFound() { - // given - BookImportData importData = new BookImportData(); - when(bookRepository.findById("missing-book")).thenReturn(Optional.empty()); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> bookReadingTimeService.updateReadingTimes("missing-book", importData) - ); - - // then - assertEquals(BooksErrorCode.BOOK_NOT_FOUND.getMessage(), exception.getMessage()); - verify(chapterRepository, never()).findByBookIdOrderByChapterNumber("missing-book"); - verify(chapterRepository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()); - verify(bookRepository, never()).save(org.mockito.ArgumentMatchers.any(Book.class)); - } - - @Test - @DisplayName("책 난이도와 일치하는 leveled results를 사용해 chapter와 book readingTime을 저장한다.") - void updateReadingTimes_updatesChapterAndBookReadingTimes() { - // given - Book book = new Book(); - book.setId("book-1"); - book.setDifficultyLevel(DifficultyLevel.A1); - - Chapter firstChapter = new Chapter(); - firstChapter.setId("chapter-1"); - firstChapter.setBookId("book-1"); - firstChapter.setChapterNumber(1); - - Chapter secondChapter = new Chapter(); - secondChapter.setId("chapter-2"); - secondChapter.setBookId("book-1"); - secondChapter.setChapterNumber(2); - - BookImportData importData = createImportData(); - - when(bookRepository.findById("book-1")).thenReturn(Optional.of(book)); - when(chapterRepository.findByBookIdOrderByChapterNumber("book-1")) - .thenReturn(List.of(firstChapter, secondChapter)); - when(readingTimeService.calculateReadingTimeFromCharacters(5)).thenReturn(3); - when(readingTimeService.calculateReadingTimeFromCharacters(4)).thenReturn(2); - - @SuppressWarnings("unchecked") - ArgumentCaptor> chaptersCaptor = - ArgumentCaptor.forClass((Class) Iterable.class); - ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); - - // when - bookReadingTimeService.updateReadingTimes("book-1", importData); - - // then - verify(chapterRepository).saveAll(chaptersCaptor.capture()); - verify(bookRepository).save(bookCaptor.capture()); - - List savedChapters = - StreamSupport.stream(chaptersCaptor.getValue().spliterator(), false).toList(); - Book savedBook = bookCaptor.getValue(); - - assertEquals(2, savedChapters.size()); - assertEquals(3, savedChapters.get(0).getReadingTime()); - assertEquals(2, savedChapters.get(1).getReadingTime()); - assertEquals(5, savedBook.getReadingTime()); - - verify(readingTimeService).calculateReadingTimeFromCharacters(5); - verify(readingTimeService).calculateReadingTimeFromCharacters(4); - } - - private BookImportData createImportData() { - BookImportData.ChunkData firstA1Chunk = createChunkData("abc"); - BookImportData.ChunkData secondA1Chunk = createChunkData("de"); - BookImportData.ChunkData chapterTwoA1Chunk = createChunkData("wxyz"); - BookImportData.ChunkData ignoredB1Chunk = createChunkData("ignored-text"); - - BookImportData.ChapterData firstA1Chapter = createChapterData(1, List.of(firstA1Chunk, secondA1Chunk)); - BookImportData.ChapterData secondA1Chapter = createChapterData(2, List.of(chapterTwoA1Chunk)); - BookImportData.ChapterData ignoredB1Chapter = createChapterData(1, List.of(ignoredB1Chunk)); - - BookImportData.TextLevelData a1Level = createTextLevelData("a1", List.of(firstA1Chapter, secondA1Chapter)); - BookImportData.TextLevelData b1Level = createTextLevelData("b1", List.of(ignoredB1Chapter)); - - BookImportData importData = new BookImportData(); - importData.setLeveledResults(List.of(a1Level, b1Level)); - return importData; - } - - private BookImportData.TextLevelData createTextLevelData(String textLevel, List chapters) { - BookImportData.TextLevelData levelData = new BookImportData.TextLevelData(); - levelData.setTextLevel(textLevel); - levelData.setChapters(chapters); - return levelData; - } - - private BookImportData.ChapterData createChapterData(int chapterNum, List chunks) { - BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); - chapterData.setChapterNum(chapterNum); - chapterData.setChunks(chunks); - return chapterData; - } - - private BookImportData.ChunkData createChunkData(String chunkText) { - BookImportData.ChunkData chunkData = new BookImportData.ChunkData(); - chunkData.setChunkText(chunkText); - return chunkData; - } + @Mock + private BookRepository bookRepository; + + @Mock + private ChapterRepository chapterRepository; + + @Mock + private ReadingTimeService readingTimeService; + + @InjectMocks + private BookReadingTimeService bookReadingTimeService; + + @Test + @DisplayName("책이 없으면 BOOK_NOT_FOUND 예외를 던진다.") + void updateReadingTimes_throwsWhenBookNotFound() { + // given + BookImportData importData = new BookImportData(); + when(bookRepository.findById("missing-book")).thenReturn(Optional.empty()); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> bookReadingTimeService.updateReadingTimes("missing-book", importData)); + + // then + assertEquals(BooksErrorCode.BOOK_NOT_FOUND.getMessage(), exception.getMessage()); + verify(chapterRepository, never()).findByBookIdOrderByChapterNumber("missing-book"); + verify(chapterRepository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()); + verify(bookRepository, never()).save(org.mockito.ArgumentMatchers.any(Book.class)); + } + + @Test + @DisplayName("책 난이도와 일치하는 leveled results를 사용해 chapter와 book readingTime을 저장한다.") + void updateReadingTimes_updatesChapterAndBookReadingTimes() { + // given + Book book = new Book(); + book.setId("book-1"); + book.setDifficultyLevel(DifficultyLevel.A1); + + Chapter firstChapter = new Chapter(); + firstChapter.setId("chapter-1"); + firstChapter.setBookId("book-1"); + firstChapter.setChapterNumber(1); + + Chapter secondChapter = new Chapter(); + secondChapter.setId("chapter-2"); + secondChapter.setBookId("book-1"); + secondChapter.setChapterNumber(2); + + BookImportData importData = createImportData(); + + when(bookRepository.findById("book-1")).thenReturn(Optional.of(book)); + when(chapterRepository.findByBookIdOrderByChapterNumber("book-1")) + .thenReturn(List.of(firstChapter, secondChapter)); + when(readingTimeService.calculateReadingTimeFromCharacters(5)).thenReturn(3); + when(readingTimeService.calculateReadingTimeFromCharacters(4)).thenReturn(2); + + @SuppressWarnings("unchecked") + ArgumentCaptor> chaptersCaptor = ArgumentCaptor.forClass((Class) Iterable.class); + ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); + + // when + bookReadingTimeService.updateReadingTimes("book-1", importData); + + // then + verify(chapterRepository).saveAll(chaptersCaptor.capture()); + verify(bookRepository).save(bookCaptor.capture()); + + List savedChapters = StreamSupport.stream(chaptersCaptor.getValue().spliterator(), false).toList(); + Book savedBook = bookCaptor.getValue(); + + assertEquals(2, savedChapters.size()); + assertEquals(3, savedChapters.get(0).getReadingTime()); + assertEquals(2, savedChapters.get(1).getReadingTime()); + assertEquals(5, savedBook.getReadingTime()); + + verify(readingTimeService).calculateReadingTimeFromCharacters(5); + verify(readingTimeService).calculateReadingTimeFromCharacters(4); + } + + private BookImportData createImportData() { + BookImportData.ChunkData firstA1Chunk = createChunkData("abc"); + BookImportData.ChunkData secondA1Chunk = createChunkData("de"); + BookImportData.ChunkData chapterTwoA1Chunk = createChunkData("wxyz"); + BookImportData.ChunkData ignoredB1Chunk = createChunkData("ignored-text"); + + BookImportData.ChapterData firstA1Chapter = createChapterData(1, List.of(firstA1Chunk, secondA1Chunk)); + BookImportData.ChapterData secondA1Chapter = createChapterData(2, List.of(chapterTwoA1Chunk)); + BookImportData.ChapterData ignoredB1Chapter = createChapterData(1, List.of(ignoredB1Chunk)); + + BookImportData.TextLevelData a1Level = createTextLevelData("a1", List.of(firstA1Chapter, secondA1Chapter)); + BookImportData.TextLevelData b1Level = createTextLevelData("b1", List.of(ignoredB1Chapter)); + + BookImportData importData = new BookImportData(); + importData.setLeveledResults(List.of(a1Level, b1Level)); + return importData; + } + + private BookImportData.TextLevelData createTextLevelData(String textLevel, + List chapters) { + BookImportData.TextLevelData levelData = new BookImportData.TextLevelData(); + levelData.setTextLevel(textLevel); + levelData.setChapters(chapters); + return levelData; + } + + private BookImportData.ChapterData createChapterData(int chapterNum, List chunks) { + BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); + chapterData.setChapterNum(chapterNum); + chapterData.setChunks(chunks); + return chapterData; + } + + private BookImportData.ChunkData createChunkData(String chunkText) { + BookImportData.ChunkData chunkData = new BookImportData.ChunkData(); + chunkData.setChunkText(chunkText); + return chunkData; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/BookServiceTest.java b/src/test/java/com/linglevel/api/content/book/service/BookServiceTest.java index 94d5b8a3..9ff5220f 100644 --- a/src/test/java/com/linglevel/api/content/book/service/BookServiceTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/BookServiceTest.java @@ -47,484 +47,431 @@ @ExtendWith(MockitoExtension.class) class BookServiceTest { - @Mock - private BookRepository bookRepository; - - @Mock - private BookProgressRepository bookProgressRepository; - - @Mock - private S3AiService s3AiService; - - @Mock - private S3TransferService s3TransferService; - - @Mock - private S3UrlService s3UrlService; - - @Mock - private BookPathStrategy bookPathStrategy; - - @Mock - private ImageResizeService imageResizeService; - - @Mock - private BookReadingTimeService bookReadingTimeService; - - @Mock - private BookImportService bookImportService; - - @InjectMocks - private BookService bookService; - - private User testUser; - - @BeforeEach - void setUp() { - testUser = new User(); - testUser.setId("test-user-id"); - testUser.setUsername("testuser"); - testUser.setEmail("test@example.com"); - testUser.setRole(UserRole.USER); - testUser.setDeleted(false); - testUser.setCreatedAt(LocalDateTime.now()); - } - - @Test - @DisplayName("importBook는 책 저장, 이미지 처리, 챕터/청크 import, reading time 갱신을 순서대로 수행한다") - void importBook_orchestratesImportFlow() { - // given - BookImportRequest request = new BookImportRequest(); - request.setId("request-1"); - - BookImportData importData = createImportData(); - Chapter savedChapter = new Chapter(); - savedChapter.setId("chapter-1"); - List savedChapters = List.of(savedChapter); - - when(s3AiService.downloadJsonFile("request-1", BookImportData.class, bookPathStrategy)) - .thenReturn(importData); - when(s3UrlService.getCoverImageUrl("request-1", bookPathStrategy)) - .thenReturn("https://cdn/request-cover.jpg"); - when(s3UrlService.getCoverImageUrl("saved-book-id", bookPathStrategy)) - .thenReturn("https://cdn/original-cover.jpg"); - when(bookPathStrategy.generateCoverImagePath("saved-book-id")) - .thenReturn("literature/saved-book-id/images/cover.jpg"); - when(imageResizeService.createSmallImage("literature/saved-book-id/images/cover.jpg")) - .thenReturn("https://cdn/small-cover.webp"); - when(bookRepository.save(any(Book.class))) - .thenAnswer(invocation -> { - Book book = invocation.getArgument(0); - if (book.getId() == null) { - book.setId("saved-book-id"); - } - return book; - }); - when(bookImportService.createChaptersFromMetadata(importData, "saved-book-id")) - .thenReturn(savedChapters); - - ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); - - // when - BookImportResponse response = bookService.importBook(request); - - // then - verify(bookRepository, org.mockito.Mockito.times(2)).save(bookCaptor.capture()); - List savedBooks = bookCaptor.getAllValues(); - Book finalSavedBook = savedBooks.get(savedBooks.size() - 1); - - assertThat(response.getId()).isEqualTo("saved-book-id"); - assertThat(finalSavedBook.getId()).isEqualTo("saved-book-id"); - assertThat(finalSavedBook.getTitle()).isEqualTo("Imported title"); - assertThat(finalSavedBook.getDifficultyLevel()).isEqualTo(DifficultyLevel.A1); - assertThat(finalSavedBook.getChapterCount()).isEqualTo(2); - assertThat(finalSavedBook.getCoverImageUrl()).isEqualTo("https://cdn/small-cover.webp"); - - verify(s3TransferService).transferImagesFromAiToStatic("request-1", "saved-book-id", bookPathStrategy); - verify(bookImportService).createChaptersFromMetadata(importData, "saved-book-id"); - verify(bookImportService).createChunksFromLeveledResults(importData, savedChapters, "saved-book-id"); - verify(bookReadingTimeService).updateReadingTimes("saved-book-id", importData); - } - - @Test - @DisplayName("cover image 리사이즈가 실패하면 원본 cover URL을 유지한다") - void importBook_keepsOriginalCoverUrlWhenResizeFails() { - // given - BookImportRequest request = new BookImportRequest(); - request.setId("request-1"); - - BookImportData importData = createImportData(); - List savedChapters = List.of(new Chapter()); - - when(s3AiService.downloadJsonFile("request-1", BookImportData.class, bookPathStrategy)) - .thenReturn(importData); - when(s3UrlService.getCoverImageUrl("request-1", bookPathStrategy)) - .thenReturn("https://cdn/request-cover.jpg"); - when(s3UrlService.getCoverImageUrl("saved-book-id", bookPathStrategy)) - .thenReturn("https://cdn/original-cover.jpg"); - when(bookPathStrategy.generateCoverImagePath("saved-book-id")) - .thenReturn("literature/saved-book-id/images/cover.jpg"); - when(imageResizeService.createSmallImage("literature/saved-book-id/images/cover.jpg")) - .thenThrow(new RuntimeException("resize failed")); - when(bookRepository.save(any(Book.class))) - .thenAnswer(invocation -> { - Book book = invocation.getArgument(0); - if (book.getId() == null) { - book.setId("saved-book-id"); - } - return book; - }); - when(bookImportService.createChaptersFromMetadata(importData, "saved-book-id")) - .thenReturn(savedChapters); - - ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); - - // when - BookImportResponse response = bookService.importBook(request); - - // then - verify(bookRepository, org.mockito.Mockito.times(2)).save(bookCaptor.capture()); - List savedBooks = bookCaptor.getAllValues(); - Book finalSavedBook = savedBooks.get(savedBooks.size() - 1); - - assertThat(response.getId()).isEqualTo("saved-book-id"); - assertThat(finalSavedBook.getCoverImageUrl()).isEqualTo("https://cdn/original-cover.jpg"); - - verify(bookImportService).createChunksFromLeveledResults(importData, savedChapters, "saved-book-id"); - verify(bookReadingTimeService).updateReadingTimes("saved-book-id", importData); - } - - @Test - @DisplayName("단일 책 조회 시 요청 언어에 맞는 번역 제목을 우선 사용한다") - void getBook_selectsTranslatedTitleByLanguage() { - // given - Book book = createBook("Original title", "Author", List.of("tag1")); - book.setTitleTranslations(new TitleTranslations("번역 제목", "Original title")); - when(bookRepository.findById(book.getId())).thenReturn(Optional.of(book)); - - // when - BookResponse response = bookService.getBook(book.getId(), testUser.getId(), LanguageCode.KO); - - // then - assertThat(response.getTitle()).isEqualTo("번역 제목"); - } - - @Test - @DisplayName("번역 제목이 비어 있으면 원본 제목으로 fallback 한다") - void getBook_fallsBackToOriginalTitleWhenTranslationMissing() { - // given - Book book = createBook("Original title", "Author", List.of("tag1")); - book.setTitleTranslations(new TitleTranslations(null, "Original title")); - when(bookRepository.findById(book.getId())).thenReturn(Optional.of(book)); - - // when - BookResponse response = bookService.getBook(book.getId(), testUser.getId(), LanguageCode.KO); - - // then - assertThat(response.getTitle()).isEqualTo("Original title"); - } - - @Test - @DisplayName("단일 책 조회 시 책이 없으면 BOOK_NOT_FOUND 예외를 던진다") - void getBook_throwsWhenBookMissing() { - // given - when(bookRepository.findById("missing-book")).thenReturn(Optional.empty()); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> bookService.getBook("missing-book", testUser.getId(), LanguageCode.EN) - ); - - // then - assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.BOOK_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("지원하지 않는 sortBy 값이면 INVALID_SORT_BY 예외를 던진다") - void getBooks_throwsWhenSortByInvalid() { - // given - GetBooksRequest request = GetBooksRequest.builder() - .sortBy("unknown-sort") - .page(1) - .limit(10) - .build(); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> bookService.getBooks(request, testUser.getId()) - ); - - // then - assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.INVALID_SORT_BY.getMessage()); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션이 함께 동작할 때 - IN_PROGRESS 필터") - void testProgressFilterWithPagination_InProgress() { - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .page(1) - .limit(5) - .build(); - - List books = createBooks(5, "Book", "Author", List.of("tag1")); - - Page bookPage = - new PageImpl<>(books, PageRequest.of(0, 5), 10); - - when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(bookPage); - - mockBookProgress(books, false); - - PageResponse response = bookService.getBooks(request, testUser.getId()); - - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(book -> { - assertThat(book.getProgressPercentage()).isGreaterThan(0.0); - assertThat(book.getIsCompleted()).isFalse(); - }); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션이 함께 동작할 때 - COMPLETED 필터") - void testProgressFilterWithPagination_Completed() { - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.COMPLETED) - .page(1) - .limit(5) - .build(); - - List books = List.of( - createBook("Book 1", "Author 1", List.of("tag1")), - createBook("Book 2", "Author 2", List.of("tag1")), - createBook("Book 3", "Author 3", List.of("tag1")), - createBook("Book 4", "Author 4", List.of("tag1")), - createBook("Book 5", "Author 5", List.of("tag1")) - ); - - Page bookPage = - new PageImpl<>(books, PageRequest.of(0, 5), 10); - - when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(bookPage); - - mockBookProgress(books, true); - - PageResponse response = bookService.getBooks(request, testUser.getId()); - - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(book -> { - assertThat(book.getProgressPercentage()).isEqualTo(100.0); - assertThat(book.getIsCompleted()).isTrue(); - }); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션이 함께 동작할 때 - NOT_STARTED 필터") - void testProgressFilterWithPagination_NotStarted() { - GetBooksRequest request = GetBooksRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .page(1) - .limit(5) - .build(); - - List books = List.of( - createBook("Book 1", "Author 1", List.of("tag1")), - createBook("Book 2", "Author 2", List.of("tag1")), - createBook("Book 3", "Author 3", List.of("tag1")), - createBook("Book 4", "Author 4", List.of("tag1")), - createBook("Book 5", "Author 5", List.of("tag1")) - ); - - Page bookPage = - new PageImpl<>(books, PageRequest.of(0, 5), 10); - - when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(bookPage); - - PageResponse response = bookService.getBooks(request, testUser.getId()); - - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(book -> { - assertThat(book.getProgressPercentage()).isEqualTo(0.0); - assertThat(book.getIsCompleted()).isFalse(); - }); - } - - @Test - @DisplayName("태그 필터링과 페이지네이션") - void testTagsFilterWithPagination() { - GetBooksRequest request = GetBooksRequest.builder() - .tags("technology") - .page(1) - .limit(10) - .build(); - - List books = createBooks(10, "Tech Book", "Author", List.of("technology")); - - Page bookPage = - new PageImpl<>(books, PageRequest.of(0, 10), 15); - - when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(bookPage); - - PageResponse response = bookService.getBooks(request, testUser.getId()); - - assertThat(response.getData()).hasSize(10); - assertThat(response.getTotalCount()).isEqualTo(15); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(book -> assertThat(book.getTags()).contains("technology")); - } - - @Test - @DisplayName("키워드 검색과 페이지네이션") - void testKeywordSearchWithPagination() { - GetBooksRequest request = GetBooksRequest.builder() - .keyword("java") - .page(1) - .limit(10) - .build(); - - List books = createBooks(10, "Java Programming", "Author", List.of("tag1")); - - Page bookPage = - new PageImpl<>(books, PageRequest.of(0, 10), 12); - - when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(bookPage); - - PageResponse response = bookService.getBooks(request, testUser.getId()); - - assertThat(response.getData()).hasSize(10); - assertThat(response.getTotalCount()).isEqualTo(12); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(book -> assertThat(book.getTitle().toLowerCase()).contains("java")); - } - - @Test - @DisplayName("복합 필터링 - 태그 + 진도 + 페이지네이션") - void testCombinedFiltersWithPagination() { - GetBooksRequest request = GetBooksRequest.builder() - .tags("technology") - .progress(ProgressStatus.IN_PROGRESS) - .page(1) - .limit(5) - .build(); - - List books = List.of( - createBook("Tech Book 1", "Author 1", List.of("technology")), - createBook("Tech Book 2", "Author 2", List.of("technology")), - createBook("Tech Book 3", "Author 3", List.of("technology")), - createBook("Tech Book 4", "Author 4", List.of("technology")), - createBook("Tech Book 5", "Author 5", List.of("technology")) - ); - - Page bookPage = - new PageImpl<>(books, PageRequest.of(0, 5), 10); - - when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())) - .thenReturn(bookPage); - - mockBookProgress(books, false); - - PageResponse response = bookService.getBooks(request, testUser.getId()); - - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - assertThat(response.getTotalPages()).isEqualTo(2); - - response.getData().forEach(book -> { - assertThat(book.getTags()).contains("technology"); - assertThat(book.getProgressPercentage()).isGreaterThan(0.0); - assertThat(book.getIsCompleted()).isFalse(); - }); - } - - private List createBooks(int count, String titlePrefix, String authorPrefix, List tags) { - List books = new java.util.ArrayList<>(); - for (int i = 1; i <= count; i++) { - books.add(createBook(titlePrefix + " " + i, authorPrefix + " " + i, tags)); - } - return books; - } - - private Book createBook(String title, String author, List tags) { - Book book = new Book(); - book.setId("book-" + title.hashCode()); - book.setTitle(title); - book.setAuthor(author); - book.setTags(tags); - book.setDifficultyLevel(DifficultyLevel.A1); - book.setChapterCount(20); - book.setReadingTime(300); - book.setAverageRating(4.5); - book.setReviewCount(100); - book.setViewCount(1000); - book.setCreatedAt(Instant.now()); - return book; - } - - private void mockBookProgress(List books, boolean isCompleted) { - List bookIds = books.stream().map(Book::getId).toList(); - List progresses = books.stream() - .map(book -> createBookProgress(testUser.getId(), book.getId(), isCompleted)) - .toList(); - - when(bookProgressRepository.findByUserIdAndBookIdIn(testUser.getId(), bookIds)) - .thenReturn(progresses); - } - - private BookProgress createBookProgress(String userId, String bookId, boolean isCompleted) { - BookProgress progress = new BookProgress(); - progress.setUserId(userId); - progress.setBookId(bookId); - progress.setCurrentReadChapterNumber(isCompleted ? 20 : 10); - progress.setMaxReadChapterNumber(isCompleted ? 20 : 10); - progress.setNormalizedProgress(isCompleted ? 100.0 : 50.0); - progress.setMaxNormalizedProgress(isCompleted ? 100.0 : 50.0); - progress.setIsCompleted(isCompleted); - progress.setUpdatedAt(Instant.now()); - return progress; - } - - private BookImportData createImportData() { - BookImportData importData = new BookImportData(); - importData.setTitle("Imported title"); - importData.setTitleTranslations(new TitleTranslations("가져온 제목", "Imported title")); - importData.setAuthor("Imported author"); - importData.setOriginalTextLevel("a1"); - importData.setLeveledResults(List.of( - createTextLevelData("a1", 2), - createTextLevelData("b1", 1) - )); - return importData; - } - - private BookImportData.TextLevelData createTextLevelData(String level, int chapterCount) { - BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); - textLevelData.setTextLevel(level); - List chapters = new java.util.ArrayList<>(); - for (int i = 1; i <= chapterCount; i++) { - BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); - chapterData.setChapterNum(i); - chapterData.setChunks(List.of()); - chapters.add(chapterData); - } - textLevelData.setChapters(chapters); - return textLevelData; - } + @Mock + private BookRepository bookRepository; + + @Mock + private BookProgressRepository bookProgressRepository; + + @Mock + private S3AiService s3AiService; + + @Mock + private S3TransferService s3TransferService; + + @Mock + private S3UrlService s3UrlService; + + @Mock + private BookPathStrategy bookPathStrategy; + + @Mock + private ImageResizeService imageResizeService; + + @Mock + private BookReadingTimeService bookReadingTimeService; + + @Mock + private BookImportService bookImportService; + + @InjectMocks + private BookService bookService; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setId("test-user-id"); + testUser.setUsername("testuser"); + testUser.setEmail("test@example.com"); + testUser.setRole(UserRole.USER); + testUser.setDeleted(false); + testUser.setCreatedAt(LocalDateTime.now()); + } + + @Test + @DisplayName("importBook는 책 저장, 이미지 처리, 챕터/청크 import, reading time 갱신을 순서대로 수행한다") + void importBook_orchestratesImportFlow() { + // given + BookImportRequest request = new BookImportRequest(); + request.setId("request-1"); + + BookImportData importData = createImportData(); + Chapter savedChapter = new Chapter(); + savedChapter.setId("chapter-1"); + List savedChapters = List.of(savedChapter); + + when(s3AiService.downloadJsonFile("request-1", BookImportData.class, bookPathStrategy)).thenReturn(importData); + when(s3UrlService.getCoverImageUrl("request-1", bookPathStrategy)).thenReturn("https://cdn/request-cover.jpg"); + when(s3UrlService.getCoverImageUrl("saved-book-id", bookPathStrategy)) + .thenReturn("https://cdn/original-cover.jpg"); + when(bookPathStrategy.generateCoverImagePath("saved-book-id")) + .thenReturn("literature/saved-book-id/images/cover.jpg"); + when(imageResizeService.createSmallImage("literature/saved-book-id/images/cover.jpg")) + .thenReturn("https://cdn/small-cover.webp"); + when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> { + Book book = invocation.getArgument(0); + if (book.getId() == null) { + book.setId("saved-book-id"); + } + return book; + }); + when(bookImportService.createChaptersFromMetadata(importData, "saved-book-id")).thenReturn(savedChapters); + + ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); + + // when + BookImportResponse response = bookService.importBook(request); + + // then + verify(bookRepository, org.mockito.Mockito.times(2)).save(bookCaptor.capture()); + List savedBooks = bookCaptor.getAllValues(); + Book finalSavedBook = savedBooks.get(savedBooks.size() - 1); + + assertThat(response.getId()).isEqualTo("saved-book-id"); + assertThat(finalSavedBook.getId()).isEqualTo("saved-book-id"); + assertThat(finalSavedBook.getTitle()).isEqualTo("Imported title"); + assertThat(finalSavedBook.getDifficultyLevel()).isEqualTo(DifficultyLevel.A1); + assertThat(finalSavedBook.getChapterCount()).isEqualTo(2); + assertThat(finalSavedBook.getCoverImageUrl()).isEqualTo("https://cdn/small-cover.webp"); + + verify(s3TransferService).transferImagesFromAiToStatic("request-1", "saved-book-id", bookPathStrategy); + verify(bookImportService).createChaptersFromMetadata(importData, "saved-book-id"); + verify(bookImportService).createChunksFromLeveledResults(importData, savedChapters, "saved-book-id"); + verify(bookReadingTimeService).updateReadingTimes("saved-book-id", importData); + } + + @Test + @DisplayName("cover image 리사이즈가 실패하면 원본 cover URL을 유지한다") + void importBook_keepsOriginalCoverUrlWhenResizeFails() { + // given + BookImportRequest request = new BookImportRequest(); + request.setId("request-1"); + + BookImportData importData = createImportData(); + List savedChapters = List.of(new Chapter()); + + when(s3AiService.downloadJsonFile("request-1", BookImportData.class, bookPathStrategy)).thenReturn(importData); + when(s3UrlService.getCoverImageUrl("request-1", bookPathStrategy)).thenReturn("https://cdn/request-cover.jpg"); + when(s3UrlService.getCoverImageUrl("saved-book-id", bookPathStrategy)) + .thenReturn("https://cdn/original-cover.jpg"); + when(bookPathStrategy.generateCoverImagePath("saved-book-id")) + .thenReturn("literature/saved-book-id/images/cover.jpg"); + when(imageResizeService.createSmallImage("literature/saved-book-id/images/cover.jpg")) + .thenThrow(new RuntimeException("resize failed")); + when(bookRepository.save(any(Book.class))).thenAnswer(invocation -> { + Book book = invocation.getArgument(0); + if (book.getId() == null) { + book.setId("saved-book-id"); + } + return book; + }); + when(bookImportService.createChaptersFromMetadata(importData, "saved-book-id")).thenReturn(savedChapters); + + ArgumentCaptor bookCaptor = ArgumentCaptor.forClass(Book.class); + + // when + BookImportResponse response = bookService.importBook(request); + + // then + verify(bookRepository, org.mockito.Mockito.times(2)).save(bookCaptor.capture()); + List savedBooks = bookCaptor.getAllValues(); + Book finalSavedBook = savedBooks.get(savedBooks.size() - 1); + + assertThat(response.getId()).isEqualTo("saved-book-id"); + assertThat(finalSavedBook.getCoverImageUrl()).isEqualTo("https://cdn/original-cover.jpg"); + + verify(bookImportService).createChunksFromLeveledResults(importData, savedChapters, "saved-book-id"); + verify(bookReadingTimeService).updateReadingTimes("saved-book-id", importData); + } + + @Test + @DisplayName("단일 책 조회 시 요청 언어에 맞는 번역 제목을 우선 사용한다") + void getBook_selectsTranslatedTitleByLanguage() { + // given + Book book = createBook("Original title", "Author", List.of("tag1")); + book.setTitleTranslations(new TitleTranslations("번역 제목", "Original title")); + when(bookRepository.findById(book.getId())).thenReturn(Optional.of(book)); + + // when + BookResponse response = bookService.getBook(book.getId(), testUser.getId(), LanguageCode.KO); + + // then + assertThat(response.getTitle()).isEqualTo("번역 제목"); + } + + @Test + @DisplayName("번역 제목이 비어 있으면 원본 제목으로 fallback 한다") + void getBook_fallsBackToOriginalTitleWhenTranslationMissing() { + // given + Book book = createBook("Original title", "Author", List.of("tag1")); + book.setTitleTranslations(new TitleTranslations(null, "Original title")); + when(bookRepository.findById(book.getId())).thenReturn(Optional.of(book)); + + // when + BookResponse response = bookService.getBook(book.getId(), testUser.getId(), LanguageCode.KO); + + // then + assertThat(response.getTitle()).isEqualTo("Original title"); + } + + @Test + @DisplayName("단일 책 조회 시 책이 없으면 BOOK_NOT_FOUND 예외를 던진다") + void getBook_throwsWhenBookMissing() { + // given + when(bookRepository.findById("missing-book")).thenReturn(Optional.empty()); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> bookService.getBook("missing-book", testUser.getId(), LanguageCode.EN)); + + // then + assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.BOOK_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("지원하지 않는 sortBy 값이면 INVALID_SORT_BY 예외를 던진다") + void getBooks_throwsWhenSortByInvalid() { + // given + GetBooksRequest request = GetBooksRequest.builder().sortBy("unknown-sort").page(1).limit(10).build(); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> bookService.getBooks(request, testUser.getId())); + + // then + assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.INVALID_SORT_BY.getMessage()); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션이 함께 동작할 때 - IN_PROGRESS 필터") + void testProgressFilterWithPagination_InProgress() { + GetBooksRequest request = GetBooksRequest.builder() + .progress(ProgressStatus.IN_PROGRESS) + .page(1) + .limit(5) + .build(); + + List books = createBooks(5, "Book", "Author", List.of("tag1")); + + Page bookPage = new PageImpl<>(books, PageRequest.of(0, 5), 10); + + when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())).thenReturn(bookPage); + + mockBookProgress(books, false); + + PageResponse response = bookService.getBooks(request, testUser.getId()); + + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(book -> { + assertThat(book.getProgressPercentage()).isGreaterThan(0.0); + assertThat(book.getIsCompleted()).isFalse(); + }); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션이 함께 동작할 때 - COMPLETED 필터") + void testProgressFilterWithPagination_Completed() { + GetBooksRequest request = GetBooksRequest.builder().progress(ProgressStatus.COMPLETED).page(1).limit(5).build(); + + List books = List.of(createBook("Book 1", "Author 1", List.of("tag1")), + createBook("Book 2", "Author 2", List.of("tag1")), createBook("Book 3", "Author 3", List.of("tag1")), + createBook("Book 4", "Author 4", List.of("tag1")), createBook("Book 5", "Author 5", List.of("tag1"))); + + Page bookPage = new PageImpl<>(books, PageRequest.of(0, 5), 10); + + when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())).thenReturn(bookPage); + + mockBookProgress(books, true); + + PageResponse response = bookService.getBooks(request, testUser.getId()); + + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(book -> { + assertThat(book.getProgressPercentage()).isEqualTo(100.0); + assertThat(book.getIsCompleted()).isTrue(); + }); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션이 함께 동작할 때 - NOT_STARTED 필터") + void testProgressFilterWithPagination_NotStarted() { + GetBooksRequest request = GetBooksRequest.builder() + .progress(ProgressStatus.NOT_STARTED) + .page(1) + .limit(5) + .build(); + + List books = List.of(createBook("Book 1", "Author 1", List.of("tag1")), + createBook("Book 2", "Author 2", List.of("tag1")), createBook("Book 3", "Author 3", List.of("tag1")), + createBook("Book 4", "Author 4", List.of("tag1")), createBook("Book 5", "Author 5", List.of("tag1"))); + + Page bookPage = new PageImpl<>(books, PageRequest.of(0, 5), 10); + + when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())).thenReturn(bookPage); + + PageResponse response = bookService.getBooks(request, testUser.getId()); + + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(book -> { + assertThat(book.getProgressPercentage()).isEqualTo(0.0); + assertThat(book.getIsCompleted()).isFalse(); + }); + } + + @Test + @DisplayName("태그 필터링과 페이지네이션") + void testTagsFilterWithPagination() { + GetBooksRequest request = GetBooksRequest.builder().tags("technology").page(1).limit(10).build(); + + List books = createBooks(10, "Tech Book", "Author", List.of("technology")); + + Page bookPage = new PageImpl<>(books, PageRequest.of(0, 10), 15); + + when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())).thenReturn(bookPage); + + PageResponse response = bookService.getBooks(request, testUser.getId()); + + assertThat(response.getData()).hasSize(10); + assertThat(response.getTotalCount()).isEqualTo(15); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(book -> assertThat(book.getTags()).contains("technology")); + } + + @Test + @DisplayName("키워드 검색과 페이지네이션") + void testKeywordSearchWithPagination() { + GetBooksRequest request = GetBooksRequest.builder().keyword("java").page(1).limit(10).build(); + + List books = createBooks(10, "Java Programming", "Author", List.of("tag1")); + + Page bookPage = new PageImpl<>(books, PageRequest.of(0, 10), 12); + + when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())).thenReturn(bookPage); + + PageResponse response = bookService.getBooks(request, testUser.getId()); + + assertThat(response.getData()).hasSize(10); + assertThat(response.getTotalCount()).isEqualTo(12); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(book -> assertThat(book.getTitle().toLowerCase()).contains("java")); + } + + @Test + @DisplayName("복합 필터링 - 태그 + 진도 + 페이지네이션") + void testCombinedFiltersWithPagination() { + GetBooksRequest request = GetBooksRequest.builder() + .tags("technology") + .progress(ProgressStatus.IN_PROGRESS) + .page(1) + .limit(5) + .build(); + + List books = List.of(createBook("Tech Book 1", "Author 1", List.of("technology")), + createBook("Tech Book 2", "Author 2", List.of("technology")), + createBook("Tech Book 3", "Author 3", List.of("technology")), + createBook("Tech Book 4", "Author 4", List.of("technology")), + createBook("Tech Book 5", "Author 5", List.of("technology"))); + + Page bookPage = new PageImpl<>(books, PageRequest.of(0, 5), 10); + + when(bookRepository.findBooksWithFilters(any(), eq(testUser.getId()), any())).thenReturn(bookPage); + + mockBookProgress(books, false); + + PageResponse response = bookService.getBooks(request, testUser.getId()); + + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + assertThat(response.getTotalPages()).isEqualTo(2); + + response.getData().forEach(book -> { + assertThat(book.getTags()).contains("technology"); + assertThat(book.getProgressPercentage()).isGreaterThan(0.0); + assertThat(book.getIsCompleted()).isFalse(); + }); + } + + private List createBooks(int count, String titlePrefix, String authorPrefix, List tags) { + List books = new java.util.ArrayList<>(); + for (int i = 1; i <= count; i++) { + books.add(createBook(titlePrefix + " " + i, authorPrefix + " " + i, tags)); + } + return books; + } + + private Book createBook(String title, String author, List tags) { + Book book = new Book(); + book.setId("book-" + title.hashCode()); + book.setTitle(title); + book.setAuthor(author); + book.setTags(tags); + book.setDifficultyLevel(DifficultyLevel.A1); + book.setChapterCount(20); + book.setReadingTime(300); + book.setAverageRating(4.5); + book.setReviewCount(100); + book.setViewCount(1000); + book.setCreatedAt(Instant.now()); + return book; + } + + private void mockBookProgress(List books, boolean isCompleted) { + List bookIds = books.stream().map(Book::getId).toList(); + List progresses = books.stream() + .map(book -> createBookProgress(testUser.getId(), book.getId(), isCompleted)) + .toList(); + + when(bookProgressRepository.findByUserIdAndBookIdIn(testUser.getId(), bookIds)).thenReturn(progresses); + } + + private BookProgress createBookProgress(String userId, String bookId, boolean isCompleted) { + BookProgress progress = new BookProgress(); + progress.setUserId(userId); + progress.setBookId(bookId); + progress.setCurrentReadChapterNumber(isCompleted ? 20 : 10); + progress.setMaxReadChapterNumber(isCompleted ? 20 : 10); + progress.setNormalizedProgress(isCompleted ? 100.0 : 50.0); + progress.setMaxNormalizedProgress(isCompleted ? 100.0 : 50.0); + progress.setIsCompleted(isCompleted); + progress.setUpdatedAt(Instant.now()); + return progress; + } + + private BookImportData createImportData() { + BookImportData importData = new BookImportData(); + importData.setTitle("Imported title"); + importData.setTitleTranslations(new TitleTranslations("가져온 제목", "Imported title")); + importData.setAuthor("Imported author"); + importData.setOriginalTextLevel("a1"); + importData.setLeveledResults(List.of(createTextLevelData("a1", 2), createTextLevelData("b1", 1))); + return importData; + } + + private BookImportData.TextLevelData createTextLevelData(String level, int chapterCount) { + BookImportData.TextLevelData textLevelData = new BookImportData.TextLevelData(); + textLevelData.setTextLevel(level); + List chapters = new java.util.ArrayList<>(); + for (int i = 1; i <= chapterCount; i++) { + BookImportData.ChapterData chapterData = new BookImportData.ChapterData(); + chapterData.setChapterNum(i); + chapterData.setChunks(List.of()); + chapters.add(chapterData); + } + textLevelData.setChapters(chapters); + return textLevelData; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/ChapterServiceTest.java b/src/test/java/com/linglevel/api/content/book/service/ChapterServiceTest.java index b22b3456..ec145ac1 100644 --- a/src/test/java/com/linglevel/api/content/book/service/ChapterServiceTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/ChapterServiceTest.java @@ -45,335 +45,327 @@ @ExtendWith(MockitoExtension.class) class ChapterServiceTest { - @Mock - private ChapterRepository chapterRepository; - - @Mock - private BookProgressRepository bookProgressRepository; - - @Mock - private ChunkRepository chunkRepository; - - @Mock - private BookService bookService; - - @Mock - private BookRepository bookRepository; - - @InjectMocks - private ChapterService chapterService; - - private User testUser; - private Book testBook; - - @BeforeEach - void setUp() { - testUser = new User(); - testUser.setId("test-user-id"); - testUser.setUsername("testuser"); - testUser.setEmail("test@example.com"); - testUser.setRole(UserRole.USER); - testUser.setDeleted(false); - testUser.setCreatedAt(LocalDateTime.now()); - - testBook = new Book(); - testBook.setId("test-book-id"); - testBook.setTitle("Test Book"); - testBook.setAuthor("Test Author"); - testBook.setDifficultyLevel(DifficultyLevel.A1); - testBook.setChapterCount(10); - testBook.setCreatedAt(Instant.now()); - - lenient().when(bookService.findById(anyString())).thenReturn(testBook); - - // Add stubs for the new repository methods called during refactoring - lenient().when(chunkRepository.findChunkCountsByChapterIds(anyList())).thenReturn(Collections.emptyList()); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션 - NOT_STARTED") - void testProgressFilterWithPagination_NotStarted() { - GetChaptersRequest request = GetChaptersRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .page(1) - .limit(3) - .build(); - - // Mock: 6-10번 챕터 (5번까지 읽어서 6번부터 NOT_STARTED) - List chapters = createChapters(3, testBook.getId(), 6, "Chapter"); - - Page chapterPage = - new PageImpl<>(chapters, PageRequest.of(0, 3), 5); - - BookProgress progress = new BookProgress(); - progress.setUserId(testUser.getId()); - progress.setBookId(testBook.getId()); - progress.setChunkId("test-chunk-id"); - progress.setCurrentReadChapterNumber(5); - progress.setMaxReadChapterNumber(5); - progress.setIsCompleted(false); - progress.setUpdatedAt(Instant.now()); - - when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) - .thenReturn(Optional.of(progress)); - - when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())) - .thenReturn(chapterPage); - - PageResponse response = chapterService.getChapters(testBook.getId(), request, testUser.getId()); - - assertThat(response.getData()).hasSize(3); - assertThat(response.getTotalCount()).isEqualTo(5); - assertThat(response.getTotalPages()).isEqualTo(2); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션 - IN_PROGRESS") - void testProgressFilterWithPagination_InProgress() { - GetChaptersRequest request = GetChaptersRequest.builder() - .progress(ProgressStatus.IN_PROGRESS) - .page(1) - .limit(10) - .build(); - - // Mock: 5번 챕터만 IN_PROGRESS - List chapters = createChapters(1, testBook.getId(), 5, "Chapter"); - - Page chapterPage = - new PageImpl<>(chapters, PageRequest.of(0, 10), 1); - - BookProgress progress = new BookProgress(); - progress.setUserId(testUser.getId()); - progress.setBookId(testBook.getId()); - progress.setChunkId("test-chunk-id"); - progress.setCurrentReadChapterNumber(5); - progress.setMaxReadChapterNumber(5); - progress.setIsCompleted(false); - progress.setUpdatedAt(Instant.now()); - - when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) - .thenReturn(Optional.of(progress)); - - when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())) - .thenReturn(chapterPage); - - PageResponse response = chapterService.getChapters(testBook.getId(), request, testUser.getId()); - - assertThat(response.getData()).hasSize(1); - assertThat(response.getTotalCount()).isEqualTo(1); - assertThat(response.getData().get(0).getChapterNumber()).isEqualTo(5); - } - - @Test - @DisplayName("진도 필터링과 페이지네이션 - COMPLETED") - void testProgressFilterWithPagination_Completed() { - GetChaptersRequest request = GetChaptersRequest.builder() - .progress(ProgressStatus.COMPLETED) - .page(1) - .limit(2) - .build(); - - // Mock: 1-4번 챕터 (5번 진행중이므로 1-4번까지 완료) - List chapters = createChapters(2, testBook.getId(), 1, "Chapter"); - - Page chapterPage = - new PageImpl<>(chapters, PageRequest.of(0, 2), 4); - - BookProgress progress = new BookProgress(); - progress.setUserId(testUser.getId()); - progress.setBookId(testBook.getId()); - progress.setChunkId("test-chunk-id"); - progress.setCurrentReadChapterNumber(5); - progress.setMaxReadChapterNumber(5); - progress.setIsCompleted(false); - progress.setUpdatedAt(Instant.now()); - - when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) - .thenReturn(Optional.of(progress)); - - when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())) - .thenReturn(chapterPage); - - PageResponse response = chapterService.getChapters(testBook.getId(), request, testUser.getId()); - - assertThat(response.getData()).hasSize(2); - assertThat(response.getTotalCount()).isEqualTo(4); - assertThat(response.getTotalPages()).isEqualTo(2); - } - - @Test - @DisplayName("진도 정보 없을 때 - NOT_STARTED는 모든 챕터 반환") - void testNoProgress_NotStarted() { - GetChaptersRequest request = GetChaptersRequest.builder() - .progress(ProgressStatus.NOT_STARTED) - .page(1) - .limit(5) - .build(); - - // Mock: 모든 챕터 (진도 정보 없음) - List chapters = createChapters(5, testBook.getId(), 1, "Chapter"); - - Page chapterPage = - new PageImpl<>(chapters, PageRequest.of(0, 5), 10); - - when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) - .thenReturn(Optional.empty()); - - when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())) - .thenReturn(chapterPage); - - PageResponse response = chapterService.getChapters(testBook.getId(), request, testUser.getId()); - - assertThat(response.getData()).hasSize(5); - assertThat(response.getTotalCount()).isEqualTo(10); - } - - @Test - @DisplayName("단일 챕터 조회 시 V3 chapterProgresses 정보를 기준으로 응답을 계산한다") - void getChapter_usesV3ChapterProgressInfo() { - // given - Chapter chapter = createChapter(testBook.getId(), 2, "Chapter 2"); - - BookProgress progress = new BookProgress(); - progress.setUserId(testUser.getId()); - progress.setBookId(testBook.getId()); - progress.setCurrentDifficultyLevel(DifficultyLevel.B1); - progress.setChapterProgresses(List.of( - BookProgress.ChapterProgressInfo.builder() - .chapterNumber(2) - .progressPercentage(37.5) - .isCompleted(false) - .build() - )); - - when(chapterRepository.findById(chapter.getId())).thenReturn(Optional.of(chapter)); - when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) - .thenReturn(Optional.of(progress)); - when(chunkRepository.findChunkCountsByChapterIds(List.of(chapter.getId()))) - .thenReturn(List.of(new ChunkCountByLevelDto(chapter.getId(), DifficultyLevel.B1, 8L))); - - // when - ChapterResponse response = chapterService.getChapter(testBook.getId(), chapter.getId(), testUser.getId()); - - // then - assertThat(response.getId()).isEqualTo(chapter.getId()); - assertThat(response.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.B1); - assertThat(response.getChunkCount()).isEqualTo(8); - assertThat(response.getProgressPercentage()).isEqualTo(37.5); - assertThat(response.getCurrentReadChunkNumber()).isEqualTo(3); - assertThat(response.getIsCompleted()).isFalse(); - } - - @Test - @DisplayName("단일 챕터 조회 시 V3 데이터가 없으면 해당 챕터를 NOT_STARTED로 계산한다") - void getChapter_returnsNotStartedWhenV3DataMissing() { - // given - Chapter chapter = createChapter(testBook.getId(), 2, "Chapter 2"); - - BookProgress progress = new BookProgress(); - progress.setUserId(testUser.getId()); - progress.setBookId(testBook.getId()); - progress.setCurrentDifficultyLevel(DifficultyLevel.B1); - progress.setChapterProgresses(null); - - when(chapterRepository.findById(chapter.getId())).thenReturn(Optional.of(chapter)); - when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) - .thenReturn(Optional.of(progress)); - when(chunkRepository.findChunkCountsByChapterIds(List.of(chapter.getId()))) - .thenReturn(List.of(new ChunkCountByLevelDto(chapter.getId(), DifficultyLevel.B1, 8L))); - - // when - ChapterResponse response = chapterService.getChapter(testBook.getId(), chapter.getId(), testUser.getId()); - - // then - assertThat(response.getId()).isEqualTo(chapter.getId()); - assertThat(response.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.B1); - assertThat(response.getChunkCount()).isEqualTo(8); - assertThat(response.getProgressPercentage()).isEqualTo(0.0); - assertThat(response.getCurrentReadChunkNumber()).isEqualTo(0); - assertThat(response.getIsCompleted()).isFalse(); - } - - @Test - @DisplayName("챕터가 다른 책에 속하면 CHAPTER_NOT_FOUND_IN_BOOK 예외를 던진다") - void getChapter_throwsWhenChapterDoesNotBelongToBook() { - // given - Chapter anotherBookChapter = createChapter("another-book", 1, "Wrong Chapter"); - when(chapterRepository.findById(anotherBookChapter.getId())).thenReturn(Optional.of(anotherBookChapter)); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> chapterService.getChapter(testBook.getId(), anotherBookChapter.getId(), testUser.getId()) - ); - - // then - assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK.getMessage()); - } - - @Test - @DisplayName("챕터 네비게이션 조회 시 이전/다음 챕터 정보를 반환한다") - void getChapterNavigation_returnsPreviousAndNextChapter() { - // given - Chapter currentChapter = createChapter(testBook.getId(), 2, "Chapter 2"); - Chapter previousChapter = createChapter(testBook.getId(), 1, "Chapter 1"); - Chapter nextChapter = createChapter(testBook.getId(), 3, "Chapter 3"); - - when(bookService.existsById(testBook.getId())).thenReturn(true); - when(chapterRepository.findById(currentChapter.getId())).thenReturn(Optional.of(currentChapter)); - when(chapterRepository.findByBookIdAndChapterNumber(testBook.getId(), 1)).thenReturn(Optional.of(previousChapter)); - when(chapterRepository.findByBookIdAndChapterNumber(testBook.getId(), 3)).thenReturn(Optional.of(nextChapter)); - - // when - ChapterNavigationResponse response = chapterService.getChapterNavigation(testBook.getId(), currentChapter.getId()); - - // then - assertThat(response.getCurrentChapterId()).isEqualTo(currentChapter.getId()); - assertThat(response.getCurrentChapterNumber()).isEqualTo(2); - assertThat(response.getHasPreviousChapter()).isTrue(); - assertThat(response.getPreviousChapterId()).isEqualTo(previousChapter.getId()); - assertThat(response.getHasNextChapter()).isTrue(); - assertThat(response.getNextChapterId()).isEqualTo(nextChapter.getId()); - } - - @Test - @DisplayName("챕터 목록 조회 시 viewCount를 증가시킨다") - void getChapters_incrementsBookViewCount() { - // given - GetChaptersRequest request = GetChaptersRequest.builder() - .page(1) - .limit(2) - .build(); - - List chapters = createChapters(1, testBook.getId(), 1, "Chapter"); - Page chapterPage = new PageImpl<>(chapters, PageRequest.of(0, 2), 1); - - when(chapterRepository.findChaptersWithFilters(anyString(), any(), any(), any())) - .thenReturn(chapterPage); - - // when - chapterService.getChapters(testBook.getId(), request, testUser.getId()); - - // then - verify(bookRepository).incrementViewCount(testBook.getId()); - } - - private List createChapters(int count, String bookId, int startNumber, String titlePrefix) { - List chapters = new java.util.ArrayList<>(); - for (int i = 0; i < count; i++) { - int chapterNum = startNumber + i; - chapters.add(createChapter(bookId, chapterNum, titlePrefix + " " + chapterNum)); - } - return chapters; - } - - private Chapter createChapter(String bookId, Integer chapterNumber, String title) { - Chapter chapter = new Chapter(); - chapter.setId("chapter-" + chapterNumber); - chapter.setBookId(bookId); - chapter.setChapterNumber(chapterNumber); - chapter.setTitle(title); - chapter.setReadingTime(30); - return chapter; - } + @Mock + private ChapterRepository chapterRepository; + + @Mock + private BookProgressRepository bookProgressRepository; + + @Mock + private ChunkRepository chunkRepository; + + @Mock + private BookService bookService; + + @Mock + private BookRepository bookRepository; + + @InjectMocks + private ChapterService chapterService; + + private User testUser; + + private Book testBook; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setId("test-user-id"); + testUser.setUsername("testuser"); + testUser.setEmail("test@example.com"); + testUser.setRole(UserRole.USER); + testUser.setDeleted(false); + testUser.setCreatedAt(LocalDateTime.now()); + + testBook = new Book(); + testBook.setId("test-book-id"); + testBook.setTitle("Test Book"); + testBook.setAuthor("Test Author"); + testBook.setDifficultyLevel(DifficultyLevel.A1); + testBook.setChapterCount(10); + testBook.setCreatedAt(Instant.now()); + + lenient().when(bookService.findById(anyString())).thenReturn(testBook); + + // Add stubs for the new repository methods called during refactoring + lenient().when(chunkRepository.findChunkCountsByChapterIds(anyList())).thenReturn(Collections.emptyList()); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션 - NOT_STARTED") + void testProgressFilterWithPagination_NotStarted() { + GetChaptersRequest request = GetChaptersRequest.builder() + .progress(ProgressStatus.NOT_STARTED) + .page(1) + .limit(3) + .build(); + + // Mock: 6-10번 챕터 (5번까지 읽어서 6번부터 NOT_STARTED) + List chapters = createChapters(3, testBook.getId(), 6, "Chapter"); + + Page chapterPage = new PageImpl<>(chapters, PageRequest.of(0, 3), 5); + + BookProgress progress = new BookProgress(); + progress.setUserId(testUser.getId()); + progress.setBookId(testBook.getId()); + progress.setChunkId("test-chunk-id"); + progress.setCurrentReadChapterNumber(5); + progress.setMaxReadChapterNumber(5); + progress.setIsCompleted(false); + progress.setUpdatedAt(Instant.now()); + + when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) + .thenReturn(Optional.of(progress)); + + when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())).thenReturn(chapterPage); + + PageResponse response = chapterService.getChapters(testBook.getId(), request, + testUser.getId()); + + assertThat(response.getData()).hasSize(3); + assertThat(response.getTotalCount()).isEqualTo(5); + assertThat(response.getTotalPages()).isEqualTo(2); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션 - IN_PROGRESS") + void testProgressFilterWithPagination_InProgress() { + GetChaptersRequest request = GetChaptersRequest.builder() + .progress(ProgressStatus.IN_PROGRESS) + .page(1) + .limit(10) + .build(); + + // Mock: 5번 챕터만 IN_PROGRESS + List chapters = createChapters(1, testBook.getId(), 5, "Chapter"); + + Page chapterPage = new PageImpl<>(chapters, PageRequest.of(0, 10), 1); + + BookProgress progress = new BookProgress(); + progress.setUserId(testUser.getId()); + progress.setBookId(testBook.getId()); + progress.setChunkId("test-chunk-id"); + progress.setCurrentReadChapterNumber(5); + progress.setMaxReadChapterNumber(5); + progress.setIsCompleted(false); + progress.setUpdatedAt(Instant.now()); + + when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) + .thenReturn(Optional.of(progress)); + + when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())).thenReturn(chapterPage); + + PageResponse response = chapterService.getChapters(testBook.getId(), request, + testUser.getId()); + + assertThat(response.getData()).hasSize(1); + assertThat(response.getTotalCount()).isEqualTo(1); + assertThat(response.getData().get(0).getChapterNumber()).isEqualTo(5); + } + + @Test + @DisplayName("진도 필터링과 페이지네이션 - COMPLETED") + void testProgressFilterWithPagination_Completed() { + GetChaptersRequest request = GetChaptersRequest.builder() + .progress(ProgressStatus.COMPLETED) + .page(1) + .limit(2) + .build(); + + // Mock: 1-4번 챕터 (5번 진행중이므로 1-4번까지 완료) + List chapters = createChapters(2, testBook.getId(), 1, "Chapter"); + + Page chapterPage = new PageImpl<>(chapters, PageRequest.of(0, 2), 4); + + BookProgress progress = new BookProgress(); + progress.setUserId(testUser.getId()); + progress.setBookId(testBook.getId()); + progress.setChunkId("test-chunk-id"); + progress.setCurrentReadChapterNumber(5); + progress.setMaxReadChapterNumber(5); + progress.setIsCompleted(false); + progress.setUpdatedAt(Instant.now()); + + when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) + .thenReturn(Optional.of(progress)); + + when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())).thenReturn(chapterPage); + + PageResponse response = chapterService.getChapters(testBook.getId(), request, + testUser.getId()); + + assertThat(response.getData()).hasSize(2); + assertThat(response.getTotalCount()).isEqualTo(4); + assertThat(response.getTotalPages()).isEqualTo(2); + } + + @Test + @DisplayName("진도 정보 없을 때 - NOT_STARTED는 모든 챕터 반환") + void testNoProgress_NotStarted() { + GetChaptersRequest request = GetChaptersRequest.builder() + .progress(ProgressStatus.NOT_STARTED) + .page(1) + .limit(5) + .build(); + + // Mock: 모든 챕터 (진도 정보 없음) + List chapters = createChapters(5, testBook.getId(), 1, "Chapter"); + + Page chapterPage = new PageImpl<>(chapters, PageRequest.of(0, 5), 10); + + when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) + .thenReturn(Optional.empty()); + + when(chapterRepository.findChaptersWithFilters(anyString(), any(), anyString(), any())).thenReturn(chapterPage); + + PageResponse response = chapterService.getChapters(testBook.getId(), request, + testUser.getId()); + + assertThat(response.getData()).hasSize(5); + assertThat(response.getTotalCount()).isEqualTo(10); + } + + @Test + @DisplayName("단일 챕터 조회 시 V3 chapterProgresses 정보를 기준으로 응답을 계산한다") + void getChapter_usesV3ChapterProgressInfo() { + // given + Chapter chapter = createChapter(testBook.getId(), 2, "Chapter 2"); + + BookProgress progress = new BookProgress(); + progress.setUserId(testUser.getId()); + progress.setBookId(testBook.getId()); + progress.setCurrentDifficultyLevel(DifficultyLevel.B1); + progress.setChapterProgresses(List.of(BookProgress.ChapterProgressInfo.builder() + .chapterNumber(2) + .progressPercentage(37.5) + .isCompleted(false) + .build())); + + when(chapterRepository.findById(chapter.getId())).thenReturn(Optional.of(chapter)); + when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) + .thenReturn(Optional.of(progress)); + when(chunkRepository.findChunkCountsByChapterIds(List.of(chapter.getId()))) + .thenReturn(List.of(new ChunkCountByLevelDto(chapter.getId(), DifficultyLevel.B1, 8L))); + + // when + ChapterResponse response = chapterService.getChapter(testBook.getId(), chapter.getId(), testUser.getId()); + + // then + assertThat(response.getId()).isEqualTo(chapter.getId()); + assertThat(response.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.B1); + assertThat(response.getChunkCount()).isEqualTo(8); + assertThat(response.getProgressPercentage()).isEqualTo(37.5); + assertThat(response.getCurrentReadChunkNumber()).isEqualTo(3); + assertThat(response.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("단일 챕터 조회 시 V3 데이터가 없으면 해당 챕터를 NOT_STARTED로 계산한다") + void getChapter_returnsNotStartedWhenV3DataMissing() { + // given + Chapter chapter = createChapter(testBook.getId(), 2, "Chapter 2"); + + BookProgress progress = new BookProgress(); + progress.setUserId(testUser.getId()); + progress.setBookId(testBook.getId()); + progress.setCurrentDifficultyLevel(DifficultyLevel.B1); + progress.setChapterProgresses(null); + + when(chapterRepository.findById(chapter.getId())).thenReturn(Optional.of(chapter)); + when(bookProgressRepository.findByUserIdAndBookId(testUser.getId(), testBook.getId())) + .thenReturn(Optional.of(progress)); + when(chunkRepository.findChunkCountsByChapterIds(List.of(chapter.getId()))) + .thenReturn(List.of(new ChunkCountByLevelDto(chapter.getId(), DifficultyLevel.B1, 8L))); + + // when + ChapterResponse response = chapterService.getChapter(testBook.getId(), chapter.getId(), testUser.getId()); + + // then + assertThat(response.getId()).isEqualTo(chapter.getId()); + assertThat(response.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.B1); + assertThat(response.getChunkCount()).isEqualTo(8); + assertThat(response.getProgressPercentage()).isEqualTo(0.0); + assertThat(response.getCurrentReadChunkNumber()).isEqualTo(0); + assertThat(response.getIsCompleted()).isFalse(); + } + + @Test + @DisplayName("챕터가 다른 책에 속하면 CHAPTER_NOT_FOUND_IN_BOOK 예외를 던진다") + void getChapter_throwsWhenChapterDoesNotBelongToBook() { + // given + Chapter anotherBookChapter = createChapter("another-book", 1, "Wrong Chapter"); + when(chapterRepository.findById(anotherBookChapter.getId())).thenReturn(Optional.of(anotherBookChapter)); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> chapterService.getChapter(testBook.getId(), anotherBookChapter.getId(), testUser.getId())); + + // then + assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK.getMessage()); + } + + @Test + @DisplayName("챕터 네비게이션 조회 시 이전/다음 챕터 정보를 반환한다") + void getChapterNavigation_returnsPreviousAndNextChapter() { + // given + Chapter currentChapter = createChapter(testBook.getId(), 2, "Chapter 2"); + Chapter previousChapter = createChapter(testBook.getId(), 1, "Chapter 1"); + Chapter nextChapter = createChapter(testBook.getId(), 3, "Chapter 3"); + + when(bookService.existsById(testBook.getId())).thenReturn(true); + when(chapterRepository.findById(currentChapter.getId())).thenReturn(Optional.of(currentChapter)); + when(chapterRepository.findByBookIdAndChapterNumber(testBook.getId(), 1)) + .thenReturn(Optional.of(previousChapter)); + when(chapterRepository.findByBookIdAndChapterNumber(testBook.getId(), 3)).thenReturn(Optional.of(nextChapter)); + + // when + ChapterNavigationResponse response = chapterService.getChapterNavigation(testBook.getId(), + currentChapter.getId()); + + // then + assertThat(response.getCurrentChapterId()).isEqualTo(currentChapter.getId()); + assertThat(response.getCurrentChapterNumber()).isEqualTo(2); + assertThat(response.getHasPreviousChapter()).isTrue(); + assertThat(response.getPreviousChapterId()).isEqualTo(previousChapter.getId()); + assertThat(response.getHasNextChapter()).isTrue(); + assertThat(response.getNextChapterId()).isEqualTo(nextChapter.getId()); + } + + @Test + @DisplayName("챕터 목록 조회 시 viewCount를 증가시킨다") + void getChapters_incrementsBookViewCount() { + // given + GetChaptersRequest request = GetChaptersRequest.builder().page(1).limit(2).build(); + + List chapters = createChapters(1, testBook.getId(), 1, "Chapter"); + Page chapterPage = new PageImpl<>(chapters, PageRequest.of(0, 2), 1); + + when(chapterRepository.findChaptersWithFilters(anyString(), any(), any(), any())).thenReturn(chapterPage); + + // when + chapterService.getChapters(testBook.getId(), request, testUser.getId()); + + // then + verify(bookRepository).incrementViewCount(testBook.getId()); + } + + private List createChapters(int count, String bookId, int startNumber, String titlePrefix) { + List chapters = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + int chapterNum = startNumber + i; + chapters.add(createChapter(bookId, chapterNum, titlePrefix + " " + chapterNum)); + } + return chapters; + } + + private Chapter createChapter(String bookId, Integer chapterNumber, String title) { + Chapter chapter = new Chapter(); + chapter.setId("chapter-" + chapterNumber); + chapter.setBookId(bookId); + chapter.setChapterNumber(chapterNumber); + chapter.setTitle(title); + chapter.setReadingTime(30); + return chapter; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/ChunkServiceTest.java b/src/test/java/com/linglevel/api/content/book/service/ChunkServiceTest.java index ac86b835..1c52fd60 100644 --- a/src/test/java/com/linglevel/api/content/book/service/ChunkServiceTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/ChunkServiceTest.java @@ -34,195 +34,174 @@ @ExtendWith(MockitoExtension.class) class ChunkServiceTest { - @Mock - private ChunkRepository chunkRepository; - - @Mock - private ChapterRepository chapterRepository; - - @Mock - private BookService bookService; - - @InjectMocks - private ChunkService chunkService; - - @Test - @DisplayName("청크 목록 조회 시 페이지 정보와 ChunkResponse 매핑을 반환한다.") - void getChunks_returnsPagedChunkResponses() { - // given - GetChunksRequest request = GetChunksRequest.builder() - .difficultyLevel(DifficultyLevel.A1) - .page(1) - .limit(300) - .build(); - - Chapter chapter = createChapter("chapter-1", "book-1"); - Chunk firstChunk = createChunk("chunk-1", "chapter-1", 1, ChunkType.TEXT, "first", null); - Chunk secondChunk = createChunk("chunk-2", "chapter-1", 2, ChunkType.IMAGE, "https://cdn/image.png", "image"); - Page chunkPage = new PageImpl<>(List.of(firstChunk, secondChunk)); - - when(bookService.existsById("book-1")).thenReturn(true); - when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(chapter)); - - ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); - when(chunkRepository.findByChapterIdAndDifficultyLevel( - ArgumentMatchers.eq("chapter-1"), - ArgumentMatchers.eq(DifficultyLevel.A1), - pageableCaptor.capture() - )).thenReturn(chunkPage); - - // when - PageResponse response = chunkService.getChunks("book-1", "chapter-1", request, "user-1"); - - // then - assertEquals(2, response.getData().size()); - assertEquals("chunk-1", response.getData().get(0).getId()); - assertEquals(ChunkType.TEXT, response.getData().get(0).getType()); - assertEquals("https://cdn/image.png", response.getData().get(1).getContent()); - assertEquals("image", response.getData().get(1).getDescription()); - - assertEquals(0, pageableCaptor.getValue().getPageNumber()); - assertEquals(200, pageableCaptor.getValue().getPageSize()); - } - - @Test - @DisplayName("책이 없으면 BOOK_NOT_FOUND 예외를 던진다.") - void getChunks_throwsWhenBookNotFound() { - // given - GetChunksRequest request = GetChunksRequest.builder() - .difficultyLevel(DifficultyLevel.A1) - .build(); - when(bookService.existsById("missing-book")).thenReturn(false); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> chunkService.getChunks("missing-book", "chapter-1", request, "user-1") - ); - - // then - assertEquals(BooksErrorCode.BOOK_NOT_FOUND.getMessage(), exception.getMessage()); - } - - @Test - @DisplayName("챕터가 다른 책에 속하면 CHAPTER_NOT_FOUND_IN_BOOK 예외를 던진다.") - void getChunks_throwsWhenChapterDoesNotBelongToBook() { - // given - GetChunksRequest request = GetChunksRequest.builder() - .difficultyLevel(DifficultyLevel.A1) - .build(); - - when(bookService.existsById("book-1")).thenReturn(true); - when(chapterRepository.findById("chapter-1")) - .thenReturn(Optional.of(createChapter("chapter-1", "another-book"))); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> chunkService.getChunks("book-1", "chapter-1", request, "user-1") - ); - - // then - assertEquals(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK.getMessage(), exception.getMessage()); - } - - @Test - @DisplayName("단일 청크 조회 시 ChunkResponse로 변환해 반환한다.") - void getChunk_returnsChunkResponse() { - // given - Chapter chapter = createChapter("chapter-1", "book-1"); - Chunk chunk = createChunk("chunk-1", "chapter-1", 3, ChunkType.TEXT, "body", null); - - when(bookService.existsById("book-1")).thenReturn(true); - when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(chapter)); - when(chunkRepository.findById("chunk-1")).thenReturn(Optional.of(chunk)); - - // when - ChunkResponse response = chunkService.getChunk("book-1", "chapter-1", "chunk-1"); - - // then - assertEquals("chunk-1", response.getId()); - assertEquals(3, response.getChunkNumber()); - assertEquals(ChunkType.TEXT, response.getType()); - assertEquals("body", response.getContent()); - } - - @Test - @DisplayName("청크가 다른 챕터에 속하면 CHUNK_NOT_FOUND 예외를 던진다.") - void getChunk_throwsWhenChunkDoesNotBelongToChapter() { - // given - Chapter chapter = createChapter("chapter-1", "book-1"); - Chunk chunk = createChunk("chunk-1", "chapter-2", 1, ChunkType.TEXT, "body", null); - - when(bookService.existsById("book-1")).thenReturn(true); - when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(chapter)); - when(chunkRepository.findById("chunk-1")).thenReturn(Optional.of(chunk)); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> chunkService.getChunk("book-1", "chapter-1", "chunk-1") - ); - - // then - assertEquals(BooksErrorCode.CHUNK_NOT_FOUND.getMessage(), exception.getMessage()); - } - - @Test - @DisplayName("findById는 청크가 없으면 CHUNK_NOT_FOUND 예외를 던진다.") - void findById_throwsWhenChunkNotFound() { - // given - when(chunkRepository.findById("missing-chunk")).thenReturn(Optional.empty()); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> chunkService.findById("missing-chunk") - ); - - // then - assertEquals(BooksErrorCode.CHUNK_NOT_FOUND.getMessage(), exception.getMessage()); - } - - @Test - @DisplayName("findFirstByChapterId는 첫 번째 청크를 반환한다.") - void findFirstByChapterId_returnsFirstChunk() { - // given - Chunk chunk = createChunk("chunk-1", "chapter-1", 1, ChunkType.TEXT, "body", null); - when(chunkRepository.findFirstByChapterIdOrderByChunkNumberAsc("chapter-1")) - .thenReturn(Optional.of(chunk)); - - // when - Chunk result = chunkService.findFirstByChapterId("chapter-1"); - - // then - assertEquals("chunk-1", result.getId()); - assertEquals(1, result.getChunkNumber()); - } - - private Chapter createChapter(String chapterId, String bookId) { - Chapter chapter = new Chapter(); - chapter.setId(chapterId); - chapter.setBookId(bookId); - return chapter; - } - - private Chunk createChunk( - String chunkId, - String chapterId, - int chunkNumber, - ChunkType type, - String content, - String description - ) { - Chunk chunk = new Chunk(); - chunk.setId(chunkId); - chunk.setChapterId(chapterId); - chunk.setChunkNumber(chunkNumber); - chunk.setDifficultyLevel(DifficultyLevel.A1); - chunk.setType(type); - chunk.setContent(content); - chunk.setDescription(description); - return chunk; - } + @Mock + private ChunkRepository chunkRepository; + + @Mock + private ChapterRepository chapterRepository; + + @Mock + private BookService bookService; + + @InjectMocks + private ChunkService chunkService; + + @Test + @DisplayName("청크 목록 조회 시 페이지 정보와 ChunkResponse 매핑을 반환한다.") + void getChunks_returnsPagedChunkResponses() { + // given + GetChunksRequest request = GetChunksRequest.builder() + .difficultyLevel(DifficultyLevel.A1) + .page(1) + .limit(300) + .build(); + + Chapter chapter = createChapter("chapter-1", "book-1"); + Chunk firstChunk = createChunk("chunk-1", "chapter-1", 1, ChunkType.TEXT, "first", null); + Chunk secondChunk = createChunk("chunk-2", "chapter-1", 2, ChunkType.IMAGE, "https://cdn/image.png", "image"); + Page chunkPage = new PageImpl<>(List.of(firstChunk, secondChunk)); + + when(bookService.existsById("book-1")).thenReturn(true); + when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(chapter)); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(chunkRepository.findByChapterIdAndDifficultyLevel(ArgumentMatchers.eq("chapter-1"), + ArgumentMatchers.eq(DifficultyLevel.A1), pageableCaptor.capture())) + .thenReturn(chunkPage); + + // when + PageResponse response = chunkService.getChunks("book-1", "chapter-1", request, "user-1"); + + // then + assertEquals(2, response.getData().size()); + assertEquals("chunk-1", response.getData().get(0).getId()); + assertEquals(ChunkType.TEXT, response.getData().get(0).getType()); + assertEquals("https://cdn/image.png", response.getData().get(1).getContent()); + assertEquals("image", response.getData().get(1).getDescription()); + + assertEquals(0, pageableCaptor.getValue().getPageNumber()); + assertEquals(200, pageableCaptor.getValue().getPageSize()); + } + + @Test + @DisplayName("책이 없으면 BOOK_NOT_FOUND 예외를 던진다.") + void getChunks_throwsWhenBookNotFound() { + // given + GetChunksRequest request = GetChunksRequest.builder().difficultyLevel(DifficultyLevel.A1).build(); + when(bookService.existsById("missing-book")).thenReturn(false); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> chunkService.getChunks("missing-book", "chapter-1", request, "user-1")); + + // then + assertEquals(BooksErrorCode.BOOK_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("챕터가 다른 책에 속하면 CHAPTER_NOT_FOUND_IN_BOOK 예외를 던진다.") + void getChunks_throwsWhenChapterDoesNotBelongToBook() { + // given + GetChunksRequest request = GetChunksRequest.builder().difficultyLevel(DifficultyLevel.A1).build(); + + when(bookService.existsById("book-1")).thenReturn(true); + when(chapterRepository.findById("chapter-1")) + .thenReturn(Optional.of(createChapter("chapter-1", "another-book"))); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> chunkService.getChunks("book-1", "chapter-1", request, "user-1")); + + // then + assertEquals(BooksErrorCode.CHAPTER_NOT_FOUND_IN_BOOK.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("단일 청크 조회 시 ChunkResponse로 변환해 반환한다.") + void getChunk_returnsChunkResponse() { + // given + Chapter chapter = createChapter("chapter-1", "book-1"); + Chunk chunk = createChunk("chunk-1", "chapter-1", 3, ChunkType.TEXT, "body", null); + + when(bookService.existsById("book-1")).thenReturn(true); + when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(chapter)); + when(chunkRepository.findById("chunk-1")).thenReturn(Optional.of(chunk)); + + // when + ChunkResponse response = chunkService.getChunk("book-1", "chapter-1", "chunk-1"); + + // then + assertEquals("chunk-1", response.getId()); + assertEquals(3, response.getChunkNumber()); + assertEquals(ChunkType.TEXT, response.getType()); + assertEquals("body", response.getContent()); + } + + @Test + @DisplayName("청크가 다른 챕터에 속하면 CHUNK_NOT_FOUND 예외를 던진다.") + void getChunk_throwsWhenChunkDoesNotBelongToChapter() { + // given + Chapter chapter = createChapter("chapter-1", "book-1"); + Chunk chunk = createChunk("chunk-1", "chapter-2", 1, ChunkType.TEXT, "body", null); + + when(bookService.existsById("book-1")).thenReturn(true); + when(chapterRepository.findById("chapter-1")).thenReturn(Optional.of(chapter)); + when(chunkRepository.findById("chunk-1")).thenReturn(Optional.of(chunk)); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> chunkService.getChunk("book-1", "chapter-1", "chunk-1")); + + // then + assertEquals(BooksErrorCode.CHUNK_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("findById는 청크가 없으면 CHUNK_NOT_FOUND 예외를 던진다.") + void findById_throwsWhenChunkNotFound() { + // given + when(chunkRepository.findById("missing-chunk")).thenReturn(Optional.empty()); + + // when + BooksException exception = assertThrows(BooksException.class, () -> chunkService.findById("missing-chunk")); + + // then + assertEquals(BooksErrorCode.CHUNK_NOT_FOUND.getMessage(), exception.getMessage()); + } + + @Test + @DisplayName("findFirstByChapterId는 첫 번째 청크를 반환한다.") + void findFirstByChapterId_returnsFirstChunk() { + // given + Chunk chunk = createChunk("chunk-1", "chapter-1", 1, ChunkType.TEXT, "body", null); + when(chunkRepository.findFirstByChapterIdOrderByChunkNumberAsc("chapter-1")).thenReturn(Optional.of(chunk)); + + // when + Chunk result = chunkService.findFirstByChapterId("chapter-1"); + + // then + assertEquals("chunk-1", result.getId()); + assertEquals(1, result.getChunkNumber()); + } + + private Chapter createChapter(String chapterId, String bookId) { + Chapter chapter = new Chapter(); + chapter.setId(chapterId); + chapter.setBookId(bookId); + return chapter; + } + + private Chunk createChunk(String chunkId, String chapterId, int chunkNumber, ChunkType type, String content, + String description) { + Chunk chunk = new Chunk(); + chunk.setId(chunkId); + chunk.setChapterId(chapterId); + chunk.setChunkNumber(chunkNumber); + chunk.setDifficultyLevel(DifficultyLevel.A1); + chunk.setType(type); + chunk.setContent(content); + chunk.setDescription(description); + return chunk; + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/ProgressServiceIntegrationTest.java b/src/test/java/com/linglevel/api/content/book/service/ProgressServiceIntegrationTest.java index 881d1fe6..d0d9ca55 100644 --- a/src/test/java/com/linglevel/api/content/book/service/ProgressServiceIntegrationTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/ProgressServiceIntegrationTest.java @@ -31,178 +31,179 @@ @DisplayName("ProgressService - 스트릭과 학습 완료 통합 테스트") class ProgressServiceIntegrationTest { - @Mock - private BookService bookService; - - @Mock - private ChapterService chapterService; - - @Mock - private ChunkService chunkService; - - @Mock - private BookProgressRepository bookProgressRepository; - - @Mock - private ChunkRepository chunkRepository; - - @Mock - private ProgressCalculationService progressCalculationService; - - @Mock - private ReadingCompletionService readingCompletionService; - - @Mock - private StreakService streakService; - - @Mock - private ChapterRepository chapterRepository; - - @InjectMocks - private ProgressService progressService; - - private static final String TEST_USER_ID = "test-user-123"; - private static final String TEST_BOOK_ID = "book-123"; - private static final String TEST_CHAPTER_ID = "chapter-1"; - private static final String TEST_CHUNK_ID = "chunk-1"; - - private Chapter testChapter; - private Chunk testChunk; - private BookProgress testProgress; - - @BeforeEach - void setUp() { - testChapter = new Chapter(); - testChapter.setId(TEST_CHAPTER_ID); - testChapter.setBookId(TEST_BOOK_ID); - testChapter.setChapterNumber(1); - - testChunk = new Chunk(); - testChunk.setId(TEST_CHUNK_ID); - testChunk.setChapterId(TEST_CHAPTER_ID); - testChunk.setChunkNumber(5); // 마지막 청크 - testChunk.setDifficultyLevel(DifficultyLevel.B1); - - testProgress = new BookProgress(); - testProgress.setUserId(TEST_USER_ID); - testProgress.setBookId(TEST_BOOK_ID); - testProgress.setChapterProgresses(new ArrayList<>()); - } - - @Test - @DisplayName("챕터 완료 시 addCompletedContent와 updateStreak 모두 호출됨") - void updateProgress_ChapterCompletion_CallsBothMethods() { - // given - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(TEST_CHUNK_ID); - - when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); - when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); - when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); - when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) - .thenReturn(Optional.of(testProgress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)) - .thenReturn(5L); // 마지막 청크 (5/5) - when(chapterRepository.countByBookId(TEST_BOOK_ID)) - .thenReturn(10); // 총 10개 챕터 - when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) - .thenReturn(120L); - when(streakService.updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID)) - .thenReturn(true); - - // when - progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); - - // then - 세 가지 메서드가 순서대로 호출됨 - verify(streakService).addStudyTime(TEST_USER_ID, 120L); - verify(streakService).updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID); - verify(streakService).addCompletedContent(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, true); - } - - @Test - @DisplayName("updateStreak가 false를 반환하면 완료 기록에도 false를 전달한다") - void updateProgress_passesFalseWhenStreakIsNotUpdated() { - // given - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(TEST_CHUNK_ID); - - when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); - when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); - when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); - when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) - .thenReturn(Optional.of(testProgress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)) - .thenReturn(5L); - when(chapterRepository.countByBookId(TEST_BOOK_ID)) - .thenReturn(10); - when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) - .thenReturn(120L); - when(streakService.updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID)) - .thenReturn(false); - - // when - progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); - - // then - verify(streakService).addStudyTime(TEST_USER_ID, 120L); - verify(streakService).updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID); - verify(streakService).addCompletedContent(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, false); - } - - @Test - @DisplayName("마지막 청크여도 읽기 시간이 30초 미만이면 스트릭 관련 메서드를 호출하지 않는다") - void updateProgress_shortReadTime_skipsStreakUpdates() { - // given - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(TEST_CHUNK_ID); - - when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); - when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); - when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); - when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) - .thenReturn(Optional.of(testProgress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)) - .thenReturn(5L); - when(chapterRepository.countByBookId(TEST_BOOK_ID)) - .thenReturn(10); - when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) - .thenReturn(29L); - - // when - progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); - - // then - verify(streakService, never()).addStudyTime(any(), anyLong()); - verify(streakService, never()).updateStreak(any(), any(), any()); - verify(streakService, never()).addCompletedContent(any(), any(), any(), anyBoolean()); - } - - @Test - @DisplayName("마지막 청크가 아니면 스트릭/완료 기록 메서드 호출 안됨") - void updateProgress_NotLastChunk_NoStreakOrCompletionMethods() { - // given - testChunk.setChunkNumber(3); // 중간 청크 - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(TEST_CHUNK_ID); - - when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); - when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); - when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); - when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) - .thenReturn(Optional.of(testProgress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)) - .thenReturn(5L); // 총 5개 중 3번째 - when(chapterRepository.countByBookId(TEST_BOOK_ID)) - .thenReturn(10); - when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) - .thenReturn(null); // 마지막 청크가 아니므로 세션 처리 없음 - - // when - progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); - - // then - 마지막 청크가 아니므로 세 메서드 모두 호출 안됨 - verify(streakService, never()).addStudyTime(any(), anyLong()); - verify(streakService, never()).addCompletedContent(any(), any(), any(), anyBoolean()); - verify(streakService, never()).updateStreak(any(), any(), any()); - } + @Mock + private BookService bookService; + + @Mock + private ChapterService chapterService; + + @Mock + private ChunkService chunkService; + + @Mock + private BookProgressRepository bookProgressRepository; + + @Mock + private ChunkRepository chunkRepository; + + @Mock + private ProgressCalculationService progressCalculationService; + + @Mock + private ReadingCompletionService readingCompletionService; + + @Mock + private StreakService streakService; + + @Mock + private ChapterRepository chapterRepository; + + @InjectMocks + private ProgressService progressService; + + private static final String TEST_USER_ID = "test-user-123"; + + private static final String TEST_BOOK_ID = "book-123"; + + private static final String TEST_CHAPTER_ID = "chapter-1"; + + private static final String TEST_CHUNK_ID = "chunk-1"; + + private Chapter testChapter; + + private Chunk testChunk; + + private BookProgress testProgress; + + @BeforeEach + void setUp() { + testChapter = new Chapter(); + testChapter.setId(TEST_CHAPTER_ID); + testChapter.setBookId(TEST_BOOK_ID); + testChapter.setChapterNumber(1); + + testChunk = new Chunk(); + testChunk.setId(TEST_CHUNK_ID); + testChunk.setChapterId(TEST_CHAPTER_ID); + testChunk.setChunkNumber(5); // 마지막 청크 + testChunk.setDifficultyLevel(DifficultyLevel.B1); + + testProgress = new BookProgress(); + testProgress.setUserId(TEST_USER_ID); + testProgress.setBookId(TEST_BOOK_ID); + testProgress.setChapterProgresses(new ArrayList<>()); + } + + @Test + @DisplayName("챕터 완료 시 addCompletedContent와 updateStreak 모두 호출됨") + void updateProgress_ChapterCompletion_CallsBothMethods() { + // given + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(TEST_CHUNK_ID); + + when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); + when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); + when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); + when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) + .thenReturn(Optional.of(testProgress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)).thenReturn(5L); // 마지막 + // 청크 + // (5/5) + when(chapterRepository.countByBookId(TEST_BOOK_ID)).thenReturn(10); // 총 10개 챕터 + when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) + .thenReturn(120L); + when(streakService.updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID)).thenReturn(true); + + // when + progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); + + // then - 세 가지 메서드가 순서대로 호출됨 + verify(streakService).addStudyTime(TEST_USER_ID, 120L); + verify(streakService).updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID); + verify(streakService).addCompletedContent(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, true); + } + + @Test + @DisplayName("updateStreak가 false를 반환하면 완료 기록에도 false를 전달한다") + void updateProgress_passesFalseWhenStreakIsNotUpdated() { + // given + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(TEST_CHUNK_ID); + + when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); + when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); + when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); + when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) + .thenReturn(Optional.of(testProgress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)).thenReturn(5L); + when(chapterRepository.countByBookId(TEST_BOOK_ID)).thenReturn(10); + when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) + .thenReturn(120L); + when(streakService.updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID)).thenReturn(false); + + // when + progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); + + // then + verify(streakService).addStudyTime(TEST_USER_ID, 120L); + verify(streakService).updateStreak(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID); + verify(streakService).addCompletedContent(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, false); + } + + @Test + @DisplayName("마지막 청크여도 읽기 시간이 30초 미만이면 스트릭 관련 메서드를 호출하지 않는다") + void updateProgress_shortReadTime_skipsStreakUpdates() { + // given + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(TEST_CHUNK_ID); + + when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); + when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); + when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); + when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) + .thenReturn(Optional.of(testProgress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)).thenReturn(5L); + when(chapterRepository.countByBookId(TEST_BOOK_ID)).thenReturn(10); + when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) + .thenReturn(29L); + + // when + progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); + + // then + verify(streakService, never()).addStudyTime(any(), anyLong()); + verify(streakService, never()).updateStreak(any(), any(), any()); + verify(streakService, never()).addCompletedContent(any(), any(), any(), anyBoolean()); + } + + @Test + @DisplayName("마지막 청크가 아니면 스트릭/완료 기록 메서드 호출 안됨") + void updateProgress_NotLastChunk_NoStreakOrCompletionMethods() { + // given + testChunk.setChunkNumber(3); // 중간 청크 + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(TEST_CHUNK_ID); + + when(bookService.existsById(TEST_BOOK_ID)).thenReturn(true); + when(chunkService.findById(TEST_CHUNK_ID)).thenReturn(testChunk); + when(chapterService.findById(TEST_CHAPTER_ID)).thenReturn(testChapter); + when(bookProgressRepository.findByUserIdAndBookId(TEST_USER_ID, TEST_BOOK_ID)) + .thenReturn(Optional.of(testProgress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(TEST_CHAPTER_ID, DifficultyLevel.B1)).thenReturn(5L); // 총 + // 5개 + // 중 + // 3번째 + when(chapterRepository.countByBookId(TEST_BOOK_ID)).thenReturn(10); + when(readingCompletionService.processReadingCompletion(TEST_USER_ID, ContentType.BOOK, TEST_CHAPTER_ID, null)) + .thenReturn(null); // 마지막 청크가 아니므로 세션 처리 없음 + + // when + progressService.updateProgress(TEST_BOOK_ID, request, TEST_USER_ID); + + // then - 마지막 청크가 아니므로 세 메서드 모두 호출 안됨 + verify(streakService, never()).addStudyTime(any(), anyLong()); + verify(streakService, never()).addCompletedContent(any(), any(), any(), anyBoolean()); + verify(streakService, never()).updateStreak(any(), any(), any()); + } + } diff --git a/src/test/java/com/linglevel/api/content/book/service/ProgressServiceTest.java b/src/test/java/com/linglevel/api/content/book/service/ProgressServiceTest.java index 3e80ba8a..38f2e27e 100644 --- a/src/test/java/com/linglevel/api/content/book/service/ProgressServiceTest.java +++ b/src/test/java/com/linglevel/api/content/book/service/ProgressServiceTest.java @@ -37,337 +37,343 @@ @ExtendWith(MockitoExtension.class) class ProgressServiceTest { - @Mock - private BookService bookService; - - @Mock - private BookProgressRepository bookProgressRepository; - - @Mock - private ChapterRepository chapterRepository; - - @Mock - private ChunkRepository chunkRepository; - - @Mock - private ChapterService chapterService; - - @Mock - private ChunkService chunkService; - - @Mock - private ReadingCompletionService readingCompletionService; - - @Mock - private StreakService streakService; - - @InjectMocks - private ProgressService progressService; - - @Captor - private ArgumentCaptor bookProgressCaptor; - - @Test - @DisplayName("오래된 Book 진행률 업데이트 시 V3 필드(chapterProgresses)가 정상적으로 마이그레이션된다") - void updateProgress_shouldLazyMigrate_forOldBookProgress() { - // Given: 마이그레이션되지 않은(V3 필드가 null인) BookProgress 설정 - String userId = "test-user"; - String bookId = "test-book"; - String chunkId = "test-chunk"; - String chapterId = "test-chapter"; - - // V3 필드(chapterProgresses)가 null인 레거시 데이터 - BookProgress legacyProgress = new BookProgress(); - legacyProgress.setId("legacy-progress-id"); - legacyProgress.setUserId(userId); - legacyProgress.setBookId(bookId); - legacyProgress.setChapterProgresses(null); // This is the legacy state - - Chunk currentChunk = new Chunk(); - currentChunk.setId(chunkId); - currentChunk.setChapterId(chapterId); - currentChunk.setChunkNumber(1); - - Chapter currentChapter = new Chapter(); - currentChapter.setId(chapterId); - currentChapter.setBookId(bookId); - currentChapter.setChapterNumber(1); - - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(chunkId); - - // Mocking - when(bookService.existsById(bookId)).thenReturn(true); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(legacyProgress)); - when(chunkService.findById(chunkId)).thenReturn(currentChunk); - // The service first finds the chunk, then gets the chapterId from it, then finds the chapter. - when(chapterService.findById(chapterId)).thenReturn(currentChapter); - when(chapterRepository.countByBookId(bookId)).thenReturn(10); - when(chunkRepository.countByChapterIdAndDifficultyLevel(any(), any())).thenReturn(100L); - - // When: 진행률 업데이트 호출 - progressService.updateProgress(bookId, request, userId); - - // Then: V3 필드(chapterProgresses)가 채워진 상태로 저장되는지 검증 - verify(bookProgressRepository).save(bookProgressCaptor.capture()); - BookProgress savedProgress = bookProgressCaptor.getValue(); - - assertThat(savedProgress.getId()).isEqualTo("legacy-progress-id"); - assertThat(savedProgress.getChapterProgresses()).isNotNull(); - // ensureMigrated initializes the list, and the subsequent logic adds the first progress info - assertThat(savedProgress.getChapterProgresses()).hasSize(1); - assertThat(savedProgress.getChapterProgresses().get(0).getChapterNumber()).isEqualTo(1); - assertThat(savedProgress.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(1, 1)); - } - - @Test - @DisplayName("진도 정보가 없으면 문서를 생성하지 않고 0% 진도를 반환한다") - void getProgress_returnsZeroProgressWhenMissing() { - // given - String userId = "user-1"; - String bookId = "book-1"; - - when(bookService.existsById(bookId)).thenReturn(true); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.empty()); - - // when - ProgressResponse response = progressService.getProgress(bookId, userId); - - // then - verify(bookProgressRepository, never()).save(any(BookProgress.class)); - verifyNoInteractions(chapterService, chunkService, chunkRepository); - - assertThat(response.getId()).isNull(); - assertThat(response.getChapterId()).isNull(); - assertThat(response.getChunkId()).isNull(); - assertThat(response.getCurrentReadChapterNumber()).isEqualTo(0); - assertThat(response.getCurrentReadChunkNumber()).isEqualTo(0); - assertThat(response.getMaxReadChapterNumber()).isEqualTo(0); - assertThat(response.getMaxReadChunkNumber()).isEqualTo(0); - assertThat(response.getNormalizedProgress()).isEqualTo(0.0); - assertThat(response.getMaxNormalizedProgress()).isEqualTo(0.0); - assertThat(response.getIsCompleted()).isFalse(); - assertThat(response.getStreakUpdated()).isFalse(); - } - - @Test - @DisplayName("기존 챕터 진행률이 있으면 같은 챕터 항목을 업데이트하고 중복 추가하지 않는다") - void updateProgress_updatesExistingChapterProgressEntry() { - // given - String userId = "user-1"; - String bookId = "book-1"; - String chunkId = "chunk-3"; - String chapterId = "chapter-1"; - - BookProgress progress = new BookProgress(); - progress.setId("progress-1"); - progress.setUserId(userId); - progress.setBookId(bookId); - progress.setChapterProgresses(new ArrayList<>()); - progress.getChapterProgresses().add(BookProgress.ChapterProgressInfo.builder() - .chapterNumber(1) - .progressPercentage(20.0) - .isCompleted(false) - .build()); - - Chunk chunk = new Chunk(); - chunk.setId(chunkId); - chunk.setChapterId(chapterId); - chunk.setChunkNumber(3); - chunk.setDifficultyLevel(DifficultyLevel.A1); - - Chapter chapter = new Chapter(); - chapter.setId(chapterId); - chapter.setBookId(bookId); - chapter.setChapterNumber(1); - - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(chunkId); - - when(bookService.existsById(bookId)).thenReturn(true); - when(chunkService.findById(chunkId)).thenReturn(chunk); - when(chapterService.findById(chapterId)).thenReturn(chapter); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(chapterId, DifficultyLevel.A1)).thenReturn(5L); - when(chapterRepository.countByBookId(bookId)).thenReturn(10); - when(readingCompletionService.processReadingCompletion(userId, com.linglevel.api.content.common.ContentType.BOOK, chapterId, null)) - .thenReturn(null); - - // when - ProgressResponse response = progressService.updateProgress(bookId, request, userId); - - // then - verify(bookProgressRepository).save(bookProgressCaptor.capture()); - BookProgress saved = bookProgressCaptor.getValue(); - - assertThat(saved.getChapterProgresses()).hasSize(1); - assertThat(saved.getChapterProgresses().get(0).getChapterNumber()).isEqualTo(1); - assertThat(saved.getChapterProgresses().get(0).getProgressPercentage()).isEqualTo(60.0); - assertThat(saved.getChapterProgresses().get(0).getIsCompleted()).isFalse(); - assertThat(saved.getCurrentReadChapterNumber()).isEqualTo(1); - assertThat(saved.getChunkId()).isEqualTo(chunkId); - assertThat(saved.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(1, 3)); - assertThat(response.getCurrentReadChunkNumber()).isEqualTo(3); - assertThat(response.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(1, 3)); - assertThat(response.getStreakUpdated()).isFalse(); - } - - @Test - @DisplayName("마지막 남은 챕터를 완료하면 책 전체를 완료 상태로 저장하고 streakUpdated를 반영한다") - void updateProgress_marksBookCompletedWhenLastRemainingChapterFinishes() { - // given - String userId = "user-1"; - String bookId = "book-1"; - String chunkId = "chunk-4"; - String chapterId = "chapter-2"; - - BookProgress progress = new BookProgress(); - progress.setId("progress-1"); - progress.setUserId(userId); - progress.setBookId(bookId); - progress.setIsCompleted(false); - progress.setChapterProgresses(new ArrayList<>()); - progress.getChapterProgresses().add(BookProgress.ChapterProgressInfo.builder() - .chapterNumber(1) - .progressPercentage(100.0) - .isCompleted(true) - .build()); - - Chunk chunk = new Chunk(); - chunk.setId(chunkId); - chunk.setChapterId(chapterId); - chunk.setChunkNumber(4); - chunk.setDifficultyLevel(DifficultyLevel.A1); - - Chapter chapter = new Chapter(); - chapter.setId(chapterId); - chapter.setBookId(bookId); - chapter.setChapterNumber(2); - - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(chunkId); - - when(bookService.existsById(bookId)).thenReturn(true); - when(chunkService.findById(chunkId)).thenReturn(chunk); - when(chapterService.findById(chapterId)).thenReturn(chapter); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(chapterId, DifficultyLevel.A1)).thenReturn(4L); - when(chapterRepository.countByBookId(bookId)).thenReturn(2); - when(readingCompletionService.processReadingCompletion(userId, com.linglevel.api.content.common.ContentType.BOOK, chapterId, null)) - .thenReturn(45L); - when(streakService.updateStreak(userId, com.linglevel.api.content.common.ContentType.BOOK, chapterId)) - .thenReturn(true); - - // when - ProgressResponse response = progressService.updateProgress(bookId, request, userId); - - // then - verify(bookProgressRepository).save(bookProgressCaptor.capture()); - BookProgress saved = bookProgressCaptor.getValue(); - - assertThat(saved.getChapterProgresses()).hasSize(2); - assertThat(saved.getChapterProgresses().get(1).getChapterNumber()).isEqualTo(2); - assertThat(saved.getChapterProgresses().get(1).getProgressPercentage()).isEqualTo(100.0); - assertThat(saved.getChapterProgresses().get(1).getIsCompleted()).isTrue(); - assertThat(saved.getIsCompleted()).isTrue(); - assertThat(saved.getCompletedAt()).isNotNull(); - assertThat(saved.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 4)); - assertThat(response.getCurrentReadChunkNumber()).isEqualTo(4); - assertThat(response.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 4)); - assertThat(response.getStreakUpdated()).isTrue(); - } - - @Test - @DisplayName("maxReadChunkNumber는 챕터 우선 순서로 업데이트된다") - void updateProgress_updatesMaxReadChunkNumberByChapterPriority() { - // given - String userId = "user-1"; - String bookId = "book-1"; - String chunkId = "chunk-1"; - String chapterId = "chapter-2"; - - BookProgress progress = new BookProgress(); - progress.setId("progress-1"); - progress.setUserId(userId); - progress.setBookId(bookId); - progress.setMaxReadChunkNumber(chapterFirstPosition(1, 100)); - progress.setChapterProgresses(new ArrayList<>()); - - Chunk chunk = new Chunk(); - chunk.setId(chunkId); - chunk.setChapterId(chapterId); - chunk.setChunkNumber(1); - chunk.setDifficultyLevel(DifficultyLevel.A1); - - Chapter chapter = new Chapter(); - chapter.setId(chapterId); - chapter.setBookId(bookId); - chapter.setChapterNumber(2); - - ProgressUpdateRequest request = new ProgressUpdateRequest(); - request.setChunkId(chunkId); - - when(bookService.existsById(bookId)).thenReturn(true); - when(chunkService.findById(chunkId)).thenReturn(chunk); - when(chapterService.findById(chapterId)).thenReturn(chapter); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); - when(chunkRepository.countByChapterIdAndDifficultyLevel(chapterId, DifficultyLevel.A1)).thenReturn(10L); - when(chapterRepository.countByBookId(bookId)).thenReturn(5); - when(readingCompletionService.processReadingCompletion(userId, com.linglevel.api.content.common.ContentType.BOOK, chapterId, null)) - .thenReturn(null); - - // when - ProgressResponse response = progressService.updateProgress(bookId, request, userId); - - // then - verify(bookProgressRepository).save(bookProgressCaptor.capture()); - BookProgress saved = bookProgressCaptor.getValue(); - assertThat(saved.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 1)); - assertThat(response.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 1)); - } - - @Test - @DisplayName("deleteProgress는 기존 진도 정보를 삭제한다") - void deleteProgress_deletesExistingProgress() { - // given - String userId = "user-1"; - String bookId = "book-1"; - - BookProgress progress = new BookProgress(); - progress.setId("progress-1"); - - when(bookService.existsById(bookId)).thenReturn(true); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); - - // when - progressService.deleteProgress(bookId, userId); - - // then - verify(bookProgressRepository).delete(progress); - } - - @Test - @DisplayName("deleteProgress는 진도 정보가 없으면 PROGRESS_NOT_FOUND 예외를 던진다") - void deleteProgress_throwsWhenProgressMissing() { - // given - String userId = "user-1"; - String bookId = "book-1"; - - when(bookService.existsById(bookId)).thenReturn(true); - when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.empty()); - - // when - BooksException exception = assertThrows( - BooksException.class, - () -> progressService.deleteProgress(bookId, userId) - ); - - // then - assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.PROGRESS_NOT_FOUND.getMessage()); - verify(bookProgressRepository, never()).delete(any(BookProgress.class)); - } - - private int chapterFirstPosition(int chapterNumber, int chunkNumber) { - return (chapterNumber << 16) | chunkNumber; - } + @Mock + private BookService bookService; + + @Mock + private BookProgressRepository bookProgressRepository; + + @Mock + private ChapterRepository chapterRepository; + + @Mock + private ChunkRepository chunkRepository; + + @Mock + private ChapterService chapterService; + + @Mock + private ChunkService chunkService; + + @Mock + private ReadingCompletionService readingCompletionService; + + @Mock + private StreakService streakService; + + @InjectMocks + private ProgressService progressService; + + @Captor + private ArgumentCaptor bookProgressCaptor; + + @Test + @DisplayName("오래된 Book 진행률 업데이트 시 V3 필드(chapterProgresses)가 정상적으로 마이그레이션된다") + void updateProgress_shouldLazyMigrate_forOldBookProgress() { + // Given: 마이그레이션되지 않은(V3 필드가 null인) BookProgress 설정 + String userId = "test-user"; + String bookId = "test-book"; + String chunkId = "test-chunk"; + String chapterId = "test-chapter"; + + // V3 필드(chapterProgresses)가 null인 레거시 데이터 + BookProgress legacyProgress = new BookProgress(); + legacyProgress.setId("legacy-progress-id"); + legacyProgress.setUserId(userId); + legacyProgress.setBookId(bookId); + legacyProgress.setChapterProgresses(null); // This is the legacy state + + Chunk currentChunk = new Chunk(); + currentChunk.setId(chunkId); + currentChunk.setChapterId(chapterId); + currentChunk.setChunkNumber(1); + + Chapter currentChapter = new Chapter(); + currentChapter.setId(chapterId); + currentChapter.setBookId(bookId); + currentChapter.setChapterNumber(1); + + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(chunkId); + + // Mocking + when(bookService.existsById(bookId)).thenReturn(true); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(legacyProgress)); + when(chunkService.findById(chunkId)).thenReturn(currentChunk); + // The service first finds the chunk, then gets the chapterId from it, then finds + // the chapter. + when(chapterService.findById(chapterId)).thenReturn(currentChapter); + when(chapterRepository.countByBookId(bookId)).thenReturn(10); + when(chunkRepository.countByChapterIdAndDifficultyLevel(any(), any())).thenReturn(100L); + + // When: 진행률 업데이트 호출 + progressService.updateProgress(bookId, request, userId); + + // Then: V3 필드(chapterProgresses)가 채워진 상태로 저장되는지 검증 + verify(bookProgressRepository).save(bookProgressCaptor.capture()); + BookProgress savedProgress = bookProgressCaptor.getValue(); + + assertThat(savedProgress.getId()).isEqualTo("legacy-progress-id"); + assertThat(savedProgress.getChapterProgresses()).isNotNull(); + // ensureMigrated initializes the list, and the subsequent logic adds the first + // progress info + assertThat(savedProgress.getChapterProgresses()).hasSize(1); + assertThat(savedProgress.getChapterProgresses().get(0).getChapterNumber()).isEqualTo(1); + assertThat(savedProgress.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(1, 1)); + } + + @Test + @DisplayName("진도 정보가 없으면 문서를 생성하지 않고 0% 진도를 반환한다") + void getProgress_returnsZeroProgressWhenMissing() { + // given + String userId = "user-1"; + String bookId = "book-1"; + + when(bookService.existsById(bookId)).thenReturn(true); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.empty()); + + // when + ProgressResponse response = progressService.getProgress(bookId, userId); + + // then + verify(bookProgressRepository, never()).save(any(BookProgress.class)); + verifyNoInteractions(chapterService, chunkService, chunkRepository); + + assertThat(response.getId()).isNull(); + assertThat(response.getChapterId()).isNull(); + assertThat(response.getChunkId()).isNull(); + assertThat(response.getCurrentReadChapterNumber()).isEqualTo(0); + assertThat(response.getCurrentReadChunkNumber()).isEqualTo(0); + assertThat(response.getMaxReadChapterNumber()).isEqualTo(0); + assertThat(response.getMaxReadChunkNumber()).isEqualTo(0); + assertThat(response.getNormalizedProgress()).isEqualTo(0.0); + assertThat(response.getMaxNormalizedProgress()).isEqualTo(0.0); + assertThat(response.getIsCompleted()).isFalse(); + assertThat(response.getStreakUpdated()).isFalse(); + } + + @Test + @DisplayName("기존 챕터 진행률이 있으면 같은 챕터 항목을 업데이트하고 중복 추가하지 않는다") + void updateProgress_updatesExistingChapterProgressEntry() { + // given + String userId = "user-1"; + String bookId = "book-1"; + String chunkId = "chunk-3"; + String chapterId = "chapter-1"; + + BookProgress progress = new BookProgress(); + progress.setId("progress-1"); + progress.setUserId(userId); + progress.setBookId(bookId); + progress.setChapterProgresses(new ArrayList<>()); + progress.getChapterProgresses() + .add(BookProgress.ChapterProgressInfo.builder() + .chapterNumber(1) + .progressPercentage(20.0) + .isCompleted(false) + .build()); + + Chunk chunk = new Chunk(); + chunk.setId(chunkId); + chunk.setChapterId(chapterId); + chunk.setChunkNumber(3); + chunk.setDifficultyLevel(DifficultyLevel.A1); + + Chapter chapter = new Chapter(); + chapter.setId(chapterId); + chapter.setBookId(bookId); + chapter.setChapterNumber(1); + + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(chunkId); + + when(bookService.existsById(bookId)).thenReturn(true); + when(chunkService.findById(chunkId)).thenReturn(chunk); + when(chapterService.findById(chapterId)).thenReturn(chapter); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(chapterId, DifficultyLevel.A1)).thenReturn(5L); + when(chapterRepository.countByBookId(bookId)).thenReturn(10); + when(readingCompletionService.processReadingCompletion(userId, + com.linglevel.api.content.common.ContentType.BOOK, chapterId, null)) + .thenReturn(null); + + // when + ProgressResponse response = progressService.updateProgress(bookId, request, userId); + + // then + verify(bookProgressRepository).save(bookProgressCaptor.capture()); + BookProgress saved = bookProgressCaptor.getValue(); + + assertThat(saved.getChapterProgresses()).hasSize(1); + assertThat(saved.getChapterProgresses().get(0).getChapterNumber()).isEqualTo(1); + assertThat(saved.getChapterProgresses().get(0).getProgressPercentage()).isEqualTo(60.0); + assertThat(saved.getChapterProgresses().get(0).getIsCompleted()).isFalse(); + assertThat(saved.getCurrentReadChapterNumber()).isEqualTo(1); + assertThat(saved.getChunkId()).isEqualTo(chunkId); + assertThat(saved.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(1, 3)); + assertThat(response.getCurrentReadChunkNumber()).isEqualTo(3); + assertThat(response.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(1, 3)); + assertThat(response.getStreakUpdated()).isFalse(); + } + + @Test + @DisplayName("마지막 남은 챕터를 완료하면 책 전체를 완료 상태로 저장하고 streakUpdated를 반영한다") + void updateProgress_marksBookCompletedWhenLastRemainingChapterFinishes() { + // given + String userId = "user-1"; + String bookId = "book-1"; + String chunkId = "chunk-4"; + String chapterId = "chapter-2"; + + BookProgress progress = new BookProgress(); + progress.setId("progress-1"); + progress.setUserId(userId); + progress.setBookId(bookId); + progress.setIsCompleted(false); + progress.setChapterProgresses(new ArrayList<>()); + progress.getChapterProgresses() + .add(BookProgress.ChapterProgressInfo.builder() + .chapterNumber(1) + .progressPercentage(100.0) + .isCompleted(true) + .build()); + + Chunk chunk = new Chunk(); + chunk.setId(chunkId); + chunk.setChapterId(chapterId); + chunk.setChunkNumber(4); + chunk.setDifficultyLevel(DifficultyLevel.A1); + + Chapter chapter = new Chapter(); + chapter.setId(chapterId); + chapter.setBookId(bookId); + chapter.setChapterNumber(2); + + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(chunkId); + + when(bookService.existsById(bookId)).thenReturn(true); + when(chunkService.findById(chunkId)).thenReturn(chunk); + when(chapterService.findById(chapterId)).thenReturn(chapter); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(chapterId, DifficultyLevel.A1)).thenReturn(4L); + when(chapterRepository.countByBookId(bookId)).thenReturn(2); + when(readingCompletionService.processReadingCompletion(userId, + com.linglevel.api.content.common.ContentType.BOOK, chapterId, null)) + .thenReturn(45L); + when(streakService.updateStreak(userId, com.linglevel.api.content.common.ContentType.BOOK, chapterId)) + .thenReturn(true); + + // when + ProgressResponse response = progressService.updateProgress(bookId, request, userId); + + // then + verify(bookProgressRepository).save(bookProgressCaptor.capture()); + BookProgress saved = bookProgressCaptor.getValue(); + + assertThat(saved.getChapterProgresses()).hasSize(2); + assertThat(saved.getChapterProgresses().get(1).getChapterNumber()).isEqualTo(2); + assertThat(saved.getChapterProgresses().get(1).getProgressPercentage()).isEqualTo(100.0); + assertThat(saved.getChapterProgresses().get(1).getIsCompleted()).isTrue(); + assertThat(saved.getIsCompleted()).isTrue(); + assertThat(saved.getCompletedAt()).isNotNull(); + assertThat(saved.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 4)); + assertThat(response.getCurrentReadChunkNumber()).isEqualTo(4); + assertThat(response.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 4)); + assertThat(response.getStreakUpdated()).isTrue(); + } + + @Test + @DisplayName("maxReadChunkNumber는 챕터 우선 순서로 업데이트된다") + void updateProgress_updatesMaxReadChunkNumberByChapterPriority() { + // given + String userId = "user-1"; + String bookId = "book-1"; + String chunkId = "chunk-1"; + String chapterId = "chapter-2"; + + BookProgress progress = new BookProgress(); + progress.setId("progress-1"); + progress.setUserId(userId); + progress.setBookId(bookId); + progress.setMaxReadChunkNumber(chapterFirstPosition(1, 100)); + progress.setChapterProgresses(new ArrayList<>()); + + Chunk chunk = new Chunk(); + chunk.setId(chunkId); + chunk.setChapterId(chapterId); + chunk.setChunkNumber(1); + chunk.setDifficultyLevel(DifficultyLevel.A1); + + Chapter chapter = new Chapter(); + chapter.setId(chapterId); + chapter.setBookId(bookId); + chapter.setChapterNumber(2); + + ProgressUpdateRequest request = new ProgressUpdateRequest(); + request.setChunkId(chunkId); + + when(bookService.existsById(bookId)).thenReturn(true); + when(chunkService.findById(chunkId)).thenReturn(chunk); + when(chapterService.findById(chapterId)).thenReturn(chapter); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); + when(chunkRepository.countByChapterIdAndDifficultyLevel(chapterId, DifficultyLevel.A1)).thenReturn(10L); + when(chapterRepository.countByBookId(bookId)).thenReturn(5); + when(readingCompletionService.processReadingCompletion(userId, + com.linglevel.api.content.common.ContentType.BOOK, chapterId, null)) + .thenReturn(null); + + // when + ProgressResponse response = progressService.updateProgress(bookId, request, userId); + + // then + verify(bookProgressRepository).save(bookProgressCaptor.capture()); + BookProgress saved = bookProgressCaptor.getValue(); + assertThat(saved.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 1)); + assertThat(response.getMaxReadChunkNumber()).isEqualTo(chapterFirstPosition(2, 1)); + } + + @Test + @DisplayName("deleteProgress는 기존 진도 정보를 삭제한다") + void deleteProgress_deletesExistingProgress() { + // given + String userId = "user-1"; + String bookId = "book-1"; + + BookProgress progress = new BookProgress(); + progress.setId("progress-1"); + + when(bookService.existsById(bookId)).thenReturn(true); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.of(progress)); + + // when + progressService.deleteProgress(bookId, userId); + + // then + verify(bookProgressRepository).delete(progress); + } + + @Test + @DisplayName("deleteProgress는 진도 정보가 없으면 PROGRESS_NOT_FOUND 예외를 던진다") + void deleteProgress_throwsWhenProgressMissing() { + // given + String userId = "user-1"; + String bookId = "book-1"; + + when(bookService.existsById(bookId)).thenReturn(true); + when(bookProgressRepository.findByUserIdAndBookId(userId, bookId)).thenReturn(Optional.empty()); + + // when + BooksException exception = assertThrows(BooksException.class, + () -> progressService.deleteProgress(bookId, userId)); + + // then + assertThat(exception.getMessage()).isEqualTo(BooksErrorCode.PROGRESS_NOT_FOUND.getMessage()); + verify(bookProgressRepository, never()).delete(any(BookProgress.class)); + } + + private int chapterFirstPosition(int chapterNumber, int chunkNumber) { + return (chapterNumber << 16) | chunkNumber; + } + } diff --git a/src/test/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryTest.java b/src/test/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryTest.java index e19d2709..b9f9376f 100644 --- a/src/test/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryTest.java +++ b/src/test/java/com/linglevel/api/content/custom/repository/CustomContentRepositoryTest.java @@ -28,388 +28,406 @@ @Import(CustomContentRepositoryImpl.class) class CustomContentRepositoryTest extends AbstractDatabaseTest { - @Autowired - private CustomContentRepository customContentRepository; - - @Autowired - private UserCustomContentRepository userCustomContentRepository; - - @Autowired - private CustomContentProgressRepository customContentProgressRepository; - - @Autowired - private org.springframework.data.mongodb.core.MongoTemplate mongoTemplate; - - private String testUserId; - private CustomContent content1; - private CustomContent content2; - private CustomContent content3; - private UserCustomContent userCustomContent1; - private UserCustomContent userCustomContent2; - private UserCustomContent userCustomContent3; - - @BeforeEach - void setUp() { - // 기존 데이터 삭제 - customContentRepository.deleteAll(); - userCustomContentRepository.deleteAll(); - customContentProgressRepository.deleteAll(); - - testUserId = "test-user-id"; - - // CustomContent 데이터 생성 - content1 = CustomContent.builder() - .userId("creator-1") - .contentRequestId("request-1") - .isDeleted(false) - .title("The Little Prince") - .author("Antoine de Saint-Exupéry") - .coverImageUrl("https://example.com/cover1.jpg") - .difficultyLevel(DifficultyLevel.A2) - .targetDifficultyLevels(Arrays.asList(DifficultyLevel.A1, DifficultyLevel.A2)) - .readingTime(30) - .averageRating(4.5) - .reviewCount(100) - .viewCount(1000) - .tags(Arrays.asList("classic", "fiction")) - .originUrl("https://example.com/prince") - .originDomain("example.com") - .createdAt(Instant.now().minusSeconds(3600)) - .updatedAt(Instant.now()) - .build(); - - content2 = CustomContent.builder() - .userId("creator-2") - .contentRequestId("request-2") - .isDeleted(false) - .title("Harry Potter") - .author("J.K. Rowling") - .coverImageUrl("https://example.com/cover2.jpg") - .difficultyLevel(DifficultyLevel.B1) - .targetDifficultyLevels(Arrays.asList(DifficultyLevel.A2, DifficultyLevel.B1)) - .readingTime(60) - .averageRating(4.8) - .reviewCount(200) - .viewCount(2000) - .tags(Arrays.asList("fantasy", "fiction")) - .originUrl("https://example.com/harry") - .originDomain("example.com") - .createdAt(Instant.now().minusSeconds(7200)) - .updatedAt(Instant.now()) - .build(); - - content3 = CustomContent.builder() - .userId("creator-3") - .contentRequestId("request-3") - .isDeleted(false) - .title("Alice in Wonderland") - .author("Lewis Carroll") - .coverImageUrl("https://example.com/cover3.jpg") - .difficultyLevel(DifficultyLevel.A2) - .targetDifficultyLevels(Arrays.asList(DifficultyLevel.A2, DifficultyLevel.B1)) - .readingTime(45) - .averageRating(4.2) - .reviewCount(150) - .viewCount(1500) - .tags(Arrays.asList("classic", "fantasy")) - .originUrl("https://example.com/alice") - .originDomain("example.com") - .createdAt(Instant.now().minusSeconds(10800)) - .updatedAt(Instant.now()) - .build(); - - // CustomContent 저장 - content1 = customContentRepository.save(content1); - content2 = customContentRepository.save(content2); - content3 = customContentRepository.save(content3); - - // UserCustomContent 매핑 생성 - userCustomContent1 = UserCustomContent.builder() - .userId(testUserId) - .customContentId(content1.getId()) - .contentRequestId("request-1") - .unlockedAt(Instant.now().minusSeconds(3600)) - .build(); - - userCustomContent2 = UserCustomContent.builder() - .userId(testUserId) - .customContentId(content2.getId()) - .contentRequestId("request-2") - .unlockedAt(Instant.now().minusSeconds(7200)) - .build(); - - userCustomContent3 = UserCustomContent.builder() - .userId(testUserId) - .customContentId(content3.getId()) - .contentRequestId("request-3") - .unlockedAt(Instant.now().minusSeconds(10800)) - .build(); - - // UserCustomContent 저장 - userCustomContentRepository.save(userCustomContent1); - userCustomContentRepository.save(userCustomContent2); - userCustomContentRepository.save(userCustomContent3); - } - - @Test - @DisplayName("UserCustomContent 매핑을 통한 aggregation 쿼리가 정상적으로 동작한다") - void findCustomContentsByUserWithFilters_shouldReturnContents() { - // Given - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - assertThat(result.getTotalElements()).isEqualTo(3); - - // createdAt 기준 내림차순 정렬 확인 - assertThat(result.getContent().get(0).getTitle()).isEqualTo("The Little Prince"); - assertThat(result.getContent().get(1).getTitle()).isEqualTo("Harry Potter"); - assertThat(result.getContent().get(2).getTitle()).isEqualTo("Alice in Wonderland"); - } - - @Test - @DisplayName("키워드 필터링이 정상적으로 동작한다") - void findCustomContentsByUserWithFilters_shouldFilterByKeyword() { - // Given - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setKeyword("prince"); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getTitle()).contains("Prince"); - } - - @Test - @DisplayName("태그 필터링이 정상적으로 동작한다") - void findCustomContentsByUserWithFilters_shouldFilterByTags() { - // Given - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setTags("classic,fiction"); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getTitle()).isEqualTo("The Little Prince"); - assertThat(result.getContent().get(0).getTags()).containsExactlyInAnyOrder("classic", "fiction"); - } - - @Test - @DisplayName("viewCount 정렬이 정상적으로 동작한다") - void findCustomContentsByUserWithFilters_shouldSortByViewCount() { - // Given - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setSortBy("view_count"); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "viewCount")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - assertThat(result.getContent().get(0).getViewCount()).isEqualTo(2000); - assertThat(result.getContent().get(1).getViewCount()).isEqualTo(1500); - assertThat(result.getContent().get(2).getViewCount()).isEqualTo(1000); - } - - @Test - @DisplayName("averageRating 정렬이 정상적으로 동작한다") - void findCustomContentsByUserWithFilters_shouldSortByAverageRating() { - // Given - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setSortBy("average_rating"); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "averageRating")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - assertThat(result.getContent().get(0).getAverageRating()).isEqualTo(4.8); - assertThat(result.getContent().get(1).getAverageRating()).isEqualTo(4.5); - assertThat(result.getContent().get(2).getAverageRating()).isEqualTo(4.2); - } - - @Test - @DisplayName("페이지네이션이 정상적으로 동작한다") - void findCustomContentsByUserWithFilters_shouldHandlePagination() { - // Given - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(2); - request.setLimit(2); - - Pageable pageable = PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(1); // 3개 중 2페이지(2개씩)니까 1개 - assertThat(result.getTotalElements()).isEqualTo(3); - assertThat(result.getTotalPages()).isEqualTo(2); - assertThat(result.getContent().get(0).getTitle()).isEqualTo("Alice in Wonderland"); - } - - @Test - @DisplayName("진행 중인 콘텐츠만 필터링한다") - void findCustomContentsByUserWithFilters_shouldFilterByProgressStatus_InProgress() { - // Given - content2에 진행 중인 progress 추가 - CustomContentProgress progress = new CustomContentProgress(); - progress.setUserId(testUserId); - progress.setCustomId(content2.getId()); - progress.setChunkId("chunk-1"); - progress.setNormalizedProgress(10.0); - progress.setCurrentDifficultyLevel(DifficultyLevel.B1); - progress.setIsCompleted(false); - customContentProgressRepository.save(progress); - - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setProgress(ProgressStatus.IN_PROGRESS); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getTitle()).isEqualTo("Harry Potter"); - } - - @Test - @DisplayName("완료된 콘텐츠만 필터링한다") - void findCustomContentsByUserWithFilters_shouldFilterByProgressStatus_Completed() { - // Given - content1을 완료 상태로 설정 - CustomContentProgress progress = new CustomContentProgress(); - progress.setUserId(testUserId); - progress.setCustomId(content1.getId()); - progress.setChunkId("chunk-50"); - progress.setNormalizedProgress(100.0); - progress.setCurrentDifficultyLevel(DifficultyLevel.A2); - progress.setIsCompleted(true); - progress.setCompletedAt(Instant.now()); - customContentProgressRepository.save(progress); - - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setProgress(ProgressStatus.COMPLETED); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getTitle()).isEqualTo("The Little Prince"); - } - - @Test - @DisplayName("시작하지 않은 콘텐츠만 필터링한다") - void findCustomContentsByUserWithFilters_shouldFilterByProgressStatus_NotStarted() { - // Given - content1에만 progress 추가 - CustomContentProgress progress = new CustomContentProgress(); - progress.setUserId(testUserId); - progress.setCustomId(content1.getId()); - progress.setChunkId("chunk-1"); - progress.setNormalizedProgress(2.0); - progress.setCurrentDifficultyLevel(DifficultyLevel.A2); - progress.setIsCompleted(false); - customContentProgressRepository.save(progress); - - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - request.setProgress(ProgressStatus.NOT_STARTED); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(2); // content2, content3 - assertThat(result.getContent()).extracting("title") - .containsExactlyInAnyOrder("Harry Potter", "Alice in Wonderland"); - } - - @Test - @DisplayName("isDeleted가 true인 콘텐츠는 제외된다") - void findCustomContentsByUserWithFilters_shouldExcludeDeletedContents() { - // Given - content1을 삭제 상태로 변경 - content1.setIsDeleted(true); - customContentRepository.save(content1); - - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(2); - assertThat(result.getContent()).extracting("title") - .containsExactlyInAnyOrder("Harry Potter", "Alice in Wonderland"); - } - - @Test - @DisplayName("다른 유저의 콘텐츠는 조회되지 않는다") - void findCustomContentsByUserWithFilters_shouldNotReturnOtherUsersContents() { - // Given - String otherUserId = "other-user-id"; - GetCustomContentsRequest request = new GetCustomContentsRequest(); - request.setPage(1); - request.setLimit(10); - - Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); - - // When - Page result = customContentRepository.findCustomContentsByUserWithFilters(otherUserId, request, pageable); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isEqualTo(0); - } + @Autowired + private CustomContentRepository customContentRepository; + + @Autowired + private UserCustomContentRepository userCustomContentRepository; + + @Autowired + private CustomContentProgressRepository customContentProgressRepository; + + @Autowired + private org.springframework.data.mongodb.core.MongoTemplate mongoTemplate; + + private String testUserId; + + private CustomContent content1; + + private CustomContent content2; + + private CustomContent content3; + + private UserCustomContent userCustomContent1; + + private UserCustomContent userCustomContent2; + + private UserCustomContent userCustomContent3; + + @BeforeEach + void setUp() { + // 기존 데이터 삭제 + customContentRepository.deleteAll(); + userCustomContentRepository.deleteAll(); + customContentProgressRepository.deleteAll(); + + testUserId = "test-user-id"; + + // CustomContent 데이터 생성 + content1 = CustomContent.builder() + .userId("creator-1") + .contentRequestId("request-1") + .isDeleted(false) + .title("The Little Prince") + .author("Antoine de Saint-Exupéry") + .coverImageUrl("https://example.com/cover1.jpg") + .difficultyLevel(DifficultyLevel.A2) + .targetDifficultyLevels(Arrays.asList(DifficultyLevel.A1, DifficultyLevel.A2)) + .readingTime(30) + .averageRating(4.5) + .reviewCount(100) + .viewCount(1000) + .tags(Arrays.asList("classic", "fiction")) + .originUrl("https://example.com/prince") + .originDomain("example.com") + .createdAt(Instant.now().minusSeconds(3600)) + .updatedAt(Instant.now()) + .build(); + + content2 = CustomContent.builder() + .userId("creator-2") + .contentRequestId("request-2") + .isDeleted(false) + .title("Harry Potter") + .author("J.K. Rowling") + .coverImageUrl("https://example.com/cover2.jpg") + .difficultyLevel(DifficultyLevel.B1) + .targetDifficultyLevels(Arrays.asList(DifficultyLevel.A2, DifficultyLevel.B1)) + .readingTime(60) + .averageRating(4.8) + .reviewCount(200) + .viewCount(2000) + .tags(Arrays.asList("fantasy", "fiction")) + .originUrl("https://example.com/harry") + .originDomain("example.com") + .createdAt(Instant.now().minusSeconds(7200)) + .updatedAt(Instant.now()) + .build(); + + content3 = CustomContent.builder() + .userId("creator-3") + .contentRequestId("request-3") + .isDeleted(false) + .title("Alice in Wonderland") + .author("Lewis Carroll") + .coverImageUrl("https://example.com/cover3.jpg") + .difficultyLevel(DifficultyLevel.A2) + .targetDifficultyLevels(Arrays.asList(DifficultyLevel.A2, DifficultyLevel.B1)) + .readingTime(45) + .averageRating(4.2) + .reviewCount(150) + .viewCount(1500) + .tags(Arrays.asList("classic", "fantasy")) + .originUrl("https://example.com/alice") + .originDomain("example.com") + .createdAt(Instant.now().minusSeconds(10800)) + .updatedAt(Instant.now()) + .build(); + + // CustomContent 저장 + content1 = customContentRepository.save(content1); + content2 = customContentRepository.save(content2); + content3 = customContentRepository.save(content3); + + // UserCustomContent 매핑 생성 + userCustomContent1 = UserCustomContent.builder() + .userId(testUserId) + .customContentId(content1.getId()) + .contentRequestId("request-1") + .unlockedAt(Instant.now().minusSeconds(3600)) + .build(); + + userCustomContent2 = UserCustomContent.builder() + .userId(testUserId) + .customContentId(content2.getId()) + .contentRequestId("request-2") + .unlockedAt(Instant.now().minusSeconds(7200)) + .build(); + + userCustomContent3 = UserCustomContent.builder() + .userId(testUserId) + .customContentId(content3.getId()) + .contentRequestId("request-3") + .unlockedAt(Instant.now().minusSeconds(10800)) + .build(); + + // UserCustomContent 저장 + userCustomContentRepository.save(userCustomContent1); + userCustomContentRepository.save(userCustomContent2); + userCustomContentRepository.save(userCustomContent3); + } + + @Test + @DisplayName("UserCustomContent 매핑을 통한 aggregation 쿼리가 정상적으로 동작한다") + void findCustomContentsByUserWithFilters_shouldReturnContents() { + // Given + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(3); + + // createdAt 기준 내림차순 정렬 확인 + assertThat(result.getContent().get(0).getTitle()).isEqualTo("The Little Prince"); + assertThat(result.getContent().get(1).getTitle()).isEqualTo("Harry Potter"); + assertThat(result.getContent().get(2).getTitle()).isEqualTo("Alice in Wonderland"); + } + + @Test + @DisplayName("키워드 필터링이 정상적으로 동작한다") + void findCustomContentsByUserWithFilters_shouldFilterByKeyword() { + // Given + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setKeyword("prince"); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).contains("Prince"); + } + + @Test + @DisplayName("태그 필터링이 정상적으로 동작한다") + void findCustomContentsByUserWithFilters_shouldFilterByTags() { + // Given + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setTags("classic,fiction"); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("The Little Prince"); + assertThat(result.getContent().get(0).getTags()).containsExactlyInAnyOrder("classic", "fiction"); + } + + @Test + @DisplayName("viewCount 정렬이 정상적으로 동작한다") + void findCustomContentsByUserWithFilters_shouldSortByViewCount() { + // Given + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setSortBy("view_count"); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "viewCount")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getViewCount()).isEqualTo(2000); + assertThat(result.getContent().get(1).getViewCount()).isEqualTo(1500); + assertThat(result.getContent().get(2).getViewCount()).isEqualTo(1000); + } + + @Test + @DisplayName("averageRating 정렬이 정상적으로 동작한다") + void findCustomContentsByUserWithFilters_shouldSortByAverageRating() { + // Given + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setSortBy("average_rating"); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "averageRating")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).getAverageRating()).isEqualTo(4.8); + assertThat(result.getContent().get(1).getAverageRating()).isEqualTo(4.5); + assertThat(result.getContent().get(2).getAverageRating()).isEqualTo(4.2); + } + + @Test + @DisplayName("페이지네이션이 정상적으로 동작한다") + void findCustomContentsByUserWithFilters_shouldHandlePagination() { + // Given + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(2); + request.setLimit(2); + + Pageable pageable = PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); // 3개 중 2페이지(2개씩)니까 1개 + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("Alice in Wonderland"); + } + + @Test + @DisplayName("진행 중인 콘텐츠만 필터링한다") + void findCustomContentsByUserWithFilters_shouldFilterByProgressStatus_InProgress() { + // Given - content2에 진행 중인 progress 추가 + CustomContentProgress progress = new CustomContentProgress(); + progress.setUserId(testUserId); + progress.setCustomId(content2.getId()); + progress.setChunkId("chunk-1"); + progress.setNormalizedProgress(10.0); + progress.setCurrentDifficultyLevel(DifficultyLevel.B1); + progress.setIsCompleted(false); + customContentProgressRepository.save(progress); + + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setProgress(ProgressStatus.IN_PROGRESS); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("Harry Potter"); + } + + @Test + @DisplayName("완료된 콘텐츠만 필터링한다") + void findCustomContentsByUserWithFilters_shouldFilterByProgressStatus_Completed() { + // Given - content1을 완료 상태로 설정 + CustomContentProgress progress = new CustomContentProgress(); + progress.setUserId(testUserId); + progress.setCustomId(content1.getId()); + progress.setChunkId("chunk-50"); + progress.setNormalizedProgress(100.0); + progress.setCurrentDifficultyLevel(DifficultyLevel.A2); + progress.setIsCompleted(true); + progress.setCompletedAt(Instant.now()); + customContentProgressRepository.save(progress); + + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setProgress(ProgressStatus.COMPLETED); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("The Little Prince"); + } + + @Test + @DisplayName("시작하지 않은 콘텐츠만 필터링한다") + void findCustomContentsByUserWithFilters_shouldFilterByProgressStatus_NotStarted() { + // Given - content1에만 progress 추가 + CustomContentProgress progress = new CustomContentProgress(); + progress.setUserId(testUserId); + progress.setCustomId(content1.getId()); + progress.setChunkId("chunk-1"); + progress.setNormalizedProgress(2.0); + progress.setCurrentDifficultyLevel(DifficultyLevel.A2); + progress.setIsCompleted(false); + customContentProgressRepository.save(progress); + + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + request.setProgress(ProgressStatus.NOT_STARTED); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(2); // content2, content3 + assertThat(result.getContent()).extracting("title") + .containsExactlyInAnyOrder("Harry Potter", "Alice in Wonderland"); + } + + @Test + @DisplayName("isDeleted가 true인 콘텐츠는 제외된다") + void findCustomContentsByUserWithFilters_shouldExcludeDeletedContents() { + // Given - content1을 삭제 상태로 변경 + content1.setIsDeleted(true); + customContentRepository.save(content1); + + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(testUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting("title") + .containsExactlyInAnyOrder("Harry Potter", "Alice in Wonderland"); + } + + @Test + @DisplayName("다른 유저의 콘텐츠는 조회되지 않는다") + void findCustomContentsByUserWithFilters_shouldNotReturnOtherUsersContents() { + // Given + String otherUserId = "other-user-id"; + GetCustomContentsRequest request = new GetCustomContentsRequest(); + request.setPage(1); + request.setLimit(10); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // When + Page result = customContentRepository.findCustomContentsByUserWithFilters(otherUserId, request, + pageable); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } diff --git a/src/test/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressServiceTest.java b/src/test/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressServiceTest.java index d97d0afb..f1eb5403 100644 --- a/src/test/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressServiceTest.java +++ b/src/test/java/com/linglevel/api/content/custom/service/CustomContentReadingProgressServiceTest.java @@ -28,77 +28,81 @@ @ExtendWith(MockitoExtension.class) class CustomContentReadingProgressServiceTest { - @Mock - private CustomContentService customContentService; - - @Mock - private CustomContentProgressRepository customContentProgressRepository; - - @Mock - private CustomContentChunkRepository customContentChunkRepository; - - @Mock - private CustomContentChunkService customContentChunkService; - - @Mock - private ProgressCalculationService progressCalculationService; - - @Mock - private ReadingCompletionService readingCompletionService; - - @Mock - private StreakService streakService; - - @InjectMocks - private CustomContentReadingProgressService customContentReadingProgressService; - - @Captor - private ArgumentCaptor customProgressCaptor; - - @Test - @DisplayName("오래된 CustomContent 진행률 업데이트 시 V2 필드가 정상적으로 마이그레이션된다") - void updateProgress_shouldLazyMigrate_forOldData() { - // Given: 마이그레이션되지 않은(V2 필드가 null인) CustomContentProgress 설정 - String userId = "test-user"; - String customId = "test-custom"; - String chunkId = "test-chunk"; - - // V2 필드가 null인 레거시 데이터 - CustomContentProgress legacyProgress = new CustomContentProgress(); - legacyProgress.setId("legacy-progress-id"); - legacyProgress.setUserId(userId); - legacyProgress.setCustomId(customId); - // legacyProgress.normalizedProgress is null - // legacyProgress.currentDifficultyLevel is null - - CustomContentChunk currentChunk = new CustomContentChunk(); - currentChunk.setId(chunkId); - currentChunk.setCustomContentId(customId); - currentChunk.setChunkNum(5); - currentChunk.setDifficultyLevel(DifficultyLevel.A2); - - CustomContentReadingProgressUpdateRequest request = new CustomContentReadingProgressUpdateRequest(); - request.setChunkId(chunkId); - - // Mocking - when(customContentService.existsById(customId)).thenReturn(true); - when(customContentProgressRepository.findByUserIdAndCustomId(userId, customId)).thenReturn(Optional.of(legacyProgress)); - when(customContentChunkService.findById(chunkId)).thenReturn(currentChunk); - when(customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(customId, DifficultyLevel.A2)).thenReturn(50L); - when(progressCalculationService.calculateNormalizedProgress(5, 50L)).thenReturn(10.0); - - // When: 진행률 업데이트 호출 - customContentReadingProgressService.updateProgress(customId, request, userId); - - // Then: V2 필드가 채워진 상태로 저장되는지 검증 - verify(customContentProgressRepository).save(customProgressCaptor.capture()); - CustomContentProgress savedProgress = customProgressCaptor.getValue(); - - assertThat(savedProgress.getId()).isEqualTo("legacy-progress-id"); - assertThat(savedProgress.getNormalizedProgress()).isNotNull(); - assertThat(savedProgress.getNormalizedProgress()).isEqualTo(10.0); - assertThat(savedProgress.getMaxNormalizedProgress()).isEqualTo(10.0); - assertThat(savedProgress.getCurrentDifficultyLevel()).isNotNull(); - assertThat(savedProgress.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.A2); - } + @Mock + private CustomContentService customContentService; + + @Mock + private CustomContentProgressRepository customContentProgressRepository; + + @Mock + private CustomContentChunkRepository customContentChunkRepository; + + @Mock + private CustomContentChunkService customContentChunkService; + + @Mock + private ProgressCalculationService progressCalculationService; + + @Mock + private ReadingCompletionService readingCompletionService; + + @Mock + private StreakService streakService; + + @InjectMocks + private CustomContentReadingProgressService customContentReadingProgressService; + + @Captor + private ArgumentCaptor customProgressCaptor; + + @Test + @DisplayName("오래된 CustomContent 진행률 업데이트 시 V2 필드가 정상적으로 마이그레이션된다") + void updateProgress_shouldLazyMigrate_forOldData() { + // Given: 마이그레이션되지 않은(V2 필드가 null인) CustomContentProgress 설정 + String userId = "test-user"; + String customId = "test-custom"; + String chunkId = "test-chunk"; + + // V2 필드가 null인 레거시 데이터 + CustomContentProgress legacyProgress = new CustomContentProgress(); + legacyProgress.setId("legacy-progress-id"); + legacyProgress.setUserId(userId); + legacyProgress.setCustomId(customId); + // legacyProgress.normalizedProgress is null + // legacyProgress.currentDifficultyLevel is null + + CustomContentChunk currentChunk = new CustomContentChunk(); + currentChunk.setId(chunkId); + currentChunk.setCustomContentId(customId); + currentChunk.setChunkNum(5); + currentChunk.setDifficultyLevel(DifficultyLevel.A2); + + CustomContentReadingProgressUpdateRequest request = new CustomContentReadingProgressUpdateRequest(); + request.setChunkId(chunkId); + + // Mocking + when(customContentService.existsById(customId)).thenReturn(true); + when(customContentProgressRepository.findByUserIdAndCustomId(userId, customId)) + .thenReturn(Optional.of(legacyProgress)); + when(customContentChunkService.findById(chunkId)).thenReturn(currentChunk); + when(customContentChunkRepository.countByCustomContentIdAndDifficultyLevelAndIsDeletedFalse(customId, + DifficultyLevel.A2)) + .thenReturn(50L); + when(progressCalculationService.calculateNormalizedProgress(5, 50L)).thenReturn(10.0); + + // When: 진행률 업데이트 호출 + customContentReadingProgressService.updateProgress(customId, request, userId); + + // Then: V2 필드가 채워진 상태로 저장되는지 검증 + verify(customContentProgressRepository).save(customProgressCaptor.capture()); + CustomContentProgress savedProgress = customProgressCaptor.getValue(); + + assertThat(savedProgress.getId()).isEqualTo("legacy-progress-id"); + assertThat(savedProgress.getNormalizedProgress()).isNotNull(); + assertThat(savedProgress.getNormalizedProgress()).isEqualTo(10.0); + assertThat(savedProgress.getMaxNormalizedProgress()).isEqualTo(10.0); + assertThat(savedProgress.getCurrentDifficultyLevel()).isNotNull(); + assertThat(savedProgress.getCurrentDifficultyLevel()).isEqualTo(DifficultyLevel.A2); + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilterTest.java b/src/test/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilterTest.java index 652d4272..003c0d81 100644 --- a/src/test/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilterTest.java +++ b/src/test/java/com/linglevel/api/content/feed/filter/filters/ContentCrawlabilityFilterTest.java @@ -22,142 +22,138 @@ @DisplayName("ContentCrawlabilityFilter 테스트") class ContentCrawlabilityFilterTest { - private ContentCrawlabilityFilter filter; + private ContentCrawlabilityFilter filter; - @Mock - private CrawlingDslRepository crawlingDslRepository; + @Mock + private CrawlingDslRepository crawlingDslRepository; + + @Mock + private CrawlingService crawlingService; + + @BeforeEach + void setUp() { + filter = new ContentCrawlabilityFilter(crawlingDslRepository, crawlingService); + } + + @Test + @DisplayName("CrawlingDsl이 없는 도메인은 불통과") + void testNoCrawlingDsl() { + // given: DSL이 없는 도메인 + when(crawlingService.extractDomain("https://unknown.com/article")).thenReturn("unknown.com"); + when(crawlingDslRepository.findByDomain("unknown.com")).thenReturn(Optional.empty()); + + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn("https://unknown.com/article"); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 불통과해야 함 + assertFalse(result.isPassed(), "DSL이 없으면 불통과"); + assertEquals("ContentCrawlabilityFilter", result.getFilterName()); + } + + @Test + @DisplayName("YouTube 도메인은 크롤링 체크 없이 통과") + void testYouTubeDomainPass() { + // given: YouTube URL + String youtubeUrl = "https://www.youtube.com/watch?v=test123"; + + when(crawlingService.extractDomain(youtubeUrl)).thenReturn("youtube.com"); + + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn(youtubeUrl); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 통과해야 함 (YouTube는 RSS에서 description 제공) + assertTrue(result.isPassed(), "YouTube는 크롤링 체크 없이 통과"); + + // CrawlingDslRepository는 호출되지 않아야 함 + verify(crawlingDslRepository, never()).findByDomain(any()); + } + + @Test + @DisplayName("contentDsl이 null이면 통과") + void testNullContentDsl() { + // given: contentDsl이 null인 경우 + CrawlingDsl dsl = CrawlingDsl.builder().domain("example.com").contentDsl(null).build(); + + when(crawlingService.extractDomain("https://example.com/article")).thenReturn("example.com"); + when(crawlingDslRepository.findByDomain("example.com")).thenReturn(Optional.of(dsl)); + + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn("https://example.com/article"); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 통과해야 함 + assertTrue(result.isPassed(), "contentDsl이 없으면 통과"); + } + + @Test + @DisplayName("URL이 null이면 필터링") + void testNullUrl() { + // given: URL이 null + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn(null); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 필터링되어야 함 + assertFalse(result.isPassed(), "URL이 null이면 필터링"); + assertEquals("ContentCrawlabilityFilter", result.getFilterName()); + assertEquals("URL is null", result.getReason()); + } + + @Test + @DisplayName("추출된 콘텐츠가 100자 미만이면 필터링") + void testShortContent() { + // given: 짧은 콘텐츠만 추출되는 페이지 + String shortContentHtml = """ + + +
+

Short.

+
+ + + """; + + // This test would require mocking Jsoup.connect, which is complex + // Better to test with real URLs or skip for integration testing + System.out.println("Note: Short content test requires integration testing with actual URLs"); + } + + @Test + @DisplayName("필터 이름 확인") + void testFilterName() { + // when + String filterName = filter.getName(); + + // then + assertEquals("ContentCrawlabilityFilter", filterName); + } + + @Test + @DisplayName("필터 순서 확인 - 가장 나중에 실행") + void testFilterOrder() { + // when + int order = filter.getOrder(); + + // then + assertEquals(100, order, "HTTP 요청이 필요하므로 가장 나중에 실행되어야 함"); + } - @Mock - private CrawlingService crawlingService; - - @BeforeEach - void setUp() { - filter = new ContentCrawlabilityFilter(crawlingDslRepository, crawlingService); - } - - @Test - @DisplayName("CrawlingDsl이 없는 도메인은 불통과") - void testNoCrawlingDsl() { - // given: DSL이 없는 도메인 - when(crawlingService.extractDomain("https://unknown.com/article")).thenReturn("unknown.com"); - when(crawlingDslRepository.findByDomain("unknown.com")) - .thenReturn(Optional.empty()); - - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn("https://unknown.com/article"); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 불통과해야 함 - assertFalse(result.isPassed(), "DSL이 없으면 불통과"); - assertEquals("ContentCrawlabilityFilter", result.getFilterName()); - } - - @Test - @DisplayName("YouTube 도메인은 크롤링 체크 없이 통과") - void testYouTubeDomainPass() { - // given: YouTube URL - String youtubeUrl = "https://www.youtube.com/watch?v=test123"; - - when(crawlingService.extractDomain(youtubeUrl)).thenReturn("youtube.com"); - - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn(youtubeUrl); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 통과해야 함 (YouTube는 RSS에서 description 제공) - assertTrue(result.isPassed(), "YouTube는 크롤링 체크 없이 통과"); - - // CrawlingDslRepository는 호출되지 않아야 함 - verify(crawlingDslRepository, never()).findByDomain(any()); - } - - @Test - @DisplayName("contentDsl이 null이면 통과") - void testNullContentDsl() { - // given: contentDsl이 null인 경우 - CrawlingDsl dsl = CrawlingDsl.builder() - .domain("example.com") - .contentDsl(null) - .build(); - - when(crawlingService.extractDomain("https://example.com/article")).thenReturn("example.com"); - when(crawlingDslRepository.findByDomain("example.com")) - .thenReturn(Optional.of(dsl)); - - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn("https://example.com/article"); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 통과해야 함 - assertTrue(result.isPassed(), "contentDsl이 없으면 통과"); - } - - @Test - @DisplayName("URL이 null이면 필터링") - void testNullUrl() { - // given: URL이 null - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn(null); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 필터링되어야 함 - assertFalse(result.isPassed(), "URL이 null이면 필터링"); - assertEquals("ContentCrawlabilityFilter", result.getFilterName()); - assertEquals("URL is null", result.getReason()); - } - - @Test - @DisplayName("추출된 콘텐츠가 100자 미만이면 필터링") - void testShortContent() { - // given: 짧은 콘텐츠만 추출되는 페이지 - String shortContentHtml = """ - - -
-

Short.

-
- - - """; - - // This test would require mocking Jsoup.connect, which is complex - // Better to test with real URLs or skip for integration testing - System.out.println("Note: Short content test requires integration testing with actual URLs"); - } - - @Test - @DisplayName("필터 이름 확인") - void testFilterName() { - // when - String filterName = filter.getName(); - - // then - assertEquals("ContentCrawlabilityFilter", filterName); - } - - @Test - @DisplayName("필터 순서 확인 - 가장 나중에 실행") - void testFilterOrder() { - // when - int order = filter.getOrder(); - - // then - assertEquals(100, order, "HTTP 요청이 필요하므로 가장 나중에 실행되어야 함"); - } } diff --git a/src/test/java/com/linglevel/api/content/feed/filter/filters/LanguageFilterTest.java b/src/test/java/com/linglevel/api/content/feed/filter/filters/LanguageFilterTest.java index 812e21fc..585930e4 100644 --- a/src/test/java/com/linglevel/api/content/feed/filter/filters/LanguageFilterTest.java +++ b/src/test/java/com/linglevel/api/content/feed/filter/filters/LanguageFilterTest.java @@ -13,309 +13,298 @@ @DisplayName("LanguageFilter 테스트") class LanguageFilterTest { - private LanguageFilter filter; - private FeedSource mockFeedSource; - - @BeforeEach - void setUp() { - filter = new LanguageFilter(); - mockFeedSource = mock(FeedSource.class); - } - - @Test - @DisplayName("영어 제목은 통과") - void testEnglishTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("How to Build a REST API with Spring Boot"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertTrue(result.isPassed(), "영어 제목은 통과해야 함"); - } - - @Test - @DisplayName("영어 + 숫자 + 특수문자 제목은 통과") - void testEnglishWithNumbersAndSpecialChars() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("Top 10 JavaScript Libraries in 2024! (Must-Know)"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertTrue(result.isPassed(), "영어 + 숫자 + 특수문자는 통과해야 함"); - } - - @Test - @DisplayName("영어 + 이모지 제목은 통과") - void testEnglishWithEmoji() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("AI is Amazing 🚀🤖 - The Future of Technology 💡"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertTrue(result.isPassed(), "영어 + 이모지는 통과해야 함"); - } - - @Test - @DisplayName("한글 제목은 필터링") - void testKoreanTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("스프링 부트로 REST API 만들기"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "한글 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - assertTrue(result.getReason().contains("Non-English title detected")); - } - - @Test - @DisplayName("일본어 제목은 필터링") - void testJapaneseTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("プログラミングの基礎"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "일본어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("중국어 제목은 필터링") - void testChineseTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("学习编程的基础知识"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "중국어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("힌디어 제목은 필터링") - void testHindiTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("प्रोग्रामिंग सीखना"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "힌디어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("아랍어 제목은 필터링") - void testArabicTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("تعلم البرمجة"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "아랍어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("페르시아어 제목은 필터링") - void testPersianTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("خاله# تهران #شماره خاله# اصفهان"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "페르시아어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("페르시아어 + 한글 혼합 제목은 필터링") - void testPersianKoreanMixedTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("خاله# تهران #شماره خاله# اصفهان이런글자도"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "페르시아어/한글 혼합 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("러시아어 제목은 필터링") - void testRussianTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("Основы программирования"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "러시아어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("태국어 제목은 필터링") - void testThaiTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("การเขียนโปรแกรม"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "태국어 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - } - - @Test - @DisplayName("영어 + 한글 혼합 제목은 필터링") - void testMixedEnglishKorean() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn("Spring Boot 튜토리얼"); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "한글이 포함되어 있으면 필터링되어야 함"); - } - - @Test - @DisplayName("빈 제목은 필터링") - void testEmptyTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn(""); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "빈 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - assertEquals("Empty title", result.getReason()); - } - - @Test - @DisplayName("null 제목은 필터링") - void testNullTitle() { - // given - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn(null); - - // when - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - // then - assertFalse(result.isPassed(), "null 제목은 필터링되어야 함"); - assertEquals("LanguageFilter", result.getFilterName()); - assertEquals("Empty title", result.getReason()); - } - - @Test - @DisplayName("필터 이름 확인") - void testFilterName() { - // when - String filterName = filter.getName(); - - // then - assertEquals("LanguageFilter", filterName); - } - - @Test - @DisplayName("필터 순서 확인 - URL 체크 다음") - void testFilterOrder() { - // when - int order = filter.getOrder(); - - // then - assertEquals(20, order, "빠른 문자 체크이므로 우선순위가 높아야 함"); - } - - @Test - @DisplayName("실제 영어 뉴스 제목들 테스트") - void testRealEnglishNewsTitles() { - String[] englishTitles = { - "OpenAI Releases GPT-5 with Revolutionary Features", - "How AI is Transforming Healthcare in 2024", - "NASA Discovers New Exoplanet That Could Support Life", - "The Future of Quantum Computing: A Deep Dive", - "Breaking: Tech Giants Announce Major Collaboration", - "10 Tips for Better Code Reviews 🚀", - "Why JavaScript Still Dominates Web Development", - "Understanding Machine Learning Basics (Part 1)" - }; - - for (String title : englishTitles) { - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn(title); - - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - assertTrue(result.isPassed(), - "영어 뉴스 제목은 통과해야 함: " + title); - } - } - - @Test - @DisplayName("실제 비영어 뉴스 제목들 테스트") - void testRealNonEnglishNewsTitles() { - String[][] nonEnglishTitles = { - {"한국어", "인공지능이 바꾸는 미래 사회"}, - {"日本語", "新しいテクノロジーの世界"}, - {"中文", "人工智能的未来发展"}, - {"हिन्दी", "तकनीकी विकास की नई दिशा"}, - {"العربية", "مستقبل الذكاء الاصطناعي"}, - {"فارسی", "خاله# تهران #شماره خاله# اصفهان"}, - {"Русский", "Будущее искусственного интеллекта"}, - {"ไทย", "อนาคตของปัญญาประดิษฐ์"} - }; - - for (String[] titlePair : nonEnglishTitles) { - String language = titlePair[0]; - String title = titlePair[1]; - - SyndEntry entry = mock(SyndEntry.class); - when(entry.getTitle()).thenReturn(title); - - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - assertFalse(result.isPassed(), - language + " 제목은 필터링되어야 함: " + title); - } - } + private LanguageFilter filter; + + private FeedSource mockFeedSource; + + @BeforeEach + void setUp() { + filter = new LanguageFilter(); + mockFeedSource = mock(FeedSource.class); + } + + @Test + @DisplayName("영어 제목은 통과") + void testEnglishTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("How to Build a REST API with Spring Boot"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertTrue(result.isPassed(), "영어 제목은 통과해야 함"); + } + + @Test + @DisplayName("영어 + 숫자 + 특수문자 제목은 통과") + void testEnglishWithNumbersAndSpecialChars() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("Top 10 JavaScript Libraries in 2024! (Must-Know)"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertTrue(result.isPassed(), "영어 + 숫자 + 특수문자는 통과해야 함"); + } + + @Test + @DisplayName("영어 + 이모지 제목은 통과") + void testEnglishWithEmoji() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("AI is Amazing 🚀🤖 - The Future of Technology 💡"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertTrue(result.isPassed(), "영어 + 이모지는 통과해야 함"); + } + + @Test + @DisplayName("한글 제목은 필터링") + void testKoreanTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("스프링 부트로 REST API 만들기"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "한글 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + assertTrue(result.getReason().contains("Non-English title detected")); + } + + @Test + @DisplayName("일본어 제목은 필터링") + void testJapaneseTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("プログラミングの基礎"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "일본어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("중국어 제목은 필터링") + void testChineseTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("学习编程的基础知识"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "중국어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("힌디어 제목은 필터링") + void testHindiTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("प्रोग्रामिंग सीखना"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "힌디어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("아랍어 제목은 필터링") + void testArabicTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("تعلم البرمجة"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "아랍어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("페르시아어 제목은 필터링") + void testPersianTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("خاله# تهران #شماره خاله# اصفهان"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "페르시아어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("페르시아어 + 한글 혼합 제목은 필터링") + void testPersianKoreanMixedTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("خاله# تهران #شماره خاله# اصفهان이런글자도"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "페르시아어/한글 혼합 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("러시아어 제목은 필터링") + void testRussianTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("Основы программирования"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "러시아어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("태국어 제목은 필터링") + void testThaiTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("การเขียนโปรแกรม"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "태국어 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + } + + @Test + @DisplayName("영어 + 한글 혼합 제목은 필터링") + void testMixedEnglishKorean() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn("Spring Boot 튜토리얼"); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "한글이 포함되어 있으면 필터링되어야 함"); + } + + @Test + @DisplayName("빈 제목은 필터링") + void testEmptyTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn(""); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "빈 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + assertEquals("Empty title", result.getReason()); + } + + @Test + @DisplayName("null 제목은 필터링") + void testNullTitle() { + // given + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn(null); + + // when + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + // then + assertFalse(result.isPassed(), "null 제목은 필터링되어야 함"); + assertEquals("LanguageFilter", result.getFilterName()); + assertEquals("Empty title", result.getReason()); + } + + @Test + @DisplayName("필터 이름 확인") + void testFilterName() { + // when + String filterName = filter.getName(); + + // then + assertEquals("LanguageFilter", filterName); + } + + @Test + @DisplayName("필터 순서 확인 - URL 체크 다음") + void testFilterOrder() { + // when + int order = filter.getOrder(); + + // then + assertEquals(20, order, "빠른 문자 체크이므로 우선순위가 높아야 함"); + } + + @Test + @DisplayName("실제 영어 뉴스 제목들 테스트") + void testRealEnglishNewsTitles() { + String[] englishTitles = { "OpenAI Releases GPT-5 with Revolutionary Features", + "How AI is Transforming Healthcare in 2024", "NASA Discovers New Exoplanet That Could Support Life", + "The Future of Quantum Computing: A Deep Dive", "Breaking: Tech Giants Announce Major Collaboration", + "10 Tips for Better Code Reviews 🚀", "Why JavaScript Still Dominates Web Development", + "Understanding Machine Learning Basics (Part 1)" }; + + for (String title : englishTitles) { + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn(title); + + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + assertTrue(result.isPassed(), "영어 뉴스 제목은 통과해야 함: " + title); + } + } + + @Test + @DisplayName("실제 비영어 뉴스 제목들 테스트") + void testRealNonEnglishNewsTitles() { + String[][] nonEnglishTitles = { { "한국어", "인공지능이 바꾸는 미래 사회" }, { "日本語", "新しいテクノロジーの世界" }, { "中文", "人工智能的未来发展" }, + { "हिन्दी", "तकनीकी विकास की नई दिशा" }, { "العربية", "مستقبل الذكاء الاصطناعي" }, + { "فارسی", "خاله# تهران #شماره خاله# اصفهان" }, { "Русский", "Будущее искусственного интеллекта" }, + { "ไทย", "อนาคตของปัญญาประดิษฐ์" } }; + + for (String[] titlePair : nonEnglishTitles) { + String language = titlePair[0]; + String title = titlePair[1]; + + SyndEntry entry = mock(SyndEntry.class); + when(entry.getTitle()).thenReturn(title); + + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + assertFalse(result.isPassed(), language + " 제목은 필터링되어야 함: " + title); + } + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeRssStructureTest.java b/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeRssStructureTest.java index 3aee50a9..de94103f 100644 --- a/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeRssStructureTest.java +++ b/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeRssStructureTest.java @@ -14,97 +14,99 @@ @DisplayName("YouTube RSS 구조 분석 테스트") class YouTubeRssStructureTest { - @Test - @DisplayName("YouTube RSS 피드의 media 네임스페이스 구조 분석") - void analyzeYouTubeRssStructure() throws Exception { - // given: Kurzgesagt YouTube 채널 RSS 피드 - String youtubeRssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; - - System.out.println("==========================================="); - System.out.println("=== YouTube RSS Structure Analysis ==="); - System.out.println("==========================================="); - System.out.println("YouTube RSS URL: " + youtubeRssUrl); - System.out.println(); - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(youtubeRssUrl))); - List entries = feed.getEntries(); - - if (entries.isEmpty()) { - System.out.println("No entries found!"); - return; - } - - // 첫 번째 엔트리 상세 분석 - SyndEntry firstEntry = entries.get(0); - - System.out.println("=== First Entry Details ==="); - System.out.println("Title: " + firstEntry.getTitle()); - System.out.println("Link: " + firstEntry.getLink()); - System.out.println(); - - System.out.println("=== Foreign Markup Analysis ==="); - if (firstEntry.getForeignMarkup() == null) { - System.out.println("No foreign markup found!"); - return; - } - - int elementCount = 0; - for (Object element : firstEntry.getForeignMarkup()) { - elementCount++; - System.out.println("\n--- Element #" + elementCount + " ---"); - - if (element instanceof Element) { - Element elem = (Element) element; - System.out.println("Element Name: " + elem.getName()); - System.out.println("Namespace URI: " + elem.getNamespaceURI()); - System.out.println("Namespace Prefix: " + elem.getNamespacePrefix()); - - // media:group인 경우 - if ("group".equals(elem.getName())) { - System.out.println("\n*** Found media:group ***"); - analyzeMediaGroup(elem); - } - } else { - System.out.println("Element type: " + element.getClass().getName()); - System.out.println("Element: " + element); - } - } - - System.out.println("\n==========================================="); - } - - private void analyzeMediaGroup(Element mediaGroup) { - System.out.println("Children of media:group:"); - - List children = mediaGroup.getChildren(); - for (Element child : children) { - System.out.println("\n - Child: " + child.getName()); - System.out.println(" Namespace: " + child.getNamespaceURI()); - - // Attributes - if (!child.getAttributes().isEmpty()) { - System.out.println(" Attributes:"); - child.getAttributes().forEach(attr -> { - System.out.println(" " + attr.getName() + " = " + attr.getValue()); - }); - } - - // Text content - String text = child.getTextTrim(); - if (!text.isEmpty()) { - System.out.println(" Text: " + (text.length() > 100 ? text.substring(0, 100) + "..." : text)); - } - - // media:content 특별 처리 - if ("content".equals(child.getName())) { - System.out.println("\n *** Found media:content ***"); - System.out.println(" All attributes:"); - child.getAttributes().forEach(attr -> { - System.out.println(" - " + attr.getName() + " = " + attr.getValue()); - }); - } - } - } + @Test + @DisplayName("YouTube RSS 피드의 media 네임스페이스 구조 분석") + void analyzeYouTubeRssStructure() throws Exception { + // given: Kurzgesagt YouTube 채널 RSS 피드 + String youtubeRssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; + + System.out.println("==========================================="); + System.out.println("=== YouTube RSS Structure Analysis ==="); + System.out.println("==========================================="); + System.out.println("YouTube RSS URL: " + youtubeRssUrl); + System.out.println(); + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(youtubeRssUrl))); + List entries = feed.getEntries(); + + if (entries.isEmpty()) { + System.out.println("No entries found!"); + return; + } + + // 첫 번째 엔트리 상세 분석 + SyndEntry firstEntry = entries.get(0); + + System.out.println("=== First Entry Details ==="); + System.out.println("Title: " + firstEntry.getTitle()); + System.out.println("Link: " + firstEntry.getLink()); + System.out.println(); + + System.out.println("=== Foreign Markup Analysis ==="); + if (firstEntry.getForeignMarkup() == null) { + System.out.println("No foreign markup found!"); + return; + } + + int elementCount = 0; + for (Object element : firstEntry.getForeignMarkup()) { + elementCount++; + System.out.println("\n--- Element #" + elementCount + " ---"); + + if (element instanceof Element) { + Element elem = (Element) element; + System.out.println("Element Name: " + elem.getName()); + System.out.println("Namespace URI: " + elem.getNamespaceURI()); + System.out.println("Namespace Prefix: " + elem.getNamespacePrefix()); + + // media:group인 경우 + if ("group".equals(elem.getName())) { + System.out.println("\n*** Found media:group ***"); + analyzeMediaGroup(elem); + } + } + else { + System.out.println("Element type: " + element.getClass().getName()); + System.out.println("Element: " + element); + } + } + + System.out.println("\n==========================================="); + } + + private void analyzeMediaGroup(Element mediaGroup) { + System.out.println("Children of media:group:"); + + List children = mediaGroup.getChildren(); + for (Element child : children) { + System.out.println("\n - Child: " + child.getName()); + System.out.println(" Namespace: " + child.getNamespaceURI()); + + // Attributes + if (!child.getAttributes().isEmpty()) { + System.out.println(" Attributes:"); + child.getAttributes().forEach(attr -> { + System.out.println(" " + attr.getName() + " = " + attr.getValue()); + }); + } + + // Text content + String text = child.getTextTrim(); + if (!text.isEmpty()) { + System.out.println(" Text: " + (text.length() > 100 ? text.substring(0, 100) + "..." : text)); + } + + // media:content 특별 처리 + if ("content".equals(child.getName())) { + System.out.println("\n *** Found media:content ***"); + System.out.println(" All attributes:"); + child.getAttributes().forEach(attr -> { + System.out.println(" - " + attr.getName() + " = " + attr.getValue()); + }); + } + } + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilterTest.java b/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilterTest.java index 78720ad2..67ba2e96 100644 --- a/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilterTest.java +++ b/src/test/java/com/linglevel/api/content/feed/filter/filters/YouTubeShortsFilterTest.java @@ -19,183 +19,183 @@ @DisplayName("YouTubeShortsFilter 테스트") class YouTubeShortsFilterTest { - private YouTubeShortsFilter filter; - - @BeforeEach - void setUp() { - filter = new YouTubeShortsFilter(); - } - - @Test - @DisplayName("실제 YouTube RSS 피드에서 duration 추출 및 Shorts 필터링 테스트") - void testRealYouTubeFeedWithDuration() throws Exception { - // given: Kurzgesagt YouTube 채널 RSS 피드 - String youtubeRssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; - - System.out.println("==========================================="); - System.out.println("=== YouTube Shorts Filter Test ==="); - System.out.println("==========================================="); - System.out.println("YouTube RSS URL: " + youtubeRssUrl); - System.out.println(); - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(youtubeRssUrl))); - List entries = feed.getEntries(); - - assertNotNull(entries, "RSS entries should not be null"); - assertFalse(entries.isEmpty(), "RSS entries should not be empty"); - - System.out.println("Total entries: " + entries.size()); - System.out.println(); - - FeedSource mockFeedSource = mock(FeedSource.class); - - // then: 각 엔트리에 대해 필터 테스트 - int totalEntries = 0; - int passedEntries = 0; - int filteredEntries = 0; - - for (SyndEntry entry : entries) { - totalEntries++; - String title = entry.getTitle(); - String url = entry.getLink(); - - // Duration 추출 (디버깅용) - Integer duration = extractDuration(entry); - - FeedFilterResult result = filter.filter(entry, mockFeedSource); - - System.out.println("-------------------------------------------"); - System.out.println("Entry #" + totalEntries); - System.out.println("Title: " + title); - System.out.println("URL: " + url); - System.out.println("Duration: " + (duration != null ? duration + "s" : "N/A")); - System.out.println("Result: " + (result.isPassed() ? "✅ PASS" : "❌ FILTERED")); - if (!result.isPassed()) { - System.out.println("Reason: " + result.getReason()); - filteredEntries++; - } else { - passedEntries++; - } - - // Duration이 60초 이하인 경우 필터링되어야 함 - if (duration != null && duration <= 60) { - assertFalse(result.isPassed(), - "Duration이 60초 이하인 영상은 필터링되어야 함: " + title); - assertEquals("YouTubeShortsFilter", result.getFilterName()); - assertTrue(result.getReason().contains("YouTube Shorts")); - } - } - - System.out.println("-------------------------------------------"); - System.out.println(); - System.out.println("=== Test Summary ==="); - System.out.println("Total entries: " + totalEntries); - System.out.println("Passed entries: " + passedEntries); - System.out.println("Filtered entries (Shorts): " + filteredEntries); - System.out.println("==========================================="); - } - - @Test - @DisplayName("URL이 null이면 통과") - void testNullUrl() { - // given: URL이 null - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn(null); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 통과해야 함 - assertTrue(result.isPassed(), "URL이 null이면 통과"); - } - - @Test - @DisplayName("ForeignMarkup이 없으면 통과") - void testNoForeignMarkup() { - // given: ForeignMarkup이 null - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn("https://youtube.com/watch?v=test"); - when(entry.getForeignMarkup()).thenReturn(null); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 통과해야 함 (duration 정보 없음) - assertTrue(result.isPassed(), "ForeignMarkup이 없으면 duration을 확인할 수 없으므로 통과"); - } - - @Test - @DisplayName("YouTube가 아닌 URL은 통과") - void testNonYouTubeUrl() { - // given: YouTube가 아닌 URL - SyndEntry entry = mock(SyndEntry.class); - when(entry.getLink()).thenReturn("https://example.com/shorts/test"); - - FeedSource feedSource = mock(FeedSource.class); - - // when: 필터 실행 - FeedFilterResult result = filter.filter(entry, feedSource); - - // then: 통과해야 함 (YouTube 필터는 YouTube에만 적용) - assertTrue(result.isPassed(), "YouTube가 아닌 URL은 필터 대상이 아님"); - } - - @Test - @DisplayName("필터 이름 확인") - void testFilterName() { - // when - String filterName = filter.getName(); - - // then - assertEquals("YouTubeShortsFilter", filterName); - } - - @Test - @DisplayName("필터 순서 확인 - URL 체크이므로 우선순위 높음") - void testFilterOrder() { - // when - int order = filter.getOrder(); - - // then - assertEquals(10, order, "빠른 체크이므로 우선순위가 높아야 함"); - } - - // Helper method to extract duration for debugging - private Integer extractDuration(SyndEntry entry) { - if (entry.getForeignMarkup() == null) { - return null; - } - - for (Object element : entry.getForeignMarkup()) { - if (element instanceof org.jdom2.Element) { - org.jdom2.Element elem = (org.jdom2.Element) element; - - if ("group".equals(elem.getName()) && - elem.getNamespaceURI() != null && - (elem.getNamespaceURI().contains("media") || - elem.getNamespaceURI().contains("mrss"))) { - - org.jdom2.Element content = elem.getChild("content", elem.getNamespace()); - if (content != null) { - String durationStr = content.getAttributeValue("duration"); - if (durationStr != null) { - try { - return Integer.parseInt(durationStr); - } catch (NumberFormatException e) { - return null; - } - } - } - } - } - } - - return null; - } + private YouTubeShortsFilter filter; + + @BeforeEach + void setUp() { + filter = new YouTubeShortsFilter(); + } + + @Test + @DisplayName("실제 YouTube RSS 피드에서 duration 추출 및 Shorts 필터링 테스트") + void testRealYouTubeFeedWithDuration() throws Exception { + // given: Kurzgesagt YouTube 채널 RSS 피드 + String youtubeRssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; + + System.out.println("==========================================="); + System.out.println("=== YouTube Shorts Filter Test ==="); + System.out.println("==========================================="); + System.out.println("YouTube RSS URL: " + youtubeRssUrl); + System.out.println(); + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(youtubeRssUrl))); + List entries = feed.getEntries(); + + assertNotNull(entries, "RSS entries should not be null"); + assertFalse(entries.isEmpty(), "RSS entries should not be empty"); + + System.out.println("Total entries: " + entries.size()); + System.out.println(); + + FeedSource mockFeedSource = mock(FeedSource.class); + + // then: 각 엔트리에 대해 필터 테스트 + int totalEntries = 0; + int passedEntries = 0; + int filteredEntries = 0; + + for (SyndEntry entry : entries) { + totalEntries++; + String title = entry.getTitle(); + String url = entry.getLink(); + + // Duration 추출 (디버깅용) + Integer duration = extractDuration(entry); + + FeedFilterResult result = filter.filter(entry, mockFeedSource); + + System.out.println("-------------------------------------------"); + System.out.println("Entry #" + totalEntries); + System.out.println("Title: " + title); + System.out.println("URL: " + url); + System.out.println("Duration: " + (duration != null ? duration + "s" : "N/A")); + System.out.println("Result: " + (result.isPassed() ? "✅ PASS" : "❌ FILTERED")); + if (!result.isPassed()) { + System.out.println("Reason: " + result.getReason()); + filteredEntries++; + } + else { + passedEntries++; + } + + // Duration이 60초 이하인 경우 필터링되어야 함 + if (duration != null && duration <= 60) { + assertFalse(result.isPassed(), "Duration이 60초 이하인 영상은 필터링되어야 함: " + title); + assertEquals("YouTubeShortsFilter", result.getFilterName()); + assertTrue(result.getReason().contains("YouTube Shorts")); + } + } + + System.out.println("-------------------------------------------"); + System.out.println(); + System.out.println("=== Test Summary ==="); + System.out.println("Total entries: " + totalEntries); + System.out.println("Passed entries: " + passedEntries); + System.out.println("Filtered entries (Shorts): " + filteredEntries); + System.out.println("==========================================="); + } + + @Test + @DisplayName("URL이 null이면 통과") + void testNullUrl() { + // given: URL이 null + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn(null); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 통과해야 함 + assertTrue(result.isPassed(), "URL이 null이면 통과"); + } + + @Test + @DisplayName("ForeignMarkup이 없으면 통과") + void testNoForeignMarkup() { + // given: ForeignMarkup이 null + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn("https://youtube.com/watch?v=test"); + when(entry.getForeignMarkup()).thenReturn(null); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 통과해야 함 (duration 정보 없음) + assertTrue(result.isPassed(), "ForeignMarkup이 없으면 duration을 확인할 수 없으므로 통과"); + } + + @Test + @DisplayName("YouTube가 아닌 URL은 통과") + void testNonYouTubeUrl() { + // given: YouTube가 아닌 URL + SyndEntry entry = mock(SyndEntry.class); + when(entry.getLink()).thenReturn("https://example.com/shorts/test"); + + FeedSource feedSource = mock(FeedSource.class); + + // when: 필터 실행 + FeedFilterResult result = filter.filter(entry, feedSource); + + // then: 통과해야 함 (YouTube 필터는 YouTube에만 적용) + assertTrue(result.isPassed(), "YouTube가 아닌 URL은 필터 대상이 아님"); + } + + @Test + @DisplayName("필터 이름 확인") + void testFilterName() { + // when + String filterName = filter.getName(); + + // then + assertEquals("YouTubeShortsFilter", filterName); + } + + @Test + @DisplayName("필터 순서 확인 - URL 체크이므로 우선순위 높음") + void testFilterOrder() { + // when + int order = filter.getOrder(); + + // then + assertEquals(10, order, "빠른 체크이므로 우선순위가 높아야 함"); + } + + // Helper method to extract duration for debugging + private Integer extractDuration(SyndEntry entry) { + if (entry.getForeignMarkup() == null) { + return null; + } + + for (Object element : entry.getForeignMarkup()) { + if (element instanceof org.jdom2.Element) { + org.jdom2.Element elem = (org.jdom2.Element) element; + + if ("group".equals(elem.getName()) && elem.getNamespaceURI() != null + && (elem.getNamespaceURI().contains("media") || elem.getNamespaceURI().contains("mrss"))) { + + org.jdom2.Element content = elem.getChild("content", elem.getNamespace()); + if (content != null) { + String durationStr = content.getAttributeValue("duration"); + if (durationStr != null) { + try { + return Integer.parseInt(durationStr); + } + catch (NumberFormatException e) { + return null; + } + } + } + } + } + } + + return null; + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/service/DevDebugTest.java b/src/test/java/com/linglevel/api/content/feed/service/DevDebugTest.java index df687350..16f99499 100644 --- a/src/test/java/com/linglevel/api/content/feed/service/DevDebugTest.java +++ b/src/test/java/com/linglevel/api/content/feed/service/DevDebugTest.java @@ -14,160 +14,167 @@ @DisplayName("Dev 환경 디버깅용 테스트") class DevDebugTest { - @Test - @DisplayName("extractThumbnailUrl 로직 상세 디버깅") - void debugExtractThumbnailUrl() throws Exception { - String rssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; - - System.out.println("==========================================="); - System.out.println("=== extractThumbnailUrl 디버깅 ==="); - System.out.println("===========================================\n"); - - // RSS 파싱 - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed rssFeed = input.build(new XmlReader(new URL(rssUrl))); - - SyndEntry entry = rssFeed.getEntries().get(0); - - System.out.println("Entry: " + entry.getTitle()); - System.out.println("URL: " + entry.getLink()); - System.out.println(); - - FeedSource feedSource = FeedSource.builder() - .url(rssUrl) - .coverImageDsl("D'meta[property=\"og:image\"]'@'content'") - .build(); - - // extractThumbnailUrl 로직 재현 (상세 로그 포함) - String result = extractThumbnailUrlWithDebugLogs(entry, entry.getLink(), feedSource); - - System.out.println("\n==========================================="); - System.out.println("최종 결과: " + result); - System.out.println("==========================================="); - } - - private String extractThumbnailUrlWithDebugLogs(SyndEntry entry, String articleUrl, FeedSource feedSource) { - // 1. Enclosures - System.out.println("[STEP 1] Enclosures 확인"); - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - System.out.println(" Enclosures 개수: " + entry.getEnclosures().size()); - String rssThumbnail = entry.getEnclosures().stream() - .filter(enc -> enc.getType() != null && enc.getType().startsWith("image/")) - .map(enc -> enc.getUrl()) - .findFirst() - .orElse(null); - if (rssThumbnail != null) { - System.out.println(" ✓ Enclosures에서 발견: " + rssThumbnail); - return rssThumbnail; - } - } - System.out.println(" ✗ Enclosures 없음"); - System.out.println(); - - // 2. Media module - System.out.println("[STEP 2] Media 모듈 확인"); - try { - if (entry.getForeignMarkup() != null) { - System.out.println(" ForeignMarkup 개수: " + entry.getForeignMarkup().size()); - - for (int i = 0; i < entry.getForeignMarkup().size(); i++) { - Object element = entry.getForeignMarkup().get(i); - System.out.println(" [" + i + "] " + element.getClass().getName()); - - if (element instanceof Element) { - Element elem = (Element) element; - System.out.println(" Name: " + elem.getName()); - System.out.println(" Namespace: " + elem.getNamespaceURI()); - System.out.println(" Prefix: " + elem.getNamespacePrefix()); - - if ("group".equals(elem.getName())) { - System.out.println(" ✓✓ media:group 발견!"); - - if (elem.getNamespaceURI() != null && elem.getNamespaceURI().contains("media")) { - System.out.println(" ✓✓✓ Namespace에 'media' 포함됨"); - - // 방법 1 - System.out.println(" [방법 1] getChild(\"thumbnail\", namespace)"); - Element thumbnail = elem.getChild("thumbnail", elem.getNamespace()); - System.out.println(" 결과: " + thumbnail); - if (thumbnail != null) { - String url = thumbnail.getAttributeValue("url"); - System.out.println(" URL: " + url); - if (url != null) { - System.out.println(" ✓ 성공!"); - return url; - } - } - - // 방법 2 - System.out.println(" [방법 2] getChildren() 순회"); - System.out.println(" 자식 개수: " + elem.getChildren().size()); - for (Object child : elem.getChildren()) { - if (child instanceof Element) { - Element childElem = (Element) child; - System.out.println(" - " + childElem.getName() + - " (Namespace: " + childElem.getNamespaceURI() + ")"); - if ("thumbnail".equals(childElem.getName())) { - String url = childElem.getAttributeValue("url"); - System.out.println(" ✓ thumbnail 발견! URL: " + url); - if (url != null) { - return url; - } - } - } - } - - System.out.println(" ✗ thumbnail을 찾지 못함"); - } else { - System.out.println(" ✗ Namespace에 'media'가 없음"); - } - } - } - } - } else { - System.out.println(" ✗ ForeignMarkup이 null"); - } - } catch (Exception e) { - System.out.println(" ✗ 에러 발생: " + e.getMessage()); - e.printStackTrace(); - } - System.out.println(); - - // 3. DSL 크롤링 - System.out.println("[STEP 3] DSL 크롤링"); - System.out.println(" coverImageDsl: " + feedSource.getCoverImageDsl()); - if (feedSource.getCoverImageDsl() != null && !feedSource.getCoverImageDsl().trim().isEmpty()) { - System.out.println(" DSL 크롤링 시뮬레이션 (실제 크롤링은 생략)"); - // 실제로는 Jsoup.connect()를 하지만 여기서는 생략 - } else { - System.out.println(" ✗ coverImageDsl이 null이거나 비어있음"); - } - - return null; - } - - @Test - @DisplayName("Java/Rome 버전 확인") - void checkVersions() { - System.out.println("==========================================="); - System.out.println("=== 환경 정보 ==="); - System.out.println("==========================================="); - System.out.println("Java Version: " + System.getProperty("java.version")); - System.out.println("Java Vendor: " + System.getProperty("java.vendor")); - System.out.println("OS: " + System.getProperty("os.name") + " " + System.getProperty("os.version")); - System.out.println(); - - // Rome 버전 확인 - try { - Package pkg = com.rometools.rome.feed.synd.SyndFeed.class.getPackage(); - System.out.println("Rome Package: " + pkg.getName()); - System.out.println("Rome Implementation Version: " + pkg.getImplementationVersion()); - System.out.println("Rome Specification Version: " + pkg.getSpecificationVersion()); - } catch (Exception e) { - System.out.println("Rome 버전 확인 실패: " + e.getMessage()); - } - - System.out.println("==========================================="); - } + @Test + @DisplayName("extractThumbnailUrl 로직 상세 디버깅") + void debugExtractThumbnailUrl() throws Exception { + String rssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; + + System.out.println("==========================================="); + System.out.println("=== extractThumbnailUrl 디버깅 ==="); + System.out.println("===========================================\n"); + + // RSS 파싱 + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed rssFeed = input.build(new XmlReader(new URL(rssUrl))); + + SyndEntry entry = rssFeed.getEntries().get(0); + + System.out.println("Entry: " + entry.getTitle()); + System.out.println("URL: " + entry.getLink()); + System.out.println(); + + FeedSource feedSource = FeedSource.builder() + .url(rssUrl) + .coverImageDsl("D'meta[property=\"og:image\"]'@'content'") + .build(); + + // extractThumbnailUrl 로직 재현 (상세 로그 포함) + String result = extractThumbnailUrlWithDebugLogs(entry, entry.getLink(), feedSource); + + System.out.println("\n==========================================="); + System.out.println("최종 결과: " + result); + System.out.println("==========================================="); + } + + private String extractThumbnailUrlWithDebugLogs(SyndEntry entry, String articleUrl, FeedSource feedSource) { + // 1. Enclosures + System.out.println("[STEP 1] Enclosures 확인"); + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + System.out.println(" Enclosures 개수: " + entry.getEnclosures().size()); + String rssThumbnail = entry.getEnclosures() + .stream() + .filter(enc -> enc.getType() != null && enc.getType().startsWith("image/")) + .map(enc -> enc.getUrl()) + .findFirst() + .orElse(null); + if (rssThumbnail != null) { + System.out.println(" ✓ Enclosures에서 발견: " + rssThumbnail); + return rssThumbnail; + } + } + System.out.println(" ✗ Enclosures 없음"); + System.out.println(); + + // 2. Media module + System.out.println("[STEP 2] Media 모듈 확인"); + try { + if (entry.getForeignMarkup() != null) { + System.out.println(" ForeignMarkup 개수: " + entry.getForeignMarkup().size()); + + for (int i = 0; i < entry.getForeignMarkup().size(); i++) { + Object element = entry.getForeignMarkup().get(i); + System.out.println(" [" + i + "] " + element.getClass().getName()); + + if (element instanceof Element) { + Element elem = (Element) element; + System.out.println(" Name: " + elem.getName()); + System.out.println(" Namespace: " + elem.getNamespaceURI()); + System.out.println(" Prefix: " + elem.getNamespacePrefix()); + + if ("group".equals(elem.getName())) { + System.out.println(" ✓✓ media:group 발견!"); + + if (elem.getNamespaceURI() != null && elem.getNamespaceURI().contains("media")) { + System.out.println(" ✓✓✓ Namespace에 'media' 포함됨"); + + // 방법 1 + System.out.println(" [방법 1] getChild(\"thumbnail\", namespace)"); + Element thumbnail = elem.getChild("thumbnail", elem.getNamespace()); + System.out.println(" 결과: " + thumbnail); + if (thumbnail != null) { + String url = thumbnail.getAttributeValue("url"); + System.out.println(" URL: " + url); + if (url != null) { + System.out.println(" ✓ 성공!"); + return url; + } + } + + // 방법 2 + System.out.println(" [방법 2] getChildren() 순회"); + System.out.println(" 자식 개수: " + elem.getChildren().size()); + for (Object child : elem.getChildren()) { + if (child instanceof Element) { + Element childElem = (Element) child; + System.out.println(" - " + childElem.getName() + " (Namespace: " + + childElem.getNamespaceURI() + ")"); + if ("thumbnail".equals(childElem.getName())) { + String url = childElem.getAttributeValue("url"); + System.out.println(" ✓ thumbnail 발견! URL: " + url); + if (url != null) { + return url; + } + } + } + } + + System.out.println(" ✗ thumbnail을 찾지 못함"); + } + else { + System.out.println(" ✗ Namespace에 'media'가 없음"); + } + } + } + } + } + else { + System.out.println(" ✗ ForeignMarkup이 null"); + } + } + catch (Exception e) { + System.out.println(" ✗ 에러 발생: " + e.getMessage()); + e.printStackTrace(); + } + System.out.println(); + + // 3. DSL 크롤링 + System.out.println("[STEP 3] DSL 크롤링"); + System.out.println(" coverImageDsl: " + feedSource.getCoverImageDsl()); + if (feedSource.getCoverImageDsl() != null && !feedSource.getCoverImageDsl().trim().isEmpty()) { + System.out.println(" DSL 크롤링 시뮬레이션 (실제 크롤링은 생략)"); + // 실제로는 Jsoup.connect()를 하지만 여기서는 생략 + } + else { + System.out.println(" ✗ coverImageDsl이 null이거나 비어있음"); + } + + return null; + } + + @Test + @DisplayName("Java/Rome 버전 확인") + void checkVersions() { + System.out.println("==========================================="); + System.out.println("=== 환경 정보 ==="); + System.out.println("==========================================="); + System.out.println("Java Version: " + System.getProperty("java.version")); + System.out.println("Java Vendor: " + System.getProperty("java.vendor")); + System.out.println("OS: " + System.getProperty("os.name") + " " + System.getProperty("os.version")); + System.out.println(); + + // Rome 버전 확인 + try { + Package pkg = com.rometools.rome.feed.synd.SyndFeed.class.getPackage(); + System.out.println("Rome Package: " + pkg.getName()); + System.out.println("Rome Implementation Version: " + pkg.getImplementationVersion()); + System.out.println("Rome Specification Version: " + pkg.getSpecificationVersion()); + } + catch (Exception e) { + System.out.println("Rome 버전 확인 실패: " + e.getMessage()); + } + + System.out.println("==========================================="); + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/service/FeedCrawlingServiceTest.java b/src/test/java/com/linglevel/api/content/feed/service/FeedCrawlingServiceTest.java index 704f69a7..60e4f5a0 100644 --- a/src/test/java/com/linglevel/api/content/feed/service/FeedCrawlingServiceTest.java +++ b/src/test/java/com/linglevel/api/content/feed/service/FeedCrawlingServiceTest.java @@ -15,418 +15,420 @@ @DisplayName("RSS Feed 크롤링 테스트") class FeedCrawlingServiceTest { - @Test - @DisplayName("BBC Technology RSS 피드 파싱") - void parseBbcTechnologyRss() throws Exception { - // given: BBC Technology RSS URL - String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertNotNull(feed.getTitle()); - assertFalse(entries.isEmpty()); - - System.out.println("=== BBC Technology RSS Feed ==="); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Feed Description: " + feed.getDescription()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 첫 5개 엔트리 출력 - entries.stream().limit(5).forEach(entry -> { - System.out.println("--- Entry ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Published: " + entry.getPublishedDate()); - System.out.println("Updated: " + entry.getUpdatedDate()); - - // Description - if (entry.getDescription() != null) { - String desc = entry.getDescription().getValue(); - System.out.println("Description: " + (desc.length() > 100 ? desc.substring(0, 100) + "..." : desc)); - } - - // Enclosures (썸네일) - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - entry.getEnclosures().forEach(enc -> { - System.out.println("Enclosure: " + enc.getUrl() + " (Type: " + enc.getType() + ")"); - }); - } - System.out.println(); - }); - - // 각 엔트리 검증 - SyndEntry firstEntry = entries.get(0); - assertNotNull(firstEntry.getTitle()); - assertNotNull(firstEntry.getLink()); - assertTrue(firstEntry.getLink().startsWith("http")); - } - - @Test - @DisplayName("TechCrunch RSS 피드 파싱") - void parseTechCrunchRss() throws Exception { - // given: TechCrunch RSS URL - String rssUrl = "https://techcrunch.com/feed/"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertNotNull(feed.getTitle()); - assertFalse(entries.isEmpty()); - - System.out.println("=== TechCrunch RSS Feed ==="); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 첫 3개 엔트리 출력 - entries.stream().limit(3).forEach(entry -> { - System.out.println("--- Entry ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Published: " + entry.getPublishedDate()); - - // Enclosures (썸네일) - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - entry.getEnclosures().forEach(enc -> { - System.out.println("Thumbnail: " + enc.getUrl()); - }); - } - System.out.println(); - }); - - SyndEntry firstEntry = entries.get(0); - assertNotNull(firstEntry.getTitle()); - assertNotNull(firstEntry.getLink()); - } - - @Test - @DisplayName("Hacker News RSS 피드 파싱") - void parseHackerNewsRss() throws Exception { - // given: Hacker News RSS URL - String rssUrl = "https://news.ycombinator.com/rss"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertFalse(entries.isEmpty()); - - System.out.println("=== Hacker News RSS Feed ==="); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 첫 5개 엔트리 출력 - entries.stream().limit(5).forEach(entry -> { - System.out.println("--- Entry ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println(); - }); - } - - @Test - @DisplayName("The Verge RSS 피드 파싱") - void parseTheVergeRss() throws Exception { - // given: The Verge RSS URL - String rssUrl = "https://www.theverge.com/rss/index.xml"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertFalse(entries.isEmpty()); - - System.out.println("=== The Verge RSS Feed ==="); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 첫 3개 엔트리 출력 - entries.stream().limit(3).forEach(entry -> { - System.out.println("--- Entry ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Published: " + entry.getPublishedDate()); - - // Enclosures (썸네일) 확인 - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - entry.getEnclosures().forEach(enc -> { - System.out.println("Thumbnail: " + enc.getUrl() + " (Type: " + enc.getType() + ")"); - }); - } - System.out.println(); - }); - } - - @Test - @DisplayName("RSS 피드 썸네일 추출 테스트") - void extractThumbnailsFromRss() throws Exception { - // given: 다양한 RSS 피드들 - String[] rssUrls = { - "https://feeds.bbci.co.uk/news/technology/rss.xml", - "https://techcrunch.com/feed/", - "https://www.theverge.com/rss/index.xml" - }; - - System.out.println("=== RSS 피드별 썸네일 추출 테스트 ===\n"); - - for (String rssUrl : rssUrls) { - try { - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("Feed: " + feed.getTitle()); - System.out.println("URL: " + rssUrl); - - long thumbnailCount = entries.stream() - .filter(entry -> entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) - .filter(entry -> entry.getEnclosures().stream() - .anyMatch(enc -> enc.getType() != null && enc.getType().startsWith("image/"))) - .count(); - - System.out.println("Total Entries: " + entries.size()); - System.out.println("Entries with Thumbnails: " + thumbnailCount); - System.out.println("Thumbnail Rate: " + String.format("%.1f%%", (thumbnailCount * 100.0 / entries.size()))); - System.out.println(); - - } catch (Exception e) { - System.out.println("Failed to parse: " + rssUrl); - System.out.println("Error: " + e.getMessage()); - System.out.println(); - } - } - } - - @Test - @DisplayName("RSS 피드 발행일 포맷 확인") - void checkPublishDateFormats() throws Exception { - // given: BBC RSS URL - String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("=== RSS 발행일 포맷 확인 ===\n"); - - entries.stream().limit(5).forEach(entry -> { - System.out.println("Title: " + entry.getTitle()); - System.out.println("Published Date: " + entry.getPublishedDate()); - System.out.println("Updated Date: " + entry.getUpdatedDate()); - - if (entry.getPublishedDate() != null) { - System.out.println("Published Instant: " + entry.getPublishedDate().toInstant()); - } else if (entry.getUpdatedDate() != null) { - System.out.println("Updated Instant: " + entry.getUpdatedDate().toInstant()); - } - System.out.println(); - }); - } - - @Test - @DisplayName("BBC RSS description 추출 테스트") - void extractDescriptionFromBbc() throws Exception { - // given: BBC RSS URL - String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("=== BBC RSS Description 추출 테스트 ===\n"); - - // 첫 3개 엔트리의 description 확인 - entries.stream().limit(3).forEach(entry -> { - System.out.println("--- Entry ---"); - System.out.println("Title: " + entry.getTitle()); - - // Description 확인 - if (entry.getDescription() != null && entry.getDescription().getValue() != null) { - String description = entry.getDescription().getValue(); - String cleaned = description.replaceAll("<[^>]*>", "").trim(); - cleaned = cleaned.replaceAll("", "").trim(); - System.out.println("Description (원본): " + description); - System.out.println("Description (정제): " + cleaned); - assertNotNull(cleaned); - assertFalse(cleaned.isEmpty()); - } else { - System.out.println("Description: (없음)"); - } - System.out.println(); - }); - } - - @Test - @DisplayName("YouTube RSS description 추출 테스트") - void extractDescriptionFromYouTube() throws Exception { - // given: YouTube 채널 RSS URL (Kurzgesagt) - String rssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("=== YouTube RSS Description 추출 테스트 ===\n"); - System.out.println("Channel: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 첫 3개 엔트리의 description 확인 - entries.stream().limit(3).forEach(entry -> { - System.out.println("--- Video Entry ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - - // 1. 일반 Description 확인 - if (entry.getDescription() != null && entry.getDescription().getValue() != null) { - String description = entry.getDescription().getValue(); - System.out.println("Standard Description: " + - (description.length() > 100 ? description.substring(0, 100) + "..." : description)); - } - - // 2. media:description 확인 - if (entry.getForeignMarkup() != null) { - System.out.println("ForeignMarkup elements: " + entry.getForeignMarkup().size()); - for (Object element : entry.getForeignMarkup()) { - if (element instanceof org.jdom2.Element) { - org.jdom2.Element elem = (org.jdom2.Element) element; - System.out.println(" - Element: " + elem.getName() + " (namespace: " + elem.getNamespaceURI() + ")"); - - if ("group".equals(elem.getName())) { - // media:description 찾기 - org.jdom2.Element descElem = elem.getChild("description", elem.getNamespace()); - if (descElem != null) { - String mediaDesc = descElem.getText(); - System.out.println("media:description (길이: " + mediaDesc.length() + "자): "); - System.out.println(mediaDesc.substring(0, Math.min(200, mediaDesc.length())) + "..."); - assertNotNull(mediaDesc); - assertFalse(mediaDesc.isEmpty()); - } - } - } - } - } - System.out.println(); - }); - } - - @Test - @DisplayName("Medium RSS description 추출 테스트") - void extractDescriptionFromMedium() throws Exception { - // given: Medium RSS URL (Programming tag) - String rssUrl = "https://medium.com/feed/tag/programming"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("=== Medium RSS Description 추출 테스트 ===\n"); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 첫 3개 엔트리의 description 확인 - entries.stream().limit(3).forEach(entry -> { - System.out.println("--- Article Entry ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - - // Description 확인 - if (entry.getDescription() != null && entry.getDescription().getValue() != null) { - String description = entry.getDescription().getValue(); - String cleaned = description.replaceAll("<[^>]*>", "").trim(); - System.out.println("Description (정제 전 길이): " + description.length()); - System.out.println("Description (정제 후): " + - (cleaned.length() > 150 ? cleaned.substring(0, 150) + "..." : cleaned)); - assertNotNull(cleaned); - assertFalse(cleaned.isEmpty()); - } - - // Content 확인 - if (entry.getContents() != null && !entry.getContents().isEmpty()) { - String content = entry.getContents().get(0).getValue(); - System.out.println("Content 존재: " + (content != null ? "Yes (길이: " + content.length() + ")" : "No")); - } - System.out.println(); - }); - } - - @Test - @DisplayName("다양한 RSS 피드 Description 추출 종합 테스트") - void extractDescriptionFromMultipleSources() throws Exception { - // given: 다양한 RSS 피드들 - String[][] rssSources = { - {"BBC", "https://feeds.bbci.co.uk/news/technology/rss.xml"}, - {"YouTube", "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"}, - {"Medium", "https://medium.com/feed/tag/programming"} - }; - - System.out.println("=== 다양한 RSS 피드 Description 추출 종합 테스트 ===\n"); - - for (String[] source : rssSources) { - String sourceName = source[0]; - String rssUrl = source[1]; - - try { - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("📰 " + sourceName + " (" + rssUrl + ")"); - System.out.println(" Total Entries: " + entries.size()); - - // description 추출 가능한 엔트리 카운트 - long withStandardDesc = entries.stream() - .filter(entry -> entry.getDescription() != null && - entry.getDescription().getValue() != null && - !entry.getDescription().getValue().trim().isEmpty()) - .count(); - - long withContent = entries.stream() - .filter(entry -> entry.getContents() != null && !entry.getContents().isEmpty()) - .count(); - - long withMediaDesc = entries.stream() - .filter(entry -> entry.getForeignMarkup() != null && !entry.getForeignMarkup().isEmpty()) - .count(); - - System.out.println(" With Standard Description: " + withStandardDesc + - " (" + String.format("%.1f%%", withStandardDesc * 100.0 / entries.size()) + ")"); - System.out.println(" With Content: " + withContent + - " (" + String.format("%.1f%%", withContent * 100.0 / entries.size()) + ")"); - System.out.println(" With Media Elements: " + withMediaDesc + - " (" + String.format("%.1f%%", withMediaDesc * 100.0 / entries.size()) + ")"); - System.out.println(); - - } catch (Exception e) { - System.out.println("❌ " + sourceName + " - Failed to parse"); - System.out.println(" Error: " + e.getMessage()); - System.out.println(); - } - } - } + @Test + @DisplayName("BBC Technology RSS 피드 파싱") + void parseBbcTechnologyRss() throws Exception { + // given: BBC Technology RSS URL + String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertNotNull(feed.getTitle()); + assertFalse(entries.isEmpty()); + + System.out.println("=== BBC Technology RSS Feed ==="); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Feed Description: " + feed.getDescription()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 첫 5개 엔트리 출력 + entries.stream().limit(5).forEach(entry -> { + System.out.println("--- Entry ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Published: " + entry.getPublishedDate()); + System.out.println("Updated: " + entry.getUpdatedDate()); + + // Description + if (entry.getDescription() != null) { + String desc = entry.getDescription().getValue(); + System.out.println("Description: " + (desc.length() > 100 ? desc.substring(0, 100) + "..." : desc)); + } + + // Enclosures (썸네일) + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + entry.getEnclosures().forEach(enc -> { + System.out.println("Enclosure: " + enc.getUrl() + " (Type: " + enc.getType() + ")"); + }); + } + System.out.println(); + }); + + // 각 엔트리 검증 + SyndEntry firstEntry = entries.get(0); + assertNotNull(firstEntry.getTitle()); + assertNotNull(firstEntry.getLink()); + assertTrue(firstEntry.getLink().startsWith("http")); + } + + @Test + @DisplayName("TechCrunch RSS 피드 파싱") + void parseTechCrunchRss() throws Exception { + // given: TechCrunch RSS URL + String rssUrl = "https://techcrunch.com/feed/"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertNotNull(feed.getTitle()); + assertFalse(entries.isEmpty()); + + System.out.println("=== TechCrunch RSS Feed ==="); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 첫 3개 엔트리 출력 + entries.stream().limit(3).forEach(entry -> { + System.out.println("--- Entry ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Published: " + entry.getPublishedDate()); + + // Enclosures (썸네일) + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + entry.getEnclosures().forEach(enc -> { + System.out.println("Thumbnail: " + enc.getUrl()); + }); + } + System.out.println(); + }); + + SyndEntry firstEntry = entries.get(0); + assertNotNull(firstEntry.getTitle()); + assertNotNull(firstEntry.getLink()); + } + + @Test + @DisplayName("Hacker News RSS 피드 파싱") + void parseHackerNewsRss() throws Exception { + // given: Hacker News RSS URL + String rssUrl = "https://news.ycombinator.com/rss"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertFalse(entries.isEmpty()); + + System.out.println("=== Hacker News RSS Feed ==="); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 첫 5개 엔트리 출력 + entries.stream().limit(5).forEach(entry -> { + System.out.println("--- Entry ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println(); + }); + } + + @Test + @DisplayName("The Verge RSS 피드 파싱") + void parseTheVergeRss() throws Exception { + // given: The Verge RSS URL + String rssUrl = "https://www.theverge.com/rss/index.xml"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertFalse(entries.isEmpty()); + + System.out.println("=== The Verge RSS Feed ==="); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 첫 3개 엔트리 출력 + entries.stream().limit(3).forEach(entry -> { + System.out.println("--- Entry ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Published: " + entry.getPublishedDate()); + + // Enclosures (썸네일) 확인 + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + entry.getEnclosures().forEach(enc -> { + System.out.println("Thumbnail: " + enc.getUrl() + " (Type: " + enc.getType() + ")"); + }); + } + System.out.println(); + }); + } + + @Test + @DisplayName("RSS 피드 썸네일 추출 테스트") + void extractThumbnailsFromRss() throws Exception { + // given: 다양한 RSS 피드들 + String[] rssUrls = { "https://feeds.bbci.co.uk/news/technology/rss.xml", "https://techcrunch.com/feed/", + "https://www.theverge.com/rss/index.xml" }; + + System.out.println("=== RSS 피드별 썸네일 추출 테스트 ===\n"); + + for (String rssUrl : rssUrls) { + try { + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("Feed: " + feed.getTitle()); + System.out.println("URL: " + rssUrl); + + long thumbnailCount = entries.stream() + .filter(entry -> entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) + .filter(entry -> entry.getEnclosures() + .stream() + .anyMatch(enc -> enc.getType() != null && enc.getType().startsWith("image/"))) + .count(); + + System.out.println("Total Entries: " + entries.size()); + System.out.println("Entries with Thumbnails: " + thumbnailCount); + System.out + .println("Thumbnail Rate: " + String.format("%.1f%%", (thumbnailCount * 100.0 / entries.size()))); + System.out.println(); + + } + catch (Exception e) { + System.out.println("Failed to parse: " + rssUrl); + System.out.println("Error: " + e.getMessage()); + System.out.println(); + } + } + } + + @Test + @DisplayName("RSS 피드 발행일 포맷 확인") + void checkPublishDateFormats() throws Exception { + // given: BBC RSS URL + String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("=== RSS 발행일 포맷 확인 ===\n"); + + entries.stream().limit(5).forEach(entry -> { + System.out.println("Title: " + entry.getTitle()); + System.out.println("Published Date: " + entry.getPublishedDate()); + System.out.println("Updated Date: " + entry.getUpdatedDate()); + + if (entry.getPublishedDate() != null) { + System.out.println("Published Instant: " + entry.getPublishedDate().toInstant()); + } + else if (entry.getUpdatedDate() != null) { + System.out.println("Updated Instant: " + entry.getUpdatedDate().toInstant()); + } + System.out.println(); + }); + } + + @Test + @DisplayName("BBC RSS description 추출 테스트") + void extractDescriptionFromBbc() throws Exception { + // given: BBC RSS URL + String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("=== BBC RSS Description 추출 테스트 ===\n"); + + // 첫 3개 엔트리의 description 확인 + entries.stream().limit(3).forEach(entry -> { + System.out.println("--- Entry ---"); + System.out.println("Title: " + entry.getTitle()); + + // Description 확인 + if (entry.getDescription() != null && entry.getDescription().getValue() != null) { + String description = entry.getDescription().getValue(); + String cleaned = description.replaceAll("<[^>]*>", "").trim(); + cleaned = cleaned.replaceAll("", "").trim(); + System.out.println("Description (원본): " + description); + System.out.println("Description (정제): " + cleaned); + assertNotNull(cleaned); + assertFalse(cleaned.isEmpty()); + } + else { + System.out.println("Description: (없음)"); + } + System.out.println(); + }); + } + + @Test + @DisplayName("YouTube RSS description 추출 테스트") + void extractDescriptionFromYouTube() throws Exception { + // given: YouTube 채널 RSS URL (Kurzgesagt) + String rssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("=== YouTube RSS Description 추출 테스트 ===\n"); + System.out.println("Channel: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 첫 3개 엔트리의 description 확인 + entries.stream().limit(3).forEach(entry -> { + System.out.println("--- Video Entry ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + + // 1. 일반 Description 확인 + if (entry.getDescription() != null && entry.getDescription().getValue() != null) { + String description = entry.getDescription().getValue(); + System.out.println("Standard Description: " + + (description.length() > 100 ? description.substring(0, 100) + "..." : description)); + } + + // 2. media:description 확인 + if (entry.getForeignMarkup() != null) { + System.out.println("ForeignMarkup elements: " + entry.getForeignMarkup().size()); + for (Object element : entry.getForeignMarkup()) { + if (element instanceof org.jdom2.Element) { + org.jdom2.Element elem = (org.jdom2.Element) element; + System.out + .println(" - Element: " + elem.getName() + " (namespace: " + elem.getNamespaceURI() + ")"); + + if ("group".equals(elem.getName())) { + // media:description 찾기 + org.jdom2.Element descElem = elem.getChild("description", elem.getNamespace()); + if (descElem != null) { + String mediaDesc = descElem.getText(); + System.out.println("media:description (길이: " + mediaDesc.length() + "자): "); + System.out.println(mediaDesc.substring(0, Math.min(200, mediaDesc.length())) + "..."); + assertNotNull(mediaDesc); + assertFalse(mediaDesc.isEmpty()); + } + } + } + } + } + System.out.println(); + }); + } + + @Test + @DisplayName("Medium RSS description 추출 테스트") + void extractDescriptionFromMedium() throws Exception { + // given: Medium RSS URL (Programming tag) + String rssUrl = "https://medium.com/feed/tag/programming"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("=== Medium RSS Description 추출 테스트 ===\n"); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 첫 3개 엔트리의 description 확인 + entries.stream().limit(3).forEach(entry -> { + System.out.println("--- Article Entry ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + + // Description 확인 + if (entry.getDescription() != null && entry.getDescription().getValue() != null) { + String description = entry.getDescription().getValue(); + String cleaned = description.replaceAll("<[^>]*>", "").trim(); + System.out.println("Description (정제 전 길이): " + description.length()); + System.out.println("Description (정제 후): " + + (cleaned.length() > 150 ? cleaned.substring(0, 150) + "..." : cleaned)); + assertNotNull(cleaned); + assertFalse(cleaned.isEmpty()); + } + + // Content 확인 + if (entry.getContents() != null && !entry.getContents().isEmpty()) { + String content = entry.getContents().get(0).getValue(); + System.out.println("Content 존재: " + (content != null ? "Yes (길이: " + content.length() + ")" : "No")); + } + System.out.println(); + }); + } + + @Test + @DisplayName("다양한 RSS 피드 Description 추출 종합 테스트") + void extractDescriptionFromMultipleSources() throws Exception { + // given: 다양한 RSS 피드들 + String[][] rssSources = { { "BBC", "https://feeds.bbci.co.uk/news/technology/rss.xml" }, + { "YouTube", "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q" }, + { "Medium", "https://medium.com/feed/tag/programming" } }; + + System.out.println("=== 다양한 RSS 피드 Description 추출 종합 테스트 ===\n"); + + for (String[] source : rssSources) { + String sourceName = source[0]; + String rssUrl = source[1]; + + try { + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("📰 " + sourceName + " (" + rssUrl + ")"); + System.out.println(" Total Entries: " + entries.size()); + + // description 추출 가능한 엔트리 카운트 + long withStandardDesc = entries.stream() + .filter(entry -> entry.getDescription() != null && entry.getDescription().getValue() != null + && !entry.getDescription().getValue().trim().isEmpty()) + .count(); + + long withContent = entries.stream() + .filter(entry -> entry.getContents() != null && !entry.getContents().isEmpty()) + .count(); + + long withMediaDesc = entries.stream() + .filter(entry -> entry.getForeignMarkup() != null && !entry.getForeignMarkup().isEmpty()) + .count(); + + System.out.println(" With Standard Description: " + withStandardDesc + " (" + + String.format("%.1f%%", withStandardDesc * 100.0 / entries.size()) + ")"); + System.out.println(" With Content: " + withContent + " (" + + String.format("%.1f%%", withContent * 100.0 / entries.size()) + ")"); + System.out.println(" With Media Elements: " + withMediaDesc + " (" + + String.format("%.1f%%", withMediaDesc * 100.0 / entries.size()) + ")"); + System.out.println(); + + } + catch (Exception e) { + System.out.println("❌ " + sourceName + " - Failed to parse"); + System.out.println(" Error: " + e.getMessage()); + System.out.println(); + } + } + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/service/FeedDescriptionExtractionTest.java b/src/test/java/com/linglevel/api/content/feed/service/FeedDescriptionExtractionTest.java index 9f85d86f..b5e05c48 100644 --- a/src/test/java/com/linglevel/api/content/feed/service/FeedDescriptionExtractionTest.java +++ b/src/test/java/com/linglevel/api/content/feed/service/FeedDescriptionExtractionTest.java @@ -16,184 +16,190 @@ @DisplayName("Feed Description 추출 통합 테스트") class FeedDescriptionExtractionTest { - @Test - @DisplayName("BBC RSS에서 description 추출 - 실제 FeedCrawlingService 메서드 호출") - void testBbcDescriptionExtraction() throws Exception { - // given: BBC RSS URL - String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("\n=== BBC RSS Description 추출 테스트 ===\n"); - - // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - int successCount = 0; - for (int i = 0; i < Math.min(5, entries.size()); i++) { - SyndEntry entry = entries.get(i); - String description = (String) extractDescriptionMethod.invoke(service, entry); - - System.out.println("--- Entry " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Description: " + (description != null ? description : "(null)")); - System.out.println(); - - if (description != null && !description.trim().isEmpty()) { - successCount++; - } - } - - System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(5, entries.size())); - assertTrue(successCount > 0, "최소 1개 이상의 description이 추출되어야 함"); - } - - @Test - @DisplayName("YouTube RSS에서 description 추출 - 실제 FeedCrawlingService 메서드 호출") - void testYouTubeDescriptionExtraction() throws Exception { - // given: YouTube RSS URL (Kurzgesagt) - String rssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("\n=== YouTube RSS Description 추출 테스트 ===\n"); - System.out.println("Channel: " + feed.getTitle()); - System.out.println(); - - // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - int successCount = 0; - for (int i = 0; i < Math.min(3, entries.size()); i++) { - SyndEntry entry = entries.get(i); - String description = (String) extractDescriptionMethod.invoke(service, entry); - - System.out.println("--- Video " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Description (길이: " + (description != null ? description.length() : 0) + "자):"); - if (description != null) { - System.out.println(description.length() > 200 ? description.substring(0, 200) + "..." : description); - } else { - System.out.println("(null)"); - } - System.out.println(); - - if (description != null && !description.trim().isEmpty()) { - successCount++; - } - } - - System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(3, entries.size())); - assertTrue(successCount > 0, "최소 1개 이상의 description이 추출되어야 함"); - } - - @Test - @DisplayName("Medium RSS에서 description 추출 - 실제 FeedCrawlingService 메서드 호출") - void testMediumDescriptionExtraction() throws Exception { - // given: Medium RSS URL - String rssUrl = "https://medium.com/feed/tag/programming"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("\n=== Medium RSS Description 추출 테스트 ===\n"); - - // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - int successCount = 0; - for (int i = 0; i < Math.min(3, entries.size()); i++) { - SyndEntry entry = entries.get(i); - String description = (String) extractDescriptionMethod.invoke(service, entry); - - System.out.println("--- Article " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Description (길이: " + (description != null ? description.length() : 0) + "자):"); - if (description != null) { - System.out.println(description.length() > 150 ? description.substring(0, 150) + "..." : description); - } else { - System.out.println("(null)"); - } - System.out.println(); - - if (description != null && !description.trim().isEmpty()) { - successCount++; - } - } - - System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(3, entries.size())); - assertTrue(successCount > 0, "최소 1개 이상의 description이 추출되어야 함"); - } - - @Test - @DisplayName("통합 테스트 - 3가지 RSS 소스 모두에서 description 추출") - void testAllSourcesDescriptionExtraction() throws Exception { - String[][] sources = { - {"BBC", "https://feeds.bbci.co.uk/news/technology/rss.xml"}, - {"YouTube", "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"}, - {"Medium", "https://medium.com/feed/tag/programming"} - }; - - System.out.println("\n=== 통합 테스트: 3가지 RSS 소스 Description 추출 ===\n"); - - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - int totalSuccess = 0; - int totalTested = 0; - - for (String[] source : sources) { - String sourceName = source[0]; - String rssUrl = source[1]; - - try { - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - int sourceSuccess = 0; - int testCount = Math.min(2, entries.size()); - - for (int i = 0; i < testCount; i++) { - SyndEntry entry = entries.get(i); - String description = (String) extractDescriptionMethod.invoke(service, entry); - if (description != null && !description.trim().isEmpty()) { - sourceSuccess++; - } - } - - totalSuccess += sourceSuccess; - totalTested += testCount; - - System.out.println("📰 " + sourceName + ": " + sourceSuccess + " / " + testCount + " 성공"); - - } catch (Exception e) { - System.out.println("❌ " + sourceName + ": 실패 - " + e.getMessage()); - } - } - - System.out.println("\n총합: " + totalSuccess + " / " + totalTested + " description 추출 성공"); - System.out.println("성공률: " + String.format("%.1f%%", (totalSuccess * 100.0 / totalTested))); - - assertTrue(totalSuccess > 0, "최소 1개 이상의 description이 추출되어야 함"); - assertTrue((totalSuccess * 100.0 / totalTested) >= 80.0, "80% 이상의 성공률이 필요함"); - } + @Test + @DisplayName("BBC RSS에서 description 추출 - 실제 FeedCrawlingService 메서드 호출") + void testBbcDescriptionExtraction() throws Exception { + // given: BBC RSS URL + String rssUrl = "https://feeds.bbci.co.uk/news/technology/rss.xml"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("\n=== BBC RSS Description 추출 테스트 ===\n"); + + // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + int successCount = 0; + for (int i = 0; i < Math.min(5, entries.size()); i++) { + SyndEntry entry = entries.get(i); + String description = (String) extractDescriptionMethod.invoke(service, entry); + + System.out.println("--- Entry " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Description: " + (description != null ? description : "(null)")); + System.out.println(); + + if (description != null && !description.trim().isEmpty()) { + successCount++; + } + } + + System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(5, entries.size())); + assertTrue(successCount > 0, "최소 1개 이상의 description이 추출되어야 함"); + } + + @Test + @DisplayName("YouTube RSS에서 description 추출 - 실제 FeedCrawlingService 메서드 호출") + void testYouTubeDescriptionExtraction() throws Exception { + // given: YouTube RSS URL (Kurzgesagt) + String rssUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("\n=== YouTube RSS Description 추출 테스트 ===\n"); + System.out.println("Channel: " + feed.getTitle()); + System.out.println(); + + // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + int successCount = 0; + for (int i = 0; i < Math.min(3, entries.size()); i++) { + SyndEntry entry = entries.get(i); + String description = (String) extractDescriptionMethod.invoke(service, entry); + + System.out.println("--- Video " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Description (길이: " + (description != null ? description.length() : 0) + "자):"); + if (description != null) { + System.out.println(description.length() > 200 ? description.substring(0, 200) + "..." : description); + } + else { + System.out.println("(null)"); + } + System.out.println(); + + if (description != null && !description.trim().isEmpty()) { + successCount++; + } + } + + System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(3, entries.size())); + assertTrue(successCount > 0, "최소 1개 이상의 description이 추출되어야 함"); + } + + @Test + @DisplayName("Medium RSS에서 description 추출 - 실제 FeedCrawlingService 메서드 호출") + void testMediumDescriptionExtraction() throws Exception { + // given: Medium RSS URL + String rssUrl = "https://medium.com/feed/tag/programming"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("\n=== Medium RSS Description 추출 테스트 ===\n"); + + // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + int successCount = 0; + for (int i = 0; i < Math.min(3, entries.size()); i++) { + SyndEntry entry = entries.get(i); + String description = (String) extractDescriptionMethod.invoke(service, entry); + + System.out.println("--- Article " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Description (길이: " + (description != null ? description.length() : 0) + "자):"); + if (description != null) { + System.out.println(description.length() > 150 ? description.substring(0, 150) + "..." : description); + } + else { + System.out.println("(null)"); + } + System.out.println(); + + if (description != null && !description.trim().isEmpty()) { + successCount++; + } + } + + System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(3, entries.size())); + assertTrue(successCount > 0, "최소 1개 이상의 description이 추출되어야 함"); + } + + @Test + @DisplayName("통합 테스트 - 3가지 RSS 소스 모두에서 description 추출") + void testAllSourcesDescriptionExtraction() throws Exception { + String[][] sources = { { "BBC", "https://feeds.bbci.co.uk/news/technology/rss.xml" }, + { "YouTube", "https://www.youtube.com/feeds/videos.xml?channel_id=UCsXVk37bltHxD1rDPwtNM8Q" }, + { "Medium", "https://medium.com/feed/tag/programming" } }; + + System.out.println("\n=== 통합 테스트: 3가지 RSS 소스 Description 추출 ===\n"); + + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + int totalSuccess = 0; + int totalTested = 0; + + for (String[] source : sources) { + String sourceName = source[0]; + String rssUrl = source[1]; + + try { + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + int sourceSuccess = 0; + int testCount = Math.min(2, entries.size()); + + for (int i = 0; i < testCount; i++) { + SyndEntry entry = entries.get(i); + String description = (String) extractDescriptionMethod.invoke(service, entry); + if (description != null && !description.trim().isEmpty()) { + sourceSuccess++; + } + } + + totalSuccess += sourceSuccess; + totalTested += testCount; + + System.out.println("📰 " + sourceName + ": " + sourceSuccess + " / " + testCount + " 성공"); + + } + catch (Exception e) { + System.out.println("❌ " + sourceName + ": 실패 - " + e.getMessage()); + } + } + + System.out.println("\n총합: " + totalSuccess + " / " + totalTested + " description 추출 성공"); + System.out.println("성공률: " + String.format("%.1f%%", (totalSuccess * 100.0 / totalTested))); + + assertTrue(totalSuccess > 0, "최소 1개 이상의 description이 추출되어야 함"); + assertTrue((totalSuccess * 100.0 / totalTested) >= 80.0, "80% 이상의 성공률이 필요함"); + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/content/feed/service/Formula1EspnThumbnailTest.java b/src/test/java/com/linglevel/api/content/feed/service/Formula1EspnThumbnailTest.java index e9a70ca6..62b06938 100644 --- a/src/test/java/com/linglevel/api/content/feed/service/Formula1EspnThumbnailTest.java +++ b/src/test/java/com/linglevel/api/content/feed/service/Formula1EspnThumbnailTest.java @@ -17,252 +17,255 @@ import static org.junit.jupiter.api.Assertions.*; @DisplayName("Formula1 & ESPN 썸네일 추출 DSL 테스트") -@DisabledIfEnvironmentVariable( - named = "CI", - matches = "true", - disabledReason = "외부 RSS/웹 페이지 의존 통합 테스트는 CI 환경에서 불안정하여 로컬에서만 실행" -) +@DisabledIfEnvironmentVariable(named = "CI", matches = "true", + disabledReason = "외부 RSS/웹 페이지 의존 통합 테스트는 CI 환경에서 불안정하여 로컬에서만 실행") class Formula1EspnThumbnailTest { - // 권장 DSL (다양한 사이트에서 작동) - private static final String RECOMMENDED_DSL = - "D'meta[property=\"og:image\"]'@'content' ? D'article figure img:not([class=\"logo\"]):not([id=\"logo\"]):not([class=\"brand\"]):not([id=\"brand\"]):not([class=\"icon\"]):not([src=\".svg\"]):not([class=\"ad\"]):not([id=\"ad\"]):not([class=\"advert\"]):not([id=\"advert\"]):not([class=\"avatar\"]):not([class=\"profile\"])'@'src' ? D'article picture img:not([class=\"logo\"]):not([id=\"logo\"]):not([class=\"brand\"]):not([id=\"brand\"]):not([class=\"icon\"]):not([src=\".svg\"]):not([class=\"ad\"]):not([id=\"ad\"]):not([class=\"advert\"]):not([id=\"advert\"]):not([class=\"avatar\"]):not([class=\"profile\"])'@'src' ? D'article .hero-image img, article .main-image img, article .featured-image img'@'src'"; - - @Test - @DisplayName("Formula1 article에서 썸네일 추출 DSL 테스트") - void testFormula1ThumbnailExtraction() throws Exception { - // given: Formula1 RSS에서 article URL 가져오기 - String rssUrl = "https://www.formula1.com/en/latest/all.xml"; - - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("\n=== Formula1 썸네일 추출 테스트 ===\n"); - System.out.println("RSS Feed: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - int successCount = 0; - int testLimit = Math.min(3, entries.size()); - - for (int i = 0; i < testLimit; i++) { - SyndEntry entry = entries.get(i); - String articleUrl = entry.getLink(); - - System.out.println("--- Article " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("URL: " + articleUrl); - - try { - // Article 페이지 크롤링 - Document doc = Jsoup.connect(articleUrl) - .timeout(15000) - .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .get(); - - CrawlerDsl crawler = new CrawlerDsl(doc); - - // 권장 DSL로 썸네일 추출 - String thumbnail = crawler.executeAsString(RECOMMENDED_DSL); - - if (thumbnail != null && !thumbnail.trim().isEmpty()) { - System.out.println("✓ 썸네일 추출 성공"); - System.out.println("Thumbnail: " + (thumbnail.length() > 80 ? thumbnail.substring(0, 80) + "..." : thumbnail)); - successCount++; - } else { - System.out.println("✗ 썸네일 추출 실패"); - - // 실패한 경우 og:image만 시도 - String ogImage = crawler.executeAsString("D'meta[property=\"og:image\"]'@'content'"); - if (ogImage != null) { - System.out.println(" og:image: " + ogImage); - } - } - - } catch (Exception e) { - System.out.println("✗ 크롤링 에러: " + e.getMessage()); - } - - System.out.println(); - } - - System.out.println("결과: " + successCount + "/" + testLimit + " 성공"); - System.out.println("성공률: " + String.format("%.1f%%", (successCount * 100.0 / testLimit))); - System.out.println(); - - assertTrue(successCount > 0, "최소 1개 이상의 썸네일이 추출되어야 함"); - } - - @Test - @DisplayName("ESPN MLB article에서 썸네일 추출 DSL 테스트") - void testEspnMlbThumbnailExtraction() throws Exception { - // given: ESPN MLB RSS에서 article URL 가져오기 - String rssUrl = "https://www.espn.com/espn/rss/mlb/news"; - - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("\n=== ESPN MLB 썸네일 추출 테스트 ===\n"); - System.out.println("RSS Feed: " + feed.getTitle()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - int successCount = 0; - int testLimit = Math.min(3, entries.size()); - - for (int i = 0; i < testLimit; i++) { - SyndEntry entry = entries.get(i); - String articleUrl = entry.getLink(); - - System.out.println("--- Article " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("URL: " + articleUrl); - - try { - // Article 페이지 크롤링 - Document doc = Jsoup.connect(articleUrl) - .timeout(15000) - .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .get(); - - CrawlerDsl crawler = new CrawlerDsl(doc); - - // 권장 DSL로 썸네일 추출 - String thumbnail = crawler.executeAsString(RECOMMENDED_DSL); - - if (thumbnail != null && !thumbnail.trim().isEmpty()) { - System.out.println("✓ 썸네일 추출 성공"); - System.out.println("Thumbnail: " + (thumbnail.length() > 80 ? thumbnail.substring(0, 80) + "..." : thumbnail)); - successCount++; - } else { - System.out.println("✗ 썸네일 추출 실패"); - - // 실패한 경우 og:image만 시도 - String ogImage = crawler.executeAsString("D'meta[property=\"og:image\"]'@'content'"); - if (ogImage != null) { - System.out.println(" og:image: " + ogImage); - } - } - - } catch (Exception e) { - System.out.println("✗ 크롤링 에러: " + e.getMessage()); - } - - System.out.println(); - } - - System.out.println("결과: " + successCount + "/" + testLimit + " 성공"); - System.out.println("성공률: " + String.format("%.1f%%", (successCount * 100.0 / testLimit))); - System.out.println(); - - assertTrue(successCount > 0, "최소 1개 이상의 썸네일이 추출되어야 함"); - } - - @Test - @DisplayName("Formula1 & ESPN 통합 썸네일 DSL 분석") - void analyzeThumbnailDslForBothSites() throws Exception { - String[][] sources = { - {"Formula1", "https://www.formula1.com/en/latest/all.xml"}, - {"ESPN MLB", "https://www.espn.com/espn/rss/mlb/news"} - }; - - System.out.println("\n=== Formula1 & ESPN 통합 썸네일 DSL 분석 ===\n"); - - for (String[] source : sources) { - String sourceName = source[0]; - String rssUrl = source[1]; - - try { - System.out.println("📰 " + sourceName); - System.out.println("─────────────────────────────────────"); - - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - if (entries.isEmpty()) { - System.out.println("⚠️ 엔트리가 없습니다."); - System.out.println(); - continue; - } - - // 첫 번째 article만 상세 분석 - SyndEntry firstEntry = entries.get(0); - String articleUrl = firstEntry.getLink(); - - System.out.println("Sample Article: " + firstEntry.getTitle()); - System.out.println("URL: " + articleUrl); - System.out.println(); - - Document doc = Jsoup.connect(articleUrl) - .timeout(15000) - .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .get(); - - CrawlerDsl crawler = new CrawlerDsl(doc); - - // 각 fallback 단계별 테스트 - System.out.println("1. og:image 메타 태그:"); - String ogImage = crawler.executeAsString("D'meta[property=\"og:image\"]'@'content'"); - System.out.println(" " + (ogImage != null ? "✓ " + ogImage : "✗ 없음")); - - System.out.println("2. article figure img:"); - String figureImg = crawler.executeAsString("D'article figure img'@'src'"); - System.out.println(" " + (figureImg != null ? "✓ " + figureImg : "✗ 없음")); - - System.out.println("3. article picture img:"); - String pictureImg = crawler.executeAsString("D'article picture img'@'src'"); - System.out.println(" " + (pictureImg != null ? "✓ " + pictureImg : "✗ 없음")); - - System.out.println("4. hero/main/featured 이미지:"); - String heroImg = crawler.executeAsString("D'article .hero-image img, article .main-image img, article .featured-image img'@'src'"); - System.out.println(" " + (heroImg != null ? "✓ " + heroImg : "✗ 없음")); - - System.out.println(); - System.out.println("최종 결과 (권장 DSL):"); - String finalResult = crawler.executeAsString(RECOMMENDED_DSL); - System.out.println(finalResult != null ? "✓ " + finalResult : "✗ 실패"); - - System.out.println(); - - } catch (Exception e) { - System.out.println("✗ 에러: " + e.getMessage()); - System.out.println(); - } - } - - System.out.println("==========================================="); - } - - @Test - @DisplayName("권장 DSL 출력 (Formula1 & ESPN용)") - void printRecommendedDslForNewSources() { - System.out.println("\n==========================================="); - System.out.println("=== Formula1 & ESPN FeedSource 설정 권장사항 ==="); - System.out.println("===========================================\n"); - - System.out.println("coverImageDsl (권장):"); - System.out.println(RECOMMENDED_DSL); - System.out.println(); - - System.out.println("설명:"); - System.out.println("- 이 DSL은 Formula1, ESPN 등 대부분의 뉴스 사이트에서 작동합니다"); - System.out.println("- RSS에 썸네일이 없어도 article 페이지에서 자동으로 추출합니다"); - System.out.println("- 4단계 fallback으로 높은 성공률을 보장합니다"); - System.out.println(); - - System.out.println("간소화된 DSL (og:image만 사용):"); - String simpleDsl = "D'meta[property=\"og:image\"]'@'content'"; - System.out.println(simpleDsl); - System.out.println("- 대부분의 사이트에서 og:image는 잘 설정되어 있습니다"); - System.out.println("- 더 빠른 크롤링이 필요한 경우 이 옵션을 사용하세요"); - System.out.println(); - - System.out.println("==========================================="); - } + // 권장 DSL (다양한 사이트에서 작동) + private static final String RECOMMENDED_DSL = "D'meta[property=\"og:image\"]'@'content' ? D'article figure img:not([class=\"logo\"]):not([id=\"logo\"]):not([class=\"brand\"]):not([id=\"brand\"]):not([class=\"icon\"]):not([src=\".svg\"]):not([class=\"ad\"]):not([id=\"ad\"]):not([class=\"advert\"]):not([id=\"advert\"]):not([class=\"avatar\"]):not([class=\"profile\"])'@'src' ? D'article picture img:not([class=\"logo\"]):not([id=\"logo\"]):not([class=\"brand\"]):not([id=\"brand\"]):not([class=\"icon\"]):not([src=\".svg\"]):not([class=\"ad\"]):not([id=\"ad\"]):not([class=\"advert\"]):not([id=\"advert\"]):not([class=\"avatar\"]):not([class=\"profile\"])'@'src' ? D'article .hero-image img, article .main-image img, article .featured-image img'@'src'"; + + @Test + @DisplayName("Formula1 article에서 썸네일 추출 DSL 테스트") + void testFormula1ThumbnailExtraction() throws Exception { + // given: Formula1 RSS에서 article URL 가져오기 + String rssUrl = "https://www.formula1.com/en/latest/all.xml"; + + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("\n=== Formula1 썸네일 추출 테스트 ===\n"); + System.out.println("RSS Feed: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + int successCount = 0; + int testLimit = Math.min(3, entries.size()); + + for (int i = 0; i < testLimit; i++) { + SyndEntry entry = entries.get(i); + String articleUrl = entry.getLink(); + + System.out.println("--- Article " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("URL: " + articleUrl); + + try { + // Article 페이지 크롤링 + Document doc = Jsoup.connect(articleUrl) + .timeout(15000) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get(); + + CrawlerDsl crawler = new CrawlerDsl(doc); + + // 권장 DSL로 썸네일 추출 + String thumbnail = crawler.executeAsString(RECOMMENDED_DSL); + + if (thumbnail != null && !thumbnail.trim().isEmpty()) { + System.out.println("✓ 썸네일 추출 성공"); + System.out.println( + "Thumbnail: " + (thumbnail.length() > 80 ? thumbnail.substring(0, 80) + "..." : thumbnail)); + successCount++; + } + else { + System.out.println("✗ 썸네일 추출 실패"); + + // 실패한 경우 og:image만 시도 + String ogImage = crawler.executeAsString("D'meta[property=\"og:image\"]'@'content'"); + if (ogImage != null) { + System.out.println(" og:image: " + ogImage); + } + } + + } + catch (Exception e) { + System.out.println("✗ 크롤링 에러: " + e.getMessage()); + } + + System.out.println(); + } + + System.out.println("결과: " + successCount + "/" + testLimit + " 성공"); + System.out.println("성공률: " + String.format("%.1f%%", (successCount * 100.0 / testLimit))); + System.out.println(); + + assertTrue(successCount > 0, "최소 1개 이상의 썸네일이 추출되어야 함"); + } + + @Test + @DisplayName("ESPN MLB article에서 썸네일 추출 DSL 테스트") + void testEspnMlbThumbnailExtraction() throws Exception { + // given: ESPN MLB RSS에서 article URL 가져오기 + String rssUrl = "https://www.espn.com/espn/rss/mlb/news"; + + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("\n=== ESPN MLB 썸네일 추출 테스트 ===\n"); + System.out.println("RSS Feed: " + feed.getTitle()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + int successCount = 0; + int testLimit = Math.min(3, entries.size()); + + for (int i = 0; i < testLimit; i++) { + SyndEntry entry = entries.get(i); + String articleUrl = entry.getLink(); + + System.out.println("--- Article " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("URL: " + articleUrl); + + try { + // Article 페이지 크롤링 + Document doc = Jsoup.connect(articleUrl) + .timeout(15000) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get(); + + CrawlerDsl crawler = new CrawlerDsl(doc); + + // 권장 DSL로 썸네일 추출 + String thumbnail = crawler.executeAsString(RECOMMENDED_DSL); + + if (thumbnail != null && !thumbnail.trim().isEmpty()) { + System.out.println("✓ 썸네일 추출 성공"); + System.out.println( + "Thumbnail: " + (thumbnail.length() > 80 ? thumbnail.substring(0, 80) + "..." : thumbnail)); + successCount++; + } + else { + System.out.println("✗ 썸네일 추출 실패"); + + // 실패한 경우 og:image만 시도 + String ogImage = crawler.executeAsString("D'meta[property=\"og:image\"]'@'content'"); + if (ogImage != null) { + System.out.println(" og:image: " + ogImage); + } + } + + } + catch (Exception e) { + System.out.println("✗ 크롤링 에러: " + e.getMessage()); + } + + System.out.println(); + } + + System.out.println("결과: " + successCount + "/" + testLimit + " 성공"); + System.out.println("성공률: " + String.format("%.1f%%", (successCount * 100.0 / testLimit))); + System.out.println(); + + assertTrue(successCount > 0, "최소 1개 이상의 썸네일이 추출되어야 함"); + } + + @Test + @DisplayName("Formula1 & ESPN 통합 썸네일 DSL 분석") + void analyzeThumbnailDslForBothSites() throws Exception { + String[][] sources = { { "Formula1", "https://www.formula1.com/en/latest/all.xml" }, + { "ESPN MLB", "https://www.espn.com/espn/rss/mlb/news" } }; + + System.out.println("\n=== Formula1 & ESPN 통합 썸네일 DSL 분석 ===\n"); + + for (String[] source : sources) { + String sourceName = source[0]; + String rssUrl = source[1]; + + try { + System.out.println("📰 " + sourceName); + System.out.println("─────────────────────────────────────"); + + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + if (entries.isEmpty()) { + System.out.println("⚠️ 엔트리가 없습니다."); + System.out.println(); + continue; + } + + // 첫 번째 article만 상세 분석 + SyndEntry firstEntry = entries.get(0); + String articleUrl = firstEntry.getLink(); + + System.out.println("Sample Article: " + firstEntry.getTitle()); + System.out.println("URL: " + articleUrl); + System.out.println(); + + Document doc = Jsoup.connect(articleUrl) + .timeout(15000) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get(); + + CrawlerDsl crawler = new CrawlerDsl(doc); + + // 각 fallback 단계별 테스트 + System.out.println("1. og:image 메타 태그:"); + String ogImage = crawler.executeAsString("D'meta[property=\"og:image\"]'@'content'"); + System.out.println(" " + (ogImage != null ? "✓ " + ogImage : "✗ 없음")); + + System.out.println("2. article figure img:"); + String figureImg = crawler.executeAsString("D'article figure img'@'src'"); + System.out.println(" " + (figureImg != null ? "✓ " + figureImg : "✗ 없음")); + + System.out.println("3. article picture img:"); + String pictureImg = crawler.executeAsString("D'article picture img'@'src'"); + System.out.println(" " + (pictureImg != null ? "✓ " + pictureImg : "✗ 없음")); + + System.out.println("4. hero/main/featured 이미지:"); + String heroImg = crawler.executeAsString( + "D'article .hero-image img, article .main-image img, article .featured-image img'@'src'"); + System.out.println(" " + (heroImg != null ? "✓ " + heroImg : "✗ 없음")); + + System.out.println(); + System.out.println("최종 결과 (권장 DSL):"); + String finalResult = crawler.executeAsString(RECOMMENDED_DSL); + System.out.println(finalResult != null ? "✓ " + finalResult : "✗ 실패"); + + System.out.println(); + + } + catch (Exception e) { + System.out.println("✗ 에러: " + e.getMessage()); + System.out.println(); + } + } + + System.out.println("==========================================="); + } + + @Test + @DisplayName("권장 DSL 출력 (Formula1 & ESPN용)") + void printRecommendedDslForNewSources() { + System.out.println("\n==========================================="); + System.out.println("=== Formula1 & ESPN FeedSource 설정 권장사항 ==="); + System.out.println("===========================================\n"); + + System.out.println("coverImageDsl (권장):"); + System.out.println(RECOMMENDED_DSL); + System.out.println(); + + System.out.println("설명:"); + System.out.println("- 이 DSL은 Formula1, ESPN 등 대부분의 뉴스 사이트에서 작동합니다"); + System.out.println("- RSS에 썸네일이 없어도 article 페이지에서 자동으로 추출합니다"); + System.out.println("- 4단계 fallback으로 높은 성공률을 보장합니다"); + System.out.println(); + + System.out.println("간소화된 DSL (og:image만 사용):"); + String simpleDsl = "D'meta[property=\"og:image\"]'@'content'"; + System.out.println(simpleDsl); + System.out.println("- 대부분의 사이트에서 og:image는 잘 설정되어 있습니다"); + System.out.println("- 더 빠른 크롤링이 필요한 경우 이 옵션을 사용하세요"); + System.out.println(); + + System.out.println("==========================================="); + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/service/MediumRssTest.java b/src/test/java/com/linglevel/api/content/feed/service/MediumRssTest.java index 51d74dd0..349b14d8 100644 --- a/src/test/java/com/linglevel/api/content/feed/service/MediumRssTest.java +++ b/src/test/java/com/linglevel/api/content/feed/service/MediumRssTest.java @@ -15,132 +15,134 @@ @DisplayName("Medium RSS 피드 테스트") class MediumRssTest { - @Test - @DisplayName("Medium Programming 태그 RSS 피드 파싱") - void parseMediumProgrammingRss() throws Exception { - // given: Medium Programming RSS URL - String rssUrl = "https://medium.com/feed/tag/programming"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertNotNull(feed.getTitle()); - assertFalse(entries.isEmpty()); - - System.out.println("==========================================="); - System.out.println("=== Medium Programming RSS Feed ==="); - System.out.println("==========================================="); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Feed Link: " + feed.getLink()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // 모든 엔트리 출력 - System.out.println("=== All Entries ===\n"); - for (int i = 0; i < entries.size(); i++) { - SyndEntry entry = entries.get(i); - System.out.println("=== Entry #" + (i + 1) + " ==="); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Author: " + (entry.getAuthor() != null ? entry.getAuthor() : "N/A")); - System.out.println("Published: " + entry.getPublishedDate()); - System.out.println("Updated: " + entry.getUpdatedDate()); - - // Description - if (entry.getDescription() != null) { - String desc = entry.getDescription().getValue(); - String cleanDesc = desc.replaceAll("<[^>]*>", "").trim(); - System.out.println("Description: " + (cleanDesc.length() > 150 ? cleanDesc.substring(0, 150) + "..." : cleanDesc)); - } - - // Categories/Tags - if (entry.getCategories() != null && !entry.getCategories().isEmpty()) { - System.out.print("Categories: "); - entry.getCategories().forEach(cat -> System.out.print(cat.getName() + ", ")); - System.out.println(); - } - - // Enclosures (썸네일) - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - System.out.println("Thumbnails:"); - entry.getEnclosures().forEach(enc -> { - System.out.println(" - " + enc.getUrl() + " (Type: " + enc.getType() + ")"); - }); - } else { - System.out.println("Thumbnails: None"); - } - - System.out.println(); - } - - // 썸네일 통계 - long thumbnailCount = entries.stream() - .filter(entry -> entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) - .count(); - - System.out.println("==========================================="); - System.out.println("=== Summary ==="); - System.out.println("==========================================="); - System.out.println("Total Entries: " + entries.size()); - System.out.println("Entries with Thumbnails: " + thumbnailCount); - System.out.println("Thumbnail Rate: " + String.format("%.1f%%", (thumbnailCount * 100.0 / entries.size()))); - System.out.println("==========================================="); - - // 검증 - SyndEntry firstEntry = entries.get(0); - assertNotNull(firstEntry.getTitle()); - assertNotNull(firstEntry.getLink()); - assertTrue(firstEntry.getLink().startsWith("http")); - } - - @Test - @DisplayName("Medium 다양한 태그 RSS 피드 비교") - void compareMediumTagFeeds() throws Exception { - // given: 다양한 Medium 태그 RSS URLs - String[] rssUrls = { - "https://medium.com/feed/tag/programming", - "https://medium.com/feed/tag/javascript", - "https://medium.com/feed/tag/technology" - }; - - System.out.println("==========================================="); - System.out.println("=== Medium 태그별 RSS 피드 비교 ==="); - System.out.println("===========================================\n"); - - for (String rssUrl : rssUrls) { - try { - SyndFeedInput input = new SyndFeedInput(); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("--- " + feed.getTitle() + " ---"); - System.out.println("URL: " + rssUrl); - System.out.println("Total Entries: " + entries.size()); - - long thumbnailCount = entries.stream() - .filter(entry -> entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) - .count(); - - System.out.println("Entries with Thumbnails: " + thumbnailCount); - System.out.println("Thumbnail Rate: " + String.format("%.1f%%", (thumbnailCount * 100.0 / entries.size()))); - - // 첫 3개 타이틀 출력 - System.out.println("\nTop 3 Articles:"); - entries.stream().limit(3).forEach(entry -> { - System.out.println(" - " + entry.getTitle()); - }); - - System.out.println("\n"); - - } catch (Exception e) { - System.out.println("Failed to parse: " + rssUrl); - System.out.println("Error: " + e.getMessage()); - System.out.println(); - } - } - } + @Test + @DisplayName("Medium Programming 태그 RSS 피드 파싱") + void parseMediumProgrammingRss() throws Exception { + // given: Medium Programming RSS URL + String rssUrl = "https://medium.com/feed/tag/programming"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertNotNull(feed.getTitle()); + assertFalse(entries.isEmpty()); + + System.out.println("==========================================="); + System.out.println("=== Medium Programming RSS Feed ==="); + System.out.println("==========================================="); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Feed Link: " + feed.getLink()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // 모든 엔트리 출력 + System.out.println("=== All Entries ===\n"); + for (int i = 0; i < entries.size(); i++) { + SyndEntry entry = entries.get(i); + System.out.println("=== Entry #" + (i + 1) + " ==="); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Author: " + (entry.getAuthor() != null ? entry.getAuthor() : "N/A")); + System.out.println("Published: " + entry.getPublishedDate()); + System.out.println("Updated: " + entry.getUpdatedDate()); + + // Description + if (entry.getDescription() != null) { + String desc = entry.getDescription().getValue(); + String cleanDesc = desc.replaceAll("<[^>]*>", "").trim(); + System.out.println( + "Description: " + (cleanDesc.length() > 150 ? cleanDesc.substring(0, 150) + "..." : cleanDesc)); + } + + // Categories/Tags + if (entry.getCategories() != null && !entry.getCategories().isEmpty()) { + System.out.print("Categories: "); + entry.getCategories().forEach(cat -> System.out.print(cat.getName() + ", ")); + System.out.println(); + } + + // Enclosures (썸네일) + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + System.out.println("Thumbnails:"); + entry.getEnclosures().forEach(enc -> { + System.out.println(" - " + enc.getUrl() + " (Type: " + enc.getType() + ")"); + }); + } + else { + System.out.println("Thumbnails: None"); + } + + System.out.println(); + } + + // 썸네일 통계 + long thumbnailCount = entries.stream() + .filter(entry -> entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) + .count(); + + System.out.println("==========================================="); + System.out.println("=== Summary ==="); + System.out.println("==========================================="); + System.out.println("Total Entries: " + entries.size()); + System.out.println("Entries with Thumbnails: " + thumbnailCount); + System.out.println("Thumbnail Rate: " + String.format("%.1f%%", (thumbnailCount * 100.0 / entries.size()))); + System.out.println("==========================================="); + + // 검증 + SyndEntry firstEntry = entries.get(0); + assertNotNull(firstEntry.getTitle()); + assertNotNull(firstEntry.getLink()); + assertTrue(firstEntry.getLink().startsWith("http")); + } + + @Test + @DisplayName("Medium 다양한 태그 RSS 피드 비교") + void compareMediumTagFeeds() throws Exception { + // given: 다양한 Medium 태그 RSS URLs + String[] rssUrls = { "https://medium.com/feed/tag/programming", "https://medium.com/feed/tag/javascript", + "https://medium.com/feed/tag/technology" }; + + System.out.println("==========================================="); + System.out.println("=== Medium 태그별 RSS 피드 비교 ==="); + System.out.println("===========================================\n"); + + for (String rssUrl : rssUrls) { + try { + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("--- " + feed.getTitle() + " ---"); + System.out.println("URL: " + rssUrl); + System.out.println("Total Entries: " + entries.size()); + + long thumbnailCount = entries.stream() + .filter(entry -> entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) + .count(); + + System.out.println("Entries with Thumbnails: " + thumbnailCount); + System.out + .println("Thumbnail Rate: " + String.format("%.1f%%", (thumbnailCount * 100.0 / entries.size()))); + + // 첫 3개 타이틀 출력 + System.out.println("\nTop 3 Articles:"); + entries.stream().limit(3).forEach(entry -> { + System.out.println(" - " + entry.getTitle()); + }); + + System.out.println("\n"); + + } + catch (Exception e) { + System.out.println("Failed to parse: " + rssUrl); + System.out.println("Error: " + e.getMessage()); + System.out.println(); + } + } + } + } diff --git a/src/test/java/com/linglevel/api/content/feed/service/NewFeedSourcesTest.java b/src/test/java/com/linglevel/api/content/feed/service/NewFeedSourcesTest.java index 546c865b..7086f2d6 100644 --- a/src/test/java/com/linglevel/api/content/feed/service/NewFeedSourcesTest.java +++ b/src/test/java/com/linglevel/api/content/feed/service/NewFeedSourcesTest.java @@ -15,240 +15,245 @@ import static org.junit.jupiter.api.Assertions.*; @DisplayName("새로운 RSS Feed 소스 파싱 테스트 (Formula1, ESPN)") -@DisabledIfEnvironmentVariable( - named = "CI", - matches = "true", - disabledReason = "외부 RSS 의존 통합 테스트는 CI 환경에서 불안정하여 로컬에서만 실행" -) +@DisabledIfEnvironmentVariable(named = "CI", matches = "true", + disabledReason = "외부 RSS 의존 통합 테스트는 CI 환경에서 불안정하여 로컬에서만 실행") class NewFeedSourcesTest { - @Test - @DisplayName("Formula1 RSS 피드 파싱 테스트") - void testFormula1RssFeed() throws Exception { - // given: Formula1 RSS URL - String rssUrl = "https://www.formula1.com/en/latest/all.xml"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertNotNull(feed.getTitle()); - assertFalse(entries.isEmpty(), "Formula1 RSS 피드에 엔트리가 있어야 함"); - - System.out.println("\n=== Formula1 RSS Feed 파싱 결과 ===\n"); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Feed Description: " + feed.getDescription()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - // 첫 5개 엔트리 상세 출력 - int successCount = 0; - for (int i = 0; i < Math.min(5, entries.size()); i++) { - SyndEntry entry = entries.get(i); - - System.out.println("--- Entry " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Published: " + entry.getPublishedDate()); - System.out.println("Author: " + entry.getAuthor()); - - // Description 추출 테스트 - String description = (String) extractDescriptionMethod.invoke(service, entry); - if (description != null && !description.trim().isEmpty()) { - System.out.println("Description (길이: " + description.length() + "자): "); - System.out.println(description.length() > 200 ? description.substring(0, 200) + "..." : description); - successCount++; - } else { - System.out.println("Description: (없음)"); - } - - // Enclosures 확인 (썸네일) - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - System.out.println("Enclosures:"); - entry.getEnclosures().forEach(enc -> { - System.out.println(" - URL: " + enc.getUrl()); - System.out.println(" Type: " + enc.getType()); - }); - } - - // ForeignMarkup 확인 (media:* 태그) - if (entry.getForeignMarkup() != null && !entry.getForeignMarkup().isEmpty()) { - System.out.println("ForeignMarkup elements: " + entry.getForeignMarkup().size()); - for (Object element : entry.getForeignMarkup()) { - if (element instanceof org.jdom2.Element) { - org.jdom2.Element elem = (org.jdom2.Element) element; - System.out.println(" - Element: " + elem.getName() + " (namespace: " + elem.getNamespaceURI() + ")"); - } - } - } - - System.out.println(); - } - - System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(5, entries.size())); - - // 각 엔트리 검증 - SyndEntry firstEntry = entries.get(0); - assertNotNull(firstEntry.getTitle(), "Title이 있어야 함"); - assertNotNull(firstEntry.getLink(), "Link가 있어야 함"); - assertTrue(firstEntry.getLink().startsWith("http"), "Link는 http로 시작해야 함"); - } - - @Test - @DisplayName("ESPN MLB RSS 피드 파싱 테스트") - void testEspnMlbRssFeed() throws Exception { - // given: ESPN MLB RSS URL - String rssUrl = "https://www.espn.com/espn/rss/mlb/news"; - - // when: RSS 피드 파싱 - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - // then: 피드 정보 확인 - assertNotNull(feed); - assertNotNull(feed.getTitle()); - assertFalse(entries.isEmpty(), "ESPN MLB RSS 피드에 엔트리가 있어야 함"); - - System.out.println("\n=== ESPN MLB RSS Feed 파싱 결과 ===\n"); - System.out.println("Feed Title: " + feed.getTitle()); - System.out.println("Feed Description: " + feed.getDescription()); - System.out.println("Total Entries: " + entries.size()); - System.out.println(); - - // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - // 첫 5개 엔트리 상세 출력 - int successCount = 0; - for (int i = 0; i < Math.min(5, entries.size()); i++) { - SyndEntry entry = entries.get(i); - - System.out.println("--- Entry " + (i + 1) + " ---"); - System.out.println("Title: " + entry.getTitle()); - System.out.println("Link: " + entry.getLink()); - System.out.println("Published: " + entry.getPublishedDate()); - System.out.println("Author: " + entry.getAuthor()); - - // Description 추출 테스트 - String description = (String) extractDescriptionMethod.invoke(service, entry); - if (description != null && !description.trim().isEmpty()) { - System.out.println("Description (길이: " + description.length() + "자): "); - System.out.println(description.length() > 200 ? description.substring(0, 200) + "..." : description); - successCount++; - } else { - System.out.println("Description: (없음)"); - } - - // Enclosures 확인 (썸네일) - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - System.out.println("Enclosures:"); - entry.getEnclosures().forEach(enc -> { - System.out.println(" - URL: " + enc.getUrl()); - System.out.println(" Type: " + enc.getType()); - }); - } - - // ForeignMarkup 확인 (media:* 태그) - if (entry.getForeignMarkup() != null && !entry.getForeignMarkup().isEmpty()) { - System.out.println("ForeignMarkup elements: " + entry.getForeignMarkup().size()); - for (Object element : entry.getForeignMarkup()) { - if (element instanceof org.jdom2.Element) { - org.jdom2.Element elem = (org.jdom2.Element) element; - System.out.println(" - Element: " + elem.getName() + " (namespace: " + elem.getNamespaceURI() + ")"); - } - } - } - - System.out.println(); - } - - System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(5, entries.size())); - - // 각 엔트리 검증 - SyndEntry firstEntry = entries.get(0); - assertNotNull(firstEntry.getTitle(), "Title이 있어야 함"); - assertNotNull(firstEntry.getLink(), "Link가 있어야 함"); - assertTrue(firstEntry.getLink().startsWith("http"), "Link는 http로 시작해야 함"); - } - - @Test - @DisplayName("Formula1 + ESPN MLB RSS 통합 비교 테스트") - void testBothNewSourcesComparison() throws Exception { - String[][] sources = { - {"Formula1", "https://www.formula1.com/en/latest/all.xml"}, - {"ESPN MLB", "https://www.espn.com/espn/rss/mlb/news"} - }; - - System.out.println("\n=== 새로운 RSS 소스 통합 비교 테스트 ===\n"); - - FeedCrawlingService service = new FeedCrawlingService(null, null, null); - Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", SyndEntry.class); - extractDescriptionMethod.setAccessible(true); - - for (String[] source : sources) { - String sourceName = source[0]; - String rssUrl = source[1]; - - try { - SyndFeedInput input = new SyndFeedInput(); - input.setAllowDoctypes(true); - SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); - List entries = feed.getEntries(); - - System.out.println("📰 " + sourceName + " (" + rssUrl + ")"); - System.out.println(" Feed Title: " + feed.getTitle()); - System.out.println(" Total Entries: " + entries.size()); - - // description 추출 가능한 엔트리 카운트 - int descriptionCount = 0; - int thumbnailCount = 0; - - for (SyndEntry entry : entries) { - // Description 체크 - String description = (String) extractDescriptionMethod.invoke(service, entry); - if (description != null && !description.trim().isEmpty()) { - descriptionCount++; - } - - // Thumbnail 체크 - if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { - boolean hasImageEnclosure = entry.getEnclosures().stream() - .anyMatch(enc -> enc.getType() != null && enc.getType().startsWith("image/")); - if (hasImageEnclosure) { - thumbnailCount++; - } - } - } - - System.out.println(" With Description: " + descriptionCount + - " (" + String.format("%.1f%%", descriptionCount * 100.0 / entries.size()) + ")"); - System.out.println(" With Thumbnail: " + thumbnailCount + - " (" + String.format("%.1f%%", thumbnailCount * 100.0 / entries.size()) + ")"); - System.out.println(); - - assertTrue(descriptionCount > 0 || thumbnailCount > 0, - sourceName + "에서 최소한 description 또는 thumbnail 중 하나는 추출되어야 함"); - - } catch (Exception e) { - System.out.println("❌ " + sourceName + " - Failed to parse"); - System.out.println(" Error: " + e.getMessage()); - e.printStackTrace(); - System.out.println(); - fail(sourceName + " RSS 파싱 실패: " + e.getMessage()); - } - } - } + @Test + @DisplayName("Formula1 RSS 피드 파싱 테스트") + void testFormula1RssFeed() throws Exception { + // given: Formula1 RSS URL + String rssUrl = "https://www.formula1.com/en/latest/all.xml"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertNotNull(feed.getTitle()); + assertFalse(entries.isEmpty(), "Formula1 RSS 피드에 엔트리가 있어야 함"); + + System.out.println("\n=== Formula1 RSS Feed 파싱 결과 ===\n"); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Feed Description: " + feed.getDescription()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + // 첫 5개 엔트리 상세 출력 + int successCount = 0; + for (int i = 0; i < Math.min(5, entries.size()); i++) { + SyndEntry entry = entries.get(i); + + System.out.println("--- Entry " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Published: " + entry.getPublishedDate()); + System.out.println("Author: " + entry.getAuthor()); + + // Description 추출 테스트 + String description = (String) extractDescriptionMethod.invoke(service, entry); + if (description != null && !description.trim().isEmpty()) { + System.out.println("Description (길이: " + description.length() + "자): "); + System.out.println(description.length() > 200 ? description.substring(0, 200) + "..." : description); + successCount++; + } + else { + System.out.println("Description: (없음)"); + } + + // Enclosures 확인 (썸네일) + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + System.out.println("Enclosures:"); + entry.getEnclosures().forEach(enc -> { + System.out.println(" - URL: " + enc.getUrl()); + System.out.println(" Type: " + enc.getType()); + }); + } + + // ForeignMarkup 확인 (media:* 태그) + if (entry.getForeignMarkup() != null && !entry.getForeignMarkup().isEmpty()) { + System.out.println("ForeignMarkup elements: " + entry.getForeignMarkup().size()); + for (Object element : entry.getForeignMarkup()) { + if (element instanceof org.jdom2.Element) { + org.jdom2.Element elem = (org.jdom2.Element) element; + System.out + .println(" - Element: " + elem.getName() + " (namespace: " + elem.getNamespaceURI() + ")"); + } + } + } + + System.out.println(); + } + + System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(5, entries.size())); + + // 각 엔트리 검증 + SyndEntry firstEntry = entries.get(0); + assertNotNull(firstEntry.getTitle(), "Title이 있어야 함"); + assertNotNull(firstEntry.getLink(), "Link가 있어야 함"); + assertTrue(firstEntry.getLink().startsWith("http"), "Link는 http로 시작해야 함"); + } + + @Test + @DisplayName("ESPN MLB RSS 피드 파싱 테스트") + void testEspnMlbRssFeed() throws Exception { + // given: ESPN MLB RSS URL + String rssUrl = "https://www.espn.com/espn/rss/mlb/news"; + + // when: RSS 피드 파싱 + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + // then: 피드 정보 확인 + assertNotNull(feed); + assertNotNull(feed.getTitle()); + assertFalse(entries.isEmpty(), "ESPN MLB RSS 피드에 엔트리가 있어야 함"); + + System.out.println("\n=== ESPN MLB RSS Feed 파싱 결과 ===\n"); + System.out.println("Feed Title: " + feed.getTitle()); + System.out.println("Feed Description: " + feed.getDescription()); + System.out.println("Total Entries: " + entries.size()); + System.out.println(); + + // FeedCrawlingService의 extractDescription 메서드를 리플렉션으로 호출 + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + // 첫 5개 엔트리 상세 출력 + int successCount = 0; + for (int i = 0; i < Math.min(5, entries.size()); i++) { + SyndEntry entry = entries.get(i); + + System.out.println("--- Entry " + (i + 1) + " ---"); + System.out.println("Title: " + entry.getTitle()); + System.out.println("Link: " + entry.getLink()); + System.out.println("Published: " + entry.getPublishedDate()); + System.out.println("Author: " + entry.getAuthor()); + + // Description 추출 테스트 + String description = (String) extractDescriptionMethod.invoke(service, entry); + if (description != null && !description.trim().isEmpty()) { + System.out.println("Description (길이: " + description.length() + "자): "); + System.out.println(description.length() > 200 ? description.substring(0, 200) + "..." : description); + successCount++; + } + else { + System.out.println("Description: (없음)"); + } + + // Enclosures 확인 (썸네일) + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + System.out.println("Enclosures:"); + entry.getEnclosures().forEach(enc -> { + System.out.println(" - URL: " + enc.getUrl()); + System.out.println(" Type: " + enc.getType()); + }); + } + + // ForeignMarkup 확인 (media:* 태그) + if (entry.getForeignMarkup() != null && !entry.getForeignMarkup().isEmpty()) { + System.out.println("ForeignMarkup elements: " + entry.getForeignMarkup().size()); + for (Object element : entry.getForeignMarkup()) { + if (element instanceof org.jdom2.Element) { + org.jdom2.Element elem = (org.jdom2.Element) element; + System.out + .println(" - Element: " + elem.getName() + " (namespace: " + elem.getNamespaceURI() + ")"); + } + } + } + + System.out.println(); + } + + System.out.println("✅ Description 추출 성공: " + successCount + " / " + Math.min(5, entries.size())); + + // 각 엔트리 검증 + SyndEntry firstEntry = entries.get(0); + assertNotNull(firstEntry.getTitle(), "Title이 있어야 함"); + assertNotNull(firstEntry.getLink(), "Link가 있어야 함"); + assertTrue(firstEntry.getLink().startsWith("http"), "Link는 http로 시작해야 함"); + } + + @Test + @DisplayName("Formula1 + ESPN MLB RSS 통합 비교 테스트") + void testBothNewSourcesComparison() throws Exception { + String[][] sources = { { "Formula1", "https://www.formula1.com/en/latest/all.xml" }, + { "ESPN MLB", "https://www.espn.com/espn/rss/mlb/news" } }; + + System.out.println("\n=== 새로운 RSS 소스 통합 비교 테스트 ===\n"); + + FeedCrawlingService service = new FeedCrawlingService(null, null, null); + Method extractDescriptionMethod = FeedCrawlingService.class.getDeclaredMethod("extractDescription", + SyndEntry.class); + extractDescriptionMethod.setAccessible(true); + + for (String[] source : sources) { + String sourceName = source[0]; + String rssUrl = source[1]; + + try { + SyndFeedInput input = new SyndFeedInput(); + input.setAllowDoctypes(true); + SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); + List entries = feed.getEntries(); + + System.out.println("📰 " + sourceName + " (" + rssUrl + ")"); + System.out.println(" Feed Title: " + feed.getTitle()); + System.out.println(" Total Entries: " + entries.size()); + + // description 추출 가능한 엔트리 카운트 + int descriptionCount = 0; + int thumbnailCount = 0; + + for (SyndEntry entry : entries) { + // Description 체크 + String description = (String) extractDescriptionMethod.invoke(service, entry); + if (description != null && !description.trim().isEmpty()) { + descriptionCount++; + } + + // Thumbnail 체크 + if (entry.getEnclosures() != null && !entry.getEnclosures().isEmpty()) { + boolean hasImageEnclosure = entry.getEnclosures() + .stream() + .anyMatch(enc -> enc.getType() != null && enc.getType().startsWith("image/")); + if (hasImageEnclosure) { + thumbnailCount++; + } + } + } + + System.out.println(" With Description: " + descriptionCount + " (" + + String.format("%.1f%%", descriptionCount * 100.0 / entries.size()) + ")"); + System.out.println(" With Thumbnail: " + thumbnailCount + " (" + + String.format("%.1f%%", thumbnailCount * 100.0 / entries.size()) + ")"); + System.out.println(); + + assertTrue(descriptionCount > 0 || thumbnailCount > 0, + sourceName + "에서 최소한 description 또는 thumbnail 중 하나는 추출되어야 함"); + + } + catch (Exception e) { + System.out.println("❌ " + sourceName + " - Failed to parse"); + System.out.println(" Error: " + e.getMessage()); + e.printStackTrace(); + System.out.println(); + fail(sourceName + " RSS 파싱 실패: " + e.getMessage()); + } + } + } + } diff --git a/src/test/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationSchedulerTest.java b/src/test/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationSchedulerTest.java index 41a75d6d..c14ccb70 100644 --- a/src/test/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationSchedulerTest.java +++ b/src/test/java/com/linglevel/api/content/recommendation/scheduler/UserPreferenceAggregationSchedulerTest.java @@ -29,231 +29,218 @@ @DisplayName("사용자 선호도 집계 스케줄러 테스트") class UserPreferenceAggregationSchedulerTest { - @Mock - private ContentAccessLogRepository contentAccessLogRepository; - - @Mock - private UserCategoryPreferenceRepository userCategoryPreferenceRepository; - - @InjectMocks - private UserPreferenceAggregationScheduler scheduler; - - private Instant now; - - @BeforeEach - void setUp() { - now = Instant.now(); - } - - @Test - @DisplayName("로그가 없으면 집계를 건너뛴다") - void skipAggregationWhenNoLogs() { - // Given - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(new ArrayList<>()); - - // When - scheduler.aggregateUserPreferences(); - - // Then - verify(userCategoryPreferenceRepository, never()).save(any()); - } - - @Test - @DisplayName("Article 접근 로그만 있는 경우 primaryCategory가 설정된다") - void setPrimaryCategoryForArticleLogsOnly() { - // Given - String userId = "user123"; - List logs = List.of( - createLog(userId, "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(1, ChronoUnit.DAYS)), - createLog(userId, "article2", ContentType.ARTICLE, ContentCategory.TECH, now.minus(2, ChronoUnit.DAYS)), - createLog(userId, "article3", ContentType.ARTICLE, ContentCategory.BUSINESS, now.minus(3, ChronoUnit.DAYS)) - ); - - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(logs); - when(userCategoryPreferenceRepository.findByUserId(userId)) - .thenReturn(Optional.empty()); - when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // When - scheduler.aggregateUserPreferences(); - - // Then - verify(userCategoryPreferenceRepository).save(argThat(preference -> - preference.getUserId().equals(userId) && - preference.getPrimaryCategory() == ContentCategory.TECH && - preference.getTotalAccessCount() == 3 && - preference.getCategoryScores().containsKey(ContentCategory.TECH) && - preference.getCategoryScores().containsKey(ContentCategory.BUSINESS) - )); - } - - @Test - @DisplayName("Book만 본 경우 primaryCategory는 null이다") - void nullPrimaryCategoryForBookOnly() { - // Given - String userId = "user456"; - List logs = List.of( - createLog(userId, "book1", ContentType.BOOK, null, now.minus(1, ChronoUnit.DAYS)), - createLog(userId, "book2", ContentType.BOOK, null, now.minus(2, ChronoUnit.DAYS)) - ); - - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(logs); - when(userCategoryPreferenceRepository.findByUserId(userId)) - .thenReturn(Optional.empty()); - when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // When - scheduler.aggregateUserPreferences(); - - // Then - verify(userCategoryPreferenceRepository).save(argThat(preference -> - preference.getUserId().equals(userId) && - preference.getPrimaryCategory() == null && - preference.getTotalAccessCount() == 2 && - preference.getCategoryScores().isEmpty() - )); - } - - @Test - @DisplayName("시간 감쇠가 적용되어 최근 로그가 더 높은 가중치를 받는다") - void applyTimeDecayWeighting() { - // Given - String userId = "user789"; - List logs = List.of( - // 최근 7일 - 가중치 1.0 - createLog(userId, "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(3, ChronoUnit.DAYS)), - // 7~30일 - 가중치 0.5 - createLog(userId, "article2", ContentType.ARTICLE, ContentCategory.BUSINESS, now.minus(15, ChronoUnit.DAYS)), - createLog(userId, "article3", ContentType.ARTICLE, ContentCategory.BUSINESS, now.minus(20, ChronoUnit.DAYS)), - // 30일 이전 - 가중치 0.2 - createLog(userId, "article4", ContentType.ARTICLE, ContentCategory.SPORTS, now.minus(60, ChronoUnit.DAYS)) - ); - - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(logs); - when(userCategoryPreferenceRepository.findByUserId(userId)) - .thenReturn(Optional.empty()); - when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // When - scheduler.aggregateUserPreferences(); - - // Then - verify(userCategoryPreferenceRepository).save(argThat(preference -> { - // TECH: 1.0, BUSINESS: 0.5 + 0.5 = 1.0, SPORTS: 0.2 - // 정규화 후: TECH = 1.0/2.2 = 0.45, BUSINESS = 1.0/2.2 = 0.45, SPORTS = 0.2/2.2 = 0.09 - Double techScore = preference.getCategoryScores().get(ContentCategory.TECH); - Double businessScore = preference.getCategoryScores().get(ContentCategory.BUSINESS); - - // TECH와 BUSINESS가 거의 동일한 점수여야 함 - return Math.abs(techScore - businessScore) < 0.01; - })); - } - - @Test - @DisplayName("여러 사용자 로그를 동시에 처리한다") - void aggregateMultipleUsers() { - // Given - List logs = List.of( - createLog("user1", "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(1, ChronoUnit.DAYS)), - createLog("user2", "article2", ContentType.ARTICLE, ContentCategory.BUSINESS, now.minus(1, ChronoUnit.DAYS)), - createLog("user3", "book1", ContentType.BOOK, null, now.minus(1, ChronoUnit.DAYS)) - ); - - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(logs); - when(userCategoryPreferenceRepository.findByUserId(anyString())) - .thenReturn(Optional.empty()); - when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // When - scheduler.aggregateUserPreferences(); - - // Then - verify(userCategoryPreferenceRepository, times(3)).save(any(UserCategoryPreference.class)); - } - - @Test - @DisplayName("개별 사용자 처리 실패 시에도 다른 사용자는 계속 처리된다") - void continueProcessingOnIndividualFailure() { - // Given - List logs = List.of( - createLog("user1", "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(1, ChronoUnit.DAYS)), - createLog("user2", "article2", ContentType.ARTICLE, ContentCategory.BUSINESS, now.minus(1, ChronoUnit.DAYS)), - createLog("user3", "article3", ContentType.ARTICLE, ContentCategory.SPORTS, now.minus(1, ChronoUnit.DAYS)) - ); - - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(logs); - - // user2 처리 시 에러 발생 - when(userCategoryPreferenceRepository.findByUserId("user1")) - .thenReturn(Optional.empty()); - when(userCategoryPreferenceRepository.findByUserId("user2")) - .thenThrow(new RuntimeException("DB connection failed")); - when(userCategoryPreferenceRepository.findByUserId("user3")) - .thenReturn(Optional.empty()); - - when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // When - scheduler.aggregateUserPreferences(); - - // Then - user1과 user3는 정상 처리됨 - verify(userCategoryPreferenceRepository, times(2)).save(any(UserCategoryPreference.class)); - } - - @Test - @DisplayName("기존 선호도 데이터가 있으면 업데이트한다") - void updateExistingPreference() { - // Given - String userId = "existingUser"; - UserCategoryPreference existingPreference = UserCategoryPreference.builder() - .id("pref123") - .userId(userId) - .primaryCategory(ContentCategory.SPORTS) - .totalAccessCount(5) - .build(); - - List newLogs = List.of( - createLog(userId, "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(1, ChronoUnit.DAYS)) - ); - - when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))) - .thenReturn(newLogs); - when(userCategoryPreferenceRepository.findByUserId(userId)) - .thenReturn(Optional.of(existingPreference)); - when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // When - scheduler.aggregateUserPreferences(); - - // Then - verify(userCategoryPreferenceRepository).save(argThat(preference -> - preference.getId().equals("pref123") && // 기존 ID 유지 - preference.getPrimaryCategory() == ContentCategory.TECH && // 새로운 카테고리로 업데이트 - preference.getTotalAccessCount() == 1 // 새로운 로그 개수 - )); - } - - // Helper method - private ContentAccessLog createLog(String userId, String contentId, ContentType contentType, - ContentCategory category, Instant accessedAt) { - return ContentAccessLog.builder() - .userId(userId) - .contentId(contentId) - .contentType(contentType) - .category(category) - .accessedAt(accessedAt) - .build(); - } + @Mock + private ContentAccessLogRepository contentAccessLogRepository; + + @Mock + private UserCategoryPreferenceRepository userCategoryPreferenceRepository; + + @InjectMocks + private UserPreferenceAggregationScheduler scheduler; + + private Instant now; + + @BeforeEach + void setUp() { + now = Instant.now(); + } + + @Test + @DisplayName("로그가 없으면 집계를 건너뛴다") + void skipAggregationWhenNoLogs() { + // Given + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(new ArrayList<>()); + + // When + scheduler.aggregateUserPreferences(); + + // Then + verify(userCategoryPreferenceRepository, never()).save(any()); + } + + @Test + @DisplayName("Article 접근 로그만 있는 경우 primaryCategory가 설정된다") + void setPrimaryCategoryForArticleLogsOnly() { + // Given + String userId = "user123"; + List logs = List.of( + createLog(userId, "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(1, ChronoUnit.DAYS)), + createLog(userId, "article2", ContentType.ARTICLE, ContentCategory.TECH, now.minus(2, ChronoUnit.DAYS)), + createLog(userId, "article3", ContentType.ARTICLE, ContentCategory.BUSINESS, + now.minus(3, ChronoUnit.DAYS))); + + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(logs); + when(userCategoryPreferenceRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + scheduler.aggregateUserPreferences(); + + // Then + verify(userCategoryPreferenceRepository).save(argThat(preference -> preference.getUserId().equals(userId) + && preference.getPrimaryCategory() == ContentCategory.TECH && preference.getTotalAccessCount() == 3 + && preference.getCategoryScores().containsKey(ContentCategory.TECH) + && preference.getCategoryScores().containsKey(ContentCategory.BUSINESS))); + } + + @Test + @DisplayName("Book만 본 경우 primaryCategory는 null이다") + void nullPrimaryCategoryForBookOnly() { + // Given + String userId = "user456"; + List logs = List.of( + createLog(userId, "book1", ContentType.BOOK, null, now.minus(1, ChronoUnit.DAYS)), + createLog(userId, "book2", ContentType.BOOK, null, now.minus(2, ChronoUnit.DAYS))); + + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(logs); + when(userCategoryPreferenceRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + scheduler.aggregateUserPreferences(); + + // Then + verify(userCategoryPreferenceRepository) + .save(argThat(preference -> preference.getUserId().equals(userId) && preference.getPrimaryCategory() == null + && preference.getTotalAccessCount() == 2 && preference.getCategoryScores().isEmpty())); + } + + @Test + @DisplayName("시간 감쇠가 적용되어 최근 로그가 더 높은 가중치를 받는다") + void applyTimeDecayWeighting() { + // Given + String userId = "user789"; + List logs = List.of( + // 최근 7일 - 가중치 1.0 + createLog(userId, "article1", ContentType.ARTICLE, ContentCategory.TECH, now.minus(3, ChronoUnit.DAYS)), + // 7~30일 - 가중치 0.5 + createLog(userId, "article2", ContentType.ARTICLE, ContentCategory.BUSINESS, + now.minus(15, ChronoUnit.DAYS)), + createLog(userId, "article3", ContentType.ARTICLE, ContentCategory.BUSINESS, + now.minus(20, ChronoUnit.DAYS)), + // 30일 이전 - 가중치 0.2 + createLog(userId, "article4", ContentType.ARTICLE, ContentCategory.SPORTS, + now.minus(60, ChronoUnit.DAYS))); + + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(logs); + when(userCategoryPreferenceRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + scheduler.aggregateUserPreferences(); + + // Then + verify(userCategoryPreferenceRepository).save(argThat(preference -> { + // TECH: 1.0, BUSINESS: 0.5 + 0.5 = 1.0, SPORTS: 0.2 + // 정규화 후: TECH = 1.0/2.2 = 0.45, BUSINESS = 1.0/2.2 = 0.45, SPORTS = 0.2/2.2 = + // 0.09 + Double techScore = preference.getCategoryScores().get(ContentCategory.TECH); + Double businessScore = preference.getCategoryScores().get(ContentCategory.BUSINESS); + + // TECH와 BUSINESS가 거의 동일한 점수여야 함 + return Math.abs(techScore - businessScore) < 0.01; + })); + } + + @Test + @DisplayName("여러 사용자 로그를 동시에 처리한다") + void aggregateMultipleUsers() { + // Given + List logs = List.of( + createLog("user1", "article1", ContentType.ARTICLE, ContentCategory.TECH, + now.minus(1, ChronoUnit.DAYS)), + createLog("user2", "article2", ContentType.ARTICLE, ContentCategory.BUSINESS, + now.minus(1, ChronoUnit.DAYS)), + createLog("user3", "book1", ContentType.BOOK, null, now.minus(1, ChronoUnit.DAYS))); + + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(logs); + when(userCategoryPreferenceRepository.findByUserId(anyString())).thenReturn(Optional.empty()); + when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + scheduler.aggregateUserPreferences(); + + // Then + verify(userCategoryPreferenceRepository, times(3)).save(any(UserCategoryPreference.class)); + } + + @Test + @DisplayName("개별 사용자 처리 실패 시에도 다른 사용자는 계속 처리된다") + void continueProcessingOnIndividualFailure() { + // Given + List logs = List.of( + createLog("user1", "article1", ContentType.ARTICLE, ContentCategory.TECH, + now.minus(1, ChronoUnit.DAYS)), + createLog("user2", "article2", ContentType.ARTICLE, ContentCategory.BUSINESS, + now.minus(1, ChronoUnit.DAYS)), + createLog("user3", "article3", ContentType.ARTICLE, ContentCategory.SPORTS, + now.minus(1, ChronoUnit.DAYS))); + + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(logs); + + // user2 처리 시 에러 발생 + when(userCategoryPreferenceRepository.findByUserId("user1")).thenReturn(Optional.empty()); + when(userCategoryPreferenceRepository.findByUserId("user2")) + .thenThrow(new RuntimeException("DB connection failed")); + when(userCategoryPreferenceRepository.findByUserId("user3")).thenReturn(Optional.empty()); + + when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + scheduler.aggregateUserPreferences(); + + // Then - user1과 user3는 정상 처리됨 + verify(userCategoryPreferenceRepository, times(2)).save(any(UserCategoryPreference.class)); + } + + @Test + @DisplayName("기존 선호도 데이터가 있으면 업데이트한다") + void updateExistingPreference() { + // Given + String userId = "existingUser"; + UserCategoryPreference existingPreference = UserCategoryPreference.builder() + .id("pref123") + .userId(userId) + .primaryCategory(ContentCategory.SPORTS) + .totalAccessCount(5) + .build(); + + List newLogs = List.of(createLog(userId, "article1", ContentType.ARTICLE, + ContentCategory.TECH, now.minus(1, ChronoUnit.DAYS))); + + when(contentAccessLogRepository.findByAccessedAtAfter(any(Instant.class))).thenReturn(newLogs); + when(userCategoryPreferenceRepository.findByUserId(userId)).thenReturn(Optional.of(existingPreference)); + when(userCategoryPreferenceRepository.save(any(UserCategoryPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // When + scheduler.aggregateUserPreferences(); + + // Then + verify(userCategoryPreferenceRepository).save(argThat(preference -> preference.getId().equals("pref123") && // 기존 + // ID + // 유지 + preference.getPrimaryCategory() == ContentCategory.TECH && // 새로운 카테고리로 + // 업데이트 + preference.getTotalAccessCount() == 1 // 새로운 로그 개수 + )); + } + + // Helper method + private ContentAccessLog createLog(String userId, String contentId, ContentType contentType, + ContentCategory category, Instant accessedAt) { + return ContentAccessLog.builder() + .userId(userId) + .contentId(contentId) + .contentType(contentType) + .category(category) + .accessedAt(accessedAt) + .build(); + } + } diff --git a/src/test/java/com/linglevel/api/crawling/dsl/CrawlerDslTest.java b/src/test/java/com/linglevel/api/crawling/dsl/CrawlerDslTest.java index d52a0654..5fc5926a 100644 --- a/src/test/java/com/linglevel/api/crawling/dsl/CrawlerDslTest.java +++ b/src/test/java/com/linglevel/api/crawling/dsl/CrawlerDslTest.java @@ -11,254 +11,252 @@ class CrawlerDslTest { - @Test - void testBasicTextExtraction() { - String html = """ - - - Test Page - - -

Welcome to Test

-
-

First paragraph

-

Second paragraph

-
- - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // Test 1: Extract title text - String result = crawler.executeAsString("D'title'#"); - assertEquals("Test Page", result); - - // Test 2: Extract h1 text - String h1Text = crawler.executeAsString("D'h1#main-title'#"); - assertEquals("Welcome to Test", h1Text); - - // Test 3: Extract all paragraphs - String paragraphs = crawler.executeAsString("D'''p'''>#"); - assertNotNull(paragraphs); - assertTrue(paragraphs.contains("First paragraph")); - assertTrue(paragraphs.contains("Second paragraph")); - } - - @Test - void testAttributeExtraction() { - String html = """ - - - Example Link - Test Image - - - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // Test 1: Extract href attribute - String href = crawler.executeAsString("D'a.link'@'href'"); - assertEquals("https://example.com", href); - - // Test 2: Extract img src - String src = crawler.executeAsString("D'img'@'src'"); - assertEquals("image.jpg", src); - - // Test 3: Extract meta content - String ogTitle = crawler.executeAsString("D'meta[property=\"og:title\"]'@'content'"); - assertEquals("Open Graph Title", ogTitle); - } - - @Test - void testNullCoalescing() { - String html = """ - - - - - -

Main Title

- - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // Test 1: First option exists - String result1 = crawler.executeAsString("D'h1.title'# ? D'meta[property=\"og:title\"]'@'content'"); - assertEquals("Main Title", result1); - - // Test 2: First option doesn't exist, fallback to second - String result2 = crawler.executeAsString("D'h1.nonexistent'# ? D'meta[property=\"og:title\"]'@'content'"); - assertEquals("Fallback Title", result2); - - // Test 3: Both don't exist - String result3 = crawler.executeAsString("D'h1.nonexistent'# ? D'h2.nonexistent'#"); - assertNull(result3); - } - - @Test - void testMapEach() { - String html = """ - - -
-

Post 1

-

Content 1

-
-
-

Post 2

-

Content 2

-
-
-

Post 3

-

Content 3

-
- - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // Extract all post titles using map each - String titles = crawler.executeAsString("D'''div.post'''>'h2'#"); - assertNotNull(titles); - assertTrue(titles.contains("Post 1")); - assertTrue(titles.contains("Post 2")); - assertTrue(titles.contains("Post 3")); - } - - @Test - void testComplexSelector() { - String html = """ - - -
-

Article Headline

-
-
-

First paragraph of article

-

Second paragraph of article

-
- - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // Test 1: Complex selector for headline - String headline = crawler.executeAsString("D'article[data-component=\"headline-block\"] h1'#"); - assertEquals("Article Headline", headline); - - // Test 2: Multiple text blocks - String content = crawler.executeAsString("D'''article[data-component=\"text-block\"] p'''>#"); - assertNotNull(content); - assertTrue(content.contains("First paragraph")); - assertTrue(content.contains("Second paragraph")); - } - - @Test - void testBBCNewsLikeDsl() { - // BBC 뉴스와 유사한 구조 - String html = """ - - - - - - -
-

Breaking News: Test Article

-
-
-

This is the first paragraph of the news article.

-

This is the second paragraph with more details.

-

This is the third paragraph concluding the story.

-
- - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // BBC 스타일 DSL: 제목 추출 (h1이 있으면 h1, 없으면 og:title) - String titleDsl = "D'article[data-component=\"headline-block\"] h1'# ? D'meta[property=\"og:title\"]'@'content'"; - String title = crawler.executeAsString(titleDsl); - assertEquals("Breaking News: Test Article", title); - - // BBC 스타일 DSL: 본문 추출 - String contentDsl = "D'''article[data-component=\"text-block\"] p'''>#"; - String content = crawler.executeAsString(contentDsl); - assertNotNull(content); - assertTrue(content.contains("first paragraph")); - assertTrue(content.contains("second paragraph")); - assertTrue(content.contains("third paragraph")); - - // BBC 스타일 DSL: 이미지 추출 - String imageDsl = "D'meta[property=\"og:image\"]'@'content'"; - String image = crawler.executeAsString(imageDsl); - assertEquals("https://example.com/bbc-image.jpg", image); - } - - @Test - void testActualBBCNews() throws IOException { - // 실제 BBC 뉴스 크롤링 테스트 - String url = "https://www.bbc.com/news/articles/c04gvx7egw5o"; - - System.out.println("Fetching BBC News: " + url); - Document doc = Jsoup.connect(url) - .timeout(10000) - .userAgent("Mozilla/5.0") - .get(); - - CrawlerDsl crawler = new CrawlerDsl(doc); - - // BBC 제목 DSL - String titleDsl = "D'article[data-component=\"headline-block\"] h1'# ? D'meta[property=\"og:title\"]'@'content'"; - String title = crawler.executeAsString(titleDsl); - - System.out.println("Title: " + title); - assertNotNull(title, "Title should not be null"); - assertFalse(title.trim().isEmpty(), "Title should not be empty"); - - // BBC 이미지 DSL - String imageDsl = "D'meta[property=\"og:image\"]'@'content'"; - String image = crawler.executeAsString(imageDsl); - - System.out.println("Image URL: " + image); - assertNotNull(image, "Image should not be null"); - } - - @Test - void testEmptyAndNullCases() { - String html = """ - - -
-

- - - """; - - CrawlerDsl crawler = new CrawlerDsl(html); - - // Test 1: Non-existent element - String result1 = crawler.executeAsString("D'h1.nonexistent'#"); - assertNull(result1); - - // Test 2: Empty element - String result2 = crawler.executeAsString("D'div.empty'#"); - assertNull(result2); // Empty text should return null - - // Test 3: Empty DSL - String result3 = crawler.executeAsString(""); - assertNull(result3); - - // Test 4: Null DSL - String result4 = crawler.executeAsString(null); - assertNull(result4); - } + @Test + void testBasicTextExtraction() { + String html = """ + + + Test Page + + +

Welcome to Test

+
+

First paragraph

+

Second paragraph

+
+ + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // Test 1: Extract title text + String result = crawler.executeAsString("D'title'#"); + assertEquals("Test Page", result); + + // Test 2: Extract h1 text + String h1Text = crawler.executeAsString("D'h1#main-title'#"); + assertEquals("Welcome to Test", h1Text); + + // Test 3: Extract all paragraphs + String paragraphs = crawler.executeAsString("D'''p'''>#"); + assertNotNull(paragraphs); + assertTrue(paragraphs.contains("First paragraph")); + assertTrue(paragraphs.contains("Second paragraph")); + } + + @Test + void testAttributeExtraction() { + String html = """ + + + Example Link + Test Image + + + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // Test 1: Extract href attribute + String href = crawler.executeAsString("D'a.link'@'href'"); + assertEquals("https://example.com", href); + + // Test 2: Extract img src + String src = crawler.executeAsString("D'img'@'src'"); + assertEquals("image.jpg", src); + + // Test 3: Extract meta content + String ogTitle = crawler.executeAsString("D'meta[property=\"og:title\"]'@'content'"); + assertEquals("Open Graph Title", ogTitle); + } + + @Test + void testNullCoalescing() { + String html = """ + + + + + +

Main Title

+ + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // Test 1: First option exists + String result1 = crawler.executeAsString("D'h1.title'# ? D'meta[property=\"og:title\"]'@'content'"); + assertEquals("Main Title", result1); + + // Test 2: First option doesn't exist, fallback to second + String result2 = crawler.executeAsString("D'h1.nonexistent'# ? D'meta[property=\"og:title\"]'@'content'"); + assertEquals("Fallback Title", result2); + + // Test 3: Both don't exist + String result3 = crawler.executeAsString("D'h1.nonexistent'# ? D'h2.nonexistent'#"); + assertNull(result3); + } + + @Test + void testMapEach() { + String html = """ + + +
+

Post 1

+

Content 1

+
+
+

Post 2

+

Content 2

+
+
+

Post 3

+

Content 3

+
+ + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // Extract all post titles using map each + String titles = crawler.executeAsString("D'''div.post'''>'h2'#"); + assertNotNull(titles); + assertTrue(titles.contains("Post 1")); + assertTrue(titles.contains("Post 2")); + assertTrue(titles.contains("Post 3")); + } + + @Test + void testComplexSelector() { + String html = """ + + +
+

Article Headline

+
+
+

First paragraph of article

+

Second paragraph of article

+
+ + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // Test 1: Complex selector for headline + String headline = crawler.executeAsString("D'article[data-component=\"headline-block\"] h1'#"); + assertEquals("Article Headline", headline); + + // Test 2: Multiple text blocks + String content = crawler.executeAsString("D'''article[data-component=\"text-block\"] p'''>#"); + assertNotNull(content); + assertTrue(content.contains("First paragraph")); + assertTrue(content.contains("Second paragraph")); + } + + @Test + void testBBCNewsLikeDsl() { + // BBC 뉴스와 유사한 구조 + String html = """ + + + + + + +
+

Breaking News: Test Article

+
+
+

This is the first paragraph of the news article.

+

This is the second paragraph with more details.

+

This is the third paragraph concluding the story.

+
+ + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // BBC 스타일 DSL: 제목 추출 (h1이 있으면 h1, 없으면 og:title) + String titleDsl = "D'article[data-component=\"headline-block\"] h1'# ? D'meta[property=\"og:title\"]'@'content'"; + String title = crawler.executeAsString(titleDsl); + assertEquals("Breaking News: Test Article", title); + + // BBC 스타일 DSL: 본문 추출 + String contentDsl = "D'''article[data-component=\"text-block\"] p'''>#"; + String content = crawler.executeAsString(contentDsl); + assertNotNull(content); + assertTrue(content.contains("first paragraph")); + assertTrue(content.contains("second paragraph")); + assertTrue(content.contains("third paragraph")); + + // BBC 스타일 DSL: 이미지 추출 + String imageDsl = "D'meta[property=\"og:image\"]'@'content'"; + String image = crawler.executeAsString(imageDsl); + assertEquals("https://example.com/bbc-image.jpg", image); + } + + @Test + void testActualBBCNews() throws IOException { + // 실제 BBC 뉴스 크롤링 테스트 + String url = "https://www.bbc.com/news/articles/c04gvx7egw5o"; + + System.out.println("Fetching BBC News: " + url); + Document doc = Jsoup.connect(url).timeout(10000).userAgent("Mozilla/5.0").get(); + + CrawlerDsl crawler = new CrawlerDsl(doc); + + // BBC 제목 DSL + String titleDsl = "D'article[data-component=\"headline-block\"] h1'# ? D'meta[property=\"og:title\"]'@'content'"; + String title = crawler.executeAsString(titleDsl); + + System.out.println("Title: " + title); + assertNotNull(title, "Title should not be null"); + assertFalse(title.trim().isEmpty(), "Title should not be empty"); + + // BBC 이미지 DSL + String imageDsl = "D'meta[property=\"og:image\"]'@'content'"; + String image = crawler.executeAsString(imageDsl); + + System.out.println("Image URL: " + image); + assertNotNull(image, "Image should not be null"); + } + + @Test + void testEmptyAndNullCases() { + String html = """ + + +
+

+ + + """; + + CrawlerDsl crawler = new CrawlerDsl(html); + + // Test 1: Non-existent element + String result1 = crawler.executeAsString("D'h1.nonexistent'#"); + assertNull(result1); + + // Test 2: Empty element + String result2 = crawler.executeAsString("D'div.empty'#"); + assertNull(result2); // Empty text should return null + + // Test 3: Empty DSL + String result3 = crawler.executeAsString(""); + assertNull(result3); + + // Test 4: Null DSL + String result4 = crawler.executeAsString(null); + assertNull(result4); + } + } diff --git a/src/test/java/com/linglevel/api/fcm/dto/NotificationMessageTest.java b/src/test/java/com/linglevel/api/fcm/dto/NotificationMessageTest.java index 02888812..02881268 100644 --- a/src/test/java/com/linglevel/api/fcm/dto/NotificationMessageTest.java +++ b/src/test/java/com/linglevel/api/fcm/dto/NotificationMessageTest.java @@ -7,48 +7,49 @@ class NotificationMessageTest { - @Test - void testContentCompletedMessages() { - // KR - String krTitle = NotificationMessage.CONTENT_COMPLETED.getTitle(CountryCode.KR); - String krBody = NotificationMessage.CONTENT_COMPLETED.getBody(CountryCode.KR, "테스트 콘텐츠"); - assertEquals("콘텐츠 준비 완료", krTitle); - assertEquals("'테스트 콘텐츠' 처리가 완료되었습니다.", krBody); - - // US - String usTitle = NotificationMessage.CONTENT_COMPLETED.getTitle(CountryCode.US); - String usBody = NotificationMessage.CONTENT_COMPLETED.getBody(CountryCode.US, "Test Content"); - assertEquals("Content Ready", usTitle); - assertEquals("'Test Content' has been successfully processed.", usBody); - - // JP - String jpTitle = NotificationMessage.CONTENT_COMPLETED.getTitle(CountryCode.JP); - String jpBody = NotificationMessage.CONTENT_COMPLETED.getBody(CountryCode.JP, "テストコンテンツ"); - assertEquals("コンテンツ準備完了", jpTitle); - assertEquals("'テストコンテンツ'の処理が完了しました。", jpBody); - } - - @Test - void testContentFailedMessages() { - // KR - assertEquals("콘텐츠 처리 실패", NotificationMessage.CONTENT_FAILED.getTitle(CountryCode.KR)); - assertEquals("처리 중 오류가 발생했습니다.", NotificationMessage.CONTENT_FAILED.getBody(CountryCode.KR)); - - // US - assertEquals("Content Processing Failed", NotificationMessage.CONTENT_FAILED.getTitle(CountryCode.US)); - assertEquals("An error occurred while processing.", NotificationMessage.CONTENT_FAILED.getBody(CountryCode.US)); - - // JP - assertEquals("コンテンツ処理失敗", NotificationMessage.CONTENT_FAILED.getTitle(CountryCode.JP)); - assertEquals("処理中にエラーが発生しました。", NotificationMessage.CONTENT_FAILED.getBody(CountryCode.JP)); - } - - @Test - void testNullCountryCodeDefaultsToUS() { - String title = NotificationMessage.CONTENT_COMPLETED.getTitle(null); - String body = NotificationMessage.CONTENT_COMPLETED.getBody(null, "Test"); - - assertEquals("Content Ready", title); - assertEquals("'Test' has been successfully processed.", body); - } + @Test + void testContentCompletedMessages() { + // KR + String krTitle = NotificationMessage.CONTENT_COMPLETED.getTitle(CountryCode.KR); + String krBody = NotificationMessage.CONTENT_COMPLETED.getBody(CountryCode.KR, "테스트 콘텐츠"); + assertEquals("콘텐츠 준비 완료", krTitle); + assertEquals("'테스트 콘텐츠' 처리가 완료되었습니다.", krBody); + + // US + String usTitle = NotificationMessage.CONTENT_COMPLETED.getTitle(CountryCode.US); + String usBody = NotificationMessage.CONTENT_COMPLETED.getBody(CountryCode.US, "Test Content"); + assertEquals("Content Ready", usTitle); + assertEquals("'Test Content' has been successfully processed.", usBody); + + // JP + String jpTitle = NotificationMessage.CONTENT_COMPLETED.getTitle(CountryCode.JP); + String jpBody = NotificationMessage.CONTENT_COMPLETED.getBody(CountryCode.JP, "テストコンテンツ"); + assertEquals("コンテンツ準備完了", jpTitle); + assertEquals("'テストコンテンツ'の処理が完了しました。", jpBody); + } + + @Test + void testContentFailedMessages() { + // KR + assertEquals("콘텐츠 처리 실패", NotificationMessage.CONTENT_FAILED.getTitle(CountryCode.KR)); + assertEquals("처리 중 오류가 발생했습니다.", NotificationMessage.CONTENT_FAILED.getBody(CountryCode.KR)); + + // US + assertEquals("Content Processing Failed", NotificationMessage.CONTENT_FAILED.getTitle(CountryCode.US)); + assertEquals("An error occurred while processing.", NotificationMessage.CONTENT_FAILED.getBody(CountryCode.US)); + + // JP + assertEquals("コンテンツ処理失敗", NotificationMessage.CONTENT_FAILED.getTitle(CountryCode.JP)); + assertEquals("処理中にエラーが発生しました。", NotificationMessage.CONTENT_FAILED.getBody(CountryCode.JP)); + } + + @Test + void testNullCountryCodeDefaultsToUS() { + String title = NotificationMessage.CONTENT_COMPLETED.getTitle(null); + String body = NotificationMessage.CONTENT_COMPLETED.getBody(null, "Test"); + + assertEquals("Content Ready", title); + assertEquals("'Test' has been successfully processed.", body); + } + } diff --git a/src/test/java/com/linglevel/api/fcm/service/FcmTokenServiceTest.java b/src/test/java/com/linglevel/api/fcm/service/FcmTokenServiceTest.java index 2be2d7eb..59c4852b 100644 --- a/src/test/java/com/linglevel/api/fcm/service/FcmTokenServiceTest.java +++ b/src/test/java/com/linglevel/api/fcm/service/FcmTokenServiceTest.java @@ -27,89 +27,84 @@ @DisplayName("FCM 토큰 비활성화 테스트") class FcmTokenServiceTest { - @Mock - private FcmTokenRepository fcmTokenRepository; - - @InjectMocks - private FcmTokenService fcmTokenService; - - @Test - @DisplayName("디바이스별 토큰 비활성화") - void testDeactivateTokenByDevice() { - // Given - String userId = "user123"; - String deviceId = "device456"; - FcmToken token = createToken(userId, deviceId, "token789"); - - when(fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId)) - .thenReturn(Optional.of(token)); - - // When - fcmTokenService.deactivateTokenByDevice(userId, deviceId); - - // Then - ArgumentCaptor captor = ArgumentCaptor.forClass(FcmToken.class); - verify(fcmTokenRepository).save(captor.capture()); - - FcmToken savedToken = captor.getValue(); - assertThat(savedToken.getIsActive()).isFalse(); - assertThat(savedToken.getUpdatedAt()).isNotNull(); - } - - @Test - @DisplayName("전체 토큰 비활성화 (로그아웃/계정삭제)") - void testDeactivateAllTokens() { - // Given - String userId = "user123"; - List tokens = List.of( - createToken(userId, "device1", "token1"), - createToken(userId, "device2", "token2"), - createToken(userId, "device3", "token3") - ); - - when(fcmTokenRepository.findByUserIdAndIsActive(userId, true)) - .thenReturn(tokens); - - // When - fcmTokenService.deactivateAllTokens(userId); - - // Then - verify(fcmTokenRepository, times(3)).save(any(FcmToken.class)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(FcmToken.class); - verify(fcmTokenRepository, atLeast(1)).save(captor.capture()); - - // 비활성화된 토큰 확인 - List savedTokens = captor.getAllValues(); - assertThat(savedTokens).allMatch(token -> !token.getIsActive()); - } - - @Test - @DisplayName("토큰이 없을 때 - 에러 없이 처리") - void testDeactivateAllTokens_NoTokens() { - // Given - String userId = "user123"; - when(fcmTokenRepository.findByUserIdAndIsActive(userId, true)) - .thenReturn(List.of()); - - // When & Then (에러 없이 완료되어야 함) - fcmTokenService.deactivateAllTokens(userId); - - verify(fcmTokenRepository, never()).save(any()); - } - - // Helper method - private FcmToken createToken(String userId, String deviceId, String fcmToken) { - return FcmToken.builder() - .id("id_" + fcmToken) - .userId(userId) - .deviceId(deviceId) - .fcmToken(fcmToken) - .platform(FcmPlatform.ANDROID) - .countryCode(CountryCode.KR) - .isActive(true) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .build(); - } + @Mock + private FcmTokenRepository fcmTokenRepository; + + @InjectMocks + private FcmTokenService fcmTokenService; + + @Test + @DisplayName("디바이스별 토큰 비활성화") + void testDeactivateTokenByDevice() { + // Given + String userId = "user123"; + String deviceId = "device456"; + FcmToken token = createToken(userId, deviceId, "token789"); + + when(fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId)).thenReturn(Optional.of(token)); + + // When + fcmTokenService.deactivateTokenByDevice(userId, deviceId); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(FcmToken.class); + verify(fcmTokenRepository).save(captor.capture()); + + FcmToken savedToken = captor.getValue(); + assertThat(savedToken.getIsActive()).isFalse(); + assertThat(savedToken.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("전체 토큰 비활성화 (로그아웃/계정삭제)") + void testDeactivateAllTokens() { + // Given + String userId = "user123"; + List tokens = List.of(createToken(userId, "device1", "token1"), + createToken(userId, "device2", "token2"), createToken(userId, "device3", "token3")); + + when(fcmTokenRepository.findByUserIdAndIsActive(userId, true)).thenReturn(tokens); + + // When + fcmTokenService.deactivateAllTokens(userId); + + // Then + verify(fcmTokenRepository, times(3)).save(any(FcmToken.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FcmToken.class); + verify(fcmTokenRepository, atLeast(1)).save(captor.capture()); + + // 비활성화된 토큰 확인 + List savedTokens = captor.getAllValues(); + assertThat(savedTokens).allMatch(token -> !token.getIsActive()); + } + + @Test + @DisplayName("토큰이 없을 때 - 에러 없이 처리") + void testDeactivateAllTokens_NoTokens() { + // Given + String userId = "user123"; + when(fcmTokenRepository.findByUserIdAndIsActive(userId, true)).thenReturn(List.of()); + + // When & Then (에러 없이 완료되어야 함) + fcmTokenService.deactivateAllTokens(userId); + + verify(fcmTokenRepository, never()).save(any()); + } + + // Helper method + private FcmToken createToken(String userId, String deviceId, String fcmToken) { + return FcmToken.builder() + .id("id_" + fcmToken) + .userId(userId) + .deviceId(deviceId) + .fcmToken(fcmToken) + .platform(FcmPlatform.ANDROID) + .countryCode(CountryCode.KR) + .isActive(true) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + } + } diff --git a/src/test/java/com/linglevel/api/s3/service/ImageResizeServiceTest.java b/src/test/java/com/linglevel/api/s3/service/ImageResizeServiceTest.java index ef01d93c..7d814733 100644 --- a/src/test/java/com/linglevel/api/s3/service/ImageResizeServiceTest.java +++ b/src/test/java/com/linglevel/api/s3/service/ImageResizeServiceTest.java @@ -32,289 +32,307 @@ @DisplayName("ImageResizeService 테스트") class ImageResizeServiceTest { - @Mock - private S3Client s3StaticClient; - - @Mock - private S3StaticService s3StaticService; - - @InjectMocks - private ImageResizeService imageResizeService; - - private final String staticBucketName = "test-bucket"; - private final String originalS3Key = "books/test-book-id/cover.jpg"; - private final String expectedThumbnailKey = "books/test-book-id/small_cover.webp"; - private final String expectedThumbnailUrl = "https://cdn.example.com/books/test-book-id/small_cover.webp"; - - @BeforeEach - void setUp() throws Exception { - // Reflection을 사용하여 private final 필드 설정 - var bucketNameField = ImageResizeService.class.getDeclaredField("staticBucketName"); - bucketNameField.setAccessible(true); - bucketNameField.set(imageResizeService, staticBucketName); - } - - @Nested - @DisplayName("썸네일 생성 테스트") - class CreateSmallImageTest { - - @Test - @DisplayName("JPG 이미지를 WebP 썸네일로 변환 성공") - void createSmallImage_Success() { - // given - 테스트용 JPG 이미지 생성 - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(512, 512)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl(expectedThumbnailKey)).thenReturn(expectedThumbnailUrl); - - // when - String result = imageResizeService.createSmallImage(originalS3Key); - - // then - assertThat(result).isEqualTo(expectedThumbnailUrl); - - // S3 다운로드 요청 검증 - ArgumentCaptor getRequestCaptor = ArgumentCaptor.forClass(GetObjectRequest.class); - verify(s3StaticClient).getObject(getRequestCaptor.capture()); - GetObjectRequest getRequest = getRequestCaptor.getValue(); - assertThat(getRequest.bucket()).isEqualTo(staticBucketName); - assertThat(getRequest.key()).isEqualTo(originalS3Key); - - // S3 업로드 요청 검증 - ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - ArgumentCaptor requestBodyCaptor = ArgumentCaptor.forClass(RequestBody.class); - verify(s3StaticClient).putObject(putRequestCaptor.capture(), requestBodyCaptor.capture()); - - PutObjectRequest putRequest = putRequestCaptor.getValue(); - assertThat(putRequest.bucket()).isEqualTo(staticBucketName); - assertThat(putRequest.key()).isEqualTo(expectedThumbnailKey); - assertThat(putRequest.contentType()).isEqualTo("image/webp"); - - // 공개 URL 생성 검증 - verify(s3StaticService).getPublicUrl(expectedThumbnailKey); - } - - @Test - @DisplayName("PNG 이미지를 WebP 썸네일로 변환 성공") - void createSmallImage_FromPng_Success() { - // given - String originalPngS3Key = "articles/test-article-id/cover.png"; - String expectedPngThumbnailKey = "articles/test-article-id/small_cover.webp"; - ResponseInputStream testImageStream = createMockResponseInputStream(createTestPngImage(1024, 768)); - - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl(expectedPngThumbnailKey)).thenReturn(expectedThumbnailUrl); - - // when - String result = imageResizeService.createSmallImage(originalPngS3Key); - - // then - assertThat(result).isEqualTo(expectedThumbnailUrl); - - ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); - - PutObjectRequest putRequest = putRequestCaptor.getValue(); - assertThat(putRequest.key()).isEqualTo(expectedPngThumbnailKey); - assertThat(putRequest.contentType()).isEqualTo("image/webp"); - } - - @Test - @DisplayName("정사각형이 아닌 이미지도 256x256 정사각형 썸네일로 변환") - void createSmallImage_RectangularImage_Success() { - // given - 직사각형 이미지 (1200x800) - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(1200, 800)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl(expectedThumbnailKey)).thenReturn(expectedThumbnailUrl); - - // when - String result = imageResizeService.createSmallImage(originalS3Key); - - // then - assertThat(result).isEqualTo(expectedThumbnailUrl); - verify(s3StaticClient).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - } - - @Test - @DisplayName("작은 이미지도 256x256으로 확대") - void createSmallImage_SmallImage_Success() { - // given - 작은 이미지 (100x100) - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(100, 100)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl(expectedThumbnailKey)).thenReturn(expectedThumbnailUrl); - - // when - String result = imageResizeService.createSmallImage(originalS3Key); - - // then - assertThat(result).isEqualTo(expectedThumbnailUrl); - verify(s3StaticClient).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - } - } - - @Nested - @DisplayName("S3 키 생성 테스트") - class S3KeyGenerationTest { - - @Test - @DisplayName("디렉토리가 있는 경우 올바른 썸네일 키 생성") - void generateSmallImagePath_WithDirectory() { - // given - String originalKey = "books/book-123/cover.jpg"; - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(256, 256)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl("books/book-123/small_cover.webp")).thenReturn("test-url"); - - // when - imageResizeService.createSmallImage(originalKey); - - // then - ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); - assertThat(putRequestCaptor.getValue().key()).isEqualTo("books/book-123/small_cover.webp"); - } - - @Test - @DisplayName("루트 디렉토리의 경우 올바른 썸네일 키 생성") - void generateSmallImagePath_RootDirectory() { - // given - String originalKey = "image.png"; - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(256, 256)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl("small_image.webp")).thenReturn("test-url"); - - // when - imageResizeService.createSmallImage(originalKey); - - // then - ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); - assertThat(putRequestCaptor.getValue().key()).isEqualTo("small_image.webp"); - } - - @Test - @DisplayName("확장자가 없는 파일의 경우 올바른 썸네일 키 생성") - void generateSmallImagePath_NoExtension() { - // given - String originalKey = "content/test-id/coverimage"; - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(256, 256)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticService.getPublicUrl("content/test-id/small_coverimage.webp")).thenReturn("test-url"); - - // when - imageResizeService.createSmallImage(originalKey); - - // then - ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); - verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); - assertThat(putRequestCaptor.getValue().key()).isEqualTo("content/test-id/small_coverimage.webp"); - } - } - - @Nested - @DisplayName("에러 처리 테스트") - class ErrorHandlingTest { - - @Test - @DisplayName("S3 다운로드 실패시 RuntimeException 발생") - void createSmallImage_S3DownloadFailure() { - // given - when(s3StaticClient.getObject(any(GetObjectRequest.class))) - .thenThrow(new RuntimeException("S3 download failed")); - - // when & then - assertThatThrownBy(() -> imageResizeService.createSmallImage(originalS3Key)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("Small image creation failed"); - - verify(s3StaticClient, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - verify(s3StaticService, never()).getPublicUrl(anyString()); - } - - @Test - @DisplayName("이미지 변환 실패시 RuntimeException 발생") - void createSmallImage_ImageConversionFailure() throws IOException { - // given - 잘못된 이미지 데이터 - ResponseInputStream invalidImageStream = createMockResponseInputStream(new ByteArrayInputStream("invalid image data".getBytes())); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(invalidImageStream); - - // when & then - assertThatThrownBy(() -> imageResizeService.createSmallImage(originalS3Key)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("Small image creation failed"); - - verify(s3StaticClient, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - verify(s3StaticService, never()).getPublicUrl(anyString()); - } - - @Test - @DisplayName("S3 업로드 실패시 RuntimeException 발생") - void createSmallImage_S3UploadFailure() throws IOException { - // given - ResponseInputStream testImageStream = createMockResponseInputStream(createTestJpgImage(256, 256)); - when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); - when(s3StaticClient.putObject(any(PutObjectRequest.class), any(RequestBody.class))) - .thenThrow(new RuntimeException("S3 upload failed")); - - // when & then - assertThatThrownBy(() -> imageResizeService.createSmallImage(originalS3Key)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("Small image creation failed"); - - verify(s3StaticService, never()).getPublicUrl(anyString()); - } - } - - // 테스트용 JPG 이미지 생성 헬퍼 메서드 - private InputStream createTestJpgImage(int width, int height) { - try { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - Graphics2D g2d = image.createGraphics(); - - // 그라데이션 배경 생성 - GradientPaint gradient = new GradientPaint(0, 0, Color.RED, width, height, Color.BLUE); - g2d.setPaint(gradient); - g2d.fillRect(0, 0, width, height); - - // 테스트 텍스트 추가 - g2d.setColor(Color.WHITE); - g2d.setFont(new Font("Arial", Font.BOLD, Math.max(12, width / 20))); - g2d.drawString("TEST " + width + "x" + height, width / 4, height / 2); - g2d.dispose(); - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(image, "jpg", baos); - return new ByteArrayInputStream(baos.toByteArray()); - } catch (IOException e) { - throw new RuntimeException("테스트 이미지 생성 실패", e); - } - } - - // 테스트용 PNG 이미지 생성 헬퍼 메서드 - private InputStream createTestPngImage(int width, int height) { - try { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - Graphics2D g2d = image.createGraphics(); - - // 투명한 배경에 원형 그리기 - g2d.setColor(new Color(0, 255, 0, 128)); // 반투명 녹색 - g2d.fillOval(width / 4, height / 4, width / 2, height / 2); - - g2d.setColor(Color.BLACK); - g2d.setFont(new Font("Arial", Font.BOLD, Math.max(12, width / 25))); - g2d.drawString("PNG " + width + "x" + height, width / 6, height / 2); - g2d.dispose(); - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(image, "png", baos); - return new ByteArrayInputStream(baos.toByteArray()); - } catch (IOException e) { - throw new RuntimeException("테스트 PNG 이미지 생성 실패", e); - } - } - - // ResponseInputStream Mock 생성 헬퍼 메서드 - private ResponseInputStream createMockResponseInputStream(InputStream inputStream) { - GetObjectResponse response = GetObjectResponse.builder().build(); - return new ResponseInputStream<>(response, inputStream); - } + @Mock + private S3Client s3StaticClient; + + @Mock + private S3StaticService s3StaticService; + + @InjectMocks + private ImageResizeService imageResizeService; + + private final String staticBucketName = "test-bucket"; + + private final String originalS3Key = "books/test-book-id/cover.jpg"; + + private final String expectedThumbnailKey = "books/test-book-id/small_cover.webp"; + + private final String expectedThumbnailUrl = "https://cdn.example.com/books/test-book-id/small_cover.webp"; + + @BeforeEach + void setUp() throws Exception { + // Reflection을 사용하여 private final 필드 설정 + var bucketNameField = ImageResizeService.class.getDeclaredField("staticBucketName"); + bucketNameField.setAccessible(true); + bucketNameField.set(imageResizeService, staticBucketName); + } + + @Nested + @DisplayName("썸네일 생성 테스트") + class CreateSmallImageTest { + + @Test + @DisplayName("JPG 이미지를 WebP 썸네일로 변환 성공") + void createSmallImage_Success() { + // given - 테스트용 JPG 이미지 생성 + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(512, 512)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl(expectedThumbnailKey)).thenReturn(expectedThumbnailUrl); + + // when + String result = imageResizeService.createSmallImage(originalS3Key); + + // then + assertThat(result).isEqualTo(expectedThumbnailUrl); + + // S3 다운로드 요청 검증 + ArgumentCaptor getRequestCaptor = ArgumentCaptor.forClass(GetObjectRequest.class); + verify(s3StaticClient).getObject(getRequestCaptor.capture()); + GetObjectRequest getRequest = getRequestCaptor.getValue(); + assertThat(getRequest.bucket()).isEqualTo(staticBucketName); + assertThat(getRequest.key()).isEqualTo(originalS3Key); + + // S3 업로드 요청 검증 + ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + ArgumentCaptor requestBodyCaptor = ArgumentCaptor.forClass(RequestBody.class); + verify(s3StaticClient).putObject(putRequestCaptor.capture(), requestBodyCaptor.capture()); + + PutObjectRequest putRequest = putRequestCaptor.getValue(); + assertThat(putRequest.bucket()).isEqualTo(staticBucketName); + assertThat(putRequest.key()).isEqualTo(expectedThumbnailKey); + assertThat(putRequest.contentType()).isEqualTo("image/webp"); + + // 공개 URL 생성 검증 + verify(s3StaticService).getPublicUrl(expectedThumbnailKey); + } + + @Test + @DisplayName("PNG 이미지를 WebP 썸네일로 변환 성공") + void createSmallImage_FromPng_Success() { + // given + String originalPngS3Key = "articles/test-article-id/cover.png"; + String expectedPngThumbnailKey = "articles/test-article-id/small_cover.webp"; + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestPngImage(1024, 768)); + + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl(expectedPngThumbnailKey)).thenReturn(expectedThumbnailUrl); + + // when + String result = imageResizeService.createSmallImage(originalPngS3Key); + + // then + assertThat(result).isEqualTo(expectedThumbnailUrl); + + ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); + + PutObjectRequest putRequest = putRequestCaptor.getValue(); + assertThat(putRequest.key()).isEqualTo(expectedPngThumbnailKey); + assertThat(putRequest.contentType()).isEqualTo("image/webp"); + } + + @Test + @DisplayName("정사각형이 아닌 이미지도 256x256 정사각형 썸네일로 변환") + void createSmallImage_RectangularImage_Success() { + // given - 직사각형 이미지 (1200x800) + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(1200, 800)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl(expectedThumbnailKey)).thenReturn(expectedThumbnailUrl); + + // when + String result = imageResizeService.createSmallImage(originalS3Key); + + // then + assertThat(result).isEqualTo(expectedThumbnailUrl); + verify(s3StaticClient).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("작은 이미지도 256x256으로 확대") + void createSmallImage_SmallImage_Success() { + // given - 작은 이미지 (100x100) + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(100, 100)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl(expectedThumbnailKey)).thenReturn(expectedThumbnailUrl); + + // when + String result = imageResizeService.createSmallImage(originalS3Key); + + // then + assertThat(result).isEqualTo(expectedThumbnailUrl); + verify(s3StaticClient).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + } + + @Nested + @DisplayName("S3 키 생성 테스트") + class S3KeyGenerationTest { + + @Test + @DisplayName("디렉토리가 있는 경우 올바른 썸네일 키 생성") + void generateSmallImagePath_WithDirectory() { + // given + String originalKey = "books/book-123/cover.jpg"; + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(256, 256)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl("books/book-123/small_cover.webp")).thenReturn("test-url"); + + // when + imageResizeService.createSmallImage(originalKey); + + // then + ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); + assertThat(putRequestCaptor.getValue().key()).isEqualTo("books/book-123/small_cover.webp"); + } + + @Test + @DisplayName("루트 디렉토리의 경우 올바른 썸네일 키 생성") + void generateSmallImagePath_RootDirectory() { + // given + String originalKey = "image.png"; + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(256, 256)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl("small_image.webp")).thenReturn("test-url"); + + // when + imageResizeService.createSmallImage(originalKey); + + // then + ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); + assertThat(putRequestCaptor.getValue().key()).isEqualTo("small_image.webp"); + } + + @Test + @DisplayName("확장자가 없는 파일의 경우 올바른 썸네일 키 생성") + void generateSmallImagePath_NoExtension() { + // given + String originalKey = "content/test-id/coverimage"; + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(256, 256)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticService.getPublicUrl("content/test-id/small_coverimage.webp")).thenReturn("test-url"); + + // when + imageResizeService.createSmallImage(originalKey); + + // then + ArgumentCaptor putRequestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3StaticClient).putObject(putRequestCaptor.capture(), any(RequestBody.class)); + assertThat(putRequestCaptor.getValue().key()).isEqualTo("content/test-id/small_coverimage.webp"); + } + + } + + @Nested + @DisplayName("에러 처리 테스트") + class ErrorHandlingTest { + + @Test + @DisplayName("S3 다운로드 실패시 RuntimeException 발생") + void createSmallImage_S3DownloadFailure() { + // given + when(s3StaticClient.getObject(any(GetObjectRequest.class))) + .thenThrow(new RuntimeException("S3 download failed")); + + // when & then + assertThatThrownBy(() -> imageResizeService.createSmallImage(originalS3Key)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Small image creation failed"); + + verify(s3StaticClient, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + verify(s3StaticService, never()).getPublicUrl(anyString()); + } + + @Test + @DisplayName("이미지 변환 실패시 RuntimeException 발생") + void createSmallImage_ImageConversionFailure() throws IOException { + // given - 잘못된 이미지 데이터 + ResponseInputStream invalidImageStream = createMockResponseInputStream( + new ByteArrayInputStream("invalid image data".getBytes())); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(invalidImageStream); + + // when & then + assertThatThrownBy(() -> imageResizeService.createSmallImage(originalS3Key)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Small image creation failed"); + + verify(s3StaticClient, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + verify(s3StaticService, never()).getPublicUrl(anyString()); + } + + @Test + @DisplayName("S3 업로드 실패시 RuntimeException 발생") + void createSmallImage_S3UploadFailure() throws IOException { + // given + ResponseInputStream testImageStream = createMockResponseInputStream( + createTestJpgImage(256, 256)); + when(s3StaticClient.getObject(any(GetObjectRequest.class))).thenReturn(testImageStream); + when(s3StaticClient.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(new RuntimeException("S3 upload failed")); + + // when & then + assertThatThrownBy(() -> imageResizeService.createSmallImage(originalS3Key)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Small image creation failed"); + + verify(s3StaticService, never()).getPublicUrl(anyString()); + } + + } + + // 테스트용 JPG 이미지 생성 헬퍼 메서드 + private InputStream createTestJpgImage(int width, int height) { + try { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = image.createGraphics(); + + // 그라데이션 배경 생성 + GradientPaint gradient = new GradientPaint(0, 0, Color.RED, width, height, Color.BLUE); + g2d.setPaint(gradient); + g2d.fillRect(0, 0, width, height); + + // 테스트 텍스트 추가 + g2d.setColor(Color.WHITE); + g2d.setFont(new Font("Arial", Font.BOLD, Math.max(12, width / 20))); + g2d.drawString("TEST " + width + "x" + height, width / 4, height / 2); + g2d.dispose(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", baos); + return new ByteArrayInputStream(baos.toByteArray()); + } + catch (IOException e) { + throw new RuntimeException("테스트 이미지 생성 실패", e); + } + } + + // 테스트용 PNG 이미지 생성 헬퍼 메서드 + private InputStream createTestPngImage(int width, int height) { + try { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = image.createGraphics(); + + // 투명한 배경에 원형 그리기 + g2d.setColor(new Color(0, 255, 0, 128)); // 반투명 녹색 + g2d.fillOval(width / 4, height / 4, width / 2, height / 2); + + g2d.setColor(Color.BLACK); + g2d.setFont(new Font("Arial", Font.BOLD, Math.max(12, width / 25))); + g2d.drawString("PNG " + width + "x" + height, width / 6, height / 2); + g2d.dispose(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return new ByteArrayInputStream(baos.toByteArray()); + } + catch (IOException e) { + throw new RuntimeException("테스트 PNG 이미지 생성 실패", e); + } + } + + // ResponseInputStream Mock 생성 헬퍼 메서드 + private ResponseInputStream createMockResponseInputStream(InputStream inputStream) { + GetObjectResponse response = GetObjectResponse.builder().build(); + return new ResponseInputStream<>(response, inputStream); + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java b/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java index f1f6679f..7f666527 100644 --- a/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java +++ b/src/test/java/com/linglevel/api/streak/scheduler/StreakProtectionSchedulerTest.java @@ -33,511 +33,438 @@ @DisplayName("스트릭 보호 스케줄러 테스트") class StreakProtectionSchedulerTest { - @Mock private UserStudyReportRepository userStudyReportRepository; + @Mock + private UserStudyReportRepository userStudyReportRepository; - @Mock private DailyCompletionRepository dailyCompletionRepository; + @Mock + private DailyCompletionRepository dailyCompletionRepository; - @Mock - private com.linglevel.api.streak.repository.FreezeTransactionRepository - freezeTransactionRepository; + @Mock + private com.linglevel.api.streak.repository.FreezeTransactionRepository freezeTransactionRepository; - @Mock private FcmTokenRepository fcmTokenRepository; + @Mock + private FcmTokenRepository fcmTokenRepository; - @Mock private FcmMessagingService fcmMessagingService; + @Mock + private FcmMessagingService fcmMessagingService; - @Mock private FcmTokenService fcmTokenService; + @Mock + private FcmTokenService fcmTokenService; - @InjectMocks private StreakProtectionScheduler scheduler; + @InjectMocks + private StreakProtectionScheduler scheduler; - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private LocalDate today; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - @BeforeEach - void setUp() { - today = LocalDate.now(KST); - } - - @Test - @DisplayName("스트릭이 있고 오늘 학습 미완료한 사용자에게 알림 전송") - void sendNotification_ToActiveUserWithoutCompletion() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + private LocalDate today; - // 오늘 학습 미완료 - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); + @BeforeEach + void setUp() { + today = LocalDate.now(KST); + } + + @Test + @DisplayName("스트릭이 있고 오늘 학습 미완료한 사용자에게 알림 전송") + void sendNotification_ToActiveUserWithoutCompletion() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + // 오늘 학습 미완료 + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + // FCM 토큰 있음 + FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), any(FcmMessageRequest.class)); + } + + @Test + @DisplayName("오늘 이미 학습 완료한 사용자는 알림 전송 안함") + void noNotification_WhenAlreadyCompleted() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + // 오늘 학습 완료 + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(true); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService, never()).sendMessage(anyString(), any(FcmMessageRequest.class)); + } + + @Test + @DisplayName("FCM 토큰이 없는 사용자는 알림 전송 안함") + void noNotification_WhenNoFcmToken() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // FCM 토큰 없음 + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of()); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService, never()).sendMessage(anyString(), any(FcmMessageRequest.class)); + } + + @Test + @DisplayName("여러 토큰이 있는 사용자에게 멀티캐스트 전송") + void sendMulticast_WhenMultipleTokens() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + // 여러 FCM 토큰 + List tokens = List.of(createFcmToken("user1", "token1", CountryCode.KR), + createFcmToken("user1", "token2", CountryCode.KR)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(tokens); + + BatchResponse batchResponse = mock(BatchResponse.class); + when(batchResponse.getSuccessCount()).thenReturn(2); + when(batchResponse.getFailureCount()).thenReturn(0); + + List responses = new ArrayList<>(); + SendResponse successResponse1 = mock(SendResponse.class); + when(successResponse1.isSuccessful()).thenReturn(true); + SendResponse successResponse2 = mock(SendResponse.class); + when(successResponse2.isSuccessful()).thenReturn(true); + responses.add(successResponse1); + responses.add(successResponse2); + + when(batchResponse.getResponses()).thenReturn(responses); + when(fcmMessagingService.sendMulticastMessage(anyList(), any(FcmMessageRequest.class))) + .thenReturn(batchResponse); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMulticastMessage(anyList(), any(FcmMessageRequest.class)); + verify(fcmMessagingService, never()).sendMessage(anyString(), any(FcmMessageRequest.class)); + } + + @Test + @DisplayName("언어 코드 변환 - 한국어") + void languageConversion_Korean() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 3); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), + argThat(request -> request.getTitle().contains("불꽃") || request.getTitle().contains("스트릭") + || request.getTitle().contains("마지막") || request.getTitle().contains("늦지") + || request.getTitle().contains("자기") || request.getTitle().contains("남았") + || request.getTitle().contains("기다려") || request.getTitle().contains("기회") + || request.getTitle().contains("거의") || request.getTitle().contains("오늘"))); + } + + @Test + @DisplayName("언어 코드 변환 - 영어") + void languageConversion_English() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 3); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.US); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), + argThat(request -> request.getTitle().contains("flame") || request.getTitle().contains("streak") + || request.getTitle().contains("chance") || request.getTitle().contains("late") + || request.getTitle().contains("minutes") || request.getTitle().contains("left") + || request.getTitle().contains("waiting") || request.getTitle().contains("Almost") + || request.getTitle().contains("Only"))); + } + + @Test + @DisplayName("언어 코드 변환 - 일본어") + void languageConversion_Japanese() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 3); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.JP); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), + argThat(request -> request.getTitle().contains("炎") || request.getTitle().contains("ストリーク") + || request.getTitle().contains("チャンス") || request.getTitle().contains("遅く") + || request.getTitle().contains("寝る前") || request.getTitle().contains("残って") + || request.getTitle().contains("待って") || request.getTitle().contains("もうすぐ") + || request.getTitle().contains("今日"))); + } + + @Test + @DisplayName("메시지에 현재 스트릭 수가 포함됨") + void messageContainsStreakCount() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 7); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), + argThat(request -> (request.getTitle() + request.getBody()).contains("7"))); + } + + @Test + @DisplayName("여러 사용자에게 개별 알림 전송") + void sendToMultipleUsers() throws Exception { + // given + List users = List.of(createUserReport("user1", 3), createUserReport("user2", 5), + createUserReport("user3", 7)); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(users); + + // 모두 학습 미완료 + when(dailyCompletionRepository.existsByUserIdAndCompletionDate(anyString(), eq(today))).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(anyString(), eq(-1), any(), any())) + .thenReturn(List.of()); + + // 각각 FCM 토큰 있음 + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) + .thenReturn(List.of(createFcmToken("user1", "token1", CountryCode.KR))); + when(fcmTokenRepository.findByUserIdAndIsActive("user2", true)) + .thenReturn(List.of(createFcmToken("user2", "token2", CountryCode.US))); + when(fcmTokenRepository.findByUserIdAndIsActive("user3", true)) + .thenReturn(List.of(createFcmToken("user3", "token3", CountryCode.JP))); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService, times(3)).sendMessage(anyString(), any(FcmMessageRequest.class)); + } + + @Test + @DisplayName("어제 프리즈를 사용한 경우 STREAK_SAVED_BY_FREEZE 메시지 전송") + void sendFreezeMessage_WhenFreezeUsedYesterday() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // 어제 프리즈 사용 (amount = -1인 트랜잭션 존재) + com.linglevel.api.streak.entity.FreezeTransaction freezeTransaction = com.linglevel.api.streak.entity.FreezeTransaction + .builder() + .userId("user1") + .amount(-1) + .description("Auto-consumed for missed day") + .createdAt(today.minusDays(1).atStartOfDay(KST).toInstant()) + .build(); + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of(freezeTransaction)); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> { + // STREAK_SAVED_BY_FREEZE 메시지는 프리즈 관련 키워드를 포함 + String title = request.getTitle(); + String body = request.getBody(); + return body.contains("프리즈") && (body.contains("꼭") || body.contains("반드시") || body.contains("학습")); + })); + } + + @Test + @DisplayName("어제 프리즈를 사용하지 않은 경우 STREAK_PROTECTION 메시지 전송") + void sendProtectionMessage_WhenNoFreezeUsed() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + // 어제 프리즈 사용 안함 (트랜잭션 없음) + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmMessagingService).sendMessage(eq("token1"), argThat(request -> { + // STREAK_PROTECTION 메시지는 "자기 전", "5분", "늦지", "기회" 등의 키워드를 포함하거나 + // 스트릭 보호 관련 메시지 (단, 프리즈 언급은 없음) + String title = request.getTitle(); + String body = request.getBody(); + return (title.contains("자기") || title.contains("남았") || title.contains("기다려") || title.contains("늦지") + || title.contains("기회") || title.contains("불꽃") || title.contains("거의") || title.contains("마무리") + || title.contains("스트릭")) && (!body.contains("프리즈")); // 프리즈 언급 없음 + })); + } + + @Test + @DisplayName("프리즈 사용 여부 확인 - 어제 날짜 범위 정확성") + void checkFreezeUsage_YesterdayDateRange() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); + + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then - 정확한 시간 범위로 조회했는지 검증 + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(java.time.Instant.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(java.time.Instant.class); + + verify(freezeTransactionRepository).findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), + startCaptor.capture(), endCaptor.capture()); + + // 어제 00:00 ~ 오늘 00:00 범위 확인 + java.time.LocalDate yesterday = today.minusDays(1); + java.time.Instant expectedStart = yesterday.atStartOfDay(KST).toInstant(); + java.time.Instant expectedEnd = today.atStartOfDay(KST).toInstant(); + + assertThat(startCaptor.getValue()).isEqualTo(expectedStart); + assertThat(endCaptor.getValue()).isEqualTo(expectedEnd); + } + + @Test + @DisplayName("전송 실패한 토큰은 비활성화됨") + void deactivateFailedTokens() throws Exception { + // given + UserStudyReport user = createUserReport("user1", 5); + when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); + + when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)).thenReturn(false); + + // 프리즈 사용 안함 + when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween(eq("user1"), eq(-1), any(), any())) + .thenReturn(List.of()); + + List tokens = List.of(createFcmToken("user1", "token1", CountryCode.KR), + createFcmToken("user1", "token2", CountryCode.KR)); + when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(tokens); + + // 하나는 성공, 하나는 실패 + BatchResponse batchResponse = mock(BatchResponse.class); + when(batchResponse.getSuccessCount()).thenReturn(1); + when(batchResponse.getFailureCount()).thenReturn(1); + + List responses = new ArrayList<>(); + SendResponse successResponse = mock(SendResponse.class); + when(successResponse.isSuccessful()).thenReturn(true); + + SendResponse failResponse = mock(SendResponse.class); + when(failResponse.isSuccessful()).thenReturn(false); + FirebaseMessagingException exception = mock(FirebaseMessagingException.class); + when(exception.getMessage()).thenReturn("Invalid token"); + when(failResponse.getException()).thenReturn(exception); + + responses.add(successResponse); + responses.add(failResponse); + + when(batchResponse.getResponses()).thenReturn(responses); + when(fcmMessagingService.sendMulticastMessage(anyList(), any(FcmMessageRequest.class))) + .thenReturn(batchResponse); + + // when + scheduler.sendStreakProtectionNotifications(); + + // then + verify(fcmTokenService).deactivateToken("token2"); + } + + // Helper methods + private UserStudyReport createUserReport(String userId, int currentStreak) { + UserStudyReport report = new UserStudyReport(); + report.setUserId(userId); + report.setCurrentStreak(currentStreak); + return report; + } + + private FcmToken createFcmToken(String userId, String token, CountryCode countryCode) { + FcmToken fcmToken = new FcmToken(); + fcmToken.setUserId(userId); + fcmToken.setFcmToken(token); + fcmToken.setCountryCode(countryCode); + fcmToken.setIsActive(true); + return fcmToken; + } - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - // FCM 토큰 있음 - FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService).sendMessage(eq("token1"), any(FcmMessageRequest.class)); - } - - @Test - @DisplayName("오늘 이미 학습 완료한 사용자는 알림 전송 안함") - void noNotification_WhenAlreadyCompleted() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - // 오늘 학습 완료 - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(true); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService, never()).sendMessage(anyString(), any(FcmMessageRequest.class)); - } - - @Test - @DisplayName("FCM 토큰이 없는 사용자는 알림 전송 안함") - void noNotification_WhenNoFcmToken() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // FCM 토큰 없음 - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of()); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService, never()).sendMessage(anyString(), any(FcmMessageRequest.class)); - } - - @Test - @DisplayName("여러 토큰이 있는 사용자에게 멀티캐스트 전송") - void sendMulticast_WhenMultipleTokens() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - // 여러 FCM 토큰 - List tokens = - List.of( - createFcmToken("user1", "token1", CountryCode.KR), - createFcmToken("user1", "token2", CountryCode.KR)); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(tokens); - - BatchResponse batchResponse = mock(BatchResponse.class); - when(batchResponse.getSuccessCount()).thenReturn(2); - when(batchResponse.getFailureCount()).thenReturn(0); - - List responses = new ArrayList<>(); - SendResponse successResponse1 = mock(SendResponse.class); - when(successResponse1.isSuccessful()).thenReturn(true); - SendResponse successResponse2 = mock(SendResponse.class); - when(successResponse2.isSuccessful()).thenReturn(true); - responses.add(successResponse1); - responses.add(successResponse2); - - when(batchResponse.getResponses()).thenReturn(responses); - when(fcmMessagingService.sendMulticastMessage(anyList(), any(FcmMessageRequest.class))) - .thenReturn(batchResponse); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService).sendMulticastMessage(anyList(), any(FcmMessageRequest.class)); - verify(fcmMessagingService, never()).sendMessage(anyString(), any(FcmMessageRequest.class)); - } - - @Test - @DisplayName("언어 코드 변환 - 한국어") - void languageConversion_Korean() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 3); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService) - .sendMessage( - eq("token1"), - argThat( - request -> - request.getTitle().contains("불꽃") - || request.getTitle().contains("스트릭") - || request.getTitle().contains("마지막") - || request.getTitle().contains("늦지") - || request.getTitle().contains("자기") - || request.getTitle().contains("남았") - || request.getTitle().contains("기다려") - || request.getTitle().contains("기회") - || request.getTitle().contains("거의") - || request.getTitle().contains("오늘"))); - } - - @Test - @DisplayName("언어 코드 변환 - 영어") - void languageConversion_English() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 3); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.US); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService) - .sendMessage( - eq("token1"), - argThat( - request -> - request.getTitle().contains("flame") - || request.getTitle().contains("streak") - || request.getTitle().contains("chance") - || request.getTitle().contains("late") - || request.getTitle().contains("minutes") - || request.getTitle().contains("left") - || request.getTitle().contains("waiting") - || request.getTitle().contains("Almost") - || request.getTitle().contains("Only"))); - } - - @Test - @DisplayName("언어 코드 변환 - 일본어") - void languageConversion_Japanese() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 3); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.JP); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService) - .sendMessage( - eq("token1"), - argThat( - request -> - request.getTitle().contains("炎") - || request.getTitle().contains("ストリーク") - || request.getTitle().contains("チャンス") - || request.getTitle().contains("遅く") - || request.getTitle().contains("寝る前") - || request.getTitle().contains("残って") - || request.getTitle().contains("待って") - || request.getTitle().contains("もうすぐ") - || request.getTitle().contains("今日"))); - } - - @Test - @DisplayName("메시지에 현재 스트릭 수가 포함됨") - void messageContainsStreakCount() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 7); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService) - .sendMessage( - eq("token1"), - argThat(request -> (request.getTitle() + request.getBody()).contains("7"))); - } - - @Test - @DisplayName("여러 사용자에게 개별 알림 전송") - void sendToMultipleUsers() throws Exception { - // given - List users = - List.of( - createUserReport("user1", 3), - createUserReport("user2", 5), - createUserReport("user3", 7)); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(users); - - // 모두 학습 미완료 - when(dailyCompletionRepository.existsByUserIdAndCompletionDate(anyString(), eq(today))) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - anyString(), eq(-1), any(), any())) - .thenReturn(List.of()); - - // 각각 FCM 토큰 있음 - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)) - .thenReturn(List.of(createFcmToken("user1", "token1", CountryCode.KR))); - when(fcmTokenRepository.findByUserIdAndIsActive("user2", true)) - .thenReturn(List.of(createFcmToken("user2", "token2", CountryCode.US))); - when(fcmTokenRepository.findByUserIdAndIsActive("user3", true)) - .thenReturn(List.of(createFcmToken("user3", "token3", CountryCode.JP))); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService, times(3)) - .sendMessage(anyString(), any(FcmMessageRequest.class)); - } - - @Test - @DisplayName("어제 프리즈를 사용한 경우 STREAK_SAVED_BY_FREEZE 메시지 전송") - void sendFreezeMessage_WhenFreezeUsedYesterday() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // 어제 프리즈 사용 (amount = -1인 트랜잭션 존재) - com.linglevel.api.streak.entity.FreezeTransaction freezeTransaction = - com.linglevel.api.streak.entity.FreezeTransaction.builder() - .userId("user1") - .amount(-1) - .description("Auto-consumed for missed day") - .createdAt(today.minusDays(1).atStartOfDay(KST).toInstant()) - .build(); - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of(freezeTransaction)); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService) - .sendMessage( - eq("token1"), - argThat( - request -> { - // STREAK_SAVED_BY_FREEZE 메시지는 프리즈 관련 키워드를 포함 - String title = request.getTitle(); - String body = request.getBody(); - return body.contains("프리즈") - && (body.contains("꼭") - || body.contains("반드시") - || body.contains("학습")); - })); - } - - @Test - @DisplayName("어제 프리즈를 사용하지 않은 경우 STREAK_PROTECTION 메시지 전송") - void sendProtectionMessage_WhenNoFreezeUsed() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - // 어제 프리즈 사용 안함 (트랜잭션 없음) - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmMessagingService) - .sendMessage( - eq("token1"), - argThat( - request -> { - // STREAK_PROTECTION 메시지는 "자기 전", "5분", "늦지", "기회" 등의 키워드를 포함하거나 - // 스트릭 보호 관련 메시지 (단, 프리즈 언급은 없음) - String title = request.getTitle(); - String body = request.getBody(); - return (title.contains("자기") - || title.contains("남았") - || title.contains("기다려") - || title.contains("늦지") - || title.contains("기회") - || title.contains("불꽃") - || title.contains("거의") - || title.contains("마무리") - || title.contains("스트릭")) - && (!body.contains("프리즈")); // 프리즈 언급 없음 - })); - } - - @Test - @DisplayName("프리즈 사용 여부 확인 - 어제 날짜 범위 정확성") - void checkFreezeUsage_YesterdayDateRange() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - FcmToken token = createFcmToken("user1", "token1", CountryCode.KR); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(List.of(token)); - - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - 정확한 시간 범위로 조회했는지 검증 - ArgumentCaptor startCaptor = - ArgumentCaptor.forClass(java.time.Instant.class); - ArgumentCaptor endCaptor = - ArgumentCaptor.forClass(java.time.Instant.class); - - verify(freezeTransactionRepository) - .findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), startCaptor.capture(), endCaptor.capture()); - - // 어제 00:00 ~ 오늘 00:00 범위 확인 - java.time.LocalDate yesterday = today.minusDays(1); - java.time.Instant expectedStart = yesterday.atStartOfDay(KST).toInstant(); - java.time.Instant expectedEnd = today.atStartOfDay(KST).toInstant(); - - assertThat(startCaptor.getValue()).isEqualTo(expectedStart); - assertThat(endCaptor.getValue()).isEqualTo(expectedEnd); - } - - @Test - @DisplayName("전송 실패한 토큰은 비활성화됨") - void deactivateFailedTokens() throws Exception { - // given - UserStudyReport user = createUserReport("user1", 5); - when(userStudyReportRepository.findByCurrentStreakGreaterThan(0)).thenReturn(List.of(user)); - - when(dailyCompletionRepository.existsByUserIdAndCompletionDate("user1", today)) - .thenReturn(false); - - // 프리즈 사용 안함 - when(freezeTransactionRepository.findByUserIdAndAmountAndCreatedAtBetween( - eq("user1"), eq(-1), any(), any())) - .thenReturn(List.of()); - - List tokens = - List.of( - createFcmToken("user1", "token1", CountryCode.KR), - createFcmToken("user1", "token2", CountryCode.KR)); - when(fcmTokenRepository.findByUserIdAndIsActive("user1", true)).thenReturn(tokens); - - // 하나는 성공, 하나는 실패 - BatchResponse batchResponse = mock(BatchResponse.class); - when(batchResponse.getSuccessCount()).thenReturn(1); - when(batchResponse.getFailureCount()).thenReturn(1); - - List responses = new ArrayList<>(); - SendResponse successResponse = mock(SendResponse.class); - when(successResponse.isSuccessful()).thenReturn(true); - - SendResponse failResponse = mock(SendResponse.class); - when(failResponse.isSuccessful()).thenReturn(false); - FirebaseMessagingException exception = mock(FirebaseMessagingException.class); - when(exception.getMessage()).thenReturn("Invalid token"); - when(failResponse.getException()).thenReturn(exception); - - responses.add(successResponse); - responses.add(failResponse); - - when(batchResponse.getResponses()).thenReturn(responses); - when(fcmMessagingService.sendMulticastMessage(anyList(), any(FcmMessageRequest.class))) - .thenReturn(batchResponse); - - // when - scheduler.sendStreakProtectionNotifications(); - - // then - verify(fcmTokenService).deactivateToken("token2"); - } - - // Helper methods - private UserStudyReport createUserReport(String userId, int currentStreak) { - UserStudyReport report = new UserStudyReport(); - report.setUserId(userId); - report.setCurrentStreak(currentStreak); - return report; - } - - private FcmToken createFcmToken(String userId, String token, CountryCode countryCode) { - FcmToken fcmToken = new FcmToken(); - fcmToken.setUserId(userId); - fcmToken.setFcmToken(token); - fcmToken.setCountryCode(countryCode); - fcmToken.setIsActive(true); - return fcmToken; - } } diff --git a/src/test/java/com/linglevel/api/streak/service/ReadingSessionServiceTest.java b/src/test/java/com/linglevel/api/streak/service/ReadingSessionServiceTest.java index 6258c53c..052af665 100644 --- a/src/test/java/com/linglevel/api/streak/service/ReadingSessionServiceTest.java +++ b/src/test/java/com/linglevel/api/streak/service/ReadingSessionServiceTest.java @@ -17,247 +17,238 @@ class ReadingSessionServiceTest extends AbstractRedisTest { - private ReadingSessionService readingSessionService; - private RedisTemplate redisTemplate; - - private static final String TEST_USER_ID = "test-user-123"; - private static final String TEST_BOOK_ID = "test-book-456"; - private static final String TEST_ARTICLE_ID = "test-article-789"; - - @BeforeEach - void setup() { - // Redis 연결 설정 - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); - config.setHostName(getRedisContainer().getHost()); - config.setPort(getRedisContainer().getMappedPort(6379)); - - JedisConnectionFactory connectionFactory = new JedisConnectionFactory(config); - connectionFactory.afterPropertiesSet(); - - // RedisTemplate 설정 - redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(connectionFactory); - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); - redisTemplate.setHashKeySerializer(new StringRedisSerializer()); - redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); - redisTemplate.afterPropertiesSet(); - - // ReadingSessionService 생성 - readingSessionService = new ReadingSessionService(redisTemplate); - } - - @AfterEach - void cleanup() { - // 테스트 후 Redis 정리 - if (readingSessionService != null) { - readingSessionService.deleteReadingSession(TEST_USER_ID); - } - } - - @Test - @DisplayName("읽기 세션 시작 - Redis에 저장 확인") - void startReadingSession_Success() { - // given - ContentType contentType = ContentType.BOOK; - String contentId = TEST_BOOK_ID; - - // when - readingSessionService.startReadingSession(TEST_USER_ID, contentType, contentId); - - // then - ReadingSession session = readingSessionService.getReadingSession(TEST_USER_ID); - assertThat(session).isNotNull(); - assertThat(session.getContentType()).isEqualTo(contentType); - assertThat(session.getContentId()).isEqualTo(contentId); - assertThat(session.getStartedAtMillis()).isNotNull(); - assertThat(session.getStartedAtMillis()).isLessThanOrEqualTo(System.currentTimeMillis()); - } - - @Test - @DisplayName("읽기 세션 조회 - 세션이 없으면 null 반환") - void getReadingSession_NotFound() { - // when - ReadingSession session = readingSessionService.getReadingSession("non-existent-user"); - - // then - assertThat(session).isNull(); - } - - @Test - @DisplayName("읽기 세션 검증 - 30초 이상 경과 시 true") - void isReadingSessionValid_After30Seconds() { - // given - 31초 전에 시작한 세션을 직접 생성 - ContentType contentType = ContentType.BOOK; - String contentId = TEST_BOOK_ID; - long thirtyOneSecondsAgo = System.currentTimeMillis() - 31_000; - - ReadingSession session = ReadingSession.builder() - .contentType(contentType) - .contentId(contentId) - .startedAtMillis(thirtyOneSecondsAgo) - .build(); - - String key = "user:" + TEST_USER_ID + ":reading_session"; - redisTemplate.opsForValue().set(key, session); - - // when - boolean isValid = readingSessionService.isReadingSessionValid(TEST_USER_ID, contentType, contentId); - - // then - assertThat(isValid).isTrue(); - } - - @Test - @DisplayName("읽기 세션 검증 - 5초 미만 경과 시 false") - void isReadingSessionValid_Before5Seconds() { - // given - 10초 전에 시작한 세션을 직접 생성 - ContentType contentType = ContentType.BOOK; - String contentId = TEST_BOOK_ID; - long tenSecondsAgo = System.currentTimeMillis() - 1_000; - - ReadingSession session = ReadingSession.builder() - .contentType(contentType) - .contentId(contentId) - .startedAtMillis(tenSecondsAgo) - .build(); - - String key = "user:" + TEST_USER_ID + ":reading_session"; - redisTemplate.opsForValue().set(key, session); - - // when - boolean isValid = readingSessionService.isReadingSessionValid(TEST_USER_ID, contentType, contentId); - - // then - assertThat(isValid).isFalse(); - } - - @Test - @DisplayName("읽기 세션 검증 - 세션이 없으면 false") - void isReadingSessionValid_NoSession() { - // when - boolean isValid = readingSessionService.isReadingSessionValid( - "non-existent-user", - ContentType.BOOK, - TEST_BOOK_ID - ); - - // then - assertThat(isValid).isFalse(); - } - - @Test - @DisplayName("읽기 세션 검증 - ContentType이 다르면 false") - void isReadingSessionValid_DifferentContentType() { - // given - 31초 전에 시작한 BOOK 세션 - long thirtyOneSecondsAgo = System.currentTimeMillis() - 31_000; - ReadingSession session = ReadingSession.builder() - .contentType(ContentType.BOOK) - .contentId(TEST_BOOK_ID) - .startedAtMillis(thirtyOneSecondsAgo) - .build(); - - String key = "user:" + TEST_USER_ID + ":reading_session"; - redisTemplate.opsForValue().set(key, session); - - // when - ARTICLE로 검증 시도 - boolean isValid = readingSessionService.isReadingSessionValid( - TEST_USER_ID, - ContentType.ARTICLE, - TEST_BOOK_ID - ); - - // then - assertThat(isValid).isFalse(); - } - - @Test - @DisplayName("읽기 세션 검증 - ContentId가 다르면 false") - void isReadingSessionValid_DifferentContentId() { - // given - 31초 전에 시작한 세션 - long thirtyOneSecondsAgo = System.currentTimeMillis() - 31_000; - ReadingSession session = ReadingSession.builder() - .contentType(ContentType.BOOK) - .contentId(TEST_BOOK_ID) - .startedAtMillis(thirtyOneSecondsAgo) - .build(); - - String key = "user:" + TEST_USER_ID + ":reading_session"; - redisTemplate.opsForValue().set(key, session); - - // when - 다른 bookId로 검증 시도 - boolean isValid = readingSessionService.isReadingSessionValid( - TEST_USER_ID, - ContentType.BOOK, - "different-book-id" - ); - - // then - assertThat(isValid).isFalse(); - } - - @Test - @DisplayName("읽기 세션 덮어쓰기 - A 작품 → B 작품 전환") - void startReadingSession_Override() { - // given - Book A 세션 시작 - readingSessionService.startReadingSession(TEST_USER_ID, ContentType.BOOK, TEST_BOOK_ID); - - // when - Article B 세션 시작 (덮어쓰기) - readingSessionService.startReadingSession(TEST_USER_ID, ContentType.ARTICLE, TEST_ARTICLE_ID); - - // then - Article B 세션만 존재 - ReadingSession session = readingSessionService.getReadingSession(TEST_USER_ID); - assertThat(session).isNotNull(); - assertThat(session.getContentType()).isEqualTo(ContentType.ARTICLE); - assertThat(session.getContentId()).isEqualTo(TEST_ARTICLE_ID); - - // Book A는 검증 실패 (세션이 덮어써졌으므로, ContentType이 다름) - boolean isValidBookA = readingSessionService.isReadingSessionValid( - TEST_USER_ID, - ContentType.BOOK, - TEST_BOOK_ID - ); - assertThat(isValidBookA).isFalse(); - } - - @Test - @DisplayName("읽기 세션 삭제") - void deleteReadingSession_Success() { - // given - readingSessionService.startReadingSession(TEST_USER_ID, ContentType.BOOK, TEST_BOOK_ID); - assertThat(readingSessionService.getReadingSession(TEST_USER_ID)).isNotNull(); - - // when - readingSessionService.deleteReadingSession(TEST_USER_ID); - - // then - assertThat(readingSessionService.getReadingSession(TEST_USER_ID)).isNull(); - } - - @Test - @DisplayName("Redis 직렬화 테스트 - ReadingSession 저장/조회") - void testRedisSerialization() { - // given - String key = "test:serialization"; - ReadingSession session = ReadingSession.builder() - .contentType(ContentType.BOOK) - .contentId(TEST_BOOK_ID) - .startedAtMillis(System.currentTimeMillis()) - .build(); - - // when - redisTemplate.opsForValue().set(key, session); - Object retrieved = redisTemplate.opsForValue().get(key); - - // then - assertThat(retrieved).isNotNull(); - assertThat(retrieved).isInstanceOf(ReadingSession.class); - ReadingSession retrievedSession = (ReadingSession) retrieved; - assertThat(retrievedSession.getContentType()).isEqualTo(ContentType.BOOK); - assertThat(retrievedSession.getContentId()).isEqualTo(TEST_BOOK_ID); - assertThat(retrievedSession.getStartedAtMillis()).isEqualTo(session.getStartedAtMillis()); - - // cleanup - redisTemplate.delete(key); - } + private ReadingSessionService readingSessionService; + + private RedisTemplate redisTemplate; + + private static final String TEST_USER_ID = "test-user-123"; + + private static final String TEST_BOOK_ID = "test-book-456"; + + private static final String TEST_ARTICLE_ID = "test-article-789"; + + @BeforeEach + void setup() { + // Redis 연결 설정 + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(getRedisContainer().getHost()); + config.setPort(getRedisContainer().getMappedPort(6379)); + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(config); + connectionFactory.afterPropertiesSet(); + + // RedisTemplate 설정 + redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.afterPropertiesSet(); + + // ReadingSessionService 생성 + readingSessionService = new ReadingSessionService(redisTemplate); + } + + @AfterEach + void cleanup() { + // 테스트 후 Redis 정리 + if (readingSessionService != null) { + readingSessionService.deleteReadingSession(TEST_USER_ID); + } + } + + @Test + @DisplayName("읽기 세션 시작 - Redis에 저장 확인") + void startReadingSession_Success() { + // given + ContentType contentType = ContentType.BOOK; + String contentId = TEST_BOOK_ID; + + // when + readingSessionService.startReadingSession(TEST_USER_ID, contentType, contentId); + + // then + ReadingSession session = readingSessionService.getReadingSession(TEST_USER_ID); + assertThat(session).isNotNull(); + assertThat(session.getContentType()).isEqualTo(contentType); + assertThat(session.getContentId()).isEqualTo(contentId); + assertThat(session.getStartedAtMillis()).isNotNull(); + assertThat(session.getStartedAtMillis()).isLessThanOrEqualTo(System.currentTimeMillis()); + } + + @Test + @DisplayName("읽기 세션 조회 - 세션이 없으면 null 반환") + void getReadingSession_NotFound() { + // when + ReadingSession session = readingSessionService.getReadingSession("non-existent-user"); + + // then + assertThat(session).isNull(); + } + + @Test + @DisplayName("읽기 세션 검증 - 30초 이상 경과 시 true") + void isReadingSessionValid_After30Seconds() { + // given - 31초 전에 시작한 세션을 직접 생성 + ContentType contentType = ContentType.BOOK; + String contentId = TEST_BOOK_ID; + long thirtyOneSecondsAgo = System.currentTimeMillis() - 31_000; + + ReadingSession session = ReadingSession.builder() + .contentType(contentType) + .contentId(contentId) + .startedAtMillis(thirtyOneSecondsAgo) + .build(); + + String key = "user:" + TEST_USER_ID + ":reading_session"; + redisTemplate.opsForValue().set(key, session); + + // when + boolean isValid = readingSessionService.isReadingSessionValid(TEST_USER_ID, contentType, contentId); + + // then + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("읽기 세션 검증 - 5초 미만 경과 시 false") + void isReadingSessionValid_Before5Seconds() { + // given - 10초 전에 시작한 세션을 직접 생성 + ContentType contentType = ContentType.BOOK; + String contentId = TEST_BOOK_ID; + long tenSecondsAgo = System.currentTimeMillis() - 1_000; + + ReadingSession session = ReadingSession.builder() + .contentType(contentType) + .contentId(contentId) + .startedAtMillis(tenSecondsAgo) + .build(); + + String key = "user:" + TEST_USER_ID + ":reading_session"; + redisTemplate.opsForValue().set(key, session); + + // when + boolean isValid = readingSessionService.isReadingSessionValid(TEST_USER_ID, contentType, contentId); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("읽기 세션 검증 - 세션이 없으면 false") + void isReadingSessionValid_NoSession() { + // when + boolean isValid = readingSessionService.isReadingSessionValid("non-existent-user", ContentType.BOOK, + TEST_BOOK_ID); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("읽기 세션 검증 - ContentType이 다르면 false") + void isReadingSessionValid_DifferentContentType() { + // given - 31초 전에 시작한 BOOK 세션 + long thirtyOneSecondsAgo = System.currentTimeMillis() - 31_000; + ReadingSession session = ReadingSession.builder() + .contentType(ContentType.BOOK) + .contentId(TEST_BOOK_ID) + .startedAtMillis(thirtyOneSecondsAgo) + .build(); + + String key = "user:" + TEST_USER_ID + ":reading_session"; + redisTemplate.opsForValue().set(key, session); + + // when - ARTICLE로 검증 시도 + boolean isValid = readingSessionService.isReadingSessionValid(TEST_USER_ID, ContentType.ARTICLE, TEST_BOOK_ID); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("읽기 세션 검증 - ContentId가 다르면 false") + void isReadingSessionValid_DifferentContentId() { + // given - 31초 전에 시작한 세션 + long thirtyOneSecondsAgo = System.currentTimeMillis() - 31_000; + ReadingSession session = ReadingSession.builder() + .contentType(ContentType.BOOK) + .contentId(TEST_BOOK_ID) + .startedAtMillis(thirtyOneSecondsAgo) + .build(); + + String key = "user:" + TEST_USER_ID + ":reading_session"; + redisTemplate.opsForValue().set(key, session); + + // when - 다른 bookId로 검증 시도 + boolean isValid = readingSessionService.isReadingSessionValid(TEST_USER_ID, ContentType.BOOK, + "different-book-id"); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("읽기 세션 덮어쓰기 - A 작품 → B 작품 전환") + void startReadingSession_Override() { + // given - Book A 세션 시작 + readingSessionService.startReadingSession(TEST_USER_ID, ContentType.BOOK, TEST_BOOK_ID); + + // when - Article B 세션 시작 (덮어쓰기) + readingSessionService.startReadingSession(TEST_USER_ID, ContentType.ARTICLE, TEST_ARTICLE_ID); + + // then - Article B 세션만 존재 + ReadingSession session = readingSessionService.getReadingSession(TEST_USER_ID); + assertThat(session).isNotNull(); + assertThat(session.getContentType()).isEqualTo(ContentType.ARTICLE); + assertThat(session.getContentId()).isEqualTo(TEST_ARTICLE_ID); + + // Book A는 검증 실패 (세션이 덮어써졌으므로, ContentType이 다름) + boolean isValidBookA = readingSessionService.isReadingSessionValid(TEST_USER_ID, ContentType.BOOK, + TEST_BOOK_ID); + assertThat(isValidBookA).isFalse(); + } + + @Test + @DisplayName("읽기 세션 삭제") + void deleteReadingSession_Success() { + // given + readingSessionService.startReadingSession(TEST_USER_ID, ContentType.BOOK, TEST_BOOK_ID); + assertThat(readingSessionService.getReadingSession(TEST_USER_ID)).isNotNull(); + + // when + readingSessionService.deleteReadingSession(TEST_USER_ID); + + // then + assertThat(readingSessionService.getReadingSession(TEST_USER_ID)).isNull(); + } + + @Test + @DisplayName("Redis 직렬화 테스트 - ReadingSession 저장/조회") + void testRedisSerialization() { + // given + String key = "test:serialization"; + ReadingSession session = ReadingSession.builder() + .contentType(ContentType.BOOK) + .contentId(TEST_BOOK_ID) + .startedAtMillis(System.currentTimeMillis()) + .build(); + + // when + redisTemplate.opsForValue().set(key, session); + Object retrieved = redisTemplate.opsForValue().get(key); + + // then + assertThat(retrieved).isNotNull(); + assertThat(retrieved).isInstanceOf(ReadingSession.class); + ReadingSession retrievedSession = (ReadingSession) retrieved; + assertThat(retrievedSession.getContentType()).isEqualTo(ContentType.BOOK); + assertThat(retrievedSession.getContentId()).isEqualTo(TEST_BOOK_ID); + assertThat(retrievedSession.getStartedAtMillis()).isEqualTo(session.getStartedAtMillis()); + + // cleanup + redisTemplate.delete(key); + } + } diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceBackfillTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceBackfillTest.java index 7bd35ddf..f252201e 100644 --- a/src/test/java/com/linglevel/api/streak/service/StreakServiceBackfillTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceBackfillTest.java @@ -26,295 +26,296 @@ import static org.assertj.core.api.Assertions.assertThat; @DataMongoTest -@Import({StreakService.class, TicketService.class}) +@Import({ StreakService.class, TicketService.class }) @DisplayName("StreakService Backfill 테스트") class StreakServiceBackfillTest extends AbstractDatabaseTest { - @Autowired - private StreakService streakService; - - @Autowired - private DailyCompletionRepository dailyCompletionRepository; - - @Autowired - private UserStudyReportRepository userStudyReportRepository; - - @Autowired - private FreezeTransactionRepository freezeTransactionRepository; - - @Autowired - private TicketTransactionRepository ticketTransactionRepository; - - @MockitoBean - private ReadingSessionService readingSessionService; - - private static final String TEST_USER_ID = "backfill-test-user"; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); - - @BeforeEach - void setUp() { - dailyCompletionRepository.deleteAll(); - userStudyReportRepository.deleteAll(); - freezeTransactionRepository.deleteAll(); - ticketTransactionRepository.deleteAll(); - } - - @Test - @DisplayName("단일 연속 streak, 모든 streakCount가 null인 경우 backfill") - void testBackfill_SingleStreak_AllNull() { - // Given: 5일 연속 학습했지만 streakCount가 모두 null - LocalDate startDate = LocalDate.of(2024, 1, 1); - - for (int i = 0; i < 5; i++) { - LocalDate date = startDate.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) // null! - .streakStatus(StreakStatus.COMPLETED) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // When: 캘린더 조회 (backfill 트리거) - CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); - - // Then: streakCount가 1, 2, 3, 4, 5로 채워짐 - assertThat(calendar.getDays()).hasSize(31); - assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); - assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(2); - assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(3); - assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(4); - assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(5); - - // DB에도 저장되었는지 확인 - DailyCompletion saved = dailyCompletionRepository - .findByUserIdAndCompletionDate(TEST_USER_ID, startDate) - .orElseThrow(); - assertThat(saved.getStreakCount()).isEqualTo(1); - } - - @Test - @DisplayName("Freeze 포함 streak, freeze는 카운트 유지") - void testBackfill_WithFreeze_MaintainCount() { - // Given: 1일 학습, 2일 freeze, 3일 학습 (streakCount null) - LocalDate day1 = LocalDate.of(2024, 1, 1); - LocalDate day2 = LocalDate.of(2024, 1, 2); - LocalDate day3 = LocalDate.of(2024, 1, 3); - - // Day 1: 학습 - DailyCompletion completion1 = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(day1) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion1); - - // Day 2: Freeze (DailyCompletion with count=0) - DailyCompletion completion2 = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(day2) - .totalCompletionCount(0) - .firstCompletionCount(0) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion2); - - FreezeTransaction freezeTx = FreezeTransaction.builder() - .userId(TEST_USER_ID) - .amount(-1) - .description("Freeze for day 2") - .createdAt(day2.atStartOfDay(KST_ZONE).toInstant()) - .build(); - freezeTransactionRepository.save(freezeTx); - - // Day 3: 학습 - DailyCompletion completion3 = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(day3) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion3); - - // When: 캘린더 조회 - CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); - - // Then: Day1=1, Day2=1 (유지!), Day3=2 - assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); - assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(1); // Freeze는 유지 - assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(2); - } - - @Test - @DisplayName("다중 독립 streak 구간, 모두 backfill") - void testBackfill_MultipleStreaks() { - // Given: - // 1-3일: streak 3 - // 4일: MISSED (끊김) - // 5-7일: 새로운 streak 3 - LocalDate day1 = LocalDate.of(2024, 1, 1); - - // First streak: 1-3일 - for (int i = 0; i < 3; i++) { - LocalDate date = day1.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // Day 4: MISSED (데이터 없음) - - // Second streak: 5-7일 - for (int i = 4; i < 7; i++) { - LocalDate date = day1.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // When: 캘린더 조회 - CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); - - // Then: - // Day 1-3: 1, 2, 3 - assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); - assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(2); - assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(3); - - // Day 4: MISSED - assertThat(calendar.getDays().get(3).getStatus()).isEqualTo(StreakStatus.MISSED); - assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(0); - - // Day 5-7: 1, 2, 3 (새로운 streak!) - assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(1); - assertThat(calendar.getDays().get(5).getStreakCount()).isEqualTo(2); - assertThat(calendar.getDays().get(6).getStreakCount()).isEqualTo(3); - } - - @Test - @DisplayName("일부는 이미 채워짐, 일부만 null인 경우") - void testBackfill_PartiallyFilled() { - // Given: - // 1-2일: 이미 streakCount 있음 - // 3-5일: streakCount null - LocalDate day1 = LocalDate.of(2024, 1, 1); - - // Day 1-2: 이미 채워진 데이터 - for (int i = 0; i < 2; i++) { - LocalDate date = day1.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(i + 1) // 이미 있음! - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // Day 3-5: null - for (int i = 2; i < 5; i++) { - LocalDate date = day1.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // When: 캘린더 조회 - CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); - - // Then: Day 3-5만 채워짐 (3, 4, 5) - assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); // 기존 유지 - assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(2); // 기존 유지 - assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(3); // 새로 채워짐 - assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(4); // 새로 채워짐 - assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(5); // 새로 채워짐 - } - - @Test - @DisplayName("캘린더 범위 밖으로 확장되는 streak") - void testBackfill_StreakExtendsBeforeCalendar() { - // Given: - // 12월 28-31일: streak 시작 - // 1월 1-5일: 계속 (null) - LocalDate dec28 = LocalDate.of(2023, 12, 28); - - // 12월 28-31일 - for (int i = 0; i < 4; i++) { - LocalDate date = dec28.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // 1월 1-5일 - LocalDate jan1 = LocalDate.of(2024, 1, 1); - for (int i = 0; i < 5; i++) { - LocalDate date = jan1.plusDays(i); - DailyCompletion completion = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(null) - .createdAt(Instant.now()) - .build(); - dailyCompletionRepository.save(completion); - } - - // When: 1월 캘린더 조회 - CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); - - // Then: 1월 1일은 5 (12/28-1/1 = 5일) - assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(5); - assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(6); - assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(7); - assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(8); - assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(9); - } + @Autowired + private StreakService streakService; + + @Autowired + private DailyCompletionRepository dailyCompletionRepository; + + @Autowired + private UserStudyReportRepository userStudyReportRepository; + + @Autowired + private FreezeTransactionRepository freezeTransactionRepository; + + @Autowired + private TicketTransactionRepository ticketTransactionRepository; + + @MockitoBean + private ReadingSessionService readingSessionService; + + private static final String TEST_USER_ID = "backfill-test-user"; + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + @BeforeEach + void setUp() { + dailyCompletionRepository.deleteAll(); + userStudyReportRepository.deleteAll(); + freezeTransactionRepository.deleteAll(); + ticketTransactionRepository.deleteAll(); + } + + @Test + @DisplayName("단일 연속 streak, 모든 streakCount가 null인 경우 backfill") + void testBackfill_SingleStreak_AllNull() { + // Given: 5일 연속 학습했지만 streakCount가 모두 null + LocalDate startDate = LocalDate.of(2024, 1, 1); + + for (int i = 0; i < 5; i++) { + LocalDate date = startDate.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) // null! + .streakStatus(StreakStatus.COMPLETED) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // When: 캘린더 조회 (backfill 트리거) + CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); + + // Then: streakCount가 1, 2, 3, 4, 5로 채워짐 + assertThat(calendar.getDays()).hasSize(31); + assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); + assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(2); + assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(3); + assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(4); + assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(5); + + // DB에도 저장되었는지 확인 + DailyCompletion saved = dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, startDate) + .orElseThrow(); + assertThat(saved.getStreakCount()).isEqualTo(1); + } + + @Test + @DisplayName("Freeze 포함 streak, freeze는 카운트 유지") + void testBackfill_WithFreeze_MaintainCount() { + // Given: 1일 학습, 2일 freeze, 3일 학습 (streakCount null) + LocalDate day1 = LocalDate.of(2024, 1, 1); + LocalDate day2 = LocalDate.of(2024, 1, 2); + LocalDate day3 = LocalDate.of(2024, 1, 3); + + // Day 1: 학습 + DailyCompletion completion1 = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(day1) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion1); + + // Day 2: Freeze (DailyCompletion with count=0) + DailyCompletion completion2 = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(day2) + .totalCompletionCount(0) + .firstCompletionCount(0) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion2); + + FreezeTransaction freezeTx = FreezeTransaction.builder() + .userId(TEST_USER_ID) + .amount(-1) + .description("Freeze for day 2") + .createdAt(day2.atStartOfDay(KST_ZONE).toInstant()) + .build(); + freezeTransactionRepository.save(freezeTx); + + // Day 3: 학습 + DailyCompletion completion3 = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(day3) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion3); + + // When: 캘린더 조회 + CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); + + // Then: Day1=1, Day2=1 (유지!), Day3=2 + assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); + assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(1); // Freeze는 유지 + assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(2); + } + + @Test + @DisplayName("다중 독립 streak 구간, 모두 backfill") + void testBackfill_MultipleStreaks() { + // Given: + // 1-3일: streak 3 + // 4일: MISSED (끊김) + // 5-7일: 새로운 streak 3 + LocalDate day1 = LocalDate.of(2024, 1, 1); + + // First streak: 1-3일 + for (int i = 0; i < 3; i++) { + LocalDate date = day1.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // Day 4: MISSED (데이터 없음) + + // Second streak: 5-7일 + for (int i = 4; i < 7; i++) { + LocalDate date = day1.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // When: 캘린더 조회 + CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); + + // Then: + // Day 1-3: 1, 2, 3 + assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); + assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(2); + assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(3); + + // Day 4: MISSED + assertThat(calendar.getDays().get(3).getStatus()).isEqualTo(StreakStatus.MISSED); + assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(0); + + // Day 5-7: 1, 2, 3 (새로운 streak!) + assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(1); + assertThat(calendar.getDays().get(5).getStreakCount()).isEqualTo(2); + assertThat(calendar.getDays().get(6).getStreakCount()).isEqualTo(3); + } + + @Test + @DisplayName("일부는 이미 채워짐, 일부만 null인 경우") + void testBackfill_PartiallyFilled() { + // Given: + // 1-2일: 이미 streakCount 있음 + // 3-5일: streakCount null + LocalDate day1 = LocalDate.of(2024, 1, 1); + + // Day 1-2: 이미 채워진 데이터 + for (int i = 0; i < 2; i++) { + LocalDate date = day1.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(i + 1) // 이미 있음! + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // Day 3-5: null + for (int i = 2; i < 5; i++) { + LocalDate date = day1.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // When: 캘린더 조회 + CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); + + // Then: Day 3-5만 채워짐 (3, 4, 5) + assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(1); // 기존 유지 + assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(2); // 기존 유지 + assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(3); // 새로 채워짐 + assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(4); // 새로 채워짐 + assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(5); // 새로 채워짐 + } + + @Test + @DisplayName("캘린더 범위 밖으로 확장되는 streak") + void testBackfill_StreakExtendsBeforeCalendar() { + // Given: + // 12월 28-31일: streak 시작 + // 1월 1-5일: 계속 (null) + LocalDate dec28 = LocalDate.of(2023, 12, 28); + + // 12월 28-31일 + for (int i = 0; i < 4; i++) { + LocalDate date = dec28.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // 1월 1-5일 + LocalDate jan1 = LocalDate.of(2024, 1, 1); + for (int i = 0; i < 5; i++) { + LocalDate date = jan1.plusDays(i); + DailyCompletion completion = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(null) + .createdAt(Instant.now()) + .build(); + dailyCompletionRepository.save(completion); + } + + // When: 1월 캘린더 조회 + CalendarResponse calendar = streakService.getCalendar(TEST_USER_ID, 2024, 1); + + // Then: 1월 1일은 5 (12/28-1/1 = 5일) + assertThat(calendar.getDays().get(0).getStreakCount()).isEqualTo(5); + assertThat(calendar.getDays().get(1).getStreakCount()).isEqualTo(6); + assertThat(calendar.getDays().get(2).getStreakCount()).isEqualTo(7); + assertThat(calendar.getDays().get(3).getStreakCount()).isEqualTo(8); + assertThat(calendar.getDays().get(4).getStreakCount()).isEqualTo(9); + } + } diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceBusinessLogicTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceBusinessLogicTest.java index 73720b0a..ee75d409 100644 --- a/src/test/java/com/linglevel/api/streak/service/StreakServiceBusinessLogicTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceBusinessLogicTest.java @@ -35,374 +35,369 @@ @DisplayName("StreakService - 비즈니스 로직 검증 테스트") class StreakServiceBusinessLogicTest { - @Mock - private UserStudyReportRepository userStudyReportRepository; - - @Mock - private DailyCompletionRepository dailyCompletionRepository; - - @Mock - private TicketService ticketService; - - @Mock - private FreezeTransactionRepository freezeTransactionRepository; - - @Mock - private ReadingSessionService readingSessionService; - - @InjectMocks - private StreakService streakService; - - private static final String TEST_USER_ID = "test-user-123"; - private static final String CHAPTER_ID_1 = "chapter-1"; - private static final String CHAPTER_ID_2 = "chapter-2"; - private static final ContentType CONTENT_TYPE = ContentType.BOOK; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); - - private UserStudyReport testReport; - private LocalDate today; - - @BeforeEach - void setUp() { - today = LocalDate.now(KST_ZONE); - testReport = new UserStudyReport(); - testReport.setUserId(TEST_USER_ID); - testReport.setCompletedContentIds(new HashSet<>()); - testReport.setCurrentStreak(0); - testReport.setLongestStreak(0); - testReport.setAvailableFreezes(0); - testReport.setTotalReadingTimeSeconds(0L); - testReport.setCreatedAt(Instant.now()); - } - - @Test - @DisplayName("첫 스트릭 완료 시 스트릭 증가") - void updateStreak_FirstTime_IncreasesStreak() { - // given - Reading session 검증은 호출하는 쪽에서 이미 수행됨 - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // when - boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - - // then - assertThat(result).isTrue(); - assertThat(testReport.getCurrentStreak()).isEqualTo(1); - assertThat(testReport.getLongestStreak()).isEqualTo(1); - verify(userStudyReportRepository).save(testReport); - } - - - @Test - @DisplayName("같은 날 두 번째 콘텐츠 완료 시 스트릭 중복 방지") - void updateStreak_SameDaySecondContent_ReturnsFalse() { - // given - 오늘 이미 스트릭 완료 - DailyCompletion existingDaily = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .streakStatus(StreakStatus.COMPLETED) - .streakCount(1) - .totalCompletionCount(1) - .firstCompletionCount(1) - .completedContents(new ArrayList<>()) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.of(existingDaily)); - - // when - boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2); - - // then - 스트릭은 업데이트 안됨 - assertThat(result).isFalse(); - verify(userStudyReportRepository, never()).save(any()); - } - - @Test - @DisplayName("5일 연속 달성 시 프리즈 1개 지급") - void updateStreak_FiveDayStreak_GrantsFreeze() { - // given - testReport.setCurrentStreak(4); - testReport.setLastCompletionDate(today.minusDays(1)); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // when - boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - - // then - assertThat(result).isTrue(); - assertThat(testReport.getCurrentStreak()).isEqualTo(5); - assertThat(testReport.getAvailableFreezes()).isEqualTo(1); - - ArgumentCaptor freezeCaptor = ArgumentCaptor.forClass(FreezeTransaction.class); - verify(freezeTransactionRepository).save(freezeCaptor.capture()); - assertThat(freezeCaptor.getValue().getAmount()).isEqualTo(1); - assertThat(freezeCaptor.getValue().getDescription()).contains("5-day streak"); - } - - @Test - @DisplayName("프리즈 2개 보유 시 추가 지급 안됨 (MAX 2개)") - void updateStreak_MaxFreezesReached_NoAdditionalFreeze() { - // given - 이미 프리즈 2개 보유 - testReport.setCurrentStreak(4); - testReport.setLastCompletionDate(today.minusDays(1)); - testReport.setAvailableFreezes(2); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // when - streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - - // then - 프리즈 증가 안함 - assertThat(testReport.getAvailableFreezes()).isEqualTo(2); - verify(freezeTransactionRepository, never()).save(any()); - } - - @Test - @DisplayName("7일 연속 달성 시 티켓 1개 지급") - void updateStreak_SevenDayStreak_GrantsTicket() { - // given - testReport.setCurrentStreak(6); - testReport.setLastCompletionDate(today.minusDays(1)); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // when - streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - - // then - assertThat(testReport.getCurrentStreak()).isEqualTo(7); - verify(ticketService).grantTicket(eq(TEST_USER_ID), eq(1), contains("7-day streak")); - } - - @Test - @DisplayName("15일, 30일 연속 달성 시 티켓 추가 지급") - void updateStreak_FifteenAndThirtyDayStreak_GrantsTickets() { - // given - 14일 스트릭 - testReport.setCurrentStreak(14); - testReport.setLastCompletionDate(today.minusDays(1)); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // when - 15일 달성 - streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - - // then - assertThat(testReport.getCurrentStreak()).isEqualTo(15); - verify(ticketService).grantTicket(eq(TEST_USER_ID), eq(1), contains("15-day streak")); - - // given - 29일 스트릭 - testReport.setCurrentStreak(29); - testReport.setLastCompletionDate(today.minusDays(1)); - reset(ticketService); - - // when - 30일 달성 - streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - - // then - assertThat(testReport.getCurrentStreak()).isEqualTo(30); - verify(ticketService).grantTicket(eq(TEST_USER_ID), eq(1), contains("30-day streak")); - } - - @Test - @DisplayName("학습 시간 누적 테스트") - void addStudyTime_AccumulatesCorrectly() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // when - 여러 번 학습 - streakService.addStudyTime(TEST_USER_ID, 120L); // 2분 - streakService.addStudyTime(TEST_USER_ID, 180L); // 3분 - streakService.addStudyTime(TEST_USER_ID, 60L); // 1분 - - // then - assertThat(testReport.getTotalReadingTimeSeconds()).isEqualTo(360L); // 6분 - verify(userStudyReportRepository, times(3)).save(testReport); - } - - @Test - @DisplayName("첫 완료 콘텐츠 기록 시 firstCompletionCount 증가") - void addCompletedContent_FirstTime_IncrementsFirstCount() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - - // when - 스트릭과 무관하게 완료만 기록 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); - - // then - assertThat(testReport.getCompletedContentIds()).contains(CHAPTER_ID_1); - - ArgumentCaptor dailyCaptor = ArgumentCaptor.forClass(DailyCompletion.class); - verify(dailyCompletionRepository).save(dailyCaptor.capture()); - - DailyCompletion savedDaily = dailyCaptor.getValue(); - assertThat(savedDaily.getFirstCompletionCount()).isEqualTo(1); - assertThat(savedDaily.getTotalCompletionCount()).isEqualTo(1); - } - - @Test - @DisplayName("이미 완료한 콘텐츠 재완료 시 totalCount는 증가, firstCount는 유지") - void addCompletedContent_AlreadyCompleted_DoesNotIncrementFirstCount() { - // given - 이미 완료한 콘텐츠 - testReport.getCompletedContentIds().add(CHAPTER_ID_1); - - DailyCompletion existing = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(0) - .createdAt(Instant.now()) - .build(); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.of(existing)); - - // when - 재완료이므로 streakUpdated=false - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); - - // then - totalCount는 증가, firstCount는 유지 - ArgumentCaptor captor = ArgumentCaptor.forClass(DailyCompletion.class); - verify(dailyCompletionRepository).save(captor.capture()); - - DailyCompletion saved = captor.getValue(); - assertThat(saved.getFirstCompletionCount()).isEqualTo(1); // 유지 - assertThat(saved.getTotalCompletionCount()).isEqualTo(2); // 증가 - } - - @Test - @DisplayName("같은 날 여러 콘텐츠 완료 시 모두 기록, firstCount는 첫 완료만 증가") - void addCompletedContent_MultipleContents_OnlyFirstIncrementsFirstCount() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - DailyCompletion dailyAfterFirst = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(0) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()) // 첫 번째 완료 - .thenReturn(Optional.of(dailyAfterFirst)); // 두 번째 완료 - - // when - 첫 번째는 스트릭 완료, 두 번째는 같은 날이라 스트릭 X - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, true); // 스트릭 완료 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2, false); // 같은 날 추가 완료 - - // then - assertThat(testReport.getCompletedContentIds()).contains(CHAPTER_ID_1, CHAPTER_ID_2); - - ArgumentCaptor dailyCaptor = ArgumentCaptor.forClass(DailyCompletion.class); - verify(dailyCompletionRepository, times(2)).save(dailyCaptor.capture()); - - // 두 번째 저장에서 firstCount는 1, totalCount는 2 - DailyCompletion secondSave = dailyCaptor.getAllValues().get(1); - assertThat(secondSave.getFirstCompletionCount()).isEqualTo(2); // 두 번째도 첫 완료 - assertThat(secondSave.getTotalCompletionCount()).isEqualTo(2); - } - - @Test - @DisplayName("재완료 콘텐츠: firstCompletionCount는 유지, totalCompletionCount는 증가") - void addCompletedContent_Recompletion_OnlyIncrementsTotalCount() { - // given - 첫 완료 - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - DailyCompletion dailyAfterFirst = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(0) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()) // 첫 완료 - .thenReturn(Optional.of(dailyAfterFirst)); // 재완료 - - // when - 첫 완료 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); - - // 재완료 시도 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); - - // then - ArgumentCaptor dailyCaptor = ArgumentCaptor.forClass(DailyCompletion.class); - verify(dailyCompletionRepository, times(2)).save(dailyCaptor.capture()); - - // 두 번째 저장 (재완료)에서 firstCount는 1, totalCount는 2가 되어야 함 - DailyCompletion secondSave = dailyCaptor.getAllValues().get(1); - assertThat(secondSave.getFirstCompletionCount()).isEqualTo(1); // 유지 - assertThat(secondSave.getTotalCompletionCount()).isEqualTo(2); // 증가 - } - - - @Test - @DisplayName("스트릭과 학습 완료 기록은 독립적") - void streakAndCompletionAreIndependent() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - // 첫 번째 완료 후 상태 - DailyCompletion dailyAfterFirst = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .streakStatus(StreakStatus.COMPLETED) - .streakCount(1) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()) // 첫 스트릭 - .thenReturn(Optional.of(dailyAfterFirst)) // 두 번째 스트릭 시도 - .thenReturn(Optional.empty()) // 첫 완료 - .thenReturn(Optional.of(dailyAfterFirst)); // 두 번째 완료 - - // when - boolean firstStreak = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); - boolean secondStreak = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2); - - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, firstStreak); - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2, secondStreak); - - // then - assertThat(firstStreak).isTrue(); // 첫 스트릭 성공 - assertThat(secondStreak).isFalse(); // 같은 날 두 번째는 실패 - assertThat(testReport.getCompletedContentIds()).contains(CHAPTER_ID_1, CHAPTER_ID_2); // 완료는 둘 다 기록 - } + @Mock + private UserStudyReportRepository userStudyReportRepository; + + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @Mock + private TicketService ticketService; + + @Mock + private FreezeTransactionRepository freezeTransactionRepository; + + @Mock + private ReadingSessionService readingSessionService; + + @InjectMocks + private StreakService streakService; + + private static final String TEST_USER_ID = "test-user-123"; + + private static final String CHAPTER_ID_1 = "chapter-1"; + + private static final String CHAPTER_ID_2 = "chapter-2"; + + private static final ContentType CONTENT_TYPE = ContentType.BOOK; + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + + private LocalDate today; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(0); + testReport.setLongestStreak(0); + testReport.setAvailableFreezes(0); + testReport.setTotalReadingTimeSeconds(0L); + testReport.setCreatedAt(Instant.now()); + } + + @Test + @DisplayName("첫 스트릭 완료 시 스트릭 증가") + void updateStreak_FirstTime_IncreasesStreak() { + // given - Reading session 검증은 호출하는 쪽에서 이미 수행됨 + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // when + boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + + // then + assertThat(result).isTrue(); + assertThat(testReport.getCurrentStreak()).isEqualTo(1); + assertThat(testReport.getLongestStreak()).isEqualTo(1); + verify(userStudyReportRepository).save(testReport); + } + + @Test + @DisplayName("같은 날 두 번째 콘텐츠 완료 시 스트릭 중복 방지") + void updateStreak_SameDaySecondContent_ReturnsFalse() { + // given - 오늘 이미 스트릭 완료 + DailyCompletion existingDaily = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .streakStatus(StreakStatus.COMPLETED) + .streakCount(1) + .totalCompletionCount(1) + .firstCompletionCount(1) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) + .thenReturn(Optional.of(existingDaily)); + + // when + boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2); + + // then - 스트릭은 업데이트 안됨 + assertThat(result).isFalse(); + verify(userStudyReportRepository, never()).save(any()); + } + + @Test + @DisplayName("5일 연속 달성 시 프리즈 1개 지급") + void updateStreak_FiveDayStreak_GrantsFreeze() { + // given + testReport.setCurrentStreak(4); + testReport.setLastCompletionDate(today.minusDays(1)); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // when + boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + + // then + assertThat(result).isTrue(); + assertThat(testReport.getCurrentStreak()).isEqualTo(5); + assertThat(testReport.getAvailableFreezes()).isEqualTo(1); + + ArgumentCaptor freezeCaptor = ArgumentCaptor.forClass(FreezeTransaction.class); + verify(freezeTransactionRepository).save(freezeCaptor.capture()); + assertThat(freezeCaptor.getValue().getAmount()).isEqualTo(1); + assertThat(freezeCaptor.getValue().getDescription()).contains("5-day streak"); + } + + @Test + @DisplayName("프리즈 2개 보유 시 추가 지급 안됨 (MAX 2개)") + void updateStreak_MaxFreezesReached_NoAdditionalFreeze() { + // given - 이미 프리즈 2개 보유 + testReport.setCurrentStreak(4); + testReport.setLastCompletionDate(today.minusDays(1)); + testReport.setAvailableFreezes(2); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // when + streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + + // then - 프리즈 증가 안함 + assertThat(testReport.getAvailableFreezes()).isEqualTo(2); + verify(freezeTransactionRepository, never()).save(any()); + } + + @Test + @DisplayName("7일 연속 달성 시 티켓 1개 지급") + void updateStreak_SevenDayStreak_GrantsTicket() { + // given + testReport.setCurrentStreak(6); + testReport.setLastCompletionDate(today.minusDays(1)); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // when + streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + + // then + assertThat(testReport.getCurrentStreak()).isEqualTo(7); + verify(ticketService).grantTicket(eq(TEST_USER_ID), eq(1), contains("7-day streak")); + } + + @Test + @DisplayName("15일, 30일 연속 달성 시 티켓 추가 지급") + void updateStreak_FifteenAndThirtyDayStreak_GrantsTickets() { + // given - 14일 스트릭 + testReport.setCurrentStreak(14); + testReport.setLastCompletionDate(today.minusDays(1)); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // when - 15일 달성 + streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + + // then + assertThat(testReport.getCurrentStreak()).isEqualTo(15); + verify(ticketService).grantTicket(eq(TEST_USER_ID), eq(1), contains("15-day streak")); + + // given - 29일 스트릭 + testReport.setCurrentStreak(29); + testReport.setLastCompletionDate(today.minusDays(1)); + reset(ticketService); + + // when - 30일 달성 + streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + + // then + assertThat(testReport.getCurrentStreak()).isEqualTo(30); + verify(ticketService).grantTicket(eq(TEST_USER_ID), eq(1), contains("30-day streak")); + } + + @Test + @DisplayName("학습 시간 누적 테스트") + void addStudyTime_AccumulatesCorrectly() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // when - 여러 번 학습 + streakService.addStudyTime(TEST_USER_ID, 120L); // 2분 + streakService.addStudyTime(TEST_USER_ID, 180L); // 3분 + streakService.addStudyTime(TEST_USER_ID, 60L); // 1분 + + // then + assertThat(testReport.getTotalReadingTimeSeconds()).isEqualTo(360L); // 6분 + verify(userStudyReportRepository, times(3)).save(testReport); + } + + @Test + @DisplayName("첫 완료 콘텐츠 기록 시 firstCompletionCount 증가") + void addCompletedContent_FirstTime_IncrementsFirstCount() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + + // when - 스트릭과 무관하게 완료만 기록 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); + + // then + assertThat(testReport.getCompletedContentIds()).contains(CHAPTER_ID_1); + + ArgumentCaptor dailyCaptor = ArgumentCaptor.forClass(DailyCompletion.class); + verify(dailyCompletionRepository).save(dailyCaptor.capture()); + + DailyCompletion savedDaily = dailyCaptor.getValue(); + assertThat(savedDaily.getFirstCompletionCount()).isEqualTo(1); + assertThat(savedDaily.getTotalCompletionCount()).isEqualTo(1); + } + + @Test + @DisplayName("이미 완료한 콘텐츠 재완료 시 totalCount는 증가, firstCount는 유지") + void addCompletedContent_AlreadyCompleted_DoesNotIncrementFirstCount() { + // given - 이미 완료한 콘텐츠 + testReport.getCompletedContentIds().add(CHAPTER_ID_1); + + DailyCompletion existing = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(0) + .createdAt(Instant.now()) + .build(); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) + .thenReturn(Optional.of(existing)); + + // when - 재완료이므로 streakUpdated=false + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); + + // then - totalCount는 증가, firstCount는 유지 + ArgumentCaptor captor = ArgumentCaptor.forClass(DailyCompletion.class); + verify(dailyCompletionRepository).save(captor.capture()); + + DailyCompletion saved = captor.getValue(); + assertThat(saved.getFirstCompletionCount()).isEqualTo(1); // 유지 + assertThat(saved.getTotalCompletionCount()).isEqualTo(2); // 증가 + } + + @Test + @DisplayName("같은 날 여러 콘텐츠 완료 시 모두 기록, firstCount는 첫 완료만 증가") + void addCompletedContent_MultipleContents_OnlyFirstIncrementsFirstCount() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + DailyCompletion dailyAfterFirst = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(0) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()) // 첫 + // 번째 + // 완료 + .thenReturn(Optional.of(dailyAfterFirst)); // 두 번째 완료 + + // when - 첫 번째는 스트릭 완료, 두 번째는 같은 날이라 스트릭 X + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, true); // 스트릭 + // 완료 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2, false); // 같은 + // 날 + // 추가 + // 완료 + + // then + assertThat(testReport.getCompletedContentIds()).contains(CHAPTER_ID_1, CHAPTER_ID_2); + + ArgumentCaptor dailyCaptor = ArgumentCaptor.forClass(DailyCompletion.class); + verify(dailyCompletionRepository, times(2)).save(dailyCaptor.capture()); + + // 두 번째 저장에서 firstCount는 1, totalCount는 2 + DailyCompletion secondSave = dailyCaptor.getAllValues().get(1); + assertThat(secondSave.getFirstCompletionCount()).isEqualTo(2); // 두 번째도 첫 완료 + assertThat(secondSave.getTotalCompletionCount()).isEqualTo(2); + } + + @Test + @DisplayName("재완료 콘텐츠: firstCompletionCount는 유지, totalCompletionCount는 증가") + void addCompletedContent_Recompletion_OnlyIncrementsTotalCount() { + // given - 첫 완료 + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + DailyCompletion dailyAfterFirst = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(0) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()) // 첫 + // 완료 + .thenReturn(Optional.of(dailyAfterFirst)); // 재완료 + + // when - 첫 완료 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); + + // 재완료 시도 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, false); + + // then + ArgumentCaptor dailyCaptor = ArgumentCaptor.forClass(DailyCompletion.class); + verify(dailyCompletionRepository, times(2)).save(dailyCaptor.capture()); + + // 두 번째 저장 (재완료)에서 firstCount는 1, totalCount는 2가 되어야 함 + DailyCompletion secondSave = dailyCaptor.getAllValues().get(1); + assertThat(secondSave.getFirstCompletionCount()).isEqualTo(1); // 유지 + assertThat(secondSave.getTotalCompletionCount()).isEqualTo(2); // 증가 + } + + @Test + @DisplayName("스트릭과 학습 완료 기록은 독립적") + void streakAndCompletionAreIndependent() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + // 첫 번째 완료 후 상태 + DailyCompletion dailyAfterFirst = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .streakStatus(StreakStatus.COMPLETED) + .streakCount(1) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()) // 첫 + // 스트릭 + .thenReturn(Optional.of(dailyAfterFirst)) // 두 번째 스트릭 시도 + .thenReturn(Optional.empty()) // 첫 완료 + .thenReturn(Optional.of(dailyAfterFirst)); // 두 번째 완료 + + // when + boolean firstStreak = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1); + boolean secondStreak = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2); + + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_1, firstStreak); + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CHAPTER_ID_2, secondStreak); + + // then + assertThat(firstStreak).isTrue(); // 첫 스트릭 성공 + assertThat(secondStreak).isFalse(); // 같은 날 두 번째는 실패 + assertThat(testReport.getCompletedContentIds()).contains(CHAPTER_ID_1, CHAPTER_ID_2); // 완료는 + // 둘 + // 다 + // 기록 + } + } diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceContentCompletionTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceContentCompletionTest.java index 85b14768..cb31b15a 100644 --- a/src/test/java/com/linglevel/api/streak/service/StreakServiceContentCompletionTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceContentCompletionTest.java @@ -34,248 +34,255 @@ @DisplayName("StreakService - 학습 완료 기록 테스트") class StreakServiceContentCompletionTest { - @Mock - private UserStudyReportRepository userStudyReportRepository; - - @Mock - private DailyCompletionRepository dailyCompletionRepository; - - @Mock - private TicketService ticketService; - - @Mock - private FreezeTransactionRepository freezeTransactionRepository; - - @Mock - private TicketTransactionRepository ticketTransactionRepository; - - @Mock - private ReadingSessionService readingSessionService; - - @InjectMocks - private StreakService streakService; - - private static final String TEST_USER_ID = "test-user-123"; - private static final String CONTENT_ID_1 = "content-chapter-1"; - private static final String CONTENT_ID_2 = "content-chapter-2"; - private static final ContentType CONTENT_TYPE = ContentType.BOOK; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); - - private UserStudyReport testReport; - private LocalDate today; - - @BeforeEach - void setUp() { - today = LocalDate.now(KST_ZONE); - testReport = new UserStudyReport(); - testReport.setUserId(TEST_USER_ID); - testReport.setCompletedContentIds(new HashSet<>()); - testReport.setCurrentStreak(0); - testReport.setLongestStreak(0); - testReport.setCreatedAt(Instant.now()); - } - - @Test - @DisplayName("첫 완료 시 UserStudyReport.completedContentIds에 추가") - void addCompletedContent_FirstCompletion_AddsToReport() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - - // when - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); - - // then - assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1); - verify(userStudyReportRepository).save(testReport); - verify(dailyCompletionRepository).save(any(DailyCompletion.class)); - } - - @Test - @DisplayName("이미 완료한 콘텐츠 재완료 시 totalCount 증가, firstCount 유지") - void addCompletedContent_DuplicateCompletion_Skipped() { - // given - testReport.getCompletedContentIds().add(CONTENT_ID_1); // 이미 완료됨 - - DailyCompletion existing = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(0) - .createdAt(Instant.now()) - .build(); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.of(existing)); - - // when - 재완료 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); - - // then - totalCount는 증가, firstCount는 유지 - verify(dailyCompletionRepository).save(any()); - verify(userStudyReportRepository).save(any()); // UserStudyReport도 저장됨 (트랜잭션 일관성) - } - - @Test - @DisplayName("같은 날 여러 콘텐츠 완료 시 모두 기록됨") - void addCompletedContent_MultipleContentsOnSameDay_AllRecorded() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - DailyCompletion existingDaily = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(1) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()) // 첫 완료 - .thenReturn(Optional.of(existingDaily)); // 두 번째 완료 - - // when - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, true); // 첫 완료로 스트릭 성공 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_2, false); // 같은 날 추가 완료 - - // then - assertThat(testReport.getCompletedContentIds()) - .contains(CONTENT_ID_1, CONTENT_ID_2); - verify(userStudyReportRepository, times(2)).save(testReport); - verify(dailyCompletionRepository, times(2)).save(any(DailyCompletion.class)); - } - - @Test - @DisplayName("첫 완료 시 DailyCompletion에 firstCompletionCount 증가") - void addCompletedContent_FirstCompletion_IncrementsFirstCount() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - DailyCompletion existingDaily = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(0) - .totalCompletionCount(0) - .completedContents(new ArrayList<>()) - .streakCount(0) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.of(existingDaily)); - - // when - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); - - // then - assertThat(existingDaily.getFirstCompletionCount()).isEqualTo(1); - assertThat(existingDaily.getTotalCompletionCount()).isEqualTo(1); - assertThat(existingDaily.getCompletedContents()).hasSize(1); - verify(dailyCompletionRepository).save(existingDaily); - } - - @Test - @DisplayName("completedContentIds null일 때 초기화 후 추가") - void addCompletedContent_NullCompletedContentIds_InitializesAndAdds() { - // given - testReport.setCompletedContentIds(null); - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()); - - // when - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); - - // then - assertThat(testReport.getCompletedContentIds()).isNotNull(); - assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1); - } - - - @Test - @DisplayName("updateStreak: 같은 날 두 번 호출 시 두 번째는 false 반환") - void updateStreak_CalledTwiceSameDay_SecondReturnsFalse() { - // given - DailyCompletion existingDaily = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(0) - .totalCompletionCount(1) // 이미 오늘 완료함 - .completedContents(new ArrayList<>()) - .streakCount(1) - .streakStatus(StreakStatus.COMPLETED) // 이미 스트릭 완료 - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.of(existingDaily)); - - // when - boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1); - - // then - assertThat(result).isFalse(); - } - - @Test - @DisplayName("스트릭과 학습 완료가 독립적으로 동작") - void streakAndCompletionAreIndependent() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - - DailyCompletion dailyAfterFirstStreak = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(0) - .totalCompletionCount(0) - .completedContents(new ArrayList<>()) - .streakCount(1) - .streakStatus(StreakStatus.COMPLETED) - .createdAt(Instant.now()) - .build(); - - DailyCompletion dailyAfterFirstContent = DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(today) - .firstCompletionCount(1) - .totalCompletionCount(1) - .completedContents(new ArrayList<>()) - .streakCount(1) - .streakStatus(StreakStatus.COMPLETED) - .createdAt(Instant.now()) - .build(); - - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) - .thenReturn(Optional.empty()) // 1. 첫 스트릭 체크 - .thenReturn(Optional.of(dailyAfterFirstStreak)) // 2. 두 번째 스트릭 체크 (이미 완료) - .thenReturn(Optional.of(dailyAfterFirstStreak)) // 3. 첫 완료 기록 - .thenReturn(Optional.of(dailyAfterFirstContent)); // 4. 두 번째 완료 기록 - - // when - 첫 번째 콘텐츠로 스트릭 업데이트 - boolean firstStreakResult = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1); - - // 두 번째 콘텐츠는 스트릭 업데이트 안됨 (같은 날) - boolean secondStreakResult = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_2); - - // 하지만 학습 완료 기록은 둘 다 됨 - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, firstStreakResult); - streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_2, secondStreakResult); - - // then - 스트릭은 하루 1번, 완료 기록은 여러 번 - assertThat(firstStreakResult).isTrue(); // 스트릭은 한 번만 - assertThat(secondStreakResult).isFalse(); // 같은 날 두 번째는 안됨 - assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1, CONTENT_ID_2); // 완료 기록은 둘 다 - } + @Mock + private UserStudyReportRepository userStudyReportRepository; + + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @Mock + private TicketService ticketService; + + @Mock + private FreezeTransactionRepository freezeTransactionRepository; + + @Mock + private TicketTransactionRepository ticketTransactionRepository; + + @Mock + private ReadingSessionService readingSessionService; + + @InjectMocks + private StreakService streakService; + + private static final String TEST_USER_ID = "test-user-123"; + + private static final String CONTENT_ID_1 = "content-chapter-1"; + + private static final String CONTENT_ID_2 = "content-chapter-2"; + + private static final ContentType CONTENT_TYPE = ContentType.BOOK; + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + + private LocalDate today; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(0); + testReport.setLongestStreak(0); + testReport.setCreatedAt(Instant.now()); + } + + @Test + @DisplayName("첫 완료 시 UserStudyReport.completedContentIds에 추가") + void addCompletedContent_FirstCompletion_AddsToReport() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + + // when + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); + + // then + assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1); + verify(userStudyReportRepository).save(testReport); + verify(dailyCompletionRepository).save(any(DailyCompletion.class)); + } + + @Test + @DisplayName("이미 완료한 콘텐츠 재완료 시 totalCount 증가, firstCount 유지") + void addCompletedContent_DuplicateCompletion_Skipped() { + // given + testReport.getCompletedContentIds().add(CONTENT_ID_1); // 이미 완료됨 + + DailyCompletion existing = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(0) + .createdAt(Instant.now()) + .build(); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) + .thenReturn(Optional.of(existing)); + + // when - 재완료 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); + + // then - totalCount는 증가, firstCount는 유지 + verify(dailyCompletionRepository).save(any()); + verify(userStudyReportRepository).save(any()); // UserStudyReport도 저장됨 (트랜잭션 일관성) + } + + @Test + @DisplayName("같은 날 여러 콘텐츠 완료 시 모두 기록됨") + void addCompletedContent_MultipleContentsOnSameDay_AllRecorded() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + DailyCompletion existingDaily = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(1) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()) // 첫 + // 완료 + .thenReturn(Optional.of(existingDaily)); // 두 번째 완료 + + // when + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, true); // 첫 + // 완료로 + // 스트릭 + // 성공 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_2, false); // 같은 + // 날 + // 추가 + // 완료 + + // then + assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1, CONTENT_ID_2); + verify(userStudyReportRepository, times(2)).save(testReport); + verify(dailyCompletionRepository, times(2)).save(any(DailyCompletion.class)); + } + + @Test + @DisplayName("첫 완료 시 DailyCompletion에 firstCompletionCount 증가") + void addCompletedContent_FirstCompletion_IncrementsFirstCount() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + DailyCompletion existingDaily = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .streakCount(0) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) + .thenReturn(Optional.of(existingDaily)); + + // when + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); + + // then + assertThat(existingDaily.getFirstCompletionCount()).isEqualTo(1); + assertThat(existingDaily.getTotalCompletionCount()).isEqualTo(1); + assertThat(existingDaily.getCompletedContents()).hasSize(1); + verify(dailyCompletionRepository).save(existingDaily); + } + + @Test + @DisplayName("completedContentIds null일 때 초기화 후 추가") + void addCompletedContent_NullCompletedContentIds_InitializesAndAdds() { + // given + testReport.setCompletedContentIds(null); + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()); + + // when + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, false); + + // then + assertThat(testReport.getCompletedContentIds()).isNotNull(); + assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1); + } + + @Test + @DisplayName("updateStreak: 같은 날 두 번 호출 시 두 번째는 false 반환") + void updateStreak_CalledTwiceSameDay_SecondReturnsFalse() { + // given + DailyCompletion existingDaily = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(0) + .totalCompletionCount(1) // 이미 오늘 완료함 + .completedContents(new ArrayList<>()) + .streakCount(1) + .streakStatus(StreakStatus.COMPLETED) // 이미 스트릭 완료 + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)) + .thenReturn(Optional.of(existingDaily)); + + // when + boolean result = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("스트릭과 학습 완료가 독립적으로 동작") + void streakAndCompletionAreIndependent() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + + DailyCompletion dailyAfterFirstStreak = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(0) + .totalCompletionCount(0) + .completedContents(new ArrayList<>()) + .streakCount(1) + .streakStatus(StreakStatus.COMPLETED) + .createdAt(Instant.now()) + .build(); + + DailyCompletion dailyAfterFirstContent = DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(today) + .firstCompletionCount(1) + .totalCompletionCount(1) + .completedContents(new ArrayList<>()) + .streakCount(1) + .streakStatus(StreakStatus.COMPLETED) + .createdAt(Instant.now()) + .build(); + + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, today)).thenReturn(Optional.empty()) // 1. + // 첫 + // 스트릭 + // 체크 + .thenReturn(Optional.of(dailyAfterFirstStreak)) // 2. 두 번째 스트릭 체크 (이미 완료) + .thenReturn(Optional.of(dailyAfterFirstStreak)) // 3. 첫 완료 기록 + .thenReturn(Optional.of(dailyAfterFirstContent)); // 4. 두 번째 완료 기록 + + // when - 첫 번째 콘텐츠로 스트릭 업데이트 + boolean firstStreakResult = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1); + + // 두 번째 콘텐츠는 스트릭 업데이트 안됨 (같은 날) + boolean secondStreakResult = streakService.updateStreak(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_2); + + // 하지만 학습 완료 기록은 둘 다 됨 + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_1, firstStreakResult); + streakService.addCompletedContent(TEST_USER_ID, CONTENT_TYPE, CONTENT_ID_2, secondStreakResult); + + // then - 스트릭은 하루 1번, 완료 기록은 여러 번 + assertThat(firstStreakResult).isTrue(); // 스트릭은 한 번만 + assertThat(secondStreakResult).isFalse(); // 같은 날 두 번째는 안됨 + assertThat(testReport.getCompletedContentIds()).contains(CONTENT_ID_1, CONTENT_ID_2); // 완료 + // 기록은 + // 둘 + // 다 + } + } diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceFreezeAutoConsumeTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceFreezeAutoConsumeTest.java index 05decdfc..8bf46c15 100644 --- a/src/test/java/com/linglevel/api/streak/service/StreakServiceFreezeAutoConsumeTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceFreezeAutoConsumeTest.java @@ -33,345 +33,355 @@ @DisplayName("StreakService - 프리즈 자동 소비 테스트") class StreakServiceFreezeAutoConsumeTest { - @Mock - private DailyCompletionRepository dailyCompletionRepository; - - @Mock - private FreezeTransactionRepository freezeTransactionRepository; - - @InjectMocks - private StreakService streakService; - - private static final String TEST_USER_ID = "test-user-123"; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); - - private UserStudyReport testReport; - private LocalDate today; - - @BeforeEach - void setUp() { - today = LocalDate.now(KST_ZONE); - testReport = new UserStudyReport(); - testReport.setUserId(TEST_USER_ID); - testReport.setCompletedContentIds(new HashSet<>()); - testReport.setCurrentStreak(5); - testReport.setLongestStreak(5); - testReport.setAvailableFreezes(1); - testReport.setLastCompletionDate(today.minusDays(2)); // 1일 전에 완료 - testReport.setStreakStartDate(today.minusDays(5)); - testReport.setTotalReadingTimeSeconds(0L); - testReport.setCreatedAt(Instant.now()); - } - - @Nested - @DisplayName("1일 놓쳤을 때") - class OneDayMissed { - - @BeforeEach - void setUp() { - testReport.setLastCompletionDate(today.minusDays(2)); // 어제를 놓침 - testReport.setCurrentStreak(5); - testReport.setAvailableFreezes(1); - } - - @Test - @DisplayName("프리즈 1개 있으면 자동 소비하고 스트릭 유지") - void withOneFreeze_ConsumeFreezeAndMaintainStreak() { - // given - LocalDate missedDate = today.minusDays(1); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate)) - .thenReturn(Optional.empty()); // 아직 처리 안됨 - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); // 스트릭 유지 - assertThat(testReport.getCurrentStreak()).isEqualTo(5); // 스트릭 유지 - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 1개 소비 - - // FreezeTransaction 저장 확인 - ArgumentCaptor transactionCaptor = ArgumentCaptor.forClass(FreezeTransaction.class); - verify(freezeTransactionRepository).save(transactionCaptor.capture()); - FreezeTransaction savedTransaction = transactionCaptor.getValue(); - assertThat(savedTransaction.getUserId()).isEqualTo(TEST_USER_ID); - assertThat(savedTransaction.getAmount()).isEqualTo(-1); // 소비 - - // DailyCompletion 저장 확인 - ArgumentCaptor completionCaptor = ArgumentCaptor.forClass(DailyCompletion.class); - verify(dailyCompletionRepository).save(completionCaptor.capture()); - DailyCompletion savedCompletion = completionCaptor.getValue(); - assertThat(savedCompletion.getStreakStatus()).isEqualTo(StreakStatus.FREEZE_USED); - assertThat(savedCompletion.getCompletionDate()).isEqualTo(missedDate); - } - - @Test - @DisplayName("프리즈 0개면 스트릭 리셋") - void withNoFreeze_ResetStreak() { - // given - testReport.setAvailableFreezes(0); - LocalDate missedDate = today.minusDays(1); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate)) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isTrue(); // 스트릭 리셋됨 - assertThat(testReport.getCurrentStreak()).isEqualTo(0); - assertThat(testReport.getLastCompletionDate()).isNull(); - assertThat(testReport.getStreakStartDate()).isNull(); - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); - - // 프리즈 트랜잭션 없음 - verify(freezeTransactionRepository, never()).save(any()); - verify(dailyCompletionRepository, never()).save(any()); - } - - @Test - @DisplayName("이미 처리된 날짜는 중복 소비하지 않음 (멱등성)") - void alreadyProcessed_NoDoubleConsumption() { - // given - LocalDate missedDate = today.minusDays(1); - DailyCompletion existingCompletion = new DailyCompletion(); - existingCompletion.setStreakStatus(StreakStatus.FREEZE_USED); // 이미 프리즈로 처리됨 - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate)) - .thenReturn(Optional.of(existingCompletion)); - - int initialFreezes = testReport.getAvailableFreezes(); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); - assertThat(testReport.getAvailableFreezes()).isEqualTo(initialFreezes); // 프리즈 소비 안됨 - - // 트랜잭션 저장 안됨 - verify(freezeTransactionRepository, never()).save(any()); - verify(dailyCompletionRepository, never()).save(any()); - } - } - - @Nested - @DisplayName("2일 놓쳤을 때") - class TwoDaysMissed { - - @BeforeEach - void setUp() { - testReport.setLastCompletionDate(today.minusDays(3)); // 2일 전, 어제 모두 놓침 - testReport.setCurrentStreak(5); - } - - @Test - @DisplayName("프리즈 2개 있으면 모두 소비하고 스트릭 유지") - void withTwoFreezes_ConsumeAllAndMaintainStreak() { - // given - testReport.setAvailableFreezes(2); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); - assertThat(testReport.getCurrentStreak()).isEqualTo(5); // 스트릭 유지 - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 2개 모두 소비 - - // FreezeTransaction 2번 저장 - verify(freezeTransactionRepository, times(2)).save(any()); - // DailyCompletion 2번 저장 - verify(dailyCompletionRepository, times(2)).save(any()); - } - - @Test - @DisplayName("프리즈 1개만 있으면 1개 소비하고 스트릭 리셋") - void withOneFreeze_ConsumeOneAndResetStreak() { - // given - testReport.setAvailableFreezes(1); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isTrue(); // 스트릭 리셋 - assertThat(testReport.getCurrentStreak()).isEqualTo(0); - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 1개 소비됨 - - // FreezeTransaction 1번만 저장 (1개만 있었으므로) - verify(freezeTransactionRepository, times(1)).save(any()); - verify(dailyCompletionRepository, times(1)).save(any()); - } - - @Test - @DisplayName("프리즈 0개면 스트릭 리셋") - void withNoFreeze_ResetStreak() { - // given - testReport.setAvailableFreezes(0); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isTrue(); - assertThat(testReport.getCurrentStreak()).isEqualTo(0); - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); - - // 프리즈 트랜잭션 없음 - verify(freezeTransactionRepository, never()).save(any()); - verify(dailyCompletionRepository, never()).save(any()); - } - - @Test - @DisplayName("1일은 처리됨, 1일은 미처리 시 미처리 1일만 소비") - void oneProcessedOneMissed_ConsumeOnlyUnprocessed() { - // given - testReport.setAvailableFreezes(2); - LocalDate missedDate1 = today.minusDays(2); - LocalDate missedDate2 = today.minusDays(1); - - // 첫 번째 날은 이미 처리됨 - DailyCompletion existingCompletion = new DailyCompletion(); - existingCompletion.setStreakStatus(StreakStatus.FREEZE_USED); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate1)) - .thenReturn(Optional.of(existingCompletion)); - - // 두 번째 날은 미처리 - when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate2)) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); - assertThat(testReport.getCurrentStreak()).isEqualTo(5); - assertThat(testReport.getAvailableFreezes()).isEqualTo(1); // 1개만 소비 - - // 미처리 1일만 트랜잭션 저장 - verify(freezeTransactionRepository, times(1)).save(any()); - verify(dailyCompletionRepository, times(1)).save(any()); - } - } - - @Nested - @DisplayName("3일 이상 놓쳤을 때") - class ThreeDaysMissed { - - @BeforeEach - void setUp() { - testReport.setLastCompletionDate(today.minusDays(4)); // 3일 놓침 - testReport.setCurrentStreak(10); - } - - @Test - @DisplayName("프리즈 2개(최대)로는 부족 -> 2개 소비하고 스트릭 리셋") - void withTwoFreezes_ConsumeAllButStillReset() { - // given - testReport.setAvailableFreezes(2); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isTrue(); // 스트릭 리셋 - assertThat(testReport.getCurrentStreak()).isEqualTo(0); - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 2개 모두 소비 - - // FreezeTransaction 2번 저장 (2개만 있었으므로) - verify(freezeTransactionRepository, times(2)).save(any()); - verify(dailyCompletionRepository, times(2)).save(any()); - } - } - - @Nested - @DisplayName("누락 없을 때") - class NoMissedDays { - - @Test - @DisplayName("어제 완료한 경우 처리 안함") - void completedYesterday_NoProcessing() { - // given - testReport.setLastCompletionDate(today.minusDays(1)); // 어제 완료 - testReport.setAvailableFreezes(1); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); - assertThat(testReport.getAvailableFreezes()).isEqualTo(1); // 프리즈 소비 안됨 - - verify(freezeTransactionRepository, never()).save(any()); - verify(dailyCompletionRepository, never()).save(any()); - } - - @Test - @DisplayName("오늘 이미 완료한 경우 처리 안함") - void completedToday_NoProcessing() { - // given - testReport.setLastCompletionDate(today); - testReport.setAvailableFreezes(1); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); - assertThat(testReport.getAvailableFreezes()).isEqualTo(1); - - verify(freezeTransactionRepository, never()).save(any()); - verify(dailyCompletionRepository, never()).save(any()); - } - } - - @Nested - @DisplayName("엣지 케이스") - class EdgeCases { - - @Test - @DisplayName("lastCompletionDate가 null이면 처리 안함") - void nullLastCompletionDate_NoProcessing() { - // given - testReport.setLastCompletionDate(null); - testReport.setAvailableFreezes(1); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - assertThat(wasReset).isFalse(); - assertThat(testReport.getAvailableFreezes()).isEqualTo(1); - - verify(freezeTransactionRepository, never()).save(any()); - verify(dailyCompletionRepository, never()).save(any()); - } - - @Test - @DisplayName("스트릭 0인데 프리즈 있으면 소비만 하고 리셋 처리") - void zeroStreakWithFreeze_StillConsumes() { - // given - testReport.setCurrentStreak(0); - testReport.setLastCompletionDate(today.minusDays(2)); - testReport.setAvailableFreezes(1); - when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) - .thenReturn(Optional.empty()); - - // when - boolean wasReset = streakService.processMissedDays(testReport, today); - - // then - 이미 0이므로 리셋 처리는 안됨 - assertThat(wasReset).isFalse(); - assertThat(testReport.getCurrentStreak()).isEqualTo(0); - assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈는 소비됨 - } - } + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @Mock + private FreezeTransactionRepository freezeTransactionRepository; + + @InjectMocks + private StreakService streakService; + + private static final String TEST_USER_ID = "test-user-123"; + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + + private LocalDate today; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(5); + testReport.setLongestStreak(5); + testReport.setAvailableFreezes(1); + testReport.setLastCompletionDate(today.minusDays(2)); // 1일 전에 완료 + testReport.setStreakStartDate(today.minusDays(5)); + testReport.setTotalReadingTimeSeconds(0L); + testReport.setCreatedAt(Instant.now()); + } + + @Nested + @DisplayName("1일 놓쳤을 때") + class OneDayMissed { + + @BeforeEach + void setUp() { + testReport.setLastCompletionDate(today.minusDays(2)); // 어제를 놓침 + testReport.setCurrentStreak(5); + testReport.setAvailableFreezes(1); + } + + @Test + @DisplayName("프리즈 1개 있으면 자동 소비하고 스트릭 유지") + void withOneFreeze_ConsumeFreezeAndMaintainStreak() { + // given + LocalDate missedDate = today.minusDays(1); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate)) + .thenReturn(Optional.empty()); // 아직 처리 안됨 + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); // 스트릭 유지 + assertThat(testReport.getCurrentStreak()).isEqualTo(5); // 스트릭 유지 + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 1개 소비 + + // FreezeTransaction 저장 확인 + ArgumentCaptor transactionCaptor = ArgumentCaptor.forClass(FreezeTransaction.class); + verify(freezeTransactionRepository).save(transactionCaptor.capture()); + FreezeTransaction savedTransaction = transactionCaptor.getValue(); + assertThat(savedTransaction.getUserId()).isEqualTo(TEST_USER_ID); + assertThat(savedTransaction.getAmount()).isEqualTo(-1); // 소비 + + // DailyCompletion 저장 확인 + ArgumentCaptor completionCaptor = ArgumentCaptor.forClass(DailyCompletion.class); + verify(dailyCompletionRepository).save(completionCaptor.capture()); + DailyCompletion savedCompletion = completionCaptor.getValue(); + assertThat(savedCompletion.getStreakStatus()).isEqualTo(StreakStatus.FREEZE_USED); + assertThat(savedCompletion.getCompletionDate()).isEqualTo(missedDate); + } + + @Test + @DisplayName("프리즈 0개면 스트릭 리셋") + void withNoFreeze_ResetStreak() { + // given + testReport.setAvailableFreezes(0); + LocalDate missedDate = today.minusDays(1); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate)) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isTrue(); // 스트릭 리셋됨 + assertThat(testReport.getCurrentStreak()).isEqualTo(0); + assertThat(testReport.getLastCompletionDate()).isNull(); + assertThat(testReport.getStreakStartDate()).isNull(); + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); + + // 프리즈 트랜잭션 없음 + verify(freezeTransactionRepository, never()).save(any()); + verify(dailyCompletionRepository, never()).save(any()); + } + + @Test + @DisplayName("이미 처리된 날짜는 중복 소비하지 않음 (멱등성)") + void alreadyProcessed_NoDoubleConsumption() { + // given + LocalDate missedDate = today.minusDays(1); + DailyCompletion existingCompletion = new DailyCompletion(); + existingCompletion.setStreakStatus(StreakStatus.FREEZE_USED); // 이미 프리즈로 처리됨 + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate)) + .thenReturn(Optional.of(existingCompletion)); + + int initialFreezes = testReport.getAvailableFreezes(); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); + assertThat(testReport.getAvailableFreezes()).isEqualTo(initialFreezes); // 프리즈 + // 소비 + // 안됨 + + // 트랜잭션 저장 안됨 + verify(freezeTransactionRepository, never()).save(any()); + verify(dailyCompletionRepository, never()).save(any()); + } + + } + + @Nested + @DisplayName("2일 놓쳤을 때") + class TwoDaysMissed { + + @BeforeEach + void setUp() { + testReport.setLastCompletionDate(today.minusDays(3)); // 2일 전, 어제 모두 놓침 + testReport.setCurrentStreak(5); + } + + @Test + @DisplayName("프리즈 2개 있으면 모두 소비하고 스트릭 유지") + void withTwoFreezes_ConsumeAllAndMaintainStreak() { + // given + testReport.setAvailableFreezes(2); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); + assertThat(testReport.getCurrentStreak()).isEqualTo(5); // 스트릭 유지 + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 2개 모두 소비 + + // FreezeTransaction 2번 저장 + verify(freezeTransactionRepository, times(2)).save(any()); + // DailyCompletion 2번 저장 + verify(dailyCompletionRepository, times(2)).save(any()); + } + + @Test + @DisplayName("프리즈 1개만 있으면 1개 소비하고 스트릭 리셋") + void withOneFreeze_ConsumeOneAndResetStreak() { + // given + testReport.setAvailableFreezes(1); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isTrue(); // 스트릭 리셋 + assertThat(testReport.getCurrentStreak()).isEqualTo(0); + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 1개 소비됨 + + // FreezeTransaction 1번만 저장 (1개만 있었으므로) + verify(freezeTransactionRepository, times(1)).save(any()); + verify(dailyCompletionRepository, times(1)).save(any()); + } + + @Test + @DisplayName("프리즈 0개면 스트릭 리셋") + void withNoFreeze_ResetStreak() { + // given + testReport.setAvailableFreezes(0); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isTrue(); + assertThat(testReport.getCurrentStreak()).isEqualTo(0); + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); + + // 프리즈 트랜잭션 없음 + verify(freezeTransactionRepository, never()).save(any()); + verify(dailyCompletionRepository, never()).save(any()); + } + + @Test + @DisplayName("1일은 처리됨, 1일은 미처리 시 미처리 1일만 소비") + void oneProcessedOneMissed_ConsumeOnlyUnprocessed() { + // given + testReport.setAvailableFreezes(2); + LocalDate missedDate1 = today.minusDays(2); + LocalDate missedDate2 = today.minusDays(1); + + // 첫 번째 날은 이미 처리됨 + DailyCompletion existingCompletion = new DailyCompletion(); + existingCompletion.setStreakStatus(StreakStatus.FREEZE_USED); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate1)) + .thenReturn(Optional.of(existingCompletion)); + + // 두 번째 날은 미처리 + when(dailyCompletionRepository.findByUserIdAndCompletionDate(TEST_USER_ID, missedDate2)) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); + assertThat(testReport.getCurrentStreak()).isEqualTo(5); + assertThat(testReport.getAvailableFreezes()).isEqualTo(1); // 1개만 소비 + + // 미처리 1일만 트랜잭션 저장 + verify(freezeTransactionRepository, times(1)).save(any()); + verify(dailyCompletionRepository, times(1)).save(any()); + } + + } + + @Nested + @DisplayName("3일 이상 놓쳤을 때") + class ThreeDaysMissed { + + @BeforeEach + void setUp() { + testReport.setLastCompletionDate(today.minusDays(4)); // 3일 놓침 + testReport.setCurrentStreak(10); + } + + @Test + @DisplayName("프리즈 2개(최대)로는 부족 -> 2개 소비하고 스트릭 리셋") + void withTwoFreezes_ConsumeAllButStillReset() { + // given + testReport.setAvailableFreezes(2); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isTrue(); // 스트릭 리셋 + assertThat(testReport.getCurrentStreak()).isEqualTo(0); + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 2개 모두 소비 + + // FreezeTransaction 2번 저장 (2개만 있었으므로) + verify(freezeTransactionRepository, times(2)).save(any()); + verify(dailyCompletionRepository, times(2)).save(any()); + } + + } + + @Nested + @DisplayName("누락 없을 때") + class NoMissedDays { + + @Test + @DisplayName("어제 완료한 경우 처리 안함") + void completedYesterday_NoProcessing() { + // given + testReport.setLastCompletionDate(today.minusDays(1)); // 어제 완료 + testReport.setAvailableFreezes(1); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); + assertThat(testReport.getAvailableFreezes()).isEqualTo(1); // 프리즈 소비 안됨 + + verify(freezeTransactionRepository, never()).save(any()); + verify(dailyCompletionRepository, never()).save(any()); + } + + @Test + @DisplayName("오늘 이미 완료한 경우 처리 안함") + void completedToday_NoProcessing() { + // given + testReport.setLastCompletionDate(today); + testReport.setAvailableFreezes(1); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); + assertThat(testReport.getAvailableFreezes()).isEqualTo(1); + + verify(freezeTransactionRepository, never()).save(any()); + verify(dailyCompletionRepository, never()).save(any()); + } + + } + + @Nested + @DisplayName("엣지 케이스") + class EdgeCases { + + @Test + @DisplayName("lastCompletionDate가 null이면 처리 안함") + void nullLastCompletionDate_NoProcessing() { + // given + testReport.setLastCompletionDate(null); + testReport.setAvailableFreezes(1); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then + assertThat(wasReset).isFalse(); + assertThat(testReport.getAvailableFreezes()).isEqualTo(1); + + verify(freezeTransactionRepository, never()).save(any()); + verify(dailyCompletionRepository, never()).save(any()); + } + + @Test + @DisplayName("스트릭 0인데 프리즈 있으면 소비만 하고 리셋 처리") + void zeroStreakWithFreeze_StillConsumes() { + // given + testReport.setCurrentStreak(0); + testReport.setLastCompletionDate(today.minusDays(2)); + testReport.setAvailableFreezes(1); + when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any())) + .thenReturn(Optional.empty()); + + // when + boolean wasReset = streakService.processMissedDays(testReport, today); + + // then - 이미 0이므로 리셋 처리는 안됨 + assertThat(wasReset).isFalse(); + assertThat(testReport.getCurrentStreak()).isEqualTo(0); + assertThat(testReport.getAvailableFreezes()).isEqualTo(0); // 프리즈는 소비됨 + } + + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java index 951738b7..38809f11 100644 --- a/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecalculateTest.java @@ -31,341 +31,298 @@ @DisplayName("StreakService - recalculateUserStudyReport 테스트") class StreakServiceRecalculateTest { - @Mock - private UserStudyReportRepository userStudyReportRepository; - - @Mock - private DailyCompletionRepository dailyCompletionRepository; - - @InjectMocks - private StreakService streakService; - - private static final String TEST_USER_ID = "test-user-123"; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); - - private UserStudyReport testReport; - private LocalDate today; - - @BeforeEach - void setUp() { - today = LocalDate.now(KST_ZONE); - testReport = new UserStudyReport(); - testReport.setUserId(TEST_USER_ID); - testReport.setCompletedContentIds(new HashSet<>()); - testReport.setCurrentStreak(0); - testReport.setLongestStreak(0); - testReport.setAvailableFreezes(0); - testReport.setTotalReadingTimeSeconds(0L); - testReport.setCreatedAt(Instant.now()); - } - - @Test - @DisplayName("완료 기록이 없으면 모든 값이 초기화된다") - void recalculate_NoCompletions_ResetsAllValues() { - // given - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(List.of()); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(0); - assertThat(result.getLongestStreak()).isEqualTo(0); - assertThat(result.getLastCompletionDate()).isNull(); - assertThat(result.getStreakStartDate()).isNull(); - verify(userStudyReportRepository).save(testReport); - } - - @Test - @DisplayName("연속 3일 완료 시 currentStreak=3, longestStreak=3") - void recalculate_ThreeConsecutiveDays_CalculatesCorrectly() { - // given - LocalDate day1 = today.minusDays(2); - LocalDate day2 = today.minusDays(1); - LocalDate day3 = today; - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2), - createCompletion(day3, StreakStatus.COMPLETED, 3) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(3); - assertThat(result.getLongestStreak()).isEqualTo(3); - assertThat(result.getLastCompletionDate()).isEqualTo(day3); - assertThat(result.getStreakStartDate()).isEqualTo(day1); - } - - @Test - @DisplayName("프리즈 사용으로 스트릭이 유지된 경우") - void recalculate_WithFreezeUsed_MaintainsStreak() { - // given - LocalDate day1 = today.minusDays(3); - LocalDate day2 = today.minusDays(2); // FREEZE_USED - LocalDate day3 = today.minusDays(1); - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.FREEZE_USED, 1), - createCompletion(day3, StreakStatus.COMPLETED, 2) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(2); - assertThat(result.getLongestStreak()).isEqualTo(2); - assertThat(result.getLastCompletionDate()).isEqualTo(day3); - assertThat(result.getStreakStartDate()).isEqualTo(day1); - } - - @Test - @DisplayName("MISSED 상태로 스트릭이 끊긴 후 다시 시작") - void recalculate_WithMissed_ResetsStreak() { - // given - LocalDate day1 = today.minusDays(5); - LocalDate day2 = today.minusDays(4); - LocalDate day3 = today.minusDays(3); // MISSED - LocalDate day4 = today.minusDays(2); - LocalDate day5 = today.minusDays(1); - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2), - createCompletion(day3, StreakStatus.MISSED, null), - createCompletion(day4, StreakStatus.COMPLETED, 1), - createCompletion(day5, StreakStatus.COMPLETED, 2) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(2); - assertThat(result.getLongestStreak()).isEqualTo(2); // 첫 번째 스트릭도 2였으므로 최장은 2 - assertThat(result.getLastCompletionDate()).isEqualTo(day5); - assertThat(result.getStreakStartDate()).isEqualTo(day4); - } - - @Test - @DisplayName("최장 스트릭이 현재 스트릭보다 길었던 경우") - void recalculate_LongestStreakInPast_KeepsLongestStreak() { - // given - LocalDate day1 = today.minusDays(6); - LocalDate day2 = today.minusDays(5); - LocalDate day3 = today.minusDays(4); - LocalDate day4 = today.minusDays(3); - LocalDate day5 = today.minusDays(2); // MISSED - LocalDate day6 = today.minusDays(1); - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2), - createCompletion(day3, StreakStatus.COMPLETED, 3), - createCompletion(day4, StreakStatus.COMPLETED, 4), - createCompletion(day5, StreakStatus.MISSED, null), - createCompletion(day6, StreakStatus.COMPLETED, 1) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(1); - assertThat(result.getLongestStreak()).isEqualTo(4); // 과거 최장 기록 - assertThat(result.getLastCompletionDate()).isEqualTo(day6); - assertThat(result.getStreakStartDate()).isEqualTo(day6); - } - - @Test - @DisplayName("연속성이 끊긴 경우 (날짜 간격이 2일 이상)") - void recalculate_GapInDates_ResetsStreak() { - // given - LocalDate day1 = today.minusDays(5); - LocalDate day2 = today.minusDays(4); - // day3(today.minusDays(3))에 기록 없음 - 연속성 끊김 - LocalDate day4 = today.minusDays(2); - LocalDate day5 = today.minusDays(1); - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2), - createCompletion(day4, StreakStatus.COMPLETED, 1), - createCompletion(day5, StreakStatus.COMPLETED, 2) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(2); - assertThat(result.getLongestStreak()).isEqualTo(2); - assertThat(result.getLastCompletionDate()).isEqualTo(day5); - assertThat(result.getStreakStartDate()).isEqualTo(day4); - } - - @Test - @DisplayName("마지막 완료일이 어제이고 오늘 기록이 없으면 currentStreak 유지") - void recalculate_LastCompletionYesterday_MaintainsStreak() { - // given - LocalDate day1 = today.minusDays(2); - LocalDate day2 = today.minusDays(1); - // 오늘은 아직 완료 안 함 - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(2); - assertThat(result.getLongestStreak()).isEqualTo(2); - assertThat(result.getLastCompletionDate()).isEqualTo(day2); - assertThat(result.getStreakStartDate()).isEqualTo(day1); - } - - @Test - @DisplayName("마지막 완료일이 2일 전이면 currentStreak=0으로 리셋") - void recalculate_LastCompletionTwoDaysAgo_ResetsStreak() { - // given - LocalDate day1 = today.minusDays(3); - LocalDate day2 = today.minusDays(2); - // 어제와 오늘 기록 없음 - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(0); - assertThat(result.getLongestStreak()).isEqualTo(2); - assertThat(result.getLastCompletionDate()).isEqualTo(day2); - assertThat(result.getStreakStartDate()).isNull(); - } - - @Test - @DisplayName("복잡한 시나리오: 프리즈 + MISSED + 여러 스트릭 구간") - void recalculate_ComplexScenario_CalculatesCorrectly() { - // given - LocalDate day1 = today.minusDays(10); - LocalDate day2 = today.minusDays(9); - LocalDate day3 = today.minusDays(8); - LocalDate day4 = today.minusDays(7); // FREEZE_USED - LocalDate day5 = today.minusDays(6); - LocalDate day6 = today.minusDays(5); // MISSED - 스트릭 끊김 - LocalDate day7 = today.minusDays(4); - LocalDate day8 = today.minusDays(3); - LocalDate day9 = today.minusDays(2); - LocalDate day10 = today.minusDays(1); - LocalDate day11 = today; - - List completions = List.of( - createCompletion(day1, StreakStatus.COMPLETED, 1), - createCompletion(day2, StreakStatus.COMPLETED, 2), - createCompletion(day3, StreakStatus.COMPLETED, 3), - createCompletion(day4, StreakStatus.FREEZE_USED, 3), - createCompletion(day5, StreakStatus.COMPLETED, 4), - createCompletion(day6, StreakStatus.MISSED, null), - createCompletion(day7, StreakStatus.COMPLETED, 1), - createCompletion(day8, StreakStatus.COMPLETED, 2), - createCompletion(day9, StreakStatus.COMPLETED, 3), - createCompletion(day10, StreakStatus.COMPLETED, 4), - createCompletion(day11, StreakStatus.COMPLETED, 5) - ); - - when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenReturn(completions); - when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); - - // then - assertThat(result.getCurrentStreak()).isEqualTo(5); // day7~day11 - assertThat(result.getLongestStreak()).isEqualTo(5); // 현재 스트릭이 가장 김 - assertThat(result.getLastCompletionDate()).isEqualTo(day11); - assertThat(result.getStreakStartDate()).isEqualTo(day7); - } - - private DailyCompletion createCompletion(LocalDate date, StreakStatus status, Integer streakCount) { - return DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .streakStatus(status) - .streakCount(streakCount) - .firstCompletionCount(0) - .totalCompletionCount(status == StreakStatus.COMPLETED ? 1 : 0) - .completedContents(new ArrayList<>()) - .createdAt(Instant.now()) - .build(); - } + @Mock + private UserStudyReportRepository userStudyReportRepository; + + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @InjectMocks + private StreakService streakService; + + private static final String TEST_USER_ID = "test-user-123"; + + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + + private LocalDate today; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(0); + testReport.setLongestStreak(0); + testReport.setAvailableFreezes(0); + testReport.setTotalReadingTimeSeconds(0L); + testReport.setCreatedAt(Instant.now()); + } + + @Test + @DisplayName("완료 기록이 없으면 모든 값이 초기화된다") + void recalculate_NoCompletions_ResetsAllValues() { + // given + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(List.of()); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(0); + assertThat(result.getLongestStreak()).isEqualTo(0); + assertThat(result.getLastCompletionDate()).isNull(); + assertThat(result.getStreakStartDate()).isNull(); + verify(userStudyReportRepository).save(testReport); + } + + @Test + @DisplayName("연속 3일 완료 시 currentStreak=3, longestStreak=3") + void recalculate_ThreeConsecutiveDays_CalculatesCorrectly() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); + LocalDate day3 = today; + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), createCompletion(day3, StreakStatus.COMPLETED, 3)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(3); + assertThat(result.getLongestStreak()).isEqualTo(3); + assertThat(result.getLastCompletionDate()).isEqualTo(day3); + assertThat(result.getStreakStartDate()).isEqualTo(day1); + } + + @Test + @DisplayName("프리즈 사용으로 스트릭이 유지된 경우") + void recalculate_WithFreezeUsed_MaintainsStreak() { + // given + LocalDate day1 = today.minusDays(3); + LocalDate day2 = today.minusDays(2); // FREEZE_USED + LocalDate day3 = today.minusDays(1); + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.FREEZE_USED, 1), createCompletion(day3, StreakStatus.COMPLETED, 2)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day3); + assertThat(result.getStreakStartDate()).isEqualTo(day1); + } + + @Test + @DisplayName("MISSED 상태로 스트릭이 끊긴 후 다시 시작") + void recalculate_WithMissed_ResetsStreak() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); + LocalDate day3 = today.minusDays(3); // MISSED + LocalDate day4 = today.minusDays(2); + LocalDate day5 = today.minusDays(1); + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), createCompletion(day3, StreakStatus.MISSED, null), + createCompletion(day4, StreakStatus.COMPLETED, 1), createCompletion(day5, StreakStatus.COMPLETED, 2)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); // 첫 번째 스트릭도 2였으므로 최장은 2 + assertThat(result.getLastCompletionDate()).isEqualTo(day5); + assertThat(result.getStreakStartDate()).isEqualTo(day4); + } + + @Test + @DisplayName("최장 스트릭이 현재 스트릭보다 길었던 경우") + void recalculate_LongestStreakInPast_KeepsLongestStreak() { + // given + LocalDate day1 = today.minusDays(6); + LocalDate day2 = today.minusDays(5); + LocalDate day3 = today.minusDays(4); + LocalDate day4 = today.minusDays(3); + LocalDate day5 = today.minusDays(2); // MISSED + LocalDate day6 = today.minusDays(1); + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), createCompletion(day3, StreakStatus.COMPLETED, 3), + createCompletion(day4, StreakStatus.COMPLETED, 4), createCompletion(day5, StreakStatus.MISSED, null), + createCompletion(day6, StreakStatus.COMPLETED, 1)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(1); + assertThat(result.getLongestStreak()).isEqualTo(4); // 과거 최장 기록 + assertThat(result.getLastCompletionDate()).isEqualTo(day6); + assertThat(result.getStreakStartDate()).isEqualTo(day6); + } + + @Test + @DisplayName("연속성이 끊긴 경우 (날짜 간격이 2일 이상)") + void recalculate_GapInDates_ResetsStreak() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); + // day3(today.minusDays(3))에 기록 없음 - 연속성 끊김 + LocalDate day4 = today.minusDays(2); + LocalDate day5 = today.minusDays(1); + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), createCompletion(day4, StreakStatus.COMPLETED, 1), + createCompletion(day5, StreakStatus.COMPLETED, 2)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day5); + assertThat(result.getStreakStartDate()).isEqualTo(day4); + } + + @Test + @DisplayName("마지막 완료일이 어제이고 오늘 기록이 없으면 currentStreak 유지") + void recalculate_LastCompletionYesterday_MaintainsStreak() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); + // 오늘은 아직 완료 안 함 + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(2); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day2); + assertThat(result.getStreakStartDate()).isEqualTo(day1); + } + + @Test + @DisplayName("마지막 완료일이 2일 전이면 currentStreak=0으로 리셋") + void recalculate_LastCompletionTwoDaysAgo_ResetsStreak() { + // given + LocalDate day1 = today.minusDays(3); + LocalDate day2 = today.minusDays(2); + // 어제와 오늘 기록 없음 + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(0); + assertThat(result.getLongestStreak()).isEqualTo(2); + assertThat(result.getLastCompletionDate()).isEqualTo(day2); + assertThat(result.getStreakStartDate()).isNull(); + } + + @Test + @DisplayName("복잡한 시나리오: 프리즈 + MISSED + 여러 스트릭 구간") + void recalculate_ComplexScenario_CalculatesCorrectly() { + // given + LocalDate day1 = today.minusDays(10); + LocalDate day2 = today.minusDays(9); + LocalDate day3 = today.minusDays(8); + LocalDate day4 = today.minusDays(7); // FREEZE_USED + LocalDate day5 = today.minusDays(6); + LocalDate day6 = today.minusDays(5); // MISSED - 스트릭 끊김 + LocalDate day7 = today.minusDays(4); + LocalDate day8 = today.minusDays(3); + LocalDate day9 = today.minusDays(2); + LocalDate day10 = today.minusDays(1); + LocalDate day11 = today; + + List completions = List.of(createCompletion(day1, StreakStatus.COMPLETED, 1), + createCompletion(day2, StreakStatus.COMPLETED, 2), createCompletion(day3, StreakStatus.COMPLETED, 3), + createCompletion(day4, StreakStatus.FREEZE_USED, 3), createCompletion(day5, StreakStatus.COMPLETED, 4), + createCompletion(day6, StreakStatus.MISSED, null), createCompletion(day7, StreakStatus.COMPLETED, 1), + createCompletion(day8, StreakStatus.COMPLETED, 2), createCompletion(day9, StreakStatus.COMPLETED, 3), + createCompletion(day10, StreakStatus.COMPLETED, 4), createCompletion(day11, StreakStatus.COMPLETED, 5)); + + when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)).thenReturn(completions); + when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + UserStudyReport result = streakService.recalculateUserStudyReport(TEST_USER_ID); + + // then + assertThat(result.getCurrentStreak()).isEqualTo(5); // day7~day11 + assertThat(result.getLongestStreak()).isEqualTo(5); // 현재 스트릭이 가장 김 + assertThat(result.getLastCompletionDate()).isEqualTo(day11); + assertThat(result.getStreakStartDate()).isEqualTo(day7); + } + + private DailyCompletion createCompletion(LocalDate date, StreakStatus status, Integer streakCount) { + return DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .streakStatus(status) + .streakCount(streakCount) + .firstCompletionCount(0) + .totalCompletionCount(status == StreakStatus.COMPLETED ? 1 : 0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + } + } diff --git a/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java index ee9a10d8..24978d81 100644 --- a/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StreakServiceRecoveryTest.java @@ -36,516 +36,504 @@ @DisplayName("StreakService - recoverStreak TDD 테스트") class StreakServiceRecoveryTest { - @Mock - private UserStudyReportRepository userStudyReportRepository; + @Mock + private UserStudyReportRepository userStudyReportRepository; - @Mock - private DailyCompletionRepository dailyCompletionRepository; + @Mock + private DailyCompletionRepository dailyCompletionRepository; - @Mock - private FreezeTransactionRepository freezeTransactionRepository; + @Mock + private FreezeTransactionRepository freezeTransactionRepository; - @InjectMocks - private StreakService streakService; + @InjectMocks + private StreakService streakService; - @Captor - private ArgumentCaptor> dailyCompletionListCaptor; + @Captor + private ArgumentCaptor> dailyCompletionListCaptor; - @Captor - private ArgumentCaptor> freezeTransactionListCaptor; + @Captor + private ArgumentCaptor> freezeTransactionListCaptor; - @Captor - private ArgumentCaptor userStudyReportCaptor; + @Captor + private ArgumentCaptor userStudyReportCaptor; - private static final String TEST_USER_ID = "test-user-123"; - private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + private static final String TEST_USER_ID = "test-user-123"; - private UserStudyReport testReport; - private LocalDate today; - private Map completionMap; + private static final ZoneId KST_ZONE = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + + private LocalDate today; + + private Map completionMap; + + @BeforeEach + void setUp() { + today = LocalDate.now(KST_ZONE); + testReport = new UserStudyReport(); + testReport.setUserId(TEST_USER_ID); + testReport.setCompletedContentIds(new HashSet<>()); + testReport.setCurrentStreak(0); + testReport.setLongestStreak(0); + testReport.setAvailableFreezes(0); + testReport.setTotalReadingTimeSeconds(0L); + testReport.setCreatedAt(Instant.now()); + + completionMap = new HashMap<>(); + } + + @Test + @DisplayName("시나리오 1: 단순 MISSED 1일 복구") + void recoverStreak_SimpleMissedDay_CreatesCompletion() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); // MISSED → 복구 대상 + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + completionMap.put(day1, day1Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + // 저장된 모든 DailyCompletion 수집 + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2가 COMPLETED로 생성되었는지 확인 + DailyCompletion day2Completion = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 completion not found")); + + assertThat(day2Completion.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Completion.getStreakCount()).isEqualTo(2); + assertThat(day2Completion.getUserId()).isEqualTo(TEST_USER_ID); + + // 프리즈 트랜잭션이 없어야 함 (복구만 했고, FREEZE_USED 아님) + verify(freezeTransactionRepository, never()).saveAll(any()); + + // UserStudyReport가 재계산되어 저장되었는지 확인 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(2); + assertThat(savedReport.getLongestStreak()).isEqualTo(2); + assertThat(savedReport.getLastCompletionDate()).isEqualTo(day2); + assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 보상 없음 + } + + @Test + @DisplayName("시나리오 2: FREEZE_USED를 COMPLETED로 복구 + 프리즈 보상") + void recoverStreak_FreezeUsedDay_ConvertsToCompletedAndRewardsFreeze() { + // given + LocalDate day1 = today.minusDays(2); + LocalDate day2 = today.minusDays(1); // FREEZE_USED → 복구 + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); + + completionMap.put(day1, day1Completion); + completionMap.put(day2, day2Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + // 저장된 모든 DailyCompletion 수집 + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2가 COMPLETED로 변경되었는지 확인 + DailyCompletion day2Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 completion not found")); + + assertThat(day2Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Updated.getStreakCount()).isEqualTo(2); + + // 프리즈 보상 확인 - 1개의 +1 트랜잭션 + verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); + + List allFreezes = new ArrayList<>(); + freezeTransactionListCaptor.getAllValues().forEach(allFreezes::addAll); + + // +1 보상 트랜잭션이 정확히 1개 있어야 함 + List rewards = allFreezes.stream().filter(tx -> tx.getAmount() == 1).toList(); + assertThat(rewards).hasSize(1); + + FreezeTransaction rewardTx = rewards.get(0); + assertThat(rewardTx.getUserId()).isEqualTo(TEST_USER_ID); + assertThat(rewardTx.getDescription()).contains(day2.toString()); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(2); + assertThat(savedReport.getLongestStreak()).isEqualTo(2); + // 프리즈: day2 복구 보상 +1 (오늘은 배치에서 처리하므로 제외) + assertThat(savedReport.getAvailableFreezes()).isEqualTo(1); + } + + @Test + @DisplayName("시나리오 3: 연속 MISSED 3일 복구") + void recoverStreak_MultipleMissedDays_CreatesAllCompletions() { + // given + LocalDate day1 = today.minusDays(4); + LocalDate day2 = today.minusDays(3); // MISSED → 복구 + LocalDate day3 = today.minusDays(2); // MISSED → 복구 + LocalDate day4 = today.minusDays(1); // MISSED → 복구 + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + completionMap.put(day1, day1Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day4); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2, day3, day4가 모두 COMPLETED로 생성되었는지 확인 + DailyCompletion day2Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 not found")); + + DailyCompletion day3Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day3)) + .findFirst() + .orElseThrow(() -> new AssertionError("day3 not found")); + + DailyCompletion day4Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day4)) + .findFirst() + .orElseThrow(() -> new AssertionError("day4 not found")); + + // streakCount 검증 + assertThat(day2Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Saved.getStreakCount()).isEqualTo(2); + + assertThat(day3Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day3Saved.getStreakCount()).isEqualTo(3); + + assertThat(day4Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day4Saved.getStreakCount()).isEqualTo(4); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(4); + assertThat(savedReport.getLongestStreak()).isEqualTo(4); + } + + @Test + @DisplayName("시나리오 4: 복구 후 이후 날짜 재계산 - 프리즈 자동 사용") + void recoverStreak_AfterRecovery_AutoUsesFreeze() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); // FREEZE_USED → COMPLETED (복구, 프리즈 +1) + LocalDate day3 = today.minusDays(3); // MISSED (복구 범위 밖, 프리즈 자동 사용) + LocalDate day4 = today.minusDays(2); // COMPLETED (연결됨) + LocalDate day5 = today.minusDays(1); // COMPLETED (연결됨) + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); + DailyCompletion day4Completion = createCompletion(day4, StreakStatus.COMPLETED, 1); + DailyCompletion day5Completion = createCompletion(day5, StreakStatus.COMPLETED, 2); + + completionMap.put(day1, day1Completion); + completionMap.put(day2, day2Completion); + completionMap.put(day4, day4Completion); + completionMap.put(day5, day5Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2: FREEZE_USED → COMPLETED (streakCount=2) + DailyCompletion day2Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 not found")); + + assertThat(day2Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Updated.getStreakCount()).isEqualTo(2); + + // day3: 프리즈 자동 사용으로 FREEZE_USED 생성 (streakCount=2 유지) + DailyCompletion day3Created = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day3)) + .findFirst() + .orElseThrow(() -> new AssertionError("day3 not found")); + + assertThat(day3Created.getStreakStatus()).isEqualTo(StreakStatus.FREEZE_USED); + assertThat(day3Created.getStreakCount()).isEqualTo(2); + + // day4: streakCount 재계산 (3으로 증가) + DailyCompletion day4Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day4)) + .findFirst() + .orElseThrow(() -> new AssertionError("day4 not found")); + + assertThat(day4Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day4Updated.getStreakCount()).isEqualTo(3); + + // day5: streakCount 재계산 (4로 증가) + DailyCompletion day5Updated = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day5)) + .findFirst() + .orElseThrow(() -> new AssertionError("day5 not found")); + + assertThat(day5Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day5Updated.getStreakCount()).isEqualTo(4); + + // 프리즈 트랜잭션 확인: +1 (day2 복구) -1 (day3 자동 사용) + verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); + + List allTxs = new ArrayList<>(); + freezeTransactionListCaptor.getAllValues().forEach(allTxs::addAll); + + // +1 보상이 정확히 1개, -1 사용이 정확히 1개 + long rewardCount = allTxs.stream().filter(tx -> tx.getAmount() == 1).count(); + long usageCount = allTxs.stream().filter(tx -> tx.getAmount() == -1).count(); + + assertThat(rewardCount).isEqualTo(1); + assertThat(usageCount).isEqualTo(1); + + FreezeTransaction rewardTx = allTxs.stream() + .filter(tx -> tx.getAmount() == 1) + .findFirst() + .orElseThrow(() -> new AssertionError("Reward transaction not found")); + assertThat(rewardTx.getDescription()).contains(day2.toString()); + + FreezeTransaction usageTx = allTxs.stream() + .filter(tx -> tx.getAmount() == -1) + .findFirst() + .orElseThrow(() -> new AssertionError("Usage transaction not found")); + assertThat(usageTx.getDescription()).contains(day3.toString()); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(4); + assertThat(savedReport.getLongestStreak()).isEqualTo(4); + assertThat(savedReport.getLastCompletionDate()).isEqualTo(day5); + assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // +1 보상 -1 사용 = 0 + } + + @Test + @DisplayName("시나리오 5: 복구 후 프리즈 부족으로 스트릭 연결 중단") + void recoverStreak_AfterRecovery_StopsWhenNoFreeze() { + // given + LocalDate day1 = today.minusDays(5); + LocalDate day2 = today.minusDays(4); // MISSED → 복구 (프리즈 보상 없음) + LocalDate day3 = today.minusDays(3); // MISSED (프리즈 없어서 연결 안 됨) + LocalDate day4 = today.minusDays(2); // COMPLETED (연결 안 됨, 새 스트릭) + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day4Completion = createCompletion(day4, StreakStatus.COMPLETED, 1); + + completionMap.put(day1, day1Completion); + completionMap.put(day4, day4Completion); + + setupMocks(); + + // when + streakService.recoverStreak(TEST_USER_ID, day2, day2); + + // then + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + + List allSaved = new ArrayList<>(); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2는 복구됨 + DailyCompletion day2Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day2)) + .findFirst() + .orElseThrow(() -> new AssertionError("day2 not found")); + + assertThat(day2Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); + assertThat(day2Saved.getStreakCount()).isEqualTo(2); + + // day3은 프리즈가 없어서 FREEZE_USED로 생성되지 않음 + boolean hasDay3 = allSaved.stream().anyMatch(dc -> dc.getCompletionDate().equals(day3)); + + assertThat(hasDay3).isFalse(); + + // day4는 기존 값 유지 (연결 안 됨) + DailyCompletion day4Saved = allSaved.stream() + .filter(dc -> dc.getCompletionDate().equals(day4)) + .findFirst() + .orElse(null); + + // day4가 저장되지 않았거나, 저장되었어도 streakCount가 1로 유지 + if (day4Saved != null) { + assertThat(day4Saved.getStreakCount()).isEqualTo(1); + } + + // UserStudyReport 검증 - day3에서 끊겼으므로 currentStreak=0 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + // day2까지만 연결, day3에서 끊김 -> 오늘까지 2일 이상 차이 -> streak=0 + assertThat(savedReport.getCurrentStreak()).isEqualTo(0); + assertThat(savedReport.getLongestStreak()).isEqualTo(2); + } + + @Test + @DisplayName("시나리오 6: 여러 FREEZE_USED 복구 + 복잡한 프리즈 사용") + void recoverStreak_MultipleFreeze_ComplexScenario() { + // given + LocalDate day1 = today.minusDays(7); // COMPLETED (streak=1) + LocalDate day2 = today.minusDays(6); // FREEZE_USED → COMPLETED (복구, 프리즈 +1) + LocalDate day3 = today.minusDays(5); // MISSED → COMPLETED (복구) + LocalDate day4 = today.minusDays(4); // MISSED (프리즈 1개 사용) + LocalDate day5 = today.minusDays(3); // COMPLETED (연결됨) + LocalDate day6 = today.minusDays(2); // COMPLETED (연결됨) + LocalDate day7 = today.minusDays(1); // COMPLETED (연결됨) + + DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); + DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); + DailyCompletion day5Completion = createCompletion(day5, StreakStatus.COMPLETED, 1); + DailyCompletion day6Completion = createCompletion(day6, StreakStatus.COMPLETED, 2); + DailyCompletion day7Completion = createCompletion(day7, StreakStatus.COMPLETED, 3); + + completionMap.put(day1, day1Completion); + completionMap.put(day2, day2Completion); + completionMap.put(day5, day5Completion); + completionMap.put(day6, day6Completion); + completionMap.put(day7, day7Completion); + + setupMocks(); + + // when - day2, day3 복구 + streakService.recoverStreak(TEST_USER_ID, day2, day3); + + // then + List allSaved = new ArrayList<>(); + verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); + dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); + + // day2: FREEZE_USED → COMPLETED (streak=2) + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day2) && dc.getStreakStatus() == StreakStatus.COMPLETED + && dc.getStreakCount() == 2)) + .isTrue(); + + // day3: MISSED → COMPLETED (streak=3) + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day3) && dc.getStreakStatus() == StreakStatus.COMPLETED + && dc.getStreakCount() == 3)) + .isTrue(); + + // day4: 프리즈 사용으로 FREEZE_USED (streak=3 유지) + assertThat(allSaved.stream() + .anyMatch(dc -> dc.getCompletionDate().equals(day4) && dc.getStreakStatus() == StreakStatus.FREEZE_USED + && dc.getStreakCount() == 3)) + .isTrue(); + + // day5, day6, day7: streakCount 재계산 + assertThat(allSaved.stream().anyMatch(dc -> dc.getCompletionDate().equals(day5) && dc.getStreakCount() == 4)) + .isTrue(); + + assertThat(allSaved.stream().anyMatch(dc -> dc.getCompletionDate().equals(day6) && dc.getStreakCount() == 5)) + .isTrue(); + + assertThat(allSaved.stream().anyMatch(dc -> dc.getCompletionDate().equals(day7) && dc.getStreakCount() == 6)) + .isTrue(); + + // 프리즈 트랜잭션: +1 (day2 보상), -1 (day4 사용) + verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); + List allTxs = new ArrayList<>(); + freezeTransactionListCaptor.getAllValues().forEach(allTxs::addAll); + + long rewards = allTxs.stream().filter(tx -> tx.getAmount() == 1).count(); + long usages = allTxs.stream().filter(tx -> tx.getAmount() == -1).count(); + + assertThat(rewards).isEqualTo(1); + assertThat(usages).isEqualTo(1); + + // UserStudyReport 검증 + verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); + UserStudyReport savedReport = userStudyReportCaptor.getValue(); + assertThat(savedReport.getCurrentStreak()).isEqualTo(6); + assertThat(savedReport.getLongestStreak()).isEqualTo(6); + assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // +1 보상 -1 사용 = 0 + } + + private DailyCompletion createCompletion(LocalDate date, StreakStatus status, Integer streakCount) { + return DailyCompletion.builder() + .userId(TEST_USER_ID) + .completionDate(date) + .streakStatus(status) + .streakCount(streakCount) + .firstCompletionCount(0) + .totalCompletionCount(status == StreakStatus.COMPLETED ? 1 : 0) + .completedContents(new ArrayList<>()) + .createdAt(Instant.now()) + .build(); + } + + private void setupMocks() { + // UserStudyReport mock + lenient().when(userStudyReportRepository.findByUserId(TEST_USER_ID)).thenReturn(Optional.of(testReport)); + lenient().when(userStudyReportRepository.save(any(UserStudyReport.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // DailyCompletion 조회 mock + lenient().when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any(LocalDate.class))) + .thenAnswer(invocation -> { + LocalDate date = invocation.getArgument(1); + return Optional.ofNullable(completionMap.get(date)); + }); + + // DailyCompletion 전체 조회 mock (recalculateUserStudyReport용) - 날짜 순으로 정렬 + lenient().when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) + .thenAnswer(invocation -> { + List sorted = new ArrayList<>(completionMap.values()); + sorted.sort((a, b) -> a.getCompletionDate().compareTo(b.getCompletionDate())); + return sorted; + }); + + // recalculateAllStreakCounts용 mock - startDate 이후 조회 + lenient().when(dailyCompletionRepository.findByUserIdAndCompletionDateGreaterThanEqualOrderByCompletionDateAsc( + eq(TEST_USER_ID), any(LocalDate.class))) + .thenAnswer(invocation -> { + LocalDate startDate = invocation.getArgument(1); + List sorted = new ArrayList<>(completionMap.values()); + sorted.sort((a, b) -> a.getCompletionDate().compareTo(b.getCompletionDate())); + return sorted.stream().filter(dc -> !dc.getCompletionDate().isBefore(startDate)).toList(); + }); + + // saveAll mock + lenient().when(dailyCompletionRepository.saveAll(anyList())).thenAnswer(invocation -> { + List saved = invocation.getArgument(0); + // completionMap 업데이트 + saved.forEach(dc -> completionMap.put(dc.getCompletionDate(), dc)); + return saved; + }); + + lenient().when(freezeTransactionRepository.saveAll(anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } - @BeforeEach - void setUp() { - today = LocalDate.now(KST_ZONE); - testReport = new UserStudyReport(); - testReport.setUserId(TEST_USER_ID); - testReport.setCompletedContentIds(new HashSet<>()); - testReport.setCurrentStreak(0); - testReport.setLongestStreak(0); - testReport.setAvailableFreezes(0); - testReport.setTotalReadingTimeSeconds(0L); - testReport.setCreatedAt(Instant.now()); - - completionMap = new HashMap<>(); - } - - @Test - @DisplayName("시나리오 1: 단순 MISSED 1일 복구") - void recoverStreak_SimpleMissedDay_CreatesCompletion() { - // given - LocalDate day1 = today.minusDays(2); - LocalDate day2 = today.minusDays(1); // MISSED → 복구 대상 - - DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); - completionMap.put(day1, day1Completion); - - setupMocks(); - - // when - streakService.recoverStreak(TEST_USER_ID, day2, day2); - - // then - verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); - - // 저장된 모든 DailyCompletion 수집 - List allSaved = new ArrayList<>(); - dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); - - // day2가 COMPLETED로 생성되었는지 확인 - DailyCompletion day2Completion = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day2)) - .findFirst() - .orElseThrow(() -> new AssertionError("day2 completion not found")); - - assertThat(day2Completion.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day2Completion.getStreakCount()).isEqualTo(2); - assertThat(day2Completion.getUserId()).isEqualTo(TEST_USER_ID); - - // 프리즈 트랜잭션이 없어야 함 (복구만 했고, FREEZE_USED 아님) - verify(freezeTransactionRepository, never()).saveAll(any()); - - // UserStudyReport가 재계산되어 저장되었는지 확인 - verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); - UserStudyReport savedReport = userStudyReportCaptor.getValue(); - assertThat(savedReport.getCurrentStreak()).isEqualTo(2); - assertThat(savedReport.getLongestStreak()).isEqualTo(2); - assertThat(savedReport.getLastCompletionDate()).isEqualTo(day2); - assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // 프리즈 보상 없음 - } - - @Test - @DisplayName("시나리오 2: FREEZE_USED를 COMPLETED로 복구 + 프리즈 보상") - void recoverStreak_FreezeUsedDay_ConvertsToCompletedAndRewardsFreeze() { - // given - LocalDate day1 = today.minusDays(2); - LocalDate day2 = today.minusDays(1); // FREEZE_USED → 복구 - - DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); - DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); - - completionMap.put(day1, day1Completion); - completionMap.put(day2, day2Completion); - - setupMocks(); - - // when - streakService.recoverStreak(TEST_USER_ID, day2, day2); - - // then - verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); - - // 저장된 모든 DailyCompletion 수집 - List allSaved = new ArrayList<>(); - dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); - - // day2가 COMPLETED로 변경되었는지 확인 - DailyCompletion day2Updated = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day2)) - .findFirst() - .orElseThrow(() -> new AssertionError("day2 completion not found")); - - assertThat(day2Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day2Updated.getStreakCount()).isEqualTo(2); - - // 프리즈 보상 확인 - 1개의 +1 트랜잭션 - verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); - - List allFreezes = new ArrayList<>(); - freezeTransactionListCaptor.getAllValues().forEach(allFreezes::addAll); - - // +1 보상 트랜잭션이 정확히 1개 있어야 함 - List rewards = allFreezes.stream() - .filter(tx -> tx.getAmount() == 1) - .toList(); - assertThat(rewards).hasSize(1); - - FreezeTransaction rewardTx = rewards.get(0); - assertThat(rewardTx.getUserId()).isEqualTo(TEST_USER_ID); - assertThat(rewardTx.getDescription()).contains(day2.toString()); - - // UserStudyReport 검증 - verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); - UserStudyReport savedReport = userStudyReportCaptor.getValue(); - assertThat(savedReport.getCurrentStreak()).isEqualTo(2); - assertThat(savedReport.getLongestStreak()).isEqualTo(2); - // 프리즈: day2 복구 보상 +1 (오늘은 배치에서 처리하므로 제외) - assertThat(savedReport.getAvailableFreezes()).isEqualTo(1); - } - - @Test - @DisplayName("시나리오 3: 연속 MISSED 3일 복구") - void recoverStreak_MultipleMissedDays_CreatesAllCompletions() { - // given - LocalDate day1 = today.minusDays(4); - LocalDate day2 = today.minusDays(3); // MISSED → 복구 - LocalDate day3 = today.minusDays(2); // MISSED → 복구 - LocalDate day4 = today.minusDays(1); // MISSED → 복구 - - DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); - completionMap.put(day1, day1Completion); - - setupMocks(); - - // when - streakService.recoverStreak(TEST_USER_ID, day2, day4); - - // then - verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); - - List allSaved = new ArrayList<>(); - dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); - - // day2, day3, day4가 모두 COMPLETED로 생성되었는지 확인 - DailyCompletion day2Saved = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day2)) - .findFirst() - .orElseThrow(() -> new AssertionError("day2 not found")); - - DailyCompletion day3Saved = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day3)) - .findFirst() - .orElseThrow(() -> new AssertionError("day3 not found")); - - DailyCompletion day4Saved = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day4)) - .findFirst() - .orElseThrow(() -> new AssertionError("day4 not found")); - - // streakCount 검증 - assertThat(day2Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day2Saved.getStreakCount()).isEqualTo(2); - - assertThat(day3Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day3Saved.getStreakCount()).isEqualTo(3); - - assertThat(day4Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day4Saved.getStreakCount()).isEqualTo(4); - - // UserStudyReport 검증 - verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); - UserStudyReport savedReport = userStudyReportCaptor.getValue(); - assertThat(savedReport.getCurrentStreak()).isEqualTo(4); - assertThat(savedReport.getLongestStreak()).isEqualTo(4); - } - - @Test - @DisplayName("시나리오 4: 복구 후 이후 날짜 재계산 - 프리즈 자동 사용") - void recoverStreak_AfterRecovery_AutoUsesFreeze() { - // given - LocalDate day1 = today.minusDays(5); - LocalDate day2 = today.minusDays(4); // FREEZE_USED → COMPLETED (복구, 프리즈 +1) - LocalDate day3 = today.minusDays(3); // MISSED (복구 범위 밖, 프리즈 자동 사용) - LocalDate day4 = today.minusDays(2); // COMPLETED (연결됨) - LocalDate day5 = today.minusDays(1); // COMPLETED (연결됨) - - DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); - DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); - DailyCompletion day4Completion = createCompletion(day4, StreakStatus.COMPLETED, 1); - DailyCompletion day5Completion = createCompletion(day5, StreakStatus.COMPLETED, 2); - - completionMap.put(day1, day1Completion); - completionMap.put(day2, day2Completion); - completionMap.put(day4, day4Completion); - completionMap.put(day5, day5Completion); - - setupMocks(); - - // when - streakService.recoverStreak(TEST_USER_ID, day2, day2); - - // then - verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); - - List allSaved = new ArrayList<>(); - dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); - - // day2: FREEZE_USED → COMPLETED (streakCount=2) - DailyCompletion day2Updated = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day2)) - .findFirst() - .orElseThrow(() -> new AssertionError("day2 not found")); - - assertThat(day2Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day2Updated.getStreakCount()).isEqualTo(2); - - // day3: 프리즈 자동 사용으로 FREEZE_USED 생성 (streakCount=2 유지) - DailyCompletion day3Created = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day3)) - .findFirst() - .orElseThrow(() -> new AssertionError("day3 not found")); - - assertThat(day3Created.getStreakStatus()).isEqualTo(StreakStatus.FREEZE_USED); - assertThat(day3Created.getStreakCount()).isEqualTo(2); - - // day4: streakCount 재계산 (3으로 증가) - DailyCompletion day4Updated = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day4)) - .findFirst() - .orElseThrow(() -> new AssertionError("day4 not found")); - - assertThat(day4Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day4Updated.getStreakCount()).isEqualTo(3); - - // day5: streakCount 재계산 (4로 증가) - DailyCompletion day5Updated = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day5)) - .findFirst() - .orElseThrow(() -> new AssertionError("day5 not found")); - - assertThat(day5Updated.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day5Updated.getStreakCount()).isEqualTo(4); - - // 프리즈 트랜잭션 확인: +1 (day2 복구) -1 (day3 자동 사용) - verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); - - List allTxs = new ArrayList<>(); - freezeTransactionListCaptor.getAllValues().forEach(allTxs::addAll); - - // +1 보상이 정확히 1개, -1 사용이 정확히 1개 - long rewardCount = allTxs.stream().filter(tx -> tx.getAmount() == 1).count(); - long usageCount = allTxs.stream().filter(tx -> tx.getAmount() == -1).count(); - - assertThat(rewardCount).isEqualTo(1); - assertThat(usageCount).isEqualTo(1); - - FreezeTransaction rewardTx = allTxs.stream() - .filter(tx -> tx.getAmount() == 1) - .findFirst() - .orElseThrow(() -> new AssertionError("Reward transaction not found")); - assertThat(rewardTx.getDescription()).contains(day2.toString()); - - FreezeTransaction usageTx = allTxs.stream() - .filter(tx -> tx.getAmount() == -1) - .findFirst() - .orElseThrow(() -> new AssertionError("Usage transaction not found")); - assertThat(usageTx.getDescription()).contains(day3.toString()); - - // UserStudyReport 검증 - verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); - UserStudyReport savedReport = userStudyReportCaptor.getValue(); - assertThat(savedReport.getCurrentStreak()).isEqualTo(4); - assertThat(savedReport.getLongestStreak()).isEqualTo(4); - assertThat(savedReport.getLastCompletionDate()).isEqualTo(day5); - assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // +1 보상 -1 사용 = 0 - } - - @Test - @DisplayName("시나리오 5: 복구 후 프리즈 부족으로 스트릭 연결 중단") - void recoverStreak_AfterRecovery_StopsWhenNoFreeze() { - // given - LocalDate day1 = today.minusDays(5); - LocalDate day2 = today.minusDays(4); // MISSED → 복구 (프리즈 보상 없음) - LocalDate day3 = today.minusDays(3); // MISSED (프리즈 없어서 연결 안 됨) - LocalDate day4 = today.minusDays(2); // COMPLETED (연결 안 됨, 새 스트릭) - - DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); - DailyCompletion day4Completion = createCompletion(day4, StreakStatus.COMPLETED, 1); - - completionMap.put(day1, day1Completion); - completionMap.put(day4, day4Completion); - - setupMocks(); - - // when - streakService.recoverStreak(TEST_USER_ID, day2, day2); - - // then - verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); - - List allSaved = new ArrayList<>(); - dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); - - // day2는 복구됨 - DailyCompletion day2Saved = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day2)) - .findFirst() - .orElseThrow(() -> new AssertionError("day2 not found")); - - assertThat(day2Saved.getStreakStatus()).isEqualTo(StreakStatus.COMPLETED); - assertThat(day2Saved.getStreakCount()).isEqualTo(2); - - // day3은 프리즈가 없어서 FREEZE_USED로 생성되지 않음 - boolean hasDay3 = allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day3)); - - assertThat(hasDay3).isFalse(); - - // day4는 기존 값 유지 (연결 안 됨) - DailyCompletion day4Saved = allSaved.stream() - .filter(dc -> dc.getCompletionDate().equals(day4)) - .findFirst() - .orElse(null); - - // day4가 저장되지 않았거나, 저장되었어도 streakCount가 1로 유지 - if (day4Saved != null) { - assertThat(day4Saved.getStreakCount()).isEqualTo(1); - } - - // UserStudyReport 검증 - day3에서 끊겼으므로 currentStreak=0 - verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); - UserStudyReport savedReport = userStudyReportCaptor.getValue(); - // day2까지만 연결, day3에서 끊김 -> 오늘까지 2일 이상 차이 -> streak=0 - assertThat(savedReport.getCurrentStreak()).isEqualTo(0); - assertThat(savedReport.getLongestStreak()).isEqualTo(2); - } - - @Test - @DisplayName("시나리오 6: 여러 FREEZE_USED 복구 + 복잡한 프리즈 사용") - void recoverStreak_MultipleFreeze_ComplexScenario() { - // given - LocalDate day1 = today.minusDays(7); // COMPLETED (streak=1) - LocalDate day2 = today.minusDays(6); // FREEZE_USED → COMPLETED (복구, 프리즈 +1) - LocalDate day3 = today.minusDays(5); // MISSED → COMPLETED (복구) - LocalDate day4 = today.minusDays(4); // MISSED (프리즈 1개 사용) - LocalDate day5 = today.minusDays(3); // COMPLETED (연결됨) - LocalDate day6 = today.minusDays(2); // COMPLETED (연결됨) - LocalDate day7 = today.minusDays(1); // COMPLETED (연결됨) - - DailyCompletion day1Completion = createCompletion(day1, StreakStatus.COMPLETED, 1); - DailyCompletion day2Completion = createCompletion(day2, StreakStatus.FREEZE_USED, 1); - DailyCompletion day5Completion = createCompletion(day5, StreakStatus.COMPLETED, 1); - DailyCompletion day6Completion = createCompletion(day6, StreakStatus.COMPLETED, 2); - DailyCompletion day7Completion = createCompletion(day7, StreakStatus.COMPLETED, 3); - - completionMap.put(day1, day1Completion); - completionMap.put(day2, day2Completion); - completionMap.put(day5, day5Completion); - completionMap.put(day6, day6Completion); - completionMap.put(day7, day7Completion); - - setupMocks(); - - // when - day2, day3 복구 - streakService.recoverStreak(TEST_USER_ID, day2, day3); - - // then - List allSaved = new ArrayList<>(); - verify(dailyCompletionRepository, atLeastOnce()).saveAll(dailyCompletionListCaptor.capture()); - dailyCompletionListCaptor.getAllValues().forEach(allSaved::addAll); - - // day2: FREEZE_USED → COMPLETED (streak=2) - assertThat(allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day2) - && dc.getStreakStatus() == StreakStatus.COMPLETED - && dc.getStreakCount() == 2)) - .isTrue(); - - // day3: MISSED → COMPLETED (streak=3) - assertThat(allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day3) - && dc.getStreakStatus() == StreakStatus.COMPLETED - && dc.getStreakCount() == 3)) - .isTrue(); - - // day4: 프리즈 사용으로 FREEZE_USED (streak=3 유지) - assertThat(allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day4) - && dc.getStreakStatus() == StreakStatus.FREEZE_USED - && dc.getStreakCount() == 3)) - .isTrue(); - - // day5, day6, day7: streakCount 재계산 - assertThat(allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day5) - && dc.getStreakCount() == 4)) - .isTrue(); - - assertThat(allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day6) - && dc.getStreakCount() == 5)) - .isTrue(); - - assertThat(allSaved.stream() - .anyMatch(dc -> dc.getCompletionDate().equals(day7) - && dc.getStreakCount() == 6)) - .isTrue(); - - // 프리즈 트랜잭션: +1 (day2 보상), -1 (day4 사용) - verify(freezeTransactionRepository, atLeastOnce()).saveAll(freezeTransactionListCaptor.capture()); - List allTxs = new ArrayList<>(); - freezeTransactionListCaptor.getAllValues().forEach(allTxs::addAll); - - long rewards = allTxs.stream().filter(tx -> tx.getAmount() == 1).count(); - long usages = allTxs.stream().filter(tx -> tx.getAmount() == -1).count(); - - assertThat(rewards).isEqualTo(1); - assertThat(usages).isEqualTo(1); - - // UserStudyReport 검증 - verify(userStudyReportRepository, atLeastOnce()).save(userStudyReportCaptor.capture()); - UserStudyReport savedReport = userStudyReportCaptor.getValue(); - assertThat(savedReport.getCurrentStreak()).isEqualTo(6); - assertThat(savedReport.getLongestStreak()).isEqualTo(6); - assertThat(savedReport.getAvailableFreezes()).isEqualTo(0); // +1 보상 -1 사용 = 0 - } - - private DailyCompletion createCompletion(LocalDate date, StreakStatus status, Integer streakCount) { - return DailyCompletion.builder() - .userId(TEST_USER_ID) - .completionDate(date) - .streakStatus(status) - .streakCount(streakCount) - .firstCompletionCount(0) - .totalCompletionCount(status == StreakStatus.COMPLETED ? 1 : 0) - .completedContents(new ArrayList<>()) - .createdAt(Instant.now()) - .build(); - } - - private void setupMocks() { - // UserStudyReport mock - lenient().when(userStudyReportRepository.findByUserId(TEST_USER_ID)) - .thenReturn(Optional.of(testReport)); - lenient().when(userStudyReportRepository.save(any(UserStudyReport.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // DailyCompletion 조회 mock - lenient().when(dailyCompletionRepository.findByUserIdAndCompletionDate(eq(TEST_USER_ID), any(LocalDate.class))) - .thenAnswer(invocation -> { - LocalDate date = invocation.getArgument(1); - return Optional.ofNullable(completionMap.get(date)); - }); - - // DailyCompletion 전체 조회 mock (recalculateUserStudyReport용) - 날짜 순으로 정렬 - lenient().when(dailyCompletionRepository.findByUserIdOrderByCompletionDateAsc(TEST_USER_ID)) - .thenAnswer(invocation -> { - List sorted = new ArrayList<>(completionMap.values()); - sorted.sort((a, b) -> a.getCompletionDate().compareTo(b.getCompletionDate())); - return sorted; - }); - - // recalculateAllStreakCounts용 mock - startDate 이후 조회 - lenient().when(dailyCompletionRepository.findByUserIdAndCompletionDateGreaterThanEqualOrderByCompletionDateAsc( - eq(TEST_USER_ID), any(LocalDate.class))) - .thenAnswer(invocation -> { - LocalDate startDate = invocation.getArgument(1); - List sorted = new ArrayList<>(completionMap.values()); - sorted.sort((a, b) -> a.getCompletionDate().compareTo(b.getCompletionDate())); - return sorted.stream() - .filter(dc -> !dc.getCompletionDate().isBefore(startDate)) - .toList(); - }); - - // saveAll mock - lenient().when(dailyCompletionRepository.saveAll(anyList())) - .thenAnswer(invocation -> { - List saved = invocation.getArgument(0); - // completionMap 업데이트 - saved.forEach(dc -> completionMap.put(dc.getCompletionDate(), dc)); - return saved; - }); - - lenient().when(freezeTransactionRepository.saveAll(anyList())) - .thenAnswer(invocation -> invocation.getArgument(0)); - } } diff --git a/src/test/java/com/linglevel/api/streak/service/StudyTimeAnalysisServiceTest.java b/src/test/java/com/linglevel/api/streak/service/StudyTimeAnalysisServiceTest.java index 6c212178..62559af5 100644 --- a/src/test/java/com/linglevel/api/streak/service/StudyTimeAnalysisServiceTest.java +++ b/src/test/java/com/linglevel/api/streak/service/StudyTimeAnalysisServiceTest.java @@ -28,211 +28,204 @@ @DisplayName("학습 시간 분석 서비스 테스트") class StudyTimeAnalysisServiceTest { - @Mock - private DailyCompletionRepository dailyCompletionRepository; - - @Mock - private UserStudyReportRepository userStudyReportRepository; - - @InjectMocks - private StudyTimeAnalysisService service; - - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private UserStudyReport testReport; - - @BeforeEach - void setUp() { - testReport = new UserStudyReport(); - testReport.setUserId("test-user"); - testReport.setCurrentStreak(5); - } - - @Test - @DisplayName("DB에 저장된 선호 시간이 있으면 바로 반환") - void getPreferredStudyHour_WithExistingValue_ReturnsStoredValue() { - // given - testReport.setPreferredStudyHour(14); - when(userStudyReportRepository.findByUserId("test-user")) - .thenReturn(Optional.of(testReport)); - - // when - Optional result = service.getPreferredStudyHour("test-user"); - - // then - assertThat(result).isPresent(); - assertThat(result.get()).isEqualTo(14); - verify(dailyCompletionRepository, never()).findByUserIdAndCompletionDateAfter(any(), any()); - } - - @Test - @DisplayName("DB에 저장된 값이 없으면 즉시 계산하여 저장") - void getPreferredStudyHour_WithoutExistingValue_CalculatesAndSaves() { - // given - testReport.setPreferredStudyHour(null); - when(userStudyReportRepository.findByUserId("test-user")) - .thenReturn(Optional.of(testReport)); - - List completions = createCompletionsAtHour(14, 5); - when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) - .thenReturn(completions); - - // when - Optional result = service.getPreferredStudyHour("test-user"); - - // then - assertThat(result).isPresent(); - assertThat(result.get()).isEqualTo(14); - verify(userStudyReportRepository).save(argThat(report -> - report.getPreferredStudyHour() == 14 && - report.getPreferredStudyHourUpdatedAt() != null - )); - } - - @Test - @DisplayName("가장 빈번한 학습 시간대 계산 - 단일 시간대") - void calculateAndSavePreferredStudyHour_SingleFrequentHour() { - // given - when(userStudyReportRepository.findByUserId("test-user")) - .thenReturn(Optional.of(testReport)); - - // 14시에 5번, 15시에 2번 학습 - List completions = new ArrayList<>(); - completions.addAll(createCompletionsAtHour(14, 5)); - completions.addAll(createCompletionsAtHour(15, 2)); - - when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) - .thenReturn(completions); - - // when - Optional result = service.calculateAndSavePreferredStudyHour("test-user"); - - // then - assertThat(result).isPresent(); - assertThat(result.get()).isEqualTo(14); - } - - @Test - @DisplayName("가장 빈번한 학습 시간대 계산 - 여러 시간대") - void calculateAndSavePreferredStudyHour_MultipleHours() { - // given - when(userStudyReportRepository.findByUserId("test-user")) - .thenReturn(Optional.of(testReport)); - - // 20시에 3번, 14시에 2번, 15시에 2번 - List completions = new ArrayList<>(); - completions.addAll(createCompletionsAtHour(20, 3)); - completions.addAll(createCompletionsAtHour(14, 2)); - completions.addAll(createCompletionsAtHour(15, 2)); - - when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) - .thenReturn(completions); - - // when - Optional result = service.calculateAndSavePreferredStudyHour("test-user"); - - // then - assertThat(result).isPresent(); - assertThat(result.get()).isEqualTo(20); - } - - @Test - @DisplayName("학습 데이터가 없으면 Empty 반환") - void calculateAndSavePreferredStudyHour_NoData_ReturnsEmpty() { - // given - when(userStudyReportRepository.findByUserId("test-user")) - .thenReturn(Optional.of(testReport)); - when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) - .thenReturn(List.of()); - - // when - Optional result = service.calculateAndSavePreferredStudyHour("test-user"); - - // then - assertThat(result).isEmpty(); - verify(userStudyReportRepository, never()).save(any()); - } - - @Test - @DisplayName("사용자가 없으면 Empty 반환") - void getPreferredStudyHour_UserNotFound_ReturnsEmpty() { - // given - when(userStudyReportRepository.findByUserId("non-existent-user")) - .thenReturn(Optional.empty()); - - // when - Optional result = service.getPreferredStudyHour("non-existent-user"); - - // then - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("UTC 시각을 KST로 정확히 변환하여 계산") - void calculateAndSavePreferredStudyHour_UtcToKstConversion() { - // given - when(userStudyReportRepository.findByUserId("test-user")) - .thenReturn(Optional.of(testReport)); - - // UTC 05:00 = KST 14:00 - Instant utcTime = ZonedDateTime.of(2025, 1, 10, 5, 0, 0, 0, ZoneId.of("UTC")).toInstant(); - List completions = createCompletionsAtInstant(utcTime, 3); - - when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) - .thenReturn(completions); - - // when - Optional result = service.calculateAndSavePreferredStudyHour("test-user"); - - // then - assertThat(result).isPresent(); - assertThat(result.get()).isEqualTo(14); // KST 14시여야 함 - } - - /** - * 특정 KST 시간에 n번 학습한 DailyCompletion 생성 - */ - private List createCompletionsAtHour(int kstHour, int count) { - List completions = new ArrayList<>(); - - for (int i = 0; i < count; i++) { - DailyCompletion completion = new DailyCompletion(); - completion.setUserId("test-user"); - completion.setCompletionDate(LocalDate.now(KST).minusDays(i)); - - // KST 시간 -> UTC로 변환 - ZonedDateTime kstTime = ZonedDateTime.of(2025, 1, 10, kstHour, 0, 0, 0, KST); - Instant utcInstant = kstTime.toInstant(); - - DailyCompletion.CompletedContent content = DailyCompletion.CompletedContent.builder() - .completedAt(utcInstant) - .build(); - - completion.setCompletedContents(List.of(content)); - completions.add(completion); - } - - return completions; - } - - /** - * 특정 Instant에 n번 학습한 DailyCompletion 생성 - */ - private List createCompletionsAtInstant(Instant instant, int count) { - List completions = new ArrayList<>(); - - for (int i = 0; i < count; i++) { - DailyCompletion completion = new DailyCompletion(); - completion.setUserId("test-user"); - completion.setCompletionDate(LocalDate.now(KST).minusDays(i)); - - DailyCompletion.CompletedContent content = DailyCompletion.CompletedContent.builder() - .completedAt(instant) - .build(); - - completion.setCompletedContents(List.of(content)); - completions.add(completion); - } - - return completions; - } + @Mock + private DailyCompletionRepository dailyCompletionRepository; + + @Mock + private UserStudyReportRepository userStudyReportRepository; + + @InjectMocks + private StudyTimeAnalysisService service; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private UserStudyReport testReport; + + @BeforeEach + void setUp() { + testReport = new UserStudyReport(); + testReport.setUserId("test-user"); + testReport.setCurrentStreak(5); + } + + @Test + @DisplayName("DB에 저장된 선호 시간이 있으면 바로 반환") + void getPreferredStudyHour_WithExistingValue_ReturnsStoredValue() { + // given + testReport.setPreferredStudyHour(14); + when(userStudyReportRepository.findByUserId("test-user")).thenReturn(Optional.of(testReport)); + + // when + Optional result = service.getPreferredStudyHour("test-user"); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(14); + verify(dailyCompletionRepository, never()).findByUserIdAndCompletionDateAfter(any(), any()); + } + + @Test + @DisplayName("DB에 저장된 값이 없으면 즉시 계산하여 저장") + void getPreferredStudyHour_WithoutExistingValue_CalculatesAndSaves() { + // given + testReport.setPreferredStudyHour(null); + when(userStudyReportRepository.findByUserId("test-user")).thenReturn(Optional.of(testReport)); + + List completions = createCompletionsAtHour(14, 5); + when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) + .thenReturn(completions); + + // when + Optional result = service.getPreferredStudyHour("test-user"); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(14); + verify(userStudyReportRepository).save(argThat( + report -> report.getPreferredStudyHour() == 14 && report.getPreferredStudyHourUpdatedAt() != null)); + } + + @Test + @DisplayName("가장 빈번한 학습 시간대 계산 - 단일 시간대") + void calculateAndSavePreferredStudyHour_SingleFrequentHour() { + // given + when(userStudyReportRepository.findByUserId("test-user")).thenReturn(Optional.of(testReport)); + + // 14시에 5번, 15시에 2번 학습 + List completions = new ArrayList<>(); + completions.addAll(createCompletionsAtHour(14, 5)); + completions.addAll(createCompletionsAtHour(15, 2)); + + when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) + .thenReturn(completions); + + // when + Optional result = service.calculateAndSavePreferredStudyHour("test-user"); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(14); + } + + @Test + @DisplayName("가장 빈번한 학습 시간대 계산 - 여러 시간대") + void calculateAndSavePreferredStudyHour_MultipleHours() { + // given + when(userStudyReportRepository.findByUserId("test-user")).thenReturn(Optional.of(testReport)); + + // 20시에 3번, 14시에 2번, 15시에 2번 + List completions = new ArrayList<>(); + completions.addAll(createCompletionsAtHour(20, 3)); + completions.addAll(createCompletionsAtHour(14, 2)); + completions.addAll(createCompletionsAtHour(15, 2)); + + when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) + .thenReturn(completions); + + // when + Optional result = service.calculateAndSavePreferredStudyHour("test-user"); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(20); + } + + @Test + @DisplayName("학습 데이터가 없으면 Empty 반환") + void calculateAndSavePreferredStudyHour_NoData_ReturnsEmpty() { + // given + when(userStudyReportRepository.findByUserId("test-user")).thenReturn(Optional.of(testReport)); + when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) + .thenReturn(List.of()); + + // when + Optional result = service.calculateAndSavePreferredStudyHour("test-user"); + + // then + assertThat(result).isEmpty(); + verify(userStudyReportRepository, never()).save(any()); + } + + @Test + @DisplayName("사용자가 없으면 Empty 반환") + void getPreferredStudyHour_UserNotFound_ReturnsEmpty() { + // given + when(userStudyReportRepository.findByUserId("non-existent-user")).thenReturn(Optional.empty()); + + // when + Optional result = service.getPreferredStudyHour("non-existent-user"); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("UTC 시각을 KST로 정확히 변환하여 계산") + void calculateAndSavePreferredStudyHour_UtcToKstConversion() { + // given + when(userStudyReportRepository.findByUserId("test-user")).thenReturn(Optional.of(testReport)); + + // UTC 05:00 = KST 14:00 + Instant utcTime = ZonedDateTime.of(2025, 1, 10, 5, 0, 0, 0, ZoneId.of("UTC")).toInstant(); + List completions = createCompletionsAtInstant(utcTime, 3); + + when(dailyCompletionRepository.findByUserIdAndCompletionDateAfter(eq("test-user"), any(LocalDate.class))) + .thenReturn(completions); + + // when + Optional result = service.calculateAndSavePreferredStudyHour("test-user"); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(14); // KST 14시여야 함 + } + + /** + * 특정 KST 시간에 n번 학습한 DailyCompletion 생성 + */ + private List createCompletionsAtHour(int kstHour, int count) { + List completions = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + DailyCompletion completion = new DailyCompletion(); + completion.setUserId("test-user"); + completion.setCompletionDate(LocalDate.now(KST).minusDays(i)); + + // KST 시간 -> UTC로 변환 + ZonedDateTime kstTime = ZonedDateTime.of(2025, 1, 10, kstHour, 0, 0, 0, KST); + Instant utcInstant = kstTime.toInstant(); + + DailyCompletion.CompletedContent content = DailyCompletion.CompletedContent.builder() + .completedAt(utcInstant) + .build(); + + completion.setCompletedContents(List.of(content)); + completions.add(completion); + } + + return completions; + } + + /** + * 특정 Instant에 n번 학습한 DailyCompletion 생성 + */ + private List createCompletionsAtInstant(Instant instant, int count) { + List completions = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + DailyCompletion completion = new DailyCompletion(); + completion.setUserId("test-user"); + completion.setCompletionDate(LocalDate.now(KST).minusDays(i)); + + DailyCompletion.CompletedContent content = DailyCompletion.CompletedContent.builder() + .completedAt(instant) + .build(); + + completion.setCompletedContents(List.of(content)); + completions.add(completion); + } + + return completions; + } + } diff --git a/src/test/java/com/linglevel/api/user/ticket/controller/TicketsControllerTest.java b/src/test/java/com/linglevel/api/user/ticket/controller/TicketsControllerTest.java index bc1d5e05..22f898bd 100644 --- a/src/test/java/com/linglevel/api/user/ticket/controller/TicketsControllerTest.java +++ b/src/test/java/com/linglevel/api/user/ticket/controller/TicketsControllerTest.java @@ -45,207 +45,192 @@ @WebMvcTest(TicketsController.class) class TicketsControllerTest { - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private TicketService ticketService; - - @MockitoBean - private UserRepository userRepository; - - // SecurityConfig에 필요한 Mock Bean들 - @MockitoBean - private JwtService jwtService; - @MockitoBean - private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; - @MockitoBean - private AdminAuthenticationFilter adminAuthenticationFilter; - @MockitoBean - private RateLimitFilter rateLimitFilter; - @MockitoBean - private ProxyManager proxyManager; - @MockitoBean - private RateLimitProperties rateLimitProperties; - @MockitoBean - private RateLimitResolver rateLimitResolver; - - private User testUser; - private TicketBalanceResponse ticketBalanceResponse; - private TicketTransactionResponse ticketTransactionResponse; - - @BeforeEach - void setUp() { - testUser = User.builder() - .id("test-user-id") - .username("testuser") - .role(UserRole.USER) - .build(); - - ticketBalanceResponse = TicketBalanceResponse.builder() - .balance(10) - .updatedAt(LocalDateTime.now()) - .build(); - - ticketTransactionResponse = TicketTransactionResponse.builder() - .id("transaction-id") - .amount(-5) - .description("Test transaction") - .createdAt(LocalDateTime.now()) - .build(); - - // Mock filters to pass through the chain - try { - doAnswer(invocation -> { - invocation.getArgument(2, FilterChain.class).doFilter(invocation.getArgument(0), invocation.getArgument(1)); - return null; - }).when(adminAuthenticationFilter).doFilter(any(), any(), any()); - - doAnswer(invocation -> { - invocation.getArgument(2, FilterChain.class).doFilter(invocation.getArgument(0), invocation.getArgument(1)); - return null; - }).when(rateLimitFilter).doFilter(any(), any(), any()); - } catch (Exception e) { - // This should not happen in a test - } - } - - private Authentication getOauthAuthentication() { - JwtClaims claims = JwtClaims.builder() - .id(testUser.getId()) - .username(testUser.getUsername()) - .role(testUser.getRole()) - .issuedAt(new Date()) - .expiresAt(new Date(System.currentTimeMillis() + 3600000)) // 1시간 후 만료 - .build(); - return new UsernamePasswordAuthenticationToken(claims, null, List.of(new SimpleGrantedAuthority(testUser.getRole().getSecurityRole()))); - } - - @Test - void 티켓잔고조회_성공() throws Exception { - // given - when(ticketService.getTicketBalance("test-user-id")).thenReturn(ticketBalanceResponse); - - // when & then - mockMvc.perform(get("/api/v1/tickets/balance") - .with(authentication(getOauthAuthentication())) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(content().contentType("application/json;charset=UTF-8")) - .andExpect(jsonPath("$.balance").value(10)) - .andExpect(jsonPath("$.updatedAt").exists()); - - verify(ticketService).getTicketBalance("test-user-id"); - } - - @Test - void 티켓잔고조회_사용자없음_500오류() throws Exception { - // given - when(ticketService.getTicketBalance(anyString())).thenThrow(new RuntimeException("User not found")); - - // when & then - mockMvc.perform(get("/api/v1/tickets/balance") - .with(authentication(getOauthAuthentication())) - .with(csrf())) - .andExpect(status().isInternalServerError()); - - verify(ticketService).getTicketBalance(anyString()); - } - - @Test - void 티켓잔고조회_인증없음_401오류() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/tickets/balance") - .with(csrf())) - .andExpect(status().isUnauthorized()); - - verify(ticketService, never()).getTicketBalance(anyString()); - } - - @Test - void 티켓거래내역조회_성공() throws Exception { - // given - Page transactionPage = new PageImpl<>( - List.of(ticketTransactionResponse), - PageRequest.of(0, 10), - 1L - ); - - when(ticketService.getTicketTransactions("test-user-id", 1, 10)) - .thenReturn(transactionPage); - - // when & then - mockMvc.perform(get("/api/v1/tickets/transactions") - .param("page", "1") - .param("limit", "10") - .with(authentication(getOauthAuthentication())) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(content().contentType("application/json;charset=UTF-8")) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data[0].id").value("transaction-id")) - .andExpect(jsonPath("$.data[0].amount").value(-5)) - .andExpect(jsonPath("$.data[0].description").value("Test transaction")) - .andExpect(jsonPath("$.totalCount").value(1)) - .andExpect(jsonPath("$.totalPages").value(1)); - - verify(ticketService).getTicketTransactions("test-user-id", 1, 10); - } - - @Test - void 티켓거래내역조회_기본파라미터() throws Exception { - // given - Page emptyPage = new PageImpl<>( - List.of(), - PageRequest.of(0, 10), - 0L - ); - - when(ticketService.getTicketTransactions("test-user-id", 1, 10)) - .thenReturn(emptyPage); - - // when & then - mockMvc.perform(get("/api/v1/tickets/transactions") - .with(authentication(getOauthAuthentication())) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(content().contentType("application/json;charset=UTF-8")) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data").isEmpty()) - .andExpect(jsonPath("$.totalCount").value(0)); - - verify(ticketService).getTicketTransactions("test-user-id", 1, 10); - } - - @Test - void 티켓예외처리_잔고부족() throws Exception { - // given - when(ticketService.getTicketBalance("test-user-id")) - .thenThrow(new TicketException(TicketErrorCode.INSUFFICIENT_BALANCE)); - - // when & then - mockMvc.perform(get("/api/v1/tickets/balance") - .with(authentication(getOauthAuthentication())) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andExpect(content().contentType("application/json;charset=UTF-8")) - .andExpect(jsonPath("$.message").value("Insufficient ticket balance.")); - - verify(ticketService).getTicketBalance("test-user-id"); - } - - @Test - void 티켓예외처리_티켓없음() throws Exception { - // given - when(ticketService.getTicketBalance("test-user-id")) - .thenThrow(new TicketException(TicketErrorCode.TICKET_NOT_FOUND)); - - // when & then - mockMvc.perform(get("/api/v1/tickets/balance") - .with(authentication(getOauthAuthentication())) - .with(csrf())) - .andExpect(status().isNotFound()) - .andExpect(content().contentType("application/json;charset=UTF-8")) - .andExpect(jsonPath("$.message").value("Ticket not found.")); - } + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TicketService ticketService; + + @MockitoBean + private UserRepository userRepository; + + // SecurityConfig에 필요한 Mock Bean들 + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @MockitoBean + private AdminAuthenticationFilter adminAuthenticationFilter; + + @MockitoBean + private RateLimitFilter rateLimitFilter; + + @MockitoBean + private ProxyManager proxyManager; + + @MockitoBean + private RateLimitProperties rateLimitProperties; + + @MockitoBean + private RateLimitResolver rateLimitResolver; + + private User testUser; + + private TicketBalanceResponse ticketBalanceResponse; + + private TicketTransactionResponse ticketTransactionResponse; + + @BeforeEach + void setUp() { + testUser = User.builder().id("test-user-id").username("testuser").role(UserRole.USER).build(); + + ticketBalanceResponse = TicketBalanceResponse.builder().balance(10).updatedAt(LocalDateTime.now()).build(); + + ticketTransactionResponse = TicketTransactionResponse.builder() + .id("transaction-id") + .amount(-5) + .description("Test transaction") + .createdAt(LocalDateTime.now()) + .build(); + + // Mock filters to pass through the chain + try { + doAnswer(invocation -> { + invocation.getArgument(2, FilterChain.class) + .doFilter(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(adminAuthenticationFilter).doFilter(any(), any(), any()); + + doAnswer(invocation -> { + invocation.getArgument(2, FilterChain.class) + .doFilter(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(rateLimitFilter).doFilter(any(), any(), any()); + } + catch (Exception e) { + // This should not happen in a test + } + } + + private Authentication getOauthAuthentication() { + JwtClaims claims = JwtClaims.builder() + .id(testUser.getId()) + .username(testUser.getUsername()) + .role(testUser.getRole()) + .issuedAt(new Date()) + .expiresAt(new Date(System.currentTimeMillis() + 3600000)) // 1시간 후 만료 + .build(); + return new UsernamePasswordAuthenticationToken(claims, null, + List.of(new SimpleGrantedAuthority(testUser.getRole().getSecurityRole()))); + } + + @Test + void 티켓잔고조회_성공() throws Exception { + // given + when(ticketService.getTicketBalance("test-user-id")).thenReturn(ticketBalanceResponse); + + // when & then + mockMvc.perform(get("/api/v1/tickets/balance").with(authentication(getOauthAuthentication())).with(csrf())) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.balance").value(10)) + .andExpect(jsonPath("$.updatedAt").exists()); + + verify(ticketService).getTicketBalance("test-user-id"); + } + + @Test + void 티켓잔고조회_사용자없음_500오류() throws Exception { + // given + when(ticketService.getTicketBalance(anyString())).thenThrow(new RuntimeException("User not found")); + + // when & then + mockMvc.perform(get("/api/v1/tickets/balance").with(authentication(getOauthAuthentication())).with(csrf())) + .andExpect(status().isInternalServerError()); + + verify(ticketService).getTicketBalance(anyString()); + } + + @Test + void 티켓잔고조회_인증없음_401오류() throws Exception { + // when & then + mockMvc.perform(get("/api/v1/tickets/balance").with(csrf())).andExpect(status().isUnauthorized()); + + verify(ticketService, never()).getTicketBalance(anyString()); + } + + @Test + void 티켓거래내역조회_성공() throws Exception { + // given + Page transactionPage = new PageImpl<>(List.of(ticketTransactionResponse), + PageRequest.of(0, 10), 1L); + + when(ticketService.getTicketTransactions("test-user-id", 1, 10)).thenReturn(transactionPage); + + // when & then + mockMvc + .perform(get("/api/v1/tickets/transactions").param("page", "1") + .param("limit", "10") + .with(authentication(getOauthAuthentication())) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].id").value("transaction-id")) + .andExpect(jsonPath("$.data[0].amount").value(-5)) + .andExpect(jsonPath("$.data[0].description").value("Test transaction")) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)); + + verify(ticketService).getTicketTransactions("test-user-id", 1, 10); + } + + @Test + void 티켓거래내역조회_기본파라미터() throws Exception { + // given + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0L); + + when(ticketService.getTicketTransactions("test-user-id", 1, 10)).thenReturn(emptyPage); + + // when & then + mockMvc.perform(get("/api/v1/tickets/transactions").with(authentication(getOauthAuthentication())).with(csrf())) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()) + .andExpect(jsonPath("$.totalCount").value(0)); + + verify(ticketService).getTicketTransactions("test-user-id", 1, 10); + } + + @Test + void 티켓예외처리_잔고부족() throws Exception { + // given + when(ticketService.getTicketBalance("test-user-id")) + .thenThrow(new TicketException(TicketErrorCode.INSUFFICIENT_BALANCE)); + + // when & then + mockMvc.perform(get("/api/v1/tickets/balance").with(authentication(getOauthAuthentication())).with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.message").value("Insufficient ticket balance.")); + + verify(ticketService).getTicketBalance("test-user-id"); + } + + @Test + void 티켓예외처리_티켓없음() throws Exception { + // given + when(ticketService.getTicketBalance("test-user-id")) + .thenThrow(new TicketException(TicketErrorCode.TICKET_NOT_FOUND)); + + // when & then + mockMvc.perform(get("/api/v1/tickets/balance").with(authentication(getOauthAuthentication())).with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(content().contentType("application/json;charset=UTF-8")) + .andExpect(jsonPath("$.message").value("Ticket not found.")); + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepositoryTest.java b/src/test/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepositoryTest.java index fbf901ab..9ff21794 100644 --- a/src/test/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepositoryTest.java +++ b/src/test/java/com/linglevel/api/user/ticket/repository/TicketTransactionRepositoryTest.java @@ -20,146 +20,157 @@ @DataMongoTest class TicketTransactionRepositoryTest extends AbstractDatabaseTest { - @Autowired - private TicketTransactionRepository ticketTransactionRepository; - - private final String testUserId = "test-user-id"; - private final String otherUserId = "other-user-id"; - private final String testReservationId = "test-reservation-id"; - - @BeforeEach - void setUp() { - ticketTransactionRepository.deleteAll(); - } - - @Test - void 사용자ID로_거래내역조회_성공() { - // given - TicketTransaction transaction1 = createTransaction(testUserId, -5, "Transaction 1", TransactionStatus.CONFIRMED); - TicketTransaction transaction2 = createTransaction(testUserId, 10, "Transaction 2", TransactionStatus.CONFIRMED); - TicketTransaction transaction3 = createTransaction(testUserId, -3, "Transaction 3", TransactionStatus.RESERVED); - TicketTransaction transaction4 = createTransaction(otherUserId, -2, "Other user transaction", TransactionStatus.CONFIRMED); - - ticketTransactionRepository.saveAll(List.of(transaction1, transaction2, transaction3, transaction4)); - - Pageable pageable = PageRequest.of(0, 10); - - // when - Page result = ticketTransactionRepository - .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, pageable); - - // then - assertThat(result.getContent()).hasSize(2); - assertThat(result.getTotalElements()).isEqualTo(2); - - // 해당 사용자의 CONFIRMED 상태 거래만 포함되어야 함 - result.getContent().forEach(transaction -> { - assertThat(transaction.getUserId()).isEqualTo(testUserId); - assertThat(transaction.getStatus()).isEqualTo(TransactionStatus.CONFIRMED); - }); - } - - @Test - void 예약ID와상태로_거래조회_성공() { - // given - TicketTransaction reservedTransaction = TicketTransaction.builder() - .userId(testUserId) - .amount(-5) - .description("Reserved transaction") - .status(TransactionStatus.RESERVED) - .reservationId(testReservationId) - .build(); - - TicketTransaction confirmedTransaction = TicketTransaction.builder() - .userId(testUserId) - .amount(-3) - .description("Confirmed transaction") - .status(TransactionStatus.CONFIRMED) - .reservationId("other-reservation-id") - .build(); - - ticketTransactionRepository.saveAll(List.of(reservedTransaction, confirmedTransaction)); - - // when - Optional result = ticketTransactionRepository - .findByReservationIdAndStatus(testReservationId, TransactionStatus.RESERVED); - - // then - assertThat(result).isPresent(); - assertThat(result.get().getDescription()).isEqualTo("Reserved transaction"); - assertThat(result.get().getReservationId()).isEqualTo(testReservationId); - assertThat(result.get().getStatus()).isEqualTo(TransactionStatus.RESERVED); - } - - @Test - void 존재하지않는예약ID로_조회시_빈Optional반환() { - // when - Optional result = ticketTransactionRepository - .findByReservationIdAndStatus("non-existent-reservation", TransactionStatus.RESERVED); - - // then - assertThat(result).isEmpty(); - } - - @Test - void 페이징처리_확인() { - // given - for (int i = 1; i <= 25; i++) { - TicketTransaction transaction = createTransaction(testUserId, -i, "Transaction " + i, TransactionStatus.CONFIRMED); - ticketTransactionRepository.save(transaction); - } - - Pageable firstPage = PageRequest.of(0, 10); - Pageable secondPage = PageRequest.of(1, 10); - - // when - Page firstResult = ticketTransactionRepository - .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, firstPage); - Page secondResult = ticketTransactionRepository - .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, secondPage); - - // then - assertThat(firstResult.getContent()).hasSize(10); - assertThat(secondResult.getContent()).hasSize(10); - assertThat(firstResult.getTotalElements()).isEqualTo(25); - assertThat(firstResult.getTotalPages()).isEqualTo(3); - - // 첫 번째 페이지와 두 번째 페이지의 내용이 다른지 확인 - assertThat(firstResult.getContent().get(0).getDescription()) - .isNotEqualTo(secondResult.getContent().get(0).getDescription()); - } - - @Test - void 거래상태별_필터링_확인() { - // given - TicketTransaction confirmedTransaction = createTransaction(testUserId, -5, "Confirmed", TransactionStatus.CONFIRMED); - TicketTransaction reservedTransaction = createTransaction(testUserId, -3, "Reserved", TransactionStatus.RESERVED); - TicketTransaction cancelledTransaction = createTransaction(testUserId, -2, "Cancelled", TransactionStatus.CANCELLED); - - ticketTransactionRepository.saveAll(List.of(confirmedTransaction, reservedTransaction, cancelledTransaction)); - - Pageable pageable = PageRequest.of(0, 10); - - // when - Page confirmedResult = ticketTransactionRepository - .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, pageable); - Page reservedResult = ticketTransactionRepository - .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.RESERVED, pageable); - - // then - assertThat(confirmedResult.getContent()).hasSize(1); - assertThat(confirmedResult.getContent().get(0).getDescription()).isEqualTo("Confirmed"); - - assertThat(reservedResult.getContent()).hasSize(1); - assertThat(reservedResult.getContent().get(0).getDescription()).isEqualTo("Reserved"); - } - - private TicketTransaction createTransaction(String userId, int amount, String description, TransactionStatus status) { - return TicketTransaction.builder() - .userId(userId) - .amount(amount) - .description(description) - .status(status) - .build(); - } + @Autowired + private TicketTransactionRepository ticketTransactionRepository; + + private final String testUserId = "test-user-id"; + + private final String otherUserId = "other-user-id"; + + private final String testReservationId = "test-reservation-id"; + + @BeforeEach + void setUp() { + ticketTransactionRepository.deleteAll(); + } + + @Test + void 사용자ID로_거래내역조회_성공() { + // given + TicketTransaction transaction1 = createTransaction(testUserId, -5, "Transaction 1", + TransactionStatus.CONFIRMED); + TicketTransaction transaction2 = createTransaction(testUserId, 10, "Transaction 2", + TransactionStatus.CONFIRMED); + TicketTransaction transaction3 = createTransaction(testUserId, -3, "Transaction 3", TransactionStatus.RESERVED); + TicketTransaction transaction4 = createTransaction(otherUserId, -2, "Other user transaction", + TransactionStatus.CONFIRMED); + + ticketTransactionRepository.saveAll(List.of(transaction1, transaction2, transaction3, transaction4)); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page result = ticketTransactionRepository + .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, pageable); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(2); + + // 해당 사용자의 CONFIRMED 상태 거래만 포함되어야 함 + result.getContent().forEach(transaction -> { + assertThat(transaction.getUserId()).isEqualTo(testUserId); + assertThat(transaction.getStatus()).isEqualTo(TransactionStatus.CONFIRMED); + }); + } + + @Test + void 예약ID와상태로_거래조회_성공() { + // given + TicketTransaction reservedTransaction = TicketTransaction.builder() + .userId(testUserId) + .amount(-5) + .description("Reserved transaction") + .status(TransactionStatus.RESERVED) + .reservationId(testReservationId) + .build(); + + TicketTransaction confirmedTransaction = TicketTransaction.builder() + .userId(testUserId) + .amount(-3) + .description("Confirmed transaction") + .status(TransactionStatus.CONFIRMED) + .reservationId("other-reservation-id") + .build(); + + ticketTransactionRepository.saveAll(List.of(reservedTransaction, confirmedTransaction)); + + // when + Optional result = ticketTransactionRepository.findByReservationIdAndStatus(testReservationId, + TransactionStatus.RESERVED); + + // then + assertThat(result).isPresent(); + assertThat(result.get().getDescription()).isEqualTo("Reserved transaction"); + assertThat(result.get().getReservationId()).isEqualTo(testReservationId); + assertThat(result.get().getStatus()).isEqualTo(TransactionStatus.RESERVED); + } + + @Test + void 존재하지않는예약ID로_조회시_빈Optional반환() { + // when + Optional result = ticketTransactionRepository + .findByReservationIdAndStatus("non-existent-reservation", TransactionStatus.RESERVED); + + // then + assertThat(result).isEmpty(); + } + + @Test + void 페이징처리_확인() { + // given + for (int i = 1; i <= 25; i++) { + TicketTransaction transaction = createTransaction(testUserId, -i, "Transaction " + i, + TransactionStatus.CONFIRMED); + ticketTransactionRepository.save(transaction); + } + + Pageable firstPage = PageRequest.of(0, 10); + Pageable secondPage = PageRequest.of(1, 10); + + // when + Page firstResult = ticketTransactionRepository + .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, firstPage); + Page secondResult = ticketTransactionRepository + .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, secondPage); + + // then + assertThat(firstResult.getContent()).hasSize(10); + assertThat(secondResult.getContent()).hasSize(10); + assertThat(firstResult.getTotalElements()).isEqualTo(25); + assertThat(firstResult.getTotalPages()).isEqualTo(3); + + // 첫 번째 페이지와 두 번째 페이지의 내용이 다른지 확인 + assertThat(firstResult.getContent().get(0).getDescription()) + .isNotEqualTo(secondResult.getContent().get(0).getDescription()); + } + + @Test + void 거래상태별_필터링_확인() { + // given + TicketTransaction confirmedTransaction = createTransaction(testUserId, -5, "Confirmed", + TransactionStatus.CONFIRMED); + TicketTransaction reservedTransaction = createTransaction(testUserId, -3, "Reserved", + TransactionStatus.RESERVED); + TicketTransaction cancelledTransaction = createTransaction(testUserId, -2, "Cancelled", + TransactionStatus.CANCELLED); + + ticketTransactionRepository.saveAll(List.of(confirmedTransaction, reservedTransaction, cancelledTransaction)); + + Pageable pageable = PageRequest.of(0, 10); + + // when + Page confirmedResult = ticketTransactionRepository + .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.CONFIRMED, pageable); + Page reservedResult = ticketTransactionRepository + .findByUserIdAndStatusOrderByCreatedAtDesc(testUserId, TransactionStatus.RESERVED, pageable); + + // then + assertThat(confirmedResult.getContent()).hasSize(1); + assertThat(confirmedResult.getContent().get(0).getDescription()).isEqualTo("Confirmed"); + + assertThat(reservedResult.getContent()).hasSize(1); + assertThat(reservedResult.getContent().get(0).getDescription()).isEqualTo("Reserved"); + } + + private TicketTransaction createTransaction(String userId, int amount, String description, + TransactionStatus status) { + return TicketTransaction.builder() + .userId(userId) + .amount(amount) + .description(description) + .status(status) + .build(); + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/user/ticket/repository/UserTicketRepositoryTest.java b/src/test/java/com/linglevel/api/user/ticket/repository/UserTicketRepositoryTest.java index 778e6b76..7ed7fb30 100644 --- a/src/test/java/com/linglevel/api/user/ticket/repository/UserTicketRepositoryTest.java +++ b/src/test/java/com/linglevel/api/user/ticket/repository/UserTicketRepositoryTest.java @@ -17,104 +17,100 @@ @DataMongoTest class UserTicketRepositoryTest extends AbstractDatabaseTest { - @Autowired - private UserTicketRepository userTicketRepository; - - private final String testUserId = "test-user-id"; - private UserTicket userTicket; - - @BeforeEach - void setUp() { - userTicketRepository.deleteAll(); - - userTicket = UserTicket.builder() - .userId(testUserId) - .balance(10) - .build(); - } - - @Test - void 사용자ID로_티켓조회_성공() { - // given - UserTicket savedUserTicket = userTicketRepository.save(userTicket); - - // when - Optional found = userTicketRepository.findByUserId(testUserId); - - // then - assertThat(found).isPresent(); - assertThat(found.get().getUserId()).isEqualTo(testUserId); - assertThat(found.get().getBalance()).isEqualTo(10); - assertThat(found.get().getId()).isEqualTo(savedUserTicket.getId()); - } - - @Test - void 존재하지않는사용자ID로_조회시_빈Optional반환() { - // when - Optional found = userTicketRepository.findByUserId("non-existent-user"); - - // then - assertThat(found).isEmpty(); - } - - @Test - void 중복된사용자ID로_티켓생성시_예외발생() { - // given - userTicketRepository.save(userTicket); - - UserTicket duplicateUserTicket = UserTicket.builder() - .userId(testUserId) - .balance(20) - .build(); - - // when & then - assertThatThrownBy(() -> userTicketRepository.save(duplicateUserTicket)) - .isInstanceOf(DuplicateKeyException.class); - } - - @Test - void 버전충돌시_OptimisticLockingFailureException발생() { - // given - UserTicket savedUserTicket = userTicketRepository.save(userTicket); - - UserTicket userTicket1 = userTicketRepository.findById(savedUserTicket.getId()).orElseThrow(); - UserTicket userTicket2 = userTicketRepository.findById(savedUserTicket.getId()).orElseThrow(); - - // when - userTicket1.setBalance(15); - userTicketRepository.save(userTicket1); - - userTicket2.setBalance(25); - - // then - assertThatThrownBy(() -> userTicketRepository.save(userTicket2)) - .isInstanceOf(OptimisticLockingFailureException.class); - } - - @Test - void 티켓잔고_업데이트_성공() { - // given - UserTicket savedUserTicket = userTicketRepository.save(userTicket); - Long originalVersion = savedUserTicket.getVersion(); - - // when - savedUserTicket.setBalance(20); - UserTicket updatedUserTicket = userTicketRepository.save(savedUserTicket); - - // then - assertThat(updatedUserTicket.getBalance()).isEqualTo(20); - assertThat(updatedUserTicket.getVersion()).isGreaterThan(originalVersion); - } - - @Test - void 기본필드_자동설정_확인() { - // when - UserTicket savedUserTicket = userTicketRepository.save(userTicket); - - // then - assertThat(savedUserTicket.getId()).isNotNull(); - assertThat(savedUserTicket.getUserId()).isEqualTo(testUserId); - assertThat(savedUserTicket.getBalance()).isEqualTo(10); - assertThat(savedUserTicket.getVersion()).isNotNull(); - } + @Autowired + private UserTicketRepository userTicketRepository; + + private final String testUserId = "test-user-id"; + + private UserTicket userTicket; + + @BeforeEach + void setUp() { + userTicketRepository.deleteAll(); + + userTicket = UserTicket.builder().userId(testUserId).balance(10).build(); + } + + @Test + void 사용자ID로_티켓조회_성공() { + // given + UserTicket savedUserTicket = userTicketRepository.save(userTicket); + + // when + Optional found = userTicketRepository.findByUserId(testUserId); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getUserId()).isEqualTo(testUserId); + assertThat(found.get().getBalance()).isEqualTo(10); + assertThat(found.get().getId()).isEqualTo(savedUserTicket.getId()); + } + + @Test + void 존재하지않는사용자ID로_조회시_빈Optional반환() { + // when + Optional found = userTicketRepository.findByUserId("non-existent-user"); + + // then + assertThat(found).isEmpty(); + } + + @Test + void 중복된사용자ID로_티켓생성시_예외발생() { + // given + userTicketRepository.save(userTicket); + + UserTicket duplicateUserTicket = UserTicket.builder().userId(testUserId).balance(20).build(); + + // when & then + assertThatThrownBy(() -> userTicketRepository.save(duplicateUserTicket)) + .isInstanceOf(DuplicateKeyException.class); + } + + @Test + void 버전충돌시_OptimisticLockingFailureException발생() { + // given + UserTicket savedUserTicket = userTicketRepository.save(userTicket); + + UserTicket userTicket1 = userTicketRepository.findById(savedUserTicket.getId()).orElseThrow(); + UserTicket userTicket2 = userTicketRepository.findById(savedUserTicket.getId()).orElseThrow(); + + // when + userTicket1.setBalance(15); + userTicketRepository.save(userTicket1); + + userTicket2.setBalance(25); + + // then + assertThatThrownBy(() -> userTicketRepository.save(userTicket2)) + .isInstanceOf(OptimisticLockingFailureException.class); + } + + @Test + void 티켓잔고_업데이트_성공() { + // given + UserTicket savedUserTicket = userTicketRepository.save(userTicket); + Long originalVersion = savedUserTicket.getVersion(); + + // when + savedUserTicket.setBalance(20); + UserTicket updatedUserTicket = userTicketRepository.save(savedUserTicket); + + // then + assertThat(updatedUserTicket.getBalance()).isEqualTo(20); + assertThat(updatedUserTicket.getVersion()).isGreaterThan(originalVersion); + } + + @Test + void 기본필드_자동설정_확인() { + // when + UserTicket savedUserTicket = userTicketRepository.save(userTicket); + + // then + assertThat(savedUserTicket.getId()).isNotNull(); + assertThat(savedUserTicket.getUserId()).isEqualTo(testUserId); + assertThat(savedUserTicket.getBalance()).isEqualTo(10); + assertThat(savedUserTicket.getVersion()).isNotNull(); + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/user/ticket/service/TicketServiceTest.java b/src/test/java/com/linglevel/api/user/ticket/service/TicketServiceTest.java index cebe4457..6121ff2e 100644 --- a/src/test/java/com/linglevel/api/user/ticket/service/TicketServiceTest.java +++ b/src/test/java/com/linglevel/api/user/ticket/service/TicketServiceTest.java @@ -32,261 +32,264 @@ @ExtendWith(MockitoExtension.class) class TicketServiceTest { - @Mock - private UserTicketRepository userTicketRepository; - - @Mock - private TicketTransactionRepository ticketTransactionRepository; - - @InjectMocks - private TicketService ticketService; - - private final String userId = "test-user-id"; - private UserTicket userTicket; - private TicketTransaction transaction; - - @BeforeEach - void setUp() { - userTicket = UserTicket.builder() - .id("ticket-id") - .userId(userId) - .balance(10) - .version(1L) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .build(); - - transaction = TicketTransaction.builder() - .id("transaction-id") - .userId(userId) - .amount(-5) - .description("Test transaction") - .status(TransactionStatus.CONFIRMED) - .createdAt(LocalDateTime.now()) - .build(); - } - - @Nested - @DisplayName("티켓 잔고 조회 테스트") - class GetTicketBalanceTest { - - @Test - void 기존사용자_티켓잔고조회_성공() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - - // when - TicketBalanceResponse response = ticketService.getTicketBalance(userId); - - // then - assertThat(response.getBalance()).isEqualTo(10); - assertThat(response.getUpdatedAt()).isEqualTo(userTicket.getUpdatedAt()); - verify(userTicketRepository).findByUserId(userId); - } - - @Test - void 신규사용자_티켓생성및잔고조회_성공() { - // given - UserTicket newUserTicket = UserTicket.builder() - .userId(userId) - .balance(10) - .build(); - - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.empty()); - when(userTicketRepository.save(any(UserTicket.class))).thenReturn(newUserTicket); - - // when - TicketBalanceResponse response = ticketService.getTicketBalance(userId); - - // then - assertThat(response.getBalance()).isEqualTo(10); - verify(userTicketRepository).findByUserId(userId); - verify(userTicketRepository).save(any(UserTicket.class)); - verify(ticketTransactionRepository).save(any(TicketTransaction.class)); - } - } - - @Nested - @DisplayName("티켓 거래 내역 조회 테스트") - class GetTicketTransactionsTest { - - @Test - void 티켓거래내역조회_성공() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - - Page transactionPage = new PageImpl<>( - List.of(transaction), - PageRequest.of(0, 10), - 1L - ); - when(ticketTransactionRepository.findByUserIdAndStatusOrderByCreatedAtDesc( - eq(userId), eq(TransactionStatus.CONFIRMED), any(PageRequest.class))) - .thenReturn(transactionPage); - - // when - Page result = ticketService.getTicketTransactions(userId, 1, 10); - - // then - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getAmount()).isEqualTo(-5); - assertThat(result.getContent().get(0).getDescription()).isEqualTo("Test transaction"); - verify(ticketTransactionRepository).findByUserIdAndStatusOrderByCreatedAtDesc( - eq(userId), eq(TransactionStatus.CONFIRMED), any(PageRequest.class)); - } - } - - @Nested - @DisplayName("티켓 예약 테스트") - class ReserveTicketTest { - - @Test - void 티켓예약_성공() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - when(userTicketRepository.save(any(UserTicket.class))).thenReturn(userTicket); - - // when - String reservationId = ticketService.reserveTicket(userId, 5, "Test reservation"); - - // then - assertThat(reservationId).isNotNull(); - assertThat(UUID.fromString(reservationId)).isNotNull(); // UUID 형식 검증 - verify(userTicketRepository).save(any(UserTicket.class)); - verify(ticketTransactionRepository).save(any(TicketTransaction.class)); - } - - @Test - void 잔고부족으로_티켓예약_실패() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - - // when & then - assertThatThrownBy(() -> ticketService.reserveTicket(userId, 15, "Test reservation")) - .isInstanceOf(TicketException.class); - - verify(userTicketRepository, never()).save(any(UserTicket.class)); - verify(ticketTransactionRepository, never()).save(any(TicketTransaction.class)); - } - } - - @Nested - @DisplayName("예약 확정 테스트") - class ConfirmReservationTest { - - @Test - void 예약확정_성공() { - // given - String reservationId = "test-reservation-id"; - TicketTransaction reservedTransaction = TicketTransaction.builder() - .id("transaction-id") - .userId(userId) - .amount(-5) - .description("Reserved transaction") - .status(TransactionStatus.RESERVED) - .reservationId(reservationId) - .build(); - - when(ticketTransactionRepository.findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED)) - .thenReturn(Optional.of(reservedTransaction)); - - // when - ticketService.confirmReservation(reservationId); - - // then - verify(ticketTransactionRepository).save(any(TicketTransaction.class)); - } - - @Test - void 존재하지않는예약_확정시_예외발생() { - // given - String reservationId = "non-existent-reservation-id"; - when(ticketTransactionRepository.findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED)) - .thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> ticketService.confirmReservation(reservationId)) - .isInstanceOf(TicketException.class); - } - } - - @Nested - @DisplayName("예약 취소 테스트") - class CancelReservationTest { - - @Test - void 예약취소_성공() { - // given - String reservationId = "test-reservation-id"; - TicketTransaction reservedTransaction = TicketTransaction.builder() - .id("transaction-id") - .userId(userId) - .amount(-5) - .description("Reserved transaction") - .status(TransactionStatus.RESERVED) - .reservationId(reservationId) - .build(); - - when(ticketTransactionRepository.findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED)) - .thenReturn(Optional.of(reservedTransaction)); - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - - // when - ticketService.cancelReservation(reservationId); - - // then - verify(userTicketRepository).save(any(UserTicket.class)); - verify(ticketTransactionRepository).save(any(TicketTransaction.class)); - } - } - - @Nested - @DisplayName("티켓 사용 테스트") - class SpendTicketTest { - - @Test - void 티켓사용_성공() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - when(userTicketRepository.save(any(UserTicket.class))).thenReturn(userTicket); - - // when - int remainingBalance = ticketService.spendTicket(userId, 5, "Test spend"); - - // then - assertThat(remainingBalance).isEqualTo(5); // 10 - 5 = 5 - verify(userTicketRepository).save(any(UserTicket.class)); - verify(ticketTransactionRepository).save(any(TicketTransaction.class)); - } - - @Test - void 잔고부족으로_티켓사용_실패() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - - // when & then - assertThatThrownBy(() -> ticketService.spendTicket(userId, 15, "Test spend")) - .isInstanceOf(TicketException.class); - } - } - - @Nested - @DisplayName("티켓 지급 테스트") - class GrantTicketTest { - - @Test - void 티켓지급_성공() { - // given - when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); - when(userTicketRepository.save(any(UserTicket.class))).thenReturn(userTicket); - - // when - int newBalance = ticketService.grantTicket(userId, 5, "Test grant"); - - // then - assertThat(newBalance).isEqualTo(15); // 10 + 5 = 15 - verify(userTicketRepository).save(any(UserTicket.class)); - verify(ticketTransactionRepository).save(any(TicketTransaction.class)); - } - } + @Mock + private UserTicketRepository userTicketRepository; + + @Mock + private TicketTransactionRepository ticketTransactionRepository; + + @InjectMocks + private TicketService ticketService; + + private final String userId = "test-user-id"; + + private UserTicket userTicket; + + private TicketTransaction transaction; + + @BeforeEach + void setUp() { + userTicket = UserTicket.builder() + .id("ticket-id") + .userId(userId) + .balance(10) + .version(1L) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + transaction = TicketTransaction.builder() + .id("transaction-id") + .userId(userId) + .amount(-5) + .description("Test transaction") + .status(TransactionStatus.CONFIRMED) + .createdAt(LocalDateTime.now()) + .build(); + } + + @Nested + @DisplayName("티켓 잔고 조회 테스트") + class GetTicketBalanceTest { + + @Test + void 기존사용자_티켓잔고조회_성공() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + + // when + TicketBalanceResponse response = ticketService.getTicketBalance(userId); + + // then + assertThat(response.getBalance()).isEqualTo(10); + assertThat(response.getUpdatedAt()).isEqualTo(userTicket.getUpdatedAt()); + verify(userTicketRepository).findByUserId(userId); + } + + @Test + void 신규사용자_티켓생성및잔고조회_성공() { + // given + UserTicket newUserTicket = UserTicket.builder().userId(userId).balance(10).build(); + + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.empty()); + when(userTicketRepository.save(any(UserTicket.class))).thenReturn(newUserTicket); + + // when + TicketBalanceResponse response = ticketService.getTicketBalance(userId); + + // then + assertThat(response.getBalance()).isEqualTo(10); + verify(userTicketRepository).findByUserId(userId); + verify(userTicketRepository).save(any(UserTicket.class)); + verify(ticketTransactionRepository).save(any(TicketTransaction.class)); + } + + } + + @Nested + @DisplayName("티켓 거래 내역 조회 테스트") + class GetTicketTransactionsTest { + + @Test + void 티켓거래내역조회_성공() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + + Page transactionPage = new PageImpl<>(List.of(transaction), PageRequest.of(0, 10), 1L); + when(ticketTransactionRepository.findByUserIdAndStatusOrderByCreatedAtDesc(eq(userId), + eq(TransactionStatus.CONFIRMED), any(PageRequest.class))) + .thenReturn(transactionPage); + + // when + Page result = ticketService.getTicketTransactions(userId, 1, 10); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getAmount()).isEqualTo(-5); + assertThat(result.getContent().get(0).getDescription()).isEqualTo("Test transaction"); + verify(ticketTransactionRepository).findByUserIdAndStatusOrderByCreatedAtDesc(eq(userId), + eq(TransactionStatus.CONFIRMED), any(PageRequest.class)); + } + + } + + @Nested + @DisplayName("티켓 예약 테스트") + class ReserveTicketTest { + + @Test + void 티켓예약_성공() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + when(userTicketRepository.save(any(UserTicket.class))).thenReturn(userTicket); + + // when + String reservationId = ticketService.reserveTicket(userId, 5, "Test reservation"); + + // then + assertThat(reservationId).isNotNull(); + assertThat(UUID.fromString(reservationId)).isNotNull(); // UUID 형식 검증 + verify(userTicketRepository).save(any(UserTicket.class)); + verify(ticketTransactionRepository).save(any(TicketTransaction.class)); + } + + @Test + void 잔고부족으로_티켓예약_실패() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + + // when & then + assertThatThrownBy(() -> ticketService.reserveTicket(userId, 15, "Test reservation")) + .isInstanceOf(TicketException.class); + + verify(userTicketRepository, never()).save(any(UserTicket.class)); + verify(ticketTransactionRepository, never()).save(any(TicketTransaction.class)); + } + + } + + @Nested + @DisplayName("예약 확정 테스트") + class ConfirmReservationTest { + + @Test + void 예약확정_성공() { + // given + String reservationId = "test-reservation-id"; + TicketTransaction reservedTransaction = TicketTransaction.builder() + .id("transaction-id") + .userId(userId) + .amount(-5) + .description("Reserved transaction") + .status(TransactionStatus.RESERVED) + .reservationId(reservationId) + .build(); + + when(ticketTransactionRepository.findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED)) + .thenReturn(Optional.of(reservedTransaction)); + + // when + ticketService.confirmReservation(reservationId); + + // then + verify(ticketTransactionRepository).save(any(TicketTransaction.class)); + } + + @Test + void 존재하지않는예약_확정시_예외발생() { + // given + String reservationId = "non-existent-reservation-id"; + when(ticketTransactionRepository.findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED)) + .thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> ticketService.confirmReservation(reservationId)) + .isInstanceOf(TicketException.class); + } + + } + + @Nested + @DisplayName("예약 취소 테스트") + class CancelReservationTest { + + @Test + void 예약취소_성공() { + // given + String reservationId = "test-reservation-id"; + TicketTransaction reservedTransaction = TicketTransaction.builder() + .id("transaction-id") + .userId(userId) + .amount(-5) + .description("Reserved transaction") + .status(TransactionStatus.RESERVED) + .reservationId(reservationId) + .build(); + + when(ticketTransactionRepository.findByReservationIdAndStatus(reservationId, TransactionStatus.RESERVED)) + .thenReturn(Optional.of(reservedTransaction)); + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + + // when + ticketService.cancelReservation(reservationId); + + // then + verify(userTicketRepository).save(any(UserTicket.class)); + verify(ticketTransactionRepository).save(any(TicketTransaction.class)); + } + + } + + @Nested + @DisplayName("티켓 사용 테스트") + class SpendTicketTest { + + @Test + void 티켓사용_성공() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + when(userTicketRepository.save(any(UserTicket.class))).thenReturn(userTicket); + + // when + int remainingBalance = ticketService.spendTicket(userId, 5, "Test spend"); + + // then + assertThat(remainingBalance).isEqualTo(5); // 10 - 5 = 5 + verify(userTicketRepository).save(any(UserTicket.class)); + verify(ticketTransactionRepository).save(any(TicketTransaction.class)); + } + + @Test + void 잔고부족으로_티켓사용_실패() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + + // when & then + assertThatThrownBy(() -> ticketService.spendTicket(userId, 15, "Test spend")) + .isInstanceOf(TicketException.class); + } + + } + + @Nested + @DisplayName("티켓 지급 테스트") + class GrantTicketTest { + + @Test + void 티켓지급_성공() { + // given + when(userTicketRepository.findByUserId(userId)).thenReturn(Optional.of(userTicket)); + when(userTicketRepository.save(any(UserTicket.class))).thenReturn(userTicket); + + // when + int newBalance = ticketService.grantTicket(userId, 5, "Test grant"); + + // then + assertThat(newBalance).isEqualTo(15); // 10 + 5 = 15 + verify(userTicketRepository).save(any(UserTicket.class)); + verify(ticketTransactionRepository).save(any(TicketTransaction.class)); + } + + } + } \ No newline at end of file diff --git a/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java b/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java index 424335a1..4662b874 100644 --- a/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordAiServiceIntegrationTest.java @@ -21,15 +21,11 @@ /** * WordAiService 통합 테스트 - 실제 AI 모델을 호출하여 프롬프트 엔지니어링 결과를 검증 * - * 주의: - * - 이 테스트는 실제 AWS Bedrock API를 호출하므로 비용이 발생합니다 - * - 기본적으로 @Disabled로 비활성화되어 있습니다 - * - 프롬프트를 수정하거나 결과를 확인할 때만 주석을 제거하여 실행하세요 + * 주의: - 이 테스트는 실제 AWS Bedrock API를 호출하므로 비용이 발생합니다 - 기본적으로 @Disabled로 비활성화되어 있습니다 - 프롬프트를 + * 수정하거나 결과를 확인할 때만 주석을 제거하여 실행하세요 * - * 실행 방법: - * 1. @Disabled 주석 제거 - * 2. ./gradlew test --tests WordAiServiceIntegrationTest - * 3. 또는 IDE에서 개별 테스트 실행 + * 실행 방법: 1. @Disabled 주석 제거 2. ./gradlew test --tests WordAiServiceIntegrationTest 3. 또는 + * IDE에서 개별 테스트 실행 */ @SpringBootTest @ActiveProfiles("local") @@ -37,690 +33,691 @@ @Disabled("실제 AI API를 호출하므로 필요할 때만 실행 (비용 발생)") class WordAiServiceIntegrationTest { - @Autowired - private WordAiService wordAiService; - - @Test - @DisplayName("일반 동사 - 과거형 입력 시 원형으로 변환되어야 함") - void testVerbPastTense_ran() { - // given - String word = "ran"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - - // 로그로 결과 출력 (프롬프트 엔지니어링 검증용) - logResults(word, results); - - // 기본 검증 - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("run"); - assertThat(result.getVariantTypes()).contains(VariantType.PAST_TENSE); - assertThat(result.getSourceLanguageCode()).isNotNull(); - assertThat(result.getTargetLanguageCode()).isNotNull(); - assertThat(result.getSummary()).isNotEmpty(); - assertThat(result.getSummary().size()).isLessThanOrEqualTo(3); - assertThat(result.getMeanings()).isNotEmpty(); - assertThat(result.getConjugations()).isNotNull(); - assertThat(result.getConjugations().getPast()).isEqualTo("ran"); - } - - @Test - @DisplayName("일반 형용사 - 최상급 입력 시 원형으로 변환되어야 함") - void testAdjectiveSuperlative_prettiest() { - // given - String word = "prettiest"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("pretty"); - assertThat(result.getVariantTypes()).contains(VariantType.SUPERLATIVE); - assertThat(result.getComparatives()).isNotNull(); - assertThat(result.getComparatives().getSuperlative()).isEqualTo("prettiest"); - } - - @Test - @DisplayName("일반 명사 - 복수형 입력 시 단수형으로 변환되어야 함 (books는 PLURAL과 THIRD_PERSON 둘 다)") - void testNounPlural_books() { - // given - String word = "books"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).hasSize(1); // 하나의 entry로 병합되어야 함 - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("book"); - // books는 명사 복수형이면서 동시에 동사 3인칭 형태이므로 둘 다 포함해야 함 - assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PLURAL, VariantType.THIRD_PERSON); - assertThat(result.getPlural()).isNotNull(); - assertThat(result.getPlural().getPlural()).isEqualTo("books"); - } - - @Test - @DisplayName("Homograph - 'saw' (see의 과거형 + 톱)") - void testHomograph_saw() { - // given - String word = "saw"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).hasSize(2); // 두 가지 의미를 가진 결과가 있어야 함 - - logResults(word, results); - - // 첫 번째 결과: see의 과거형 - WordAnalysisResult seeResult = results.stream() - .filter(r -> r.getOriginalForm().equals("see")) - .findFirst() - .orElseThrow(() -> new AssertionError("'see' 결과를 찾을 수 없습니다")); - - assertThat(seeResult.getVariantTypes()).contains(VariantType.PAST_TENSE); - assertThat(seeResult.getConjugations()).isNotNull(); - assertThat(seeResult.getConjugations().getPast()).isEqualTo("saw"); - assertThat(seeResult.getSummary()).contains("보다"); - - // 두 번째 결과: 톱 (명사) - WordAnalysisResult sawNounResult = results.stream() - .filter(r -> r.getOriginalForm().equals("saw")) - .findFirst() - .orElseThrow(() -> new AssertionError("'saw' (명사) 결과를 찾을 수 없습니다")); - - assertThat(sawNounResult.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(sawNounResult.getPlural()).isNotNull(); - assertThat(sawNounResult.getSummary()).contains("톱"); - } - - @Test - @DisplayName("Homograph - 'rose' (rise의 과거형 + 장미)") - void testHomograph_rose() { - // given - String word = "rose"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).hasSize(2); - - logResults(word, results); - - // rise의 과거형 - WordAnalysisResult riseResult = results.stream() - .filter(r -> r.getOriginalForm().equals("rise")) - .findFirst() - .orElseThrow(() -> new AssertionError("'rise' 결과를 찾을 수 없습니다")); - - assertThat(riseResult.getVariantTypes()).contains(VariantType.PAST_TENSE); - - // 장미 (명사) - WordAnalysisResult roseNounResult = results.stream() - .filter(r -> r.getOriginalForm().equals("rose")) - .findFirst() - .orElseThrow(() -> new AssertionError("'rose' (명사) 결과를 찾을 수 없습니다")); - - assertThat(roseNounResult.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(roseNounResult.getSummary()).contains("장미"); - } - - @Test - @DisplayName("Homograph - 'left' (leave의 과거형 + 왼쪽)") - void testHomograph_left() { - // given - String word = "left"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).hasSize(2); - - logResults(word, results); - - // 1. 'leave'의 과거형 검증 - WordAnalysisResult leaveResult = results.stream() - .filter(r -> r.getOriginalForm().equals("leave")) - .findFirst() - .orElseThrow(() -> new AssertionError("'leave' 결과를 찾을 수 없습니다")); - - assertThat(leaveResult.getVariantTypes()).contains(VariantType.PAST_TENSE); - assertThat(leaveResult.getSummary()).anyMatch(s -> s.contains("떠나다") || s.contains("남기다")); - - // 2. '왼쪽'이라는 의미의 'left' 검증 - WordAnalysisResult leftResult = results.stream() - .filter(r -> r.getOriginalForm().equals("left")) - .findFirst() - .orElseThrow(() -> new AssertionError("'left' (형용사/명사) 결과를 찾을 수 없습니다")); - - assertThat(leftResult.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(leftResult.getSummary()).contains("왼쪽"); - } - - @Test - @DisplayName("원형 단어 - 'run' (동사)") - void testOriginalForm_run() { - // given - String word = "run"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).hasSize(1); - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("run"); - assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(result.getSummary()).containsAnyOf("달리다", "운영하다", "작동하다"); - assertThat(result.getConjugations()).isNotNull(); - assertThat(result.getConjugations().getPast()).isEqualTo("ran"); - assertThat(result.getConjugations().getPresentParticiple()).isEqualTo("running"); - } - - @Test - @DisplayName("불규칙 복수형 - 'children'") - void testIrregularPlural_children() { - // given - String word = "children"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("child"); - assertThat(result.getVariantTypes()).contains(VariantType.PLURAL); - assertThat(result.getPlural()).isNotNull(); - assertThat(result.getPlural().getSingular()).isEqualTo("child"); - assertThat(result.getPlural().getPlural()).isEqualTo("children"); - } - - @Test - @DisplayName("불규칙 동사 - 'went' (go의 과거형)") - void testIrregularVerb_went() { - // given - String word = "went"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("go"); - assertThat(result.getVariantTypes()).contains(VariantType.PAST_TENSE); - assertThat(result.getConjugations()).isNotNull(); - assertThat(result.getConjugations().getPast()).isEqualTo("went"); - assertThat(result.getConjugations().getPastParticiple()).isEqualTo("gone"); - } - - @Test - @DisplayName("현재분사 - 'running'") - void testPresentParticiple_running() { - // given - String word = "running"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("run"); - assertThat(result.getVariantTypes()).contains(VariantType.PRESENT_PARTICIPLE); - assertThat(result.getConjugations()).isNotNull(); - assertThat(result.getConjugations().getPresentParticiple()).isEqualTo("running"); - } - - @Test - @DisplayName("3인칭 단수 - 'goes'") - void testThirdPerson_goes() { - // given - String word = "goes"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("go"); - assertThat(result.getVariantTypes()).contains(VariantType.THIRD_PERSON); - assertThat(result.getConjugations()).isNotNull(); - assertThat(result.getConjugations().getThirdPerson()).isEqualTo("goes"); - } - - @Test - @DisplayName("말이 안되는 단어 입력 시, WordsException 예외를 던져야 함") - void testNonsensicalWord_shouldThrowException() { - // given - String word = "asdfqwer"; - String targetLanguage = "KO"; - - // when & then - // AI가 의미 없는 단어에 대해 빈 배열을 반환하면 WordsException을 그대로 던짐 - WordsException exception = assertThrows(WordsException.class, () -> { - wordAiService.analyzeWord(word, targetLanguage); - }); - - assertThat(exception.getErrorCode()).isEqualTo(WordsErrorCode.WORD_IS_MEANINGLESS); - } - - // ===== 실패 사례 기반 추가 테스트 ===== - - @Test - @DisplayName("대명사 - 'I' (주격 1인칭 대명사)") - void testPronoun_I() { - // given - String word = "I"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("I"); - assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(result.getMeanings()).isNotEmpty(); - assertThat(result.getMeanings().get(0).getPartOfSpeech()).isEqualTo(PartOfSpeech.PRONOUN); - } - - @Test - @DisplayName("대명사 - 'him' (목적격 3인칭 남성 대명사)") - void testPronoun_him() { - // given - String word = "him"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isIn("he", "him"); - assertThat(result.getMeanings()).isNotEmpty(); - } - - @Test - @DisplayName("대명사 - 'them' (목적격 3인칭 복수 대명사)") - void testPronoun_them() { - // given - String word = "them"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isIn("they", "them"); - assertThat(result.getMeanings()).isNotEmpty(); - } - - @Test - @DisplayName("부사 - 'absolutely' (-ly 형태 부사)") - void testAdverb_absolutely() { - // given - String word = "absolutely"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("absolutely"); - assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(result.getMeanings()).isNotEmpty(); - assertThat(result.getMeanings().get(0).getPartOfSpeech()).isEqualTo(PartOfSpeech.ADVERB); - } - - @Test - @DisplayName("부사 - 'carefully' (형용사에서 파생된 부사)") - void testAdverb_carefully() { - // given - String word = "carefully"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("carefully"); - assertThat(result.getMeanings()).isNotEmpty(); - } - - @Test - @DisplayName("부사 - 'quickly'") - void testAdverb_quickly() { - // given - String word = "quickly"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("quickly"); - } - - @Test - @DisplayName("과거분사/형용사 - 'confused' (혼란스러운)") - void testParticipleAdjective_confused() { - // given - String word = "confused"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - // confused는 confuse의 과거/과거분사이면서 동시에 형용사로도 쓰임 - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("confuse"); - assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PAST_TENSE, VariantType.PAST_PARTICIPLE); - assertThat(result.getMeanings()).isNotEmpty(); - } - - @Test - @DisplayName("과거분사/형용사 - 'interested' (관심있는)") - void testParticipleAdjective_interested() { - // given - String word = "interested"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("interest"); - assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PAST_TENSE, VariantType.PAST_PARTICIPLE); - } - - @Test - @DisplayName("과거분사/형용사 - 'worried' (걱정하는)") - void testParticipleAdjective_worried() { - // given - String word = "worried"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("worry"); - assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PAST_TENSE, VariantType.PAST_PARTICIPLE); - } - - @Test - @DisplayName("숫자 - 'one' (기수)") - void testNumber_one() { - // given - String word = "one"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("one"); - assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); - assertThat(result.getMeanings()).isNotEmpty(); - } - - @Test - @DisplayName("숫자 - 'eight' (기수)") - void testNumber_eight() { - // given - String word = "eight"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("eight"); - } - - @Test - @DisplayName("서수 - 'eleventh' (11번째)") - void testOrdinal_eleventh() { - // given - String word = "eleventh"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("eleventh"); - } - - @Test - @DisplayName("서수 - 'twentieth' (20번째)") - void testOrdinal_twentieth() { - // given - String word = "twentieth"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("twentieth"); - } - - @Test - @DisplayName("호칭 - 'Mr' (미스터)") - void testTitle_Mr() { - // given - String word = "Mr"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isIn("Mr", "mr", "MR"); - } - - @Test - @DisplayName("호칭 - 'Ms' (미즈)") - void testTitle_Ms() { - // given - String word = "Ms"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isIn("Ms", "ms", "MS"); - } - - @Test - @DisplayName("관계대명사 - 'whom' (목적격 관계대명사)") - void testRelativePronoun_whom() { - // given - String word = "whom"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("whom"); - assertThat(result.getMeanings()).isNotEmpty(); - assertThat(result.getMeanings().get(0).getExample()).isNotBlank(); - } - - @Test - @DisplayName("의문부사 - 'where' (어디)") - void testInterrogativeAdverb_where() { - // given - String word = "where"; - String targetLanguage = "KO"; - - // when - List results = wordAiService.analyzeWord(word, targetLanguage); - - // then - assertThat(results).isNotEmpty(); - logResults(word, results); - - WordAnalysisResult result = results.get(0); - assertThat(result.getOriginalForm()).isEqualTo("where"); - } - - /** - * 테스트 결과를 보기 좋게 로그로 출력 - */ - private void logResults(String word, List results) { - log.info("\n" + "=".repeat(80)); - log.info("검색어: {}", word); - log.info("결과 개수: {}", results.size()); - log.info("=".repeat(80)); - - for (int i = 0; i < results.size(); i++) { - WordAnalysisResult result = results.get(i); - log.info("\n[결과 #{}]", i + 1); - log.info(" 원형: {}", result.getOriginalForm()); - log.info(" 변형 타입: {}", result.getVariantTypes()); - log.info(" 언어: {} -> {}", result.getSourceLanguageCode(), result.getTargetLanguageCode()); - log.info(" 요약: {}", result.getSummary()); - - log.info(" 의미:"); - result.getMeanings().forEach(meaning -> { - log.info(" - [{}] {}", meaning.getPartOfSpeech(), meaning.getMeaning()); - log.info(" 예문: {}", meaning.getExample()); - log.info(" 번역: {}", meaning.getExampleTranslation()); - }); - - if (result.getConjugations() != null) { - log.info(" 동사 활용:"); - log.info(" - 현재: {}", result.getConjugations().getPresent()); - log.info(" - 과거: {}", result.getConjugations().getPast()); - log.info(" - 과거분사: {}", result.getConjugations().getPastParticiple()); - log.info(" - 현재분사: {}", result.getConjugations().getPresentParticiple()); - log.info(" - 3인칭: {}", result.getConjugations().getThirdPerson()); - } - - if (result.getComparatives() != null) { - log.info(" 형용사 비교:"); - log.info(" - 원급: {}", result.getComparatives().getPositive()); - log.info(" - 비교급: {}", result.getComparatives().getComparative()); - log.info(" - 최상급: {}", result.getComparatives().getSuperlative()); - } - - if (result.getPlural() != null) { - log.info(" 명사 복수:"); - log.info(" - 단수: {}", result.getPlural().getSingular()); - log.info(" - 복수: {}", result.getPlural().getPlural()); - } - } - - log.info("\n" + "=".repeat(80) + "\n"); - } + @Autowired + private WordAiService wordAiService; + + @Test + @DisplayName("일반 동사 - 과거형 입력 시 원형으로 변환되어야 함") + void testVerbPastTense_ran() { + // given + String word = "ran"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + + // 로그로 결과 출력 (프롬프트 엔지니어링 검증용) + logResults(word, results); + + // 기본 검증 + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("run"); + assertThat(result.getVariantTypes()).contains(VariantType.PAST_TENSE); + assertThat(result.getSourceLanguageCode()).isNotNull(); + assertThat(result.getTargetLanguageCode()).isNotNull(); + assertThat(result.getSummary()).isNotEmpty(); + assertThat(result.getSummary().size()).isLessThanOrEqualTo(3); + assertThat(result.getMeanings()).isNotEmpty(); + assertThat(result.getConjugations()).isNotNull(); + assertThat(result.getConjugations().getPast()).isEqualTo("ran"); + } + + @Test + @DisplayName("일반 형용사 - 최상급 입력 시 원형으로 변환되어야 함") + void testAdjectiveSuperlative_prettiest() { + // given + String word = "prettiest"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("pretty"); + assertThat(result.getVariantTypes()).contains(VariantType.SUPERLATIVE); + assertThat(result.getComparatives()).isNotNull(); + assertThat(result.getComparatives().getSuperlative()).isEqualTo("prettiest"); + } + + @Test + @DisplayName("일반 명사 - 복수형 입력 시 단수형으로 변환되어야 함 (books는 PLURAL과 THIRD_PERSON 둘 다)") + void testNounPlural_books() { + // given + String word = "books"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).hasSize(1); // 하나의 entry로 병합되어야 함 + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("book"); + // books는 명사 복수형이면서 동시에 동사 3인칭 형태이므로 둘 다 포함해야 함 + assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PLURAL, VariantType.THIRD_PERSON); + assertThat(result.getPlural()).isNotNull(); + assertThat(result.getPlural().getPlural()).isEqualTo("books"); + } + + @Test + @DisplayName("Homograph - 'saw' (see의 과거형 + 톱)") + void testHomograph_saw() { + // given + String word = "saw"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).hasSize(2); // 두 가지 의미를 가진 결과가 있어야 함 + + logResults(word, results); + + // 첫 번째 결과: see의 과거형 + WordAnalysisResult seeResult = results.stream() + .filter(r -> r.getOriginalForm().equals("see")) + .findFirst() + .orElseThrow(() -> new AssertionError("'see' 결과를 찾을 수 없습니다")); + + assertThat(seeResult.getVariantTypes()).contains(VariantType.PAST_TENSE); + assertThat(seeResult.getConjugations()).isNotNull(); + assertThat(seeResult.getConjugations().getPast()).isEqualTo("saw"); + assertThat(seeResult.getSummary()).contains("보다"); + + // 두 번째 결과: 톱 (명사) + WordAnalysisResult sawNounResult = results.stream() + .filter(r -> r.getOriginalForm().equals("saw")) + .findFirst() + .orElseThrow(() -> new AssertionError("'saw' (명사) 결과를 찾을 수 없습니다")); + + assertThat(sawNounResult.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(sawNounResult.getPlural()).isNotNull(); + assertThat(sawNounResult.getSummary()).contains("톱"); + } + + @Test + @DisplayName("Homograph - 'rose' (rise의 과거형 + 장미)") + void testHomograph_rose() { + // given + String word = "rose"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).hasSize(2); + + logResults(word, results); + + // rise의 과거형 + WordAnalysisResult riseResult = results.stream() + .filter(r -> r.getOriginalForm().equals("rise")) + .findFirst() + .orElseThrow(() -> new AssertionError("'rise' 결과를 찾을 수 없습니다")); + + assertThat(riseResult.getVariantTypes()).contains(VariantType.PAST_TENSE); + + // 장미 (명사) + WordAnalysisResult roseNounResult = results.stream() + .filter(r -> r.getOriginalForm().equals("rose")) + .findFirst() + .orElseThrow(() -> new AssertionError("'rose' (명사) 결과를 찾을 수 없습니다")); + + assertThat(roseNounResult.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(roseNounResult.getSummary()).contains("장미"); + } + + @Test + @DisplayName("Homograph - 'left' (leave의 과거형 + 왼쪽)") + void testHomograph_left() { + // given + String word = "left"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).hasSize(2); + + logResults(word, results); + + // 1. 'leave'의 과거형 검증 + WordAnalysisResult leaveResult = results.stream() + .filter(r -> r.getOriginalForm().equals("leave")) + .findFirst() + .orElseThrow(() -> new AssertionError("'leave' 결과를 찾을 수 없습니다")); + + assertThat(leaveResult.getVariantTypes()).contains(VariantType.PAST_TENSE); + assertThat(leaveResult.getSummary()).anyMatch(s -> s.contains("떠나다") || s.contains("남기다")); + + // 2. '왼쪽'이라는 의미의 'left' 검증 + WordAnalysisResult leftResult = results.stream() + .filter(r -> r.getOriginalForm().equals("left")) + .findFirst() + .orElseThrow(() -> new AssertionError("'left' (형용사/명사) 결과를 찾을 수 없습니다")); + + assertThat(leftResult.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(leftResult.getSummary()).contains("왼쪽"); + } + + @Test + @DisplayName("원형 단어 - 'run' (동사)") + void testOriginalForm_run() { + // given + String word = "run"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).hasSize(1); + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("run"); + assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(result.getSummary()).containsAnyOf("달리다", "운영하다", "작동하다"); + assertThat(result.getConjugations()).isNotNull(); + assertThat(result.getConjugations().getPast()).isEqualTo("ran"); + assertThat(result.getConjugations().getPresentParticiple()).isEqualTo("running"); + } + + @Test + @DisplayName("불규칙 복수형 - 'children'") + void testIrregularPlural_children() { + // given + String word = "children"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("child"); + assertThat(result.getVariantTypes()).contains(VariantType.PLURAL); + assertThat(result.getPlural()).isNotNull(); + assertThat(result.getPlural().getSingular()).isEqualTo("child"); + assertThat(result.getPlural().getPlural()).isEqualTo("children"); + } + + @Test + @DisplayName("불규칙 동사 - 'went' (go의 과거형)") + void testIrregularVerb_went() { + // given + String word = "went"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("go"); + assertThat(result.getVariantTypes()).contains(VariantType.PAST_TENSE); + assertThat(result.getConjugations()).isNotNull(); + assertThat(result.getConjugations().getPast()).isEqualTo("went"); + assertThat(result.getConjugations().getPastParticiple()).isEqualTo("gone"); + } + + @Test + @DisplayName("현재분사 - 'running'") + void testPresentParticiple_running() { + // given + String word = "running"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("run"); + assertThat(result.getVariantTypes()).contains(VariantType.PRESENT_PARTICIPLE); + assertThat(result.getConjugations()).isNotNull(); + assertThat(result.getConjugations().getPresentParticiple()).isEqualTo("running"); + } + + @Test + @DisplayName("3인칭 단수 - 'goes'") + void testThirdPerson_goes() { + // given + String word = "goes"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("go"); + assertThat(result.getVariantTypes()).contains(VariantType.THIRD_PERSON); + assertThat(result.getConjugations()).isNotNull(); + assertThat(result.getConjugations().getThirdPerson()).isEqualTo("goes"); + } + + @Test + @DisplayName("말이 안되는 단어 입력 시, WordsException 예외를 던져야 함") + void testNonsensicalWord_shouldThrowException() { + // given + String word = "asdfqwer"; + String targetLanguage = "KO"; + + // when & then + // AI가 의미 없는 단어에 대해 빈 배열을 반환하면 WordsException을 그대로 던짐 + WordsException exception = assertThrows(WordsException.class, () -> { + wordAiService.analyzeWord(word, targetLanguage); + }); + + assertThat(exception.getErrorCode()).isEqualTo(WordsErrorCode.WORD_IS_MEANINGLESS); + } + + // ===== 실패 사례 기반 추가 테스트 ===== + + @Test + @DisplayName("대명사 - 'I' (주격 1인칭 대명사)") + void testPronoun_I() { + // given + String word = "I"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("I"); + assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(result.getMeanings()).isNotEmpty(); + assertThat(result.getMeanings().get(0).getPartOfSpeech()).isEqualTo(PartOfSpeech.PRONOUN); + } + + @Test + @DisplayName("대명사 - 'him' (목적격 3인칭 남성 대명사)") + void testPronoun_him() { + // given + String word = "him"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isIn("he", "him"); + assertThat(result.getMeanings()).isNotEmpty(); + } + + @Test + @DisplayName("대명사 - 'them' (목적격 3인칭 복수 대명사)") + void testPronoun_them() { + // given + String word = "them"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isIn("they", "them"); + assertThat(result.getMeanings()).isNotEmpty(); + } + + @Test + @DisplayName("부사 - 'absolutely' (-ly 형태 부사)") + void testAdverb_absolutely() { + // given + String word = "absolutely"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("absolutely"); + assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(result.getMeanings()).isNotEmpty(); + assertThat(result.getMeanings().get(0).getPartOfSpeech()).isEqualTo(PartOfSpeech.ADVERB); + } + + @Test + @DisplayName("부사 - 'carefully' (형용사에서 파생된 부사)") + void testAdverb_carefully() { + // given + String word = "carefully"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("carefully"); + assertThat(result.getMeanings()).isNotEmpty(); + } + + @Test + @DisplayName("부사 - 'quickly'") + void testAdverb_quickly() { + // given + String word = "quickly"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("quickly"); + } + + @Test + @DisplayName("과거분사/형용사 - 'confused' (혼란스러운)") + void testParticipleAdjective_confused() { + // given + String word = "confused"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + // confused는 confuse의 과거/과거분사이면서 동시에 형용사로도 쓰임 + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("confuse"); + assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PAST_TENSE, VariantType.PAST_PARTICIPLE); + assertThat(result.getMeanings()).isNotEmpty(); + } + + @Test + @DisplayName("과거분사/형용사 - 'interested' (관심있는)") + void testParticipleAdjective_interested() { + // given + String word = "interested"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("interest"); + assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PAST_TENSE, VariantType.PAST_PARTICIPLE); + } + + @Test + @DisplayName("과거분사/형용사 - 'worried' (걱정하는)") + void testParticipleAdjective_worried() { + // given + String word = "worried"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("worry"); + assertThat(result.getVariantTypes()).containsAnyOf(VariantType.PAST_TENSE, VariantType.PAST_PARTICIPLE); + } + + @Test + @DisplayName("숫자 - 'one' (기수)") + void testNumber_one() { + // given + String word = "one"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("one"); + assertThat(result.getVariantTypes()).contains(VariantType.ORIGINAL_FORM); + assertThat(result.getMeanings()).isNotEmpty(); + } + + @Test + @DisplayName("숫자 - 'eight' (기수)") + void testNumber_eight() { + // given + String word = "eight"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("eight"); + } + + @Test + @DisplayName("서수 - 'eleventh' (11번째)") + void testOrdinal_eleventh() { + // given + String word = "eleventh"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("eleventh"); + } + + @Test + @DisplayName("서수 - 'twentieth' (20번째)") + void testOrdinal_twentieth() { + // given + String word = "twentieth"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("twentieth"); + } + + @Test + @DisplayName("호칭 - 'Mr' (미스터)") + void testTitle_Mr() { + // given + String word = "Mr"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isIn("Mr", "mr", "MR"); + } + + @Test + @DisplayName("호칭 - 'Ms' (미즈)") + void testTitle_Ms() { + // given + String word = "Ms"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isIn("Ms", "ms", "MS"); + } + + @Test + @DisplayName("관계대명사 - 'whom' (목적격 관계대명사)") + void testRelativePronoun_whom() { + // given + String word = "whom"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("whom"); + assertThat(result.getMeanings()).isNotEmpty(); + assertThat(result.getMeanings().get(0).getExample()).isNotBlank(); + } + + @Test + @DisplayName("의문부사 - 'where' (어디)") + void testInterrogativeAdverb_where() { + // given + String word = "where"; + String targetLanguage = "KO"; + + // when + List results = wordAiService.analyzeWord(word, targetLanguage); + + // then + assertThat(results).isNotEmpty(); + logResults(word, results); + + WordAnalysisResult result = results.get(0); + assertThat(result.getOriginalForm()).isEqualTo("where"); + } + + /** + * 테스트 결과를 보기 좋게 로그로 출력 + */ + private void logResults(String word, List results) { + log.info("\n" + "=".repeat(80)); + log.info("검색어: {}", word); + log.info("결과 개수: {}", results.size()); + log.info("=".repeat(80)); + + for (int i = 0; i < results.size(); i++) { + WordAnalysisResult result = results.get(i); + log.info("\n[결과 #{}]", i + 1); + log.info(" 원형: {}", result.getOriginalForm()); + log.info(" 변형 타입: {}", result.getVariantTypes()); + log.info(" 언어: {} -> {}", result.getSourceLanguageCode(), result.getTargetLanguageCode()); + log.info(" 요약: {}", result.getSummary()); + + log.info(" 의미:"); + result.getMeanings().forEach(meaning -> { + log.info(" - [{}] {}", meaning.getPartOfSpeech(), meaning.getMeaning()); + log.info(" 예문: {}", meaning.getExample()); + log.info(" 번역: {}", meaning.getExampleTranslation()); + }); + + if (result.getConjugations() != null) { + log.info(" 동사 활용:"); + log.info(" - 현재: {}", result.getConjugations().getPresent()); + log.info(" - 과거: {}", result.getConjugations().getPast()); + log.info(" - 과거분사: {}", result.getConjugations().getPastParticiple()); + log.info(" - 현재분사: {}", result.getConjugations().getPresentParticiple()); + log.info(" - 3인칭: {}", result.getConjugations().getThirdPerson()); + } + + if (result.getComparatives() != null) { + log.info(" 형용사 비교:"); + log.info(" - 원급: {}", result.getComparatives().getPositive()); + log.info(" - 비교급: {}", result.getComparatives().getComparative()); + log.info(" - 최상급: {}", result.getComparatives().getSuperlative()); + } + + if (result.getPlural() != null) { + log.info(" 명사 복수:"); + log.info(" - 단수: {}", result.getPlural().getSingular()); + log.info(" - 복수: {}", result.getPlural().getPlural()); + } + } + + log.info("\n" + "=".repeat(80) + "\n"); + } + } diff --git a/src/test/java/com/linglevel/api/word/service/WordServiceTest.java b/src/test/java/com/linglevel/api/word/service/WordServiceTest.java index 4366c4be..bf1769d2 100644 --- a/src/test/java/com/linglevel/api/word/service/WordServiceTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordServiceTest.java @@ -30,440 +30,425 @@ import static org.mockito.Mockito.*; /** - * WordService 단위 테스트 - * Mock을 사용하여 DB나 외부 API 호출 없이 로직을 테스트합니다. + * WordService 단위 테스트 Mock을 사용하여 DB나 외부 API 호출 없이 로직을 테스트합니다. */ @ExtendWith(MockitoExtension.class) class WordServiceTest { - @Mock - private WordRepository wordRepository; - - @Mock - private WordBookmarkRepository wordBookmarkRepository; - - @Mock - private WordVariantRepository wordVariantRepository; - - @Mock - private WordAiService wordAiService; - - @Mock - private InvalidWordRepository invalidWordRepository; - - @Mock - private WordSingleFlightRedisCoordinator singleFlightCoordinator; - - private WordService wordService; - private WordPersistenceService wordPersistenceService; - - private Word sampleWord; - private String userId = "test-user-123"; - - @BeforeEach - void setUp() { - wordPersistenceService = new WordPersistenceService( - wordRepository, - wordVariantRepository, - invalidWordRepository - ); - wordService = new WordService( - wordRepository, - wordBookmarkRepository, - wordVariantRepository, - invalidWordRepository, - wordAiService, - singleFlightCoordinator, - wordPersistenceService - ); - - lenient().when(singleFlightCoordinator.execute(anyString(), any(LanguageCode.class), any(), any())) - .thenAnswer(invocation -> { - Supplier> lookup = invocation.getArgument(3); - Optional existing = lookup.get(); - if (existing.isPresent()) { - return existing.get(); - } - - Supplier supplier = invocation.getArgument(2); - return supplier.get(); - }); - - // 샘플 Word 데이터 생성 - sampleWord = Word.builder() - .id("word-123") - .word("run") - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .summary(List.of("달리다", "운영하다", "작동하다")) - .meanings(List.of( - Meaning.builder() - .partOfSpeech(PartOfSpeech.VERB) - .meaning("달리다") - .example("I {run} every morning.") - .exampleTranslation("나는 매일 아침 달립니다.") - .build(), - Meaning.builder() - .partOfSpeech(PartOfSpeech.VERB) - .meaning("운영하다") - .example("She {runs} a company.") - .exampleTranslation("그녀는 회사를 운영합니다.") - .build() - )) - .relatedForms(RelatedForms.builder() - .conjugations(RelatedForms.Conjugations.builder() - .present("run") - .past("ran") - .pastParticiple("run") - .presentParticiple("running") - .thirdPerson("runs") - .build()) - .build()) - .build(); - } - - @Test - @DisplayName("DB에 단어가 있으면 바로 반환") - void getOrCreateWords_단어가_DB에_있는_경우() { - // given - WordVariant wordVariant = WordVariant.builder() - .word("run") - .originalForm("run") - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .build(); - - when(wordVariantRepository.findAllByWord("run")).thenReturn(List.of(wordVariant)); - when(wordRepository.findByWordAndTargetLanguageCode("run", LanguageCode.KO)).thenReturn(Optional.of(sampleWord)); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(false); - - // when - WordSearchResponse response = wordService.getOrCreateWords(userId, "run", LanguageCode.KO); - - // then - assertThat(response).isNotNull(); - assertThat(response.getSearchedWord()).isEqualTo("run"); - assertThat(response.getResults()).hasSize(1); - assertThat(response.getResults().get(0).getOriginalForm()).isEqualTo("run"); - assertThat(response.getResults().get(0).getBookmarked()).isFalse(); - - // AI 호출 없이 DB에서만 조회되었는지 확인 - verify(wordVariantRepository).findAllByWord("run"); - verify(wordRepository).findByWordAndTargetLanguageCode("run", LanguageCode.KO); - verify(wordAiService, never()).analyzeWord(anyString(), anyString()); - } - - @Test - @DisplayName("DB에 단어가 없으면 AI 호출 후 저장") - void getOrCreateWords_단어가_DB에_없는_경우_AI_호출() { - // given - String newWord = "magnificent"; - - WordAnalysisResult analysisResult = WordAnalysisResult.builder() - .originalForm(newWord) - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .summary(List.of("훌륭한", "장엄한", "멋진")) - .meanings(List.of( - Meaning.builder() - .partOfSpeech(PartOfSpeech.ADJECTIVE) - .meaning("훌륭한, 장엄한") - .example("The view is {magnificent}.") - .exampleTranslation("그 경치는 장엄합니다.") - .build() - )) - .comparatives(RelatedForms.Comparatives.builder() - .positive("magnificent") - .comparative("more magnificent") - .superlative("most magnificent") - .build()) - .build(); - - Word savedWord = Word.builder() - .word(newWord) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .summary(List.of("훌륭한", "장엄한", "멋진")) - .meanings(analysisResult.getMeanings()) - .relatedForms(RelatedForms.builder() - .comparatives(analysisResult.getComparatives()) - .build()) - .build(); - - when(wordVariantRepository.findAllByWord(newWord)).thenReturn(List.of()); - when(wordAiService.analyzeWord(newWord, LanguageCode.KO.getCode())).thenReturn(List.of(analysisResult)); - when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode( - newWord, LanguageCode.EN, LanguageCode.KO)).thenReturn(Optional.empty()); - when(wordRepository.findByWordAndTargetLanguageCode(newWord, LanguageCode.KO)) - .thenReturn(Optional.of(savedWord)); - when(wordRepository.save(any(Word.class))).thenReturn(savedWord); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, newWord)).thenReturn(false); - when(wordVariantRepository.findByWordIn(anyList())).thenReturn(List.of()); - - // when - WordSearchResponse response = wordService.getOrCreateWords(userId, newWord, LanguageCode.KO); - - // then - assertThat(response).isNotNull(); - assertThat(response.getSearchedWord()).isEqualTo(newWord); - assertThat(response.getResults()).hasSize(1); - assertThat(response.getResults().get(0).getOriginalForm()).isEqualTo(newWord); - - // AI가 호출되었는지 확인 - verify(wordAiService, atLeastOnce()).analyzeWord(newWord, LanguageCode.KO.getCode()); - verify(wordRepository).save(any(Word.class)); - verify(wordVariantRepository).save(any(WordVariant.class)); - } - - @Test - @DisplayName("변형 단어(과거형)를 검색하면 원형 단어 정보를 반환") - void getOrCreateWords_변형_단어_검색() { - // given - String variantWord = "ran"; // run의 과거형 - WordVariant wordVariant = WordVariant.builder() - .word(variantWord) - .originalForm("run") - .variantTypes(List.of(VariantType.PAST_TENSE)) - .build(); - - when(wordVariantRepository.findAllByWord(variantWord)).thenReturn(List.of(wordVariant)); - when(wordRepository.findByWordAndTargetLanguageCode("run", LanguageCode.KO)).thenReturn(Optional.of(sampleWord)); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(false); - - // when - WordSearchResponse response = wordService.getOrCreateWords(userId, variantWord, LanguageCode.KO); - - // then - assertThat(response).isNotNull(); - assertThat(response.getSearchedWord()).isEqualTo(variantWord); - assertThat(response.getResults()).hasSize(1); - assertThat(response.getResults().get(0).getOriginalForm()).isEqualTo("run"); - assertThat(response.getResults().get(0).getVariantTypes()).contains(VariantType.PAST_TENSE); - - // AI 호출 없이 variant 테이블과 원형 단어로 해결되었는지 확인 - verify(wordVariantRepository).findAllByWord(variantWord); - verify(wordRepository).findByWordAndTargetLanguageCode("run", LanguageCode.KO); - verify(wordAiService, never()).analyzeWord(anyString(), anyString()); - } - - @Test - @DisplayName("단어 저장 시 모든 변형 형태를 WordVariant에 저장") - void saveWordVariants_모든_변형_형태_저장() { - // given - WordAnalysisResult analysisResult = WordAnalysisResult.builder() - .originalForm(sampleWord.getWord()) - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .sourceLanguageCode(sampleWord.getSourceLanguageCode()) - .targetLanguageCode(sampleWord.getTargetLanguageCode()) - .summary(sampleWord.getSummary()) - .meanings(sampleWord.getMeanings()) - .conjugations(sampleWord.getRelatedForms().getConjugations()) - .build(); - - when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode( - sampleWord.getWord(), - sampleWord.getSourceLanguageCode(), - sampleWord.getTargetLanguageCode() - )).thenReturn(Optional.empty()); - when(wordRepository.save(any(Word.class))).thenReturn(sampleWord); - when(wordVariantRepository.findByWordIn(anyList())).thenReturn(List.of()); - - // when - wordPersistenceService.saveAnalysisResults("run", List.of(analysisResult), Optional.empty()); - - // then - // 동사 변형 4개가 저장되어야 함 (past, pastParticiple, presentParticiple, thirdPerson) - // "run"은 pastParticiple이 원형과 같으므로 3개만 저장 - verify(wordVariantRepository).saveAll(anyList()); - } - - @Test - @DisplayName("북마크된 단어는 isBookmarked가 true") - void getOrCreateWords_북마크된_단어() { - // given - WordVariant wordVariant = WordVariant.builder() - .word("run") - .originalForm("run") - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .build(); - - when(wordVariantRepository.findAllByWord("run")).thenReturn(List.of(wordVariant)); - when(wordRepository.findByWordAndTargetLanguageCode("run", LanguageCode.KO)).thenReturn(Optional.of(sampleWord)); - when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(true); - - // when - WordSearchResponse response = wordService.getOrCreateWords(userId, "run", LanguageCode.KO); - - // then - assertThat(response.getResults().get(0).getBookmarked()).isTrue(); - } - - @Test - @DisplayName("single-flight timeout은 무의미 단어로 캐시하지 않고 별도 에러를 반환") - void getOrCreateWords_singleFlightTimeout_doesNotCacheInvalidWord() { - String word = "resilience"; - - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); - when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); - when(singleFlightCoordinator.execute(eq(word), eq(LanguageCode.KO), any(), any())) - .thenThrow(new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); - - assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) - .isInstanceOf(WordsException.class) - .hasMessageContaining("temporarily delayed"); - - verify(invalidWordRepository, never()).save(any()); - } - - @Test - @DisplayName("translation-miss 경로의 single-flight timeout도 WORD_ANALYSIS_TIMEOUT으로 변환") - void getOrCreateWords_translationMissTimeout_returnsDomainTimeoutError() { - String inputWord = "ran"; - String originalForm = "run"; - - WordVariant wordVariant = WordVariant.builder() - .word(inputWord) - .originalForm(originalForm) - .variantTypes(List.of(VariantType.PAST_TENSE)) - .build(); - - when(wordVariantRepository.findAllByWord(inputWord)).thenReturn(List.of(wordVariant)); - when(wordRepository.findByWordAndTargetLanguageCode(originalForm, LanguageCode.KO)) - .thenReturn(Optional.empty()); - when(singleFlightCoordinator.execute(eq(originalForm), eq(LanguageCode.KO), any(), any())) - .thenThrow(new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); - - assertThatThrownBy(() -> wordService.getOrCreateWords(userId, inputWord, LanguageCode.KO)) - .isInstanceOf(WordsException.class) - .hasMessageContaining("temporarily delayed"); - } - - @Test - @DisplayName("AI 분석 실패는 invalid 캐시에 반영하지 않고 분석 실패 에러로 전파") - void getOrCreateWords_aiAnalysisFailure_doesNotCacheInvalidWord() { - String word = "resilience"; - WordsException failure = new WordsException(WordsErrorCode.WORD_ANALYSIS_FAILED); - - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); - when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); - when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())) - .thenThrow(failure); - - assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) - .isSameAs(failure) - .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) - .isEqualTo(WordsErrorCode.WORD_ANALYSIS_FAILED)); - - verify(invalidWordRepository, never()).save(any()); - } - - @Test - @DisplayName("DB 저장 실패는 invalid 캐시에 반영하지 않고 그대로 전파") - void getOrCreateWords_persistenceFailure_doesNotCacheInvalidWord() { - String word = "resilience"; - DataIntegrityViolationException failure = new DataIntegrityViolationException("duplicate variant"); - - WordAnalysisResult analysisResult = WordAnalysisResult.builder() - .originalForm(word) - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .summary(List.of("회복력")) - .meanings(List.of()) - .build(); - - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); - when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); - when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())).thenReturn(List.of(analysisResult)); - when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode(word, LanguageCode.EN, LanguageCode.KO)) - .thenReturn(Optional.empty()); - when(wordRepository.save(any(Word.class))).thenThrow(failure); - - assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) - .isSameAs(failure); - - verify(invalidWordRepository, never()).save(any()); - } - - @Test - @DisplayName("single-flight leader의 WORD_IS_MEANINGLESS 예외는 invalid 캐시에 반영") - void getOrCreateWords_singleFlightLeaderMeaninglessException_cachesInvalidWord() { - String word = "asdfqwer"; - - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); - when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); - when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())) - .thenThrow(new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS)); - - assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) - .isInstanceOf(WordsException.class) - .hasMessageContaining("meaningless"); - - verify(invalidWordRepository).save(any()); - } - - @Test - @DisplayName("3회 미만 invalid 캐시는 single-flight 사전 조회에서도 재시도를 허용") - void getOrCreateWordEntities_cachedInvalidBelowThreshold_allowsRetryThroughSingleFlightLookup() { - String word = "resilience"; - InvalidWord cachedInvalidWord = InvalidWord.builder() - .word(word) - .attemptCount(1) - .build(); - - WordAnalysisResult analysisResult = WordAnalysisResult.builder() - .originalForm(word) - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .summary(List.of("회복력")) - .meanings(List.of()) - .build(); - - Word savedWord = Word.builder() - .word(word) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .summary(List.of("회복력")) - .meanings(List.of()) - .build(); - - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); - when(invalidWordRepository.findByWord(word)).thenReturn(Optional.of(cachedInvalidWord)); - when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())).thenReturn(List.of(analysisResult)); - when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode(word, LanguageCode.EN, LanguageCode.KO)) - .thenReturn(Optional.empty()); - when(wordRepository.save(any(Word.class))).thenReturn(savedWord); - when(wordVariantRepository.findByWordAndOriginalForm(word, word)).thenReturn(Optional.empty()); - - List variants = wordService.getOrCreateWordEntities(word, LanguageCode.KO); - - assertThat(variants).hasSize(1); - assertThat(variants.get(0).getOriginalForm()).isEqualTo(word); - verify(wordAiService).analyzeWord(word, LanguageCode.KO.getCode()); - verify(invalidWordRepository).delete(cachedInvalidWord); - } - - @Test - @DisplayName("translation-miss 경로에서 follower DB 조회가 도메인 에러를 반환하면 그대로 전파") - void getOrCreateWords_translationMissFollowerLookupFailure_propagatesDomainError() { - String inputWord = "ran"; - String originalForm = "run"; - - WordVariant wordVariant = WordVariant.builder() - .word(inputWord) - .originalForm(originalForm) - .variantTypes(List.of(VariantType.PAST_TENSE)) - .build(); - - when(wordVariantRepository.findAllByWord(inputWord)).thenReturn(List.of(wordVariant)); - when(wordRepository.findByWordAndTargetLanguageCode(originalForm, LanguageCode.KO)) - .thenReturn(Optional.empty()); - when(singleFlightCoordinator.execute(eq(originalForm), eq(LanguageCode.KO), any(), any())) - .thenThrow(new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS)); - - assertThatThrownBy(() -> wordService.getOrCreateWords(userId, inputWord, LanguageCode.KO)) - .isInstanceOf(WordsException.class) - .hasMessageContaining("meaningless"); - } + @Mock + private WordRepository wordRepository; + + @Mock + private WordBookmarkRepository wordBookmarkRepository; + + @Mock + private WordVariantRepository wordVariantRepository; + + @Mock + private WordAiService wordAiService; + + @Mock + private InvalidWordRepository invalidWordRepository; + + @Mock + private WordSingleFlightRedisCoordinator singleFlightCoordinator; + + private WordService wordService; + + private WordPersistenceService wordPersistenceService; + + private Word sampleWord; + + private String userId = "test-user-123"; + + @BeforeEach + void setUp() { + wordPersistenceService = new WordPersistenceService(wordRepository, wordVariantRepository, + invalidWordRepository); + wordService = new WordService(wordRepository, wordBookmarkRepository, wordVariantRepository, + invalidWordRepository, wordAiService, singleFlightCoordinator, wordPersistenceService); + + lenient().when(singleFlightCoordinator.execute(anyString(), any(LanguageCode.class), any(), any())) + .thenAnswer(invocation -> { + Supplier> lookup = invocation.getArgument(3); + Optional existing = lookup.get(); + if (existing.isPresent()) { + return existing.get(); + } + + Supplier supplier = invocation.getArgument(2); + return supplier.get(); + }); + + // 샘플 Word 데이터 생성 + sampleWord = Word.builder() + .id("word-123") + .word("run") + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .summary(List.of("달리다", "운영하다", "작동하다")) + .meanings(List.of( + Meaning.builder() + .partOfSpeech(PartOfSpeech.VERB) + .meaning("달리다") + .example("I {run} every morning.") + .exampleTranslation("나는 매일 아침 달립니다.") + .build(), + Meaning.builder() + .partOfSpeech(PartOfSpeech.VERB) + .meaning("운영하다") + .example("She {runs} a company.") + .exampleTranslation("그녀는 회사를 운영합니다.") + .build())) + .relatedForms(RelatedForms.builder() + .conjugations(RelatedForms.Conjugations.builder() + .present("run") + .past("ran") + .pastParticiple("run") + .presentParticiple("running") + .thirdPerson("runs") + .build()) + .build()) + .build(); + } + + @Test + @DisplayName("DB에 단어가 있으면 바로 반환") + void getOrCreateWords_단어가_DB에_있는_경우() { + // given + WordVariant wordVariant = WordVariant.builder() + .word("run") + .originalForm("run") + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .build(); + + when(wordVariantRepository.findAllByWord("run")).thenReturn(List.of(wordVariant)); + when(wordRepository.findByWordAndTargetLanguageCode("run", LanguageCode.KO)) + .thenReturn(Optional.of(sampleWord)); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(false); + + // when + WordSearchResponse response = wordService.getOrCreateWords(userId, "run", LanguageCode.KO); + + // then + assertThat(response).isNotNull(); + assertThat(response.getSearchedWord()).isEqualTo("run"); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOriginalForm()).isEqualTo("run"); + assertThat(response.getResults().get(0).getBookmarked()).isFalse(); + + // AI 호출 없이 DB에서만 조회되었는지 확인 + verify(wordVariantRepository).findAllByWord("run"); + verify(wordRepository).findByWordAndTargetLanguageCode("run", LanguageCode.KO); + verify(wordAiService, never()).analyzeWord(anyString(), anyString()); + } + + @Test + @DisplayName("DB에 단어가 없으면 AI 호출 후 저장") + void getOrCreateWords_단어가_DB에_없는_경우_AI_호출() { + // given + String newWord = "magnificent"; + + WordAnalysisResult analysisResult = WordAnalysisResult.builder() + .originalForm(newWord) + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .summary(List.of("훌륭한", "장엄한", "멋진")) + .meanings(List.of(Meaning.builder() + .partOfSpeech(PartOfSpeech.ADJECTIVE) + .meaning("훌륭한, 장엄한") + .example("The view is {magnificent}.") + .exampleTranslation("그 경치는 장엄합니다.") + .build())) + .comparatives(RelatedForms.Comparatives.builder() + .positive("magnificent") + .comparative("more magnificent") + .superlative("most magnificent") + .build()) + .build(); + + Word savedWord = Word.builder() + .word(newWord) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .summary(List.of("훌륭한", "장엄한", "멋진")) + .meanings(analysisResult.getMeanings()) + .relatedForms(RelatedForms.builder().comparatives(analysisResult.getComparatives()).build()) + .build(); + + when(wordVariantRepository.findAllByWord(newWord)).thenReturn(List.of()); + when(wordAiService.analyzeWord(newWord, LanguageCode.KO.getCode())).thenReturn(List.of(analysisResult)); + when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode(newWord, LanguageCode.EN, + LanguageCode.KO)) + .thenReturn(Optional.empty()); + when(wordRepository.findByWordAndTargetLanguageCode(newWord, LanguageCode.KO)) + .thenReturn(Optional.of(savedWord)); + when(wordRepository.save(any(Word.class))).thenReturn(savedWord); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, newWord)).thenReturn(false); + when(wordVariantRepository.findByWordIn(anyList())).thenReturn(List.of()); + + // when + WordSearchResponse response = wordService.getOrCreateWords(userId, newWord, LanguageCode.KO); + + // then + assertThat(response).isNotNull(); + assertThat(response.getSearchedWord()).isEqualTo(newWord); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOriginalForm()).isEqualTo(newWord); + + // AI가 호출되었는지 확인 + verify(wordAiService, atLeastOnce()).analyzeWord(newWord, LanguageCode.KO.getCode()); + verify(wordRepository).save(any(Word.class)); + verify(wordVariantRepository).save(any(WordVariant.class)); + } + + @Test + @DisplayName("변형 단어(과거형)를 검색하면 원형 단어 정보를 반환") + void getOrCreateWords_변형_단어_검색() { + // given + String variantWord = "ran"; // run의 과거형 + WordVariant wordVariant = WordVariant.builder() + .word(variantWord) + .originalForm("run") + .variantTypes(List.of(VariantType.PAST_TENSE)) + .build(); + + when(wordVariantRepository.findAllByWord(variantWord)).thenReturn(List.of(wordVariant)); + when(wordRepository.findByWordAndTargetLanguageCode("run", LanguageCode.KO)) + .thenReturn(Optional.of(sampleWord)); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(false); + + // when + WordSearchResponse response = wordService.getOrCreateWords(userId, variantWord, LanguageCode.KO); + + // then + assertThat(response).isNotNull(); + assertThat(response.getSearchedWord()).isEqualTo(variantWord); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOriginalForm()).isEqualTo("run"); + assertThat(response.getResults().get(0).getVariantTypes()).contains(VariantType.PAST_TENSE); + + // AI 호출 없이 variant 테이블과 원형 단어로 해결되었는지 확인 + verify(wordVariantRepository).findAllByWord(variantWord); + verify(wordRepository).findByWordAndTargetLanguageCode("run", LanguageCode.KO); + verify(wordAiService, never()).analyzeWord(anyString(), anyString()); + } + + @Test + @DisplayName("단어 저장 시 모든 변형 형태를 WordVariant에 저장") + void saveWordVariants_모든_변형_형태_저장() { + // given + WordAnalysisResult analysisResult = WordAnalysisResult.builder() + .originalForm(sampleWord.getWord()) + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .sourceLanguageCode(sampleWord.getSourceLanguageCode()) + .targetLanguageCode(sampleWord.getTargetLanguageCode()) + .summary(sampleWord.getSummary()) + .meanings(sampleWord.getMeanings()) + .conjugations(sampleWord.getRelatedForms().getConjugations()) + .build(); + + when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode(sampleWord.getWord(), + sampleWord.getSourceLanguageCode(), sampleWord.getTargetLanguageCode())) + .thenReturn(Optional.empty()); + when(wordRepository.save(any(Word.class))).thenReturn(sampleWord); + when(wordVariantRepository.findByWordIn(anyList())).thenReturn(List.of()); + + // when + wordPersistenceService.saveAnalysisResults("run", List.of(analysisResult), Optional.empty()); + + // then + // 동사 변형 4개가 저장되어야 함 (past, pastParticiple, presentParticiple, thirdPerson) + // "run"은 pastParticiple이 원형과 같으므로 3개만 저장 + verify(wordVariantRepository).saveAll(anyList()); + } + + @Test + @DisplayName("북마크된 단어는 isBookmarked가 true") + void getOrCreateWords_북마크된_단어() { + // given + WordVariant wordVariant = WordVariant.builder() + .word("run") + .originalForm("run") + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .build(); + + when(wordVariantRepository.findAllByWord("run")).thenReturn(List.of(wordVariant)); + when(wordRepository.findByWordAndTargetLanguageCode("run", LanguageCode.KO)) + .thenReturn(Optional.of(sampleWord)); + when(wordBookmarkRepository.existsByUserIdAndWord(userId, "run")).thenReturn(true); + + // when + WordSearchResponse response = wordService.getOrCreateWords(userId, "run", LanguageCode.KO); + + // then + assertThat(response.getResults().get(0).getBookmarked()).isTrue(); + } + + @Test + @DisplayName("single-flight timeout은 무의미 단어로 캐시하지 않고 별도 에러를 반환") + void getOrCreateWords_singleFlightTimeout_doesNotCacheInvalidWord() { + String word = "resilience"; + + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); + when(singleFlightCoordinator.execute(eq(word), eq(LanguageCode.KO), any(), any())) + .thenThrow(new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); + + assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) + .isInstanceOf(WordsException.class) + .hasMessageContaining("temporarily delayed"); + + verify(invalidWordRepository, never()).save(any()); + } + + @Test + @DisplayName("translation-miss 경로의 single-flight timeout도 WORD_ANALYSIS_TIMEOUT으로 변환") + void getOrCreateWords_translationMissTimeout_returnsDomainTimeoutError() { + String inputWord = "ran"; + String originalForm = "run"; + + WordVariant wordVariant = WordVariant.builder() + .word(inputWord) + .originalForm(originalForm) + .variantTypes(List.of(VariantType.PAST_TENSE)) + .build(); + + when(wordVariantRepository.findAllByWord(inputWord)).thenReturn(List.of(wordVariant)); + when(wordRepository.findByWordAndTargetLanguageCode(originalForm, LanguageCode.KO)) + .thenReturn(Optional.empty()); + when(singleFlightCoordinator.execute(eq(originalForm), eq(LanguageCode.KO), any(), any())) + .thenThrow(new WordsException(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); + + assertThatThrownBy(() -> wordService.getOrCreateWords(userId, inputWord, LanguageCode.KO)) + .isInstanceOf(WordsException.class) + .hasMessageContaining("temporarily delayed"); + } + + @Test + @DisplayName("AI 분석 실패는 invalid 캐시에 반영하지 않고 분석 실패 에러로 전파") + void getOrCreateWords_aiAnalysisFailure_doesNotCacheInvalidWord() { + String word = "resilience"; + WordsException failure = new WordsException(WordsErrorCode.WORD_ANALYSIS_FAILED); + + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); + when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())).thenThrow(failure); + + assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)).isSameAs(failure) + .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) + .isEqualTo(WordsErrorCode.WORD_ANALYSIS_FAILED)); + + verify(invalidWordRepository, never()).save(any()); + } + + @Test + @DisplayName("DB 저장 실패는 invalid 캐시에 반영하지 않고 그대로 전파") + void getOrCreateWords_persistenceFailure_doesNotCacheInvalidWord() { + String word = "resilience"; + DataIntegrityViolationException failure = new DataIntegrityViolationException("duplicate variant"); + + WordAnalysisResult analysisResult = WordAnalysisResult.builder() + .originalForm(word) + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .summary(List.of("회복력")) + .meanings(List.of()) + .build(); + + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); + when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())).thenReturn(List.of(analysisResult)); + when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode(word, LanguageCode.EN, + LanguageCode.KO)) + .thenReturn(Optional.empty()); + when(wordRepository.save(any(Word.class))).thenThrow(failure); + + assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)).isSameAs(failure); + + verify(invalidWordRepository, never()).save(any()); + } + + @Test + @DisplayName("single-flight leader의 WORD_IS_MEANINGLESS 예외는 invalid 캐시에 반영") + void getOrCreateWords_singleFlightLeaderMeaninglessException_cachesInvalidWord() { + String word = "asdfqwer"; + + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + when(invalidWordRepository.findByWord(word)).thenReturn(Optional.empty()); + when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())) + .thenThrow(new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS)); + + assertThatThrownBy(() -> wordService.getOrCreateWords(userId, word, LanguageCode.KO)) + .isInstanceOf(WordsException.class) + .hasMessageContaining("meaningless"); + + verify(invalidWordRepository).save(any()); + } + + @Test + @DisplayName("3회 미만 invalid 캐시는 single-flight 사전 조회에서도 재시도를 허용") + void getOrCreateWordEntities_cachedInvalidBelowThreshold_allowsRetryThroughSingleFlightLookup() { + String word = "resilience"; + InvalidWord cachedInvalidWord = InvalidWord.builder().word(word).attemptCount(1).build(); + + WordAnalysisResult analysisResult = WordAnalysisResult.builder() + .originalForm(word) + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .summary(List.of("회복력")) + .meanings(List.of()) + .build(); + + Word savedWord = Word.builder() + .word(word) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .summary(List.of("회복력")) + .meanings(List.of()) + .build(); + + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + when(invalidWordRepository.findByWord(word)).thenReturn(Optional.of(cachedInvalidWord)); + when(wordAiService.analyzeWord(word, LanguageCode.KO.getCode())).thenReturn(List.of(analysisResult)); + when(wordRepository.findByWordAndSourceLanguageCodeAndTargetLanguageCode(word, LanguageCode.EN, + LanguageCode.KO)) + .thenReturn(Optional.empty()); + when(wordRepository.save(any(Word.class))).thenReturn(savedWord); + when(wordVariantRepository.findByWordAndOriginalForm(word, word)).thenReturn(Optional.empty()); + + List variants = wordService.getOrCreateWordEntities(word, LanguageCode.KO); + + assertThat(variants).hasSize(1); + assertThat(variants.get(0).getOriginalForm()).isEqualTo(word); + verify(wordAiService).analyzeWord(word, LanguageCode.KO.getCode()); + verify(invalidWordRepository).delete(cachedInvalidWord); + } + + @Test + @DisplayName("translation-miss 경로에서 follower DB 조회가 도메인 에러를 반환하면 그대로 전파") + void getOrCreateWords_translationMissFollowerLookupFailure_propagatesDomainError() { + String inputWord = "ran"; + String originalForm = "run"; + + WordVariant wordVariant = WordVariant.builder() + .word(inputWord) + .originalForm(originalForm) + .variantTypes(List.of(VariantType.PAST_TENSE)) + .build(); + + when(wordVariantRepository.findAllByWord(inputWord)).thenReturn(List.of(wordVariant)); + when(wordRepository.findByWordAndTargetLanguageCode(originalForm, LanguageCode.KO)) + .thenReturn(Optional.empty()); + when(singleFlightCoordinator.execute(eq(originalForm), eq(LanguageCode.KO), any(), any())) + .thenThrow(new WordsException(WordsErrorCode.WORD_IS_MEANINGLESS)); + + assertThatThrownBy(() -> wordService.getOrCreateWords(userId, inputWord, LanguageCode.KO)) + .isInstanceOf(WordsException.class) + .hasMessageContaining("meaningless"); + } + } diff --git a/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorIntegrationTest.java b/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorIntegrationTest.java index 58fca00f..5764cd1e 100644 --- a/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorIntegrationTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorIntegrationTest.java @@ -35,215 +35,193 @@ class WordSingleFlightRedisCoordinatorIntegrationTest extends AbstractRedisTest { - private CoordinatorFixture nodeA; - private CoordinatorFixture nodeB; - - @BeforeEach - void setUp() { - nodeA = createNode(3_000); - nodeB = createNode(3_000); - flushAll(nodeA.template); - } - - @AfterEach - void tearDown() { - if (nodeA != null) { - nodeA.close(); - } - if (nodeB != null) { - nodeB.close(); - } - } - - @Test - @DisplayName("실제 Redis에서 두 인스턴스 동시 요청 시 AI 호출은 1회만 수행되고 follower는 조회 결과를 반환한다") - void deduplicatesAcrossTwoCoordinatorsUsingRealRedis() throws Exception { - AtomicInteger aiCalls = new AtomicInteger(); - AtomicReference> stored = new AtomicReference<>(); - ExecutorService executor = Executors.newFixedThreadPool(2); - CountDownLatch start = new CountDownLatch(1); - - try { - Future> f1 = executor.submit(() -> { - start.await(1, TimeUnit.SECONDS); - return nodeA.coordinator.execute( - "run", - LanguageCode.KO, - () -> { - aiCalls.incrementAndGet(); - sleep(250); - List result = List.of(sample("run")); - stored.set(result); - return result; - }, - () -> Optional.ofNullable(stored.get()) - ); - }); - - Future> f2 = executor.submit(() -> { - start.await(1, TimeUnit.SECONDS); - return nodeB.coordinator.execute( - "run", - LanguageCode.KO, - () -> { - aiCalls.incrementAndGet(); - List result = List.of(sample("run")); - stored.set(result); - return result; - }, - () -> Optional.ofNullable(stored.get()) - ); - }); - - start.countDown(); - - List r1 = f1.get(5, TimeUnit.SECONDS); - List r2 = f2.get(5, TimeUnit.SECONDS); - - assertThat(r1).hasSize(1); - assertThat(r2).hasSize(1); - assertThat(r1.get(0).getOriginalForm()).isEqualTo("run"); - assertThat(r2.get(0).getOriginalForm()).isEqualTo("run"); - assertThat(aiCalls.get()).isEqualTo(1); - } finally { - executor.shutdownNow(); - } - } - - @Test - @DisplayName("leader 실패 후 저장 결과가 없으면 follower는 timeout으로 실패한다") - void followerTimesOutWhenLeaderFailsWithoutStoredResultUsingRealRedis() throws Exception { - RuntimeException leaderFailure = new RuntimeException("bedrock unavailable"); - AtomicInteger aiCalls = new AtomicInteger(); - ExecutorService executor = Executors.newFixedThreadPool(2); - CountDownLatch leaderEntered = new CountDownLatch(1); - - try { - Future> leader = executor.submit(() -> - nodeA.coordinator.execute( - "left", - LanguageCode.KO, - () -> { - aiCalls.incrementAndGet(); - leaderEntered.countDown(); - sleep(250); - throw leaderFailure; - }, - Optional::empty - ) - ); - - assertThat(leaderEntered.await(2, TimeUnit.SECONDS)).isTrue(); - - Future> follower = executor.submit(() -> - nodeB.coordinator.execute( - "left", - LanguageCode.KO, - () -> { - aiCalls.incrementAndGet(); - return List.of(sample("left")); - }, - Optional::empty - ) - ); - - assertThatThrownBy(() -> leader.get(5, TimeUnit.SECONDS)) - .hasCause(leaderFailure); - assertThatThrownBy(() -> follower.get(5, TimeUnit.SECONDS)) - .hasCauseInstanceOf(WordsException.class); - assertThat(aiCalls.get()).isEqualTo(1); - } finally { - executor.shutdownNow(); - } - } - - private CoordinatorFixture createNode(long waitTimeoutMs) { - GenericContainer redis = getRedisContainer(); - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redis.getHost(), redis.getMappedPort(6379)); - - JedisConnectionFactory connectionFactory = new JedisConnectionFactory(config); - connectionFactory.afterPropertiesSet(); - - StringRedisTemplate template = new StringRedisTemplate(); - template.setConnectionFactory(connectionFactory); - template.afterPropertiesSet(); - - RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer(); - listenerContainer.setConnectionFactory(connectionFactory); - listenerContainer.afterPropertiesSet(); - listenerContainer.start(); - - Config redissonConfig = new Config(); - redissonConfig.useSingleServer() - .setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(6379)); - RedissonClient redissonClient = Redisson.create(redissonConfig); - - WordSingleFlightProperties properties = new WordSingleFlightProperties(); - properties.setEnabled(true); - properties.setWaitTimeoutMs(waitTimeoutMs); - properties.setResultSchemaVersion("v2"); - - WordSingleFlightRedisCoordinator coordinator = new WordSingleFlightRedisCoordinator( - template, - listenerContainer, - redissonClient, - properties - ); - ReflectionTestUtils.invokeMethod(coordinator, "initialize"); - - return new CoordinatorFixture(connectionFactory, template, listenerContainer, redissonClient, coordinator); - } - - private void flushAll(StringRedisTemplate template) { - RedisConnection connection = template.getConnectionFactory().getConnection(); - try { - connection.serverCommands().flushAll(); - } finally { - connection.close(); - } - } - - private WordAnalysisResult sample(String originalForm) { - return WordAnalysisResult.builder() - .originalForm(originalForm) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .build(); - } - - private void sleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - } - - private record CoordinatorFixture( - JedisConnectionFactory connectionFactory, - StringRedisTemplate template, - RedisMessageListenerContainer listenerContainer, - RedissonClient redissonClient, - WordSingleFlightRedisCoordinator coordinator - ) { - void close() { - try { - ReflectionTestUtils.invokeMethod(coordinator, "shutdown"); - } catch (Exception ignored) { - } - try { - listenerContainer.stop(); - } catch (Exception ignored) { - } - try { - redissonClient.shutdown(); - } catch (Exception ignored) { - } - try { - connectionFactory.destroy(); - } catch (Exception ignored) { - } - } - } + private CoordinatorFixture nodeA; + + private CoordinatorFixture nodeB; + + @BeforeEach + void setUp() { + nodeA = createNode(3_000); + nodeB = createNode(3_000); + flushAll(nodeA.template); + } + + @AfterEach + void tearDown() { + if (nodeA != null) { + nodeA.close(); + } + if (nodeB != null) { + nodeB.close(); + } + } + + @Test + @DisplayName("실제 Redis에서 두 인스턴스 동시 요청 시 AI 호출은 1회만 수행되고 follower는 조회 결과를 반환한다") + void deduplicatesAcrossTwoCoordinatorsUsingRealRedis() throws Exception { + AtomicInteger aiCalls = new AtomicInteger(); + AtomicReference> stored = new AtomicReference<>(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + + try { + Future> f1 = executor.submit(() -> { + start.await(1, TimeUnit.SECONDS); + return nodeA.coordinator.execute("run", LanguageCode.KO, () -> { + aiCalls.incrementAndGet(); + sleep(250); + List result = List.of(sample("run")); + stored.set(result); + return result; + }, () -> Optional.ofNullable(stored.get())); + }); + + Future> f2 = executor.submit(() -> { + start.await(1, TimeUnit.SECONDS); + return nodeB.coordinator.execute("run", LanguageCode.KO, () -> { + aiCalls.incrementAndGet(); + List result = List.of(sample("run")); + stored.set(result); + return result; + }, () -> Optional.ofNullable(stored.get())); + }); + + start.countDown(); + + List r1 = f1.get(5, TimeUnit.SECONDS); + List r2 = f2.get(5, TimeUnit.SECONDS); + + assertThat(r1).hasSize(1); + assertThat(r2).hasSize(1); + assertThat(r1.get(0).getOriginalForm()).isEqualTo("run"); + assertThat(r2.get(0).getOriginalForm()).isEqualTo("run"); + assertThat(aiCalls.get()).isEqualTo(1); + } + finally { + executor.shutdownNow(); + } + } + + @Test + @DisplayName("leader 실패 후 저장 결과가 없으면 follower는 timeout으로 실패한다") + void followerTimesOutWhenLeaderFailsWithoutStoredResultUsingRealRedis() throws Exception { + RuntimeException leaderFailure = new RuntimeException("bedrock unavailable"); + AtomicInteger aiCalls = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch leaderEntered = new CountDownLatch(1); + + try { + Future> leader = executor + .submit(() -> nodeA.coordinator.execute("left", LanguageCode.KO, () -> { + aiCalls.incrementAndGet(); + leaderEntered.countDown(); + sleep(250); + throw leaderFailure; + }, Optional::empty)); + + assertThat(leaderEntered.await(2, TimeUnit.SECONDS)).isTrue(); + + Future> follower = executor + .submit(() -> nodeB.coordinator.execute("left", LanguageCode.KO, () -> { + aiCalls.incrementAndGet(); + return List.of(sample("left")); + }, Optional::empty)); + + assertThatThrownBy(() -> leader.get(5, TimeUnit.SECONDS)).hasCause(leaderFailure); + assertThatThrownBy(() -> follower.get(5, TimeUnit.SECONDS)).hasCauseInstanceOf(WordsException.class); + assertThat(aiCalls.get()).isEqualTo(1); + } + finally { + executor.shutdownNow(); + } + } + + private CoordinatorFixture createNode(long waitTimeoutMs) { + GenericContainer redis = getRedisContainer(); + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redis.getHost(), + redis.getMappedPort(6379)); + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(config); + connectionFactory.afterPropertiesSet(); + + StringRedisTemplate template = new StringRedisTemplate(); + template.setConnectionFactory(connectionFactory); + template.afterPropertiesSet(); + + RedisMessageListenerContainer listenerContainer = new RedisMessageListenerContainer(); + listenerContainer.setConnectionFactory(connectionFactory); + listenerContainer.afterPropertiesSet(); + listenerContainer.start(); + + Config redissonConfig = new Config(); + redissonConfig.useSingleServer().setAddress("redis://" + redis.getHost() + ":" + redis.getMappedPort(6379)); + RedissonClient redissonClient = Redisson.create(redissonConfig); + + WordSingleFlightProperties properties = new WordSingleFlightProperties(); + properties.setEnabled(true); + properties.setWaitTimeoutMs(waitTimeoutMs); + properties.setResultSchemaVersion("v2"); + + WordSingleFlightRedisCoordinator coordinator = new WordSingleFlightRedisCoordinator(template, listenerContainer, + redissonClient, properties); + ReflectionTestUtils.invokeMethod(coordinator, "initialize"); + + return new CoordinatorFixture(connectionFactory, template, listenerContainer, redissonClient, coordinator); + } + + private void flushAll(StringRedisTemplate template) { + RedisConnection connection = template.getConnectionFactory().getConnection(); + try { + connection.serverCommands().flushAll(); + } + finally { + connection.close(); + } + } + + private WordAnalysisResult sample(String originalForm) { + return WordAnalysisResult.builder() + .originalForm(originalForm) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .build(); + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private record CoordinatorFixture(JedisConnectionFactory connectionFactory, StringRedisTemplate template, + RedisMessageListenerContainer listenerContainer, RedissonClient redissonClient, + WordSingleFlightRedisCoordinator coordinator) { + void close() { + try { + ReflectionTestUtils.invokeMethod(coordinator, "shutdown"); + } + catch (Exception ignored) { + } + try { + listenerContainer.stop(); + } + catch (Exception ignored) { + } + try { + redissonClient.shutdown(); + } + catch (Exception ignored) { + } + try { + connectionFactory.destroy(); + } + catch (Exception ignored) { + } + } + } + } diff --git a/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorTest.java b/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorTest.java index a52d2a08..01c33c71 100644 --- a/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordSingleFlightRedisCoordinatorTest.java @@ -43,291 +43,236 @@ @MockitoSettings(strictness = Strictness.LENIENT) class WordSingleFlightRedisCoordinatorTest { - @Mock - private StringRedisTemplate stringRedisTemplate; - - @Mock - private RedisMessageListenerContainer redisMessageListenerContainer; - - @Mock - private RedissonClient redissonClient; - - @Mock - private RLock redissonLock; - - private WordSingleFlightProperties properties; - private WordSingleFlightRedisCoordinator coordinator; - - @BeforeEach - void setUp() { - properties = new WordSingleFlightProperties(); - properties.setEnabled(true); - properties.setWaitTimeoutMs(120); - properties.setResultSchemaVersion("v2"); - - when(redissonClient.getLock(anyString())).thenReturn(redissonLock); - when(redissonLock.isHeldByCurrentThread()).thenReturn(true); - - coordinator = new WordSingleFlightRedisCoordinator( - stringRedisTemplate, - redisMessageListenerContainer, - redissonClient, - properties - ); - ReflectionTestUtils.invokeMethod(coordinator, "initialize"); - } - - @Test - @DisplayName("동일 키 동시 요청은 leader action을 한 번만 실행하고 follower는 조회 함수 결과를 반환한다") - void execute_deduplicatesConcurrentRequests() throws Exception { - stubTryLock(true, false); - - AtomicInteger aiCalls = new AtomicInteger(); - AtomicReference> stored = new AtomicReference<>(); - WordAnalysisResult sample = sample("run"); - - CountDownLatch start = new CountDownLatch(1); - ExecutorService executor = Executors.newFixedThreadPool(2); - - try { - Future> f1 = executor.submit(() -> { - start.await(1, TimeUnit.SECONDS); - return coordinator.execute( - "run", - LanguageCode.KO, - () -> { - aiCalls.incrementAndGet(); - sleep(50); - List result = List.of(sample); - stored.set(result); - return result; - }, - () -> Optional.ofNullable(stored.get()) - ); - }); - - Future> f2 = executor.submit(() -> { - start.await(1, TimeUnit.SECONDS); - return coordinator.execute( - "run", - LanguageCode.KO, - () -> { - aiCalls.incrementAndGet(); - List result = List.of(sample); - stored.set(result); - return result; - }, - () -> Optional.ofNullable(stored.get()) - ); - }); - - start.countDown(); - - List r1 = f1.get(2, TimeUnit.SECONDS); - List r2 = f2.get(2, TimeUnit.SECONDS); - - assertThat(r1).hasSize(1); - assertThat(r2).hasSize(1); - assertThat(aiCalls.get()).isEqualTo(1); - } finally { - executor.shutdownNow(); - } - } - - @Test - @DisplayName("알림 유실 상황에서도 timeout 이후 조회 함수로 DB 결과를 반환한다") - void execute_fallbacksToLookupAfterTimeout() { - stubTryLock(false); - - WordAnalysisResult sample = sample("book"); - AtomicInteger lookupCalls = new AtomicInteger(); - - List result = coordinator.execute( - "book", - LanguageCode.KO, - () -> { - throw new IllegalStateException("follower path should not run leader action"); - }, - () -> { - lookupCalls.incrementAndGet(); - return Optional.of(List.of(sample)); - } - ); - - assertThat(result).hasSize(1); - assertThat(result.get(0).getOriginalForm()).isEqualTo("book"); - assertThat(lookupCalls.get()).isEqualTo(1); - } - - @Test - @DisplayName("leader 실패 후 DB 결과가 없으면 follower는 timeout으로 실패한다") - void execute_followerTimesOutWhenLeaderFailsWithoutStoredResult() { - stubTryLock(true, false); - - RuntimeException failure = new RuntimeException("bedrock failure"); - - assertThatThrownBy(() -> - coordinator.execute( - "left", - LanguageCode.KO, - () -> { - throw failure; - }, - Optional::empty - ) - ).isSameAs(failure); - - assertThatThrownBy(() -> - coordinator.execute( - "left", - LanguageCode.KO, - () -> { - throw new IllegalStateException("follower path should not run leader action"); - }, - Optional::empty - ) - ).isInstanceOf(WordsException.class) - .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) - .isEqualTo(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); - } - - @Test - @DisplayName("follower는 leader lock이 유지되는 동안 DB 조회 함수를 실행하지 않는다") - void execute_doesNotRunFollowerLookupWhileLeaderLockIsHeld() { - stubTryLock(false); - - AtomicInteger lookupCalls = new AtomicInteger(); - - assertThatThrownBy(() -> - coordinator.execute( - "saw", - LanguageCode.KO, - () -> { - throw new IllegalStateException("follower path should not run leader action"); - }, - () -> { - lookupCalls.incrementAndGet(); - return Optional.empty(); - } - ) - ).isInstanceOf(WordsException.class) - .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) - .isEqualTo(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); - - assertThat(lookupCalls.get()).isEqualTo(1); - } - - @Test - @DisplayName("follower 조회 함수의 예외는 그대로 전파된다") - void execute_propagatesFollowerLookupException() { - stubTryLock(false); - - IllegalStateException failure = new IllegalStateException("db lookup failed"); - - assertThatThrownBy(() -> - coordinator.execute( - "typooo", - LanguageCode.KO, - () -> { - throw new IllegalStateException("follower path should not run leader action"); - }, - () -> { - throw failure; - } - ) - ).isSameAs(failure); - } - - @Test - @DisplayName("leader 완료 시 lock을 해제한 뒤 done을 발행한다") - void execute_releasesLeaderLockBeforePublishingDone() { - stubTryLock(true); - - List result = coordinator.execute( - "run", - LanguageCode.KO, - () -> List.of(sample("run")), - Optional::empty - ); - - assertThat(result).hasSize(1); - InOrder inOrder = inOrder(redissonLock, stringRedisTemplate); - inOrder.verify(redissonLock).unlock(); - inOrder.verify(stringRedisTemplate).convertAndSend(anyString(), anyString()); - } - - @Test - @DisplayName("done publish가 실패해도 leader lock은 먼저 해제되어 있다") - void execute_releasesLeaderLockBeforePublishFailure() { - stubTryLock(true); - - RuntimeException publishFailure = new RuntimeException("redis publish failed"); - doThrow(publishFailure).when(stringRedisTemplate).convertAndSend(anyString(), anyString()); - - assertThatThrownBy(() -> - coordinator.execute( - "run", - LanguageCode.KO, - () -> List.of(sample("run")), - Optional::empty - ) - ).isSameAs(publishFailure); - - InOrder inOrder = inOrder(redissonLock, stringRedisTemplate); - inOrder.verify(redissonLock).unlock(); - inOrder.verify(stringRedisTemplate).convertAndSend(anyString(), anyString()); - } - - @Test - @DisplayName("lock holder가 기존 결과를 발견하면 대기 중인 follower를 깨우도록 done을 발행한다") - void execute_publishesDoneWhenLockHolderFindsExistingResult() { - stubTryLock(true); - - WordAnalysisResult sample = sample("run"); - - List result = coordinator.execute( - "run", - LanguageCode.KO, - () -> { - throw new IllegalStateException("leader action should not run when result already exists"); - }, - () -> Optional.of(List.of(sample)) - ); - - assertThat(result).hasSize(1); - InOrder inOrder = inOrder(redissonLock, stringRedisTemplate); - inOrder.verify(redissonLock).unlock(); - inOrder.verify(stringRedisTemplate).convertAndSend(anyString(), anyString()); - } - - private WordAnalysisResult sample(String originalForm) { - return WordAnalysisResult.builder() - .originalForm(originalForm) - .sourceLanguageCode(LanguageCode.EN) - .targetLanguageCode(LanguageCode.KO) - .build(); - } - private void sleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - } - - private void stubTryLock(boolean first, boolean... others) { - Boolean[] sequence = new Boolean[others.length + 1]; - sequence[0] = first; - for (int i = 0; i < others.length; i++) { - sequence[i + 1] = others[i]; - } - - try { - when(redissonLock.tryLock(0, TimeUnit.MILLISECONDS)) - .thenReturn(sequence[0], Arrays.copyOfRange(sequence, 1, sequence.length)); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Mock + private RedisMessageListenerContainer redisMessageListenerContainer; + + @Mock + private RedissonClient redissonClient; + + @Mock + private RLock redissonLock; + + private WordSingleFlightProperties properties; + + private WordSingleFlightRedisCoordinator coordinator; + + @BeforeEach + void setUp() { + properties = new WordSingleFlightProperties(); + properties.setEnabled(true); + properties.setWaitTimeoutMs(120); + properties.setResultSchemaVersion("v2"); + + when(redissonClient.getLock(anyString())).thenReturn(redissonLock); + when(redissonLock.isHeldByCurrentThread()).thenReturn(true); + + coordinator = new WordSingleFlightRedisCoordinator(stringRedisTemplate, redisMessageListenerContainer, + redissonClient, properties); + ReflectionTestUtils.invokeMethod(coordinator, "initialize"); + } + + @Test + @DisplayName("동일 키 동시 요청은 leader action을 한 번만 실행하고 follower는 조회 함수 결과를 반환한다") + void execute_deduplicatesConcurrentRequests() throws Exception { + stubTryLock(true, false); + + AtomicInteger aiCalls = new AtomicInteger(); + AtomicReference> stored = new AtomicReference<>(); + WordAnalysisResult sample = sample("run"); + + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + + try { + Future> f1 = executor.submit(() -> { + start.await(1, TimeUnit.SECONDS); + return coordinator.execute("run", LanguageCode.KO, () -> { + aiCalls.incrementAndGet(); + sleep(50); + List result = List.of(sample); + stored.set(result); + return result; + }, () -> Optional.ofNullable(stored.get())); + }); + + Future> f2 = executor.submit(() -> { + start.await(1, TimeUnit.SECONDS); + return coordinator.execute("run", LanguageCode.KO, () -> { + aiCalls.incrementAndGet(); + List result = List.of(sample); + stored.set(result); + return result; + }, () -> Optional.ofNullable(stored.get())); + }); + + start.countDown(); + + List r1 = f1.get(2, TimeUnit.SECONDS); + List r2 = f2.get(2, TimeUnit.SECONDS); + + assertThat(r1).hasSize(1); + assertThat(r2).hasSize(1); + assertThat(aiCalls.get()).isEqualTo(1); + } + finally { + executor.shutdownNow(); + } + } + + @Test + @DisplayName("알림 유실 상황에서도 timeout 이후 조회 함수로 DB 결과를 반환한다") + void execute_fallbacksToLookupAfterTimeout() { + stubTryLock(false); + + WordAnalysisResult sample = sample("book"); + AtomicInteger lookupCalls = new AtomicInteger(); + + List result = coordinator.execute("book", LanguageCode.KO, () -> { + throw new IllegalStateException("follower path should not run leader action"); + }, () -> { + lookupCalls.incrementAndGet(); + return Optional.of(List.of(sample)); + }); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getOriginalForm()).isEqualTo("book"); + assertThat(lookupCalls.get()).isEqualTo(1); + } + + @Test + @DisplayName("leader 실패 후 DB 결과가 없으면 follower는 timeout으로 실패한다") + void execute_followerTimesOutWhenLeaderFailsWithoutStoredResult() { + stubTryLock(true, false); + + RuntimeException failure = new RuntimeException("bedrock failure"); + + assertThatThrownBy(() -> coordinator.execute("left", LanguageCode.KO, () -> { + throw failure; + }, Optional::empty)).isSameAs(failure); + + assertThatThrownBy(() -> coordinator.execute("left", LanguageCode.KO, () -> { + throw new IllegalStateException("follower path should not run leader action"); + }, Optional::empty)).isInstanceOf(WordsException.class) + .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) + .isEqualTo(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); + } + + @Test + @DisplayName("follower는 leader lock이 유지되는 동안 DB 조회 함수를 실행하지 않는다") + void execute_doesNotRunFollowerLookupWhileLeaderLockIsHeld() { + stubTryLock(false); + + AtomicInteger lookupCalls = new AtomicInteger(); + + assertThatThrownBy(() -> coordinator.execute("saw", LanguageCode.KO, () -> { + throw new IllegalStateException("follower path should not run leader action"); + }, () -> { + lookupCalls.incrementAndGet(); + return Optional.empty(); + })).isInstanceOf(WordsException.class) + .satisfies(ex -> assertThat(((WordsException) ex).getErrorCode()) + .isEqualTo(WordsErrorCode.WORD_ANALYSIS_TIMEOUT)); + + assertThat(lookupCalls.get()).isEqualTo(1); + } + + @Test + @DisplayName("follower 조회 함수의 예외는 그대로 전파된다") + void execute_propagatesFollowerLookupException() { + stubTryLock(false); + + IllegalStateException failure = new IllegalStateException("db lookup failed"); + + assertThatThrownBy(() -> coordinator.execute("typooo", LanguageCode.KO, () -> { + throw new IllegalStateException("follower path should not run leader action"); + }, () -> { + throw failure; + })).isSameAs(failure); + } + + @Test + @DisplayName("leader 완료 시 lock을 해제한 뒤 done을 발행한다") + void execute_releasesLeaderLockBeforePublishingDone() { + stubTryLock(true); + + List result = coordinator.execute("run", LanguageCode.KO, () -> List.of(sample("run")), + Optional::empty); + + assertThat(result).hasSize(1); + InOrder inOrder = inOrder(redissonLock, stringRedisTemplate); + inOrder.verify(redissonLock).unlock(); + inOrder.verify(stringRedisTemplate).convertAndSend(anyString(), anyString()); + } + + @Test + @DisplayName("done publish가 실패해도 leader lock은 먼저 해제되어 있다") + void execute_releasesLeaderLockBeforePublishFailure() { + stubTryLock(true); + + RuntimeException publishFailure = new RuntimeException("redis publish failed"); + doThrow(publishFailure).when(stringRedisTemplate).convertAndSend(anyString(), anyString()); + + assertThatThrownBy( + () -> coordinator.execute("run", LanguageCode.KO, () -> List.of(sample("run")), Optional::empty)) + .isSameAs(publishFailure); + + InOrder inOrder = inOrder(redissonLock, stringRedisTemplate); + inOrder.verify(redissonLock).unlock(); + inOrder.verify(stringRedisTemplate).convertAndSend(anyString(), anyString()); + } + + @Test + @DisplayName("lock holder가 기존 결과를 발견하면 대기 중인 follower를 깨우도록 done을 발행한다") + void execute_publishesDoneWhenLockHolderFindsExistingResult() { + stubTryLock(true); + + WordAnalysisResult sample = sample("run"); + + List result = coordinator.execute("run", LanguageCode.KO, () -> { + throw new IllegalStateException("leader action should not run when result already exists"); + }, () -> Optional.of(List.of(sample))); + + assertThat(result).hasSize(1); + InOrder inOrder = inOrder(redissonLock, stringRedisTemplate); + inOrder.verify(redissonLock).unlock(); + inOrder.verify(stringRedisTemplate).convertAndSend(anyString(), anyString()); + } + + private WordAnalysisResult sample(String originalForm) { + return WordAnalysisResult.builder() + .originalForm(originalForm) + .sourceLanguageCode(LanguageCode.EN) + .targetLanguageCode(LanguageCode.KO) + .build(); + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private void stubTryLock(boolean first, boolean... others) { + Boolean[] sequence = new Boolean[others.length + 1]; + sequence[0] = first; + for (int i = 0; i < others.length; i++) { + sequence[i + 1] = others[i]; + } + + try { + when(redissonLock.tryLock(0, TimeUnit.MILLISECONDS)).thenReturn(sequence[0], + Arrays.copyOfRange(sequence, 1, sequence.length)); + } + catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } diff --git a/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java b/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java index eb91d66c..17530ff7 100644 --- a/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java +++ b/src/test/java/com/linglevel/api/word/service/WordVariantServiceTest.java @@ -18,48 +18,48 @@ @ExtendWith(MockitoExtension.class) class WordVariantServiceTest { - @Mock - private WordVariantRepository wordVariantRepository; + @Mock + private WordVariantRepository wordVariantRepository; - @InjectMocks - private WordVariantService wordVariantService; + @InjectMocks + private WordVariantService wordVariantService; - @Test - @DisplayName("기존 WordVariant가 여러 개 있으면 모두 원형 후보로 반환한다") - void getOriginalForms_existingVariants_returnsAllOriginalForms() { - // given - String word = "saw"; - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of( - WordVariant.builder() - .word(word) - .originalForm("see") - .variantTypes(List.of(VariantType.PAST_TENSE)) - .build(), - WordVariant.builder() - .word(word) - .originalForm("saw") - .variantTypes(List.of(VariantType.ORIGINAL_FORM)) - .build() - )); + @Test + @DisplayName("기존 WordVariant가 여러 개 있으면 모두 원형 후보로 반환한다") + void getOriginalForms_existingVariants_returnsAllOriginalForms() { + // given + String word = "saw"; + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of( + WordVariant.builder() + .word(word) + .originalForm("see") + .variantTypes(List.of(VariantType.PAST_TENSE)) + .build(), + WordVariant.builder() + .word(word) + .originalForm("saw") + .variantTypes(List.of(VariantType.ORIGINAL_FORM)) + .build())); - // when - List originalForms = wordVariantService.getOriginalForms(word); + // when + List originalForms = wordVariantService.getOriginalForms(word); - // then - assertThat(originalForms).containsExactly("see", "saw"); - } + // then + assertThat(originalForms).containsExactly("see", "saw"); + } - @Test - @DisplayName("기존 WordVariant가 없으면 빈 원형 후보 목록을 반환한다") - void getOriginalForms_noExistingVariants_returnsEmptyList() { - // given - String word = "saw"; - when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); + @Test + @DisplayName("기존 WordVariant가 없으면 빈 원형 후보 목록을 반환한다") + void getOriginalForms_noExistingVariants_returnsEmptyList() { + // given + String word = "saw"; + when(wordVariantRepository.findAllByWord(word)).thenReturn(List.of()); - // when - List originalForms = wordVariantService.getOriginalForms(word); + // when + List originalForms = wordVariantService.getOriginalForms(word); + + // then + assertThat(originalForms).isEmpty(); + } - // then - assertThat(originalForms).isEmpty(); - } } diff --git a/src/test/java/com/linglevel/api/word/validator/WordValidatorTest.java b/src/test/java/com/linglevel/api/word/validator/WordValidatorTest.java index e3523abe..62109c35 100644 --- a/src/test/java/com/linglevel/api/word/validator/WordValidatorTest.java +++ b/src/test/java/com/linglevel/api/word/validator/WordValidatorTest.java @@ -13,287 +13,277 @@ class WordValidatorTest { - private WordValidator wordValidator; - - @BeforeEach - void setUp() { - wordValidator = new WordValidator(); - } - - @Test - @DisplayName("정상적인 영어 단어는 소문자로 변환하여 반환") - void validateAndPreprocess_정상적인_영어_단어() { - // given - String word = "Hello"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("hello"); - } - - @Test - @DisplayName("정상적인 한글 단어는 그대로 반환") - void validateAndPreprocess_정상적인_한글_단어() { - // given - String word = "안녕하세요"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("안녕하세요"); - } - - @Test - @DisplayName("정상적인 일본어 단어는 그대로 반환") - void validateAndPreprocess_정상적인_일본어_단어() { - // given - String word = "こんにちは"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("こんにちは"); - } - - @Test - @DisplayName("정상적인 중국어 단어는 그대로 반환") - void validateAndPreprocess_정상적인_중국어_단어() { - // given - String word = "你好"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("你好"); - } - - @Test - @DisplayName("숫자가 포함된 단어는 허용") - void validateAndPreprocess_숫자가_포함된_단어() { - // given - String word = "word123"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("word123"); - } - - @Test - @DisplayName("앞뒤 특수문자는 제거됨") - void validateAndPreprocess_앞뒤_특수문자_제거() { - // given - String word = "!!!Hello???"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("hello"); - } - - @ParameterizedTest - @ValueSource(strings = {"'word'", "\"word\"", "...word...", "(word)", "[word]", "{word}"}) - @DisplayName("다양한 특수문자로 둘러싸인 단어는 전처리 후 반환") - void validateAndPreprocess_다양한_특수문자_제거(String word) { - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("word"); - } - - @Test - @DisplayName("대문자는 소문자로 변환") - void validateAndPreprocess_대문자_소문자_변환() { - // given - String word = "HELLO"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("hello"); - } - - @Test - @DisplayName("혼합된 대소문자는 모두 소문자로 변환") - void validateAndPreprocess_혼합_대소문자_변환() { - // given - String word = "HeLLo"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("hello"); - } - - @Test - @DisplayName("앞뒤 특수문자 제거 + 대소문자 변환이 함께 적용") - void validateAndPreprocess_전처리_종합() { - // given - String word = "!!!HELLO???"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo("hello"); - } - - @Test - @DisplayName("null 입력시 예외 발생") - void validateAndPreprocess_null_입력() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess(null)) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("빈 문자열 입력시 예외 발생") - void validateAndPreprocess_빈_문자열() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess("")) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("특수문자만 있는 경우 예외 발생") - void validateAndPreprocess_특수문자만_있음() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess("!!!???")) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("단어 내부에 공백이 있으면 예외 발생") - void validateAndPreprocess_내부_공백() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello world")) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("단어 내부에 탭이 있으면 예외 발생") - void validateAndPreprocess_내부_탭() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello\tworld")) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("단어 내부에 엔터가 있으면 예외 발생") - void validateAndPreprocess_내부_엔터() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello\nworld")) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("단어 내부에 특수문자가 있으면 예외 발생") - void validateAndPreprocess_내부_특수문자() { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello-world")) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @ParameterizedTest - @ValueSource(strings = {"hello@world", "hello#world", "hello$world", "hello%world"}) - @DisplayName("다양한 특수문자가 내부에 있으면 예외 발생") - void validateAndPreprocess_다양한_내부_특수문자(String word) { - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); - } - - @Test - @DisplayName("정확히 50자인 단어는 허용") - void validateAndPreprocess_50자_단어_허용() { - // given - String word = "a".repeat(50); - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).isEqualTo(word); - assertThat(result).hasSize(50); - } - - @Test - @DisplayName("50자를 초과하는 단어는 예외 발생") - void validateAndPreprocess_50자_초과() { - // given - String word = "a".repeat(51); - - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.WORD_TOO_LONG.getMessage()); - } - - @Test - @DisplayName("전처리 후 50자를 초과하면 예외 발생하지 않음 - 전처리 전 길이가 51자, 전처리 후 50자") - void validateAndPreprocess_전처리_후_50자() { - // given - 앞뒤 특수문자 1개씩 포함하여 총 52자 - String word = "!" + "a".repeat(50) + "!"; - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).hasSize(50); - } - - @Test - @DisplayName("전처리 후 50자를 초과하면 예외 발생") - void validateAndPreprocess_전처리_후_50자_초과() { - // given - 앞뒤 특수문자 1개씩 포함하여 총 53자 - String word = "!" + "a".repeat(51) + "!"; - - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.WORD_TOO_LONG.getMessage()); - } - - @Test - @DisplayName("한글 50자는 허용") - void validateAndPreprocess_한글_50자() { - // given - String word = "가".repeat(50); - - // when - String result = wordValidator.validateAndPreprocess(word); - - // then - assertThat(result).hasSize(50); - } - - @Test - @DisplayName("한글 51자는 예외 발생") - void validateAndPreprocess_한글_51자() { - // given - String word = "가".repeat(51); - - // when & then - assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)) - .isInstanceOf(WordsException.class) - .hasMessage(WordsErrorCode.WORD_TOO_LONG.getMessage()); - } + private WordValidator wordValidator; + + @BeforeEach + void setUp() { + wordValidator = new WordValidator(); + } + + @Test + @DisplayName("정상적인 영어 단어는 소문자로 변환하여 반환") + void validateAndPreprocess_정상적인_영어_단어() { + // given + String word = "Hello"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("hello"); + } + + @Test + @DisplayName("정상적인 한글 단어는 그대로 반환") + void validateAndPreprocess_정상적인_한글_단어() { + // given + String word = "안녕하세요"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("안녕하세요"); + } + + @Test + @DisplayName("정상적인 일본어 단어는 그대로 반환") + void validateAndPreprocess_정상적인_일본어_단어() { + // given + String word = "こんにちは"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("こんにちは"); + } + + @Test + @DisplayName("정상적인 중국어 단어는 그대로 반환") + void validateAndPreprocess_정상적인_중국어_단어() { + // given + String word = "你好"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("你好"); + } + + @Test + @DisplayName("숫자가 포함된 단어는 허용") + void validateAndPreprocess_숫자가_포함된_단어() { + // given + String word = "word123"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("word123"); + } + + @Test + @DisplayName("앞뒤 특수문자는 제거됨") + void validateAndPreprocess_앞뒤_특수문자_제거() { + // given + String word = "!!!Hello???"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("hello"); + } + + @ParameterizedTest + @ValueSource(strings = { "'word'", "\"word\"", "...word...", "(word)", "[word]", "{word}" }) + @DisplayName("다양한 특수문자로 둘러싸인 단어는 전처리 후 반환") + void validateAndPreprocess_다양한_특수문자_제거(String word) { + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("word"); + } + + @Test + @DisplayName("대문자는 소문자로 변환") + void validateAndPreprocess_대문자_소문자_변환() { + // given + String word = "HELLO"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("hello"); + } + + @Test + @DisplayName("혼합된 대소문자는 모두 소문자로 변환") + void validateAndPreprocess_혼합_대소문자_변환() { + // given + String word = "HeLLo"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("hello"); + } + + @Test + @DisplayName("앞뒤 특수문자 제거 + 대소문자 변환이 함께 적용") + void validateAndPreprocess_전처리_종합() { + // given + String word = "!!!HELLO???"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo("hello"); + } + + @Test + @DisplayName("null 입력시 예외 발생") + void validateAndPreprocess_null_입력() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess(null)).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("빈 문자열 입력시 예외 발생") + void validateAndPreprocess_빈_문자열() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess("")).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("특수문자만 있는 경우 예외 발생") + void validateAndPreprocess_특수문자만_있음() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess("!!!???")).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("단어 내부에 공백이 있으면 예외 발생") + void validateAndPreprocess_내부_공백() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello world")).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("단어 내부에 탭이 있으면 예외 발생") + void validateAndPreprocess_내부_탭() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello\tworld")).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("단어 내부에 엔터가 있으면 예외 발생") + void validateAndPreprocess_내부_엔터() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello\nworld")).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("단어 내부에 특수문자가 있으면 예외 발생") + void validateAndPreprocess_내부_특수문자() { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess("hello-world")).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = { "hello@world", "hello#world", "hello$world", "hello%world" }) + @DisplayName("다양한 특수문자가 내부에 있으면 예외 발생") + void validateAndPreprocess_다양한_내부_특수문자(String word) { + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.INVALID_WORD_FORMAT.getMessage()); + } + + @Test + @DisplayName("정확히 50자인 단어는 허용") + void validateAndPreprocess_50자_단어_허용() { + // given + String word = "a".repeat(50); + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).isEqualTo(word); + assertThat(result).hasSize(50); + } + + @Test + @DisplayName("50자를 초과하는 단어는 예외 발생") + void validateAndPreprocess_50자_초과() { + // given + String word = "a".repeat(51); + + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.WORD_TOO_LONG.getMessage()); + } + + @Test + @DisplayName("전처리 후 50자를 초과하면 예외 발생하지 않음 - 전처리 전 길이가 51자, 전처리 후 50자") + void validateAndPreprocess_전처리_후_50자() { + // given - 앞뒤 특수문자 1개씩 포함하여 총 52자 + String word = "!" + "a".repeat(50) + "!"; + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).hasSize(50); + } + + @Test + @DisplayName("전처리 후 50자를 초과하면 예외 발생") + void validateAndPreprocess_전처리_후_50자_초과() { + // given - 앞뒤 특수문자 1개씩 포함하여 총 53자 + String word = "!" + "a".repeat(51) + "!"; + + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.WORD_TOO_LONG.getMessage()); + } + + @Test + @DisplayName("한글 50자는 허용") + void validateAndPreprocess_한글_50자() { + // given + String word = "가".repeat(50); + + // when + String result = wordValidator.validateAndPreprocess(word); + + // then + assertThat(result).hasSize(50); + } + + @Test + @DisplayName("한글 51자는 예외 발생") + void validateAndPreprocess_한글_51자() { + // given + String word = "가".repeat(51); + + // when & then + assertThatThrownBy(() -> wordValidator.validateAndPreprocess(word)).isInstanceOf(WordsException.class) + .hasMessage(WordsErrorCode.WORD_TOO_LONG.getMessage()); + } + }