From ca0f3719cc74da7079573dcc3a26c9d12083a0a1 Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Fri, 22 May 2026 18:19:58 +0900 Subject: [PATCH 01/10] revert: SOU-638 rollback to 455c656 --- .github/workflows/dev-cicd.yaml | 16 +- .github/workflows/prod-cicd.yaml | 17 +- Dockerfile | 5 +- build.gradle | 179 ++- deploy/dev/docker-compose.app.yaml | 3 - deploy/dev/docker-compose.db.yaml | 2 +- deploy/dev/docker-compose.monitoring.yaml | 44 - deploy/dev/prometheus.yaml | 16 - deploy/dev/scripts/deploy.sh | 39 +- deploy/dev/scripts/rollback.sh | 14 +- deploy/prod/docker-compose.blue.yaml | 5 +- deploy/prod/docker-compose.db.yaml | 34 - deploy/prod/docker-compose.green.yaml | 5 +- deploy/prod/docker-compose.logging.yaml | 37 - deploy/prod/docker-compose.monitoring.yaml | 78 -- deploy/prod/loki-config.yaml | 47 - deploy/prod/prometheus.yaml | 38 - deploy/prod/promtail.yaml | 28 - deploy/prod/scripts/deploy.sh | 99 +- deploy/shared/db-backup.sh | 65 -- deploy/shared/discord-notify.sh | 2 +- deploy/shared/system-monitor.sh | 31 +- src/docs/asciidoc/app/index.adoc | 1 - src/docs/asciidoc/app/souvenir.adoc | 12 +- src/docs/asciidoc/app/user.adoc | 22 +- src/docs/asciidoc/app/wishlist.adoc | 24 - .../googlegeocoding/GoogleGeocoding.java | 2 +- .../googleplaces/GooglePlaces.java | 4 +- .../adapter/storage/file/NcpStorage.java | 4 +- .../adapter/webapi/admin/AdminEmailApi.java | 70 -- .../adapter/webapi/admin/AdminNoticeApi.java | 5 +- .../adapter/webapi/admin/AdminPushApi.java | 83 -- .../admin/dto/EmailBroadcastRequest.java | 19 - .../admin/dto/EmailTestSendRequest.java | 26 - .../admin/dto/PushBroadcastRequest.java | 19 - .../adapter/webapi/location/GeocodingApi.java | 11 +- .../adapter/webapi/location/LocationApi.java | 13 +- .../webapi/location/dto/SearchResponse.java | 3 +- .../souzip/adapter/webapi/user/NoticeApi.java | 2 +- .../email/EmailBroadcastService.java | 109 -- .../email/UserEmailQueryService.java | 24 - .../email/dto/EmailBroadcastResult.java | 9 - .../email/provided/UserEmailFinder.java | 8 - .../location/ReverseGeocodingService.java | 2 +- .../application/location/dto/SearchPlace.java | 2 +- .../location/provided/ReverseGeocoding.java | 2 +- .../location/required/AddressProvider.java | 2 +- .../notification/FcmNotificationService.java | 135 --- .../notification/FcmTokenCommandService.java | 57 - .../FcmTokenNotFoundException.java | 20 - .../notification/FcmTokenQueryService.java | 46 - .../PushBroadcastHistoryCommandService.java | 31 - .../PushBroadcastHistoryQueryService.java | 32 - .../dto/MarketingConsentStats.java | 8 - .../dto/PushBroadcastHistoryResponse.java | 32 - .../notification/dto/PushBroadcastResult.java | 9 - .../notification/provided/FcmTokenFinder.java | 17 - .../required/FcmTokenRepository.java | 34 - .../PushBroadcastHistoryRepository.java | 13 - .../security/jwt/JwtAuthenticationFilter.java | 57 - .../security/jwt/JwtTokenProvider.java | 86 -- .../adapter/social/AppleSocialClient.java | 46 - .../adapter/social/GoogleSocialClient.java | 55 - .../adapter/social/KakaoSocialClient.java | 62 - .../auth/adapter/social/SocialProperties.java | 15 - .../com/souzip/auth/adapter/web/AuthApi.java | 48 - .../auth/adapter/web/dto/LoginRequest.java | 6 - .../auth/adapter/web/dto/LoginResponse.java | 22 - .../auth/adapter/web/dto/RefreshRequest.java | 6 - .../auth/adapter/web/dto/RefreshResponse.java | 15 - .../souzip/auth/application/AuthService.java | 92 -- .../auth/application/SocialClientFactory.java | 27 - .../auth/application/dto/LoginInfo.java | 10 - .../auth/application/dto/RefreshInfo.java | 7 - .../auth/application/dto/SocialUserInfo.java | 4 - .../souzip/auth/application/dto/UserInfo.java | 9 - .../application/exception/AuthErrorCode.java | 27 - .../application/exception/AuthException.java | 46 - .../auth/application/provided/Auth.java | 13 - .../required/RefreshTokenRepository.java | 24 - .../application/required/SocialClient.java | 10 - .../application/required/TokenProvider.java | 11 - .../auth/application/required/UserPort.java | 8 - .../com/souzip/auth/domain/RefreshToken.java | 34 - .../admin/application/AdminAuthService.java | 34 +- .../application/AdminCityQueryUseCase.java | 2 +- .../admin/application/port/CityQueryPort.java | 6 +- .../query/AdminCityQueryService.java | 4 +- .../admin/exception/AdminErrorCode.java | 2 +- .../admin/exception/AdminException.java | 2 +- .../AdminExpiredRefreshTokenException.java | 2 +- .../AdminInvalidRefreshTokenException.java | 2 +- .../exception/AdminLoginFailedException.java | 2 +- .../exception/AdminNotFoundException.java | 2 +- .../exception/InvalidUsernameException.java | 2 +- .../persistence/CityQueryAdapter.java | 34 +- .../jwt/AdminJwtAuthenticationFilter.java | 16 +- .../presentation/AdminAuthController.java | 9 +- .../AdminManagementController.java | 62 +- .../presentation/response/AdminResponse.java | 37 +- .../souzip/domain/audit/entity/AuditLog.java | 82 +- .../domain/audit/service/AuditService.java | 46 +- .../domain/auth/client/AppleApiClient.java | 59 + .../domain/auth/client/GoogleApiClient.java | 55 + .../domain/auth/client/KakaoApiClient.java | 55 + .../domain/auth/client/OAuthClient.java | 7 + .../auth/client/OAuthClientFactory.java | 27 + .../auth/controller/AuthController.java | 44 + .../souzip/domain/auth/dto/AppleUserInfo.java | 45 + .../domain/auth/dto/GoogleUserInfo.java | 34 + .../souzip/domain/auth/dto/KakaoUserInfo.java | 44 + .../souzip/domain/auth/dto/LoginRequest.java | 5 + .../souzip/domain/auth/dto/LoginResponse.java | 27 + .../souzip/domain/auth/dto/LoginUserInfo.java | 15 + .../souzip/domain/auth/dto/OAuthUserInfo.java | 10 + .../domain/auth/dto/RefreshRequest.java | 6 + .../domain/auth/dto/RefreshResponse.java | 10 + .../domain/auth/entity/RefreshToken.java | 49 + .../repository/RefreshTokenRepository.java | 13 + .../RefreshTokenRepositoryCustom.java | 8 + .../RefreshTokenRepositoryImpl.java | 25 + .../RefreshTokenCleanupScheduler.java | 13 +- .../domain/auth/service/AuthService.java | 241 ++++ .../controller/CategoryController.java | 2 +- .../command/CityCommandService.java | 6 +- .../com/souzip/domain/city/entity/City.java | 36 +- .../country/controller/CountryController.java | 2 +- .../souzip/domain/country/entity/Country.java | 56 +- .../country/service/CountryService.java | 5 +- .../controller/CurrencyController.java | 4 +- .../currency/service/CurrencyService.java | 4 +- .../service/ExchangeRateService.java | 100 +- .../java/com/souzip/domain/file/File.java | 2 +- .../com/souzip/domain/location/Location.java | 4 +- .../controller/AppleMigrationController.java | 28 + .../dto/AppleMigrationPrepareResponse.java | 23 + .../service/AppleMigrationPrepService.java | 177 +++ .../apple/service/AppleTransferService.java | 198 ++++ .../java/com/souzip/domain/notice/Notice.java | 5 +- .../domain/notification/DeviceType.java | 6 - .../souzip/domain/notification/FcmToken.java | 93 -- .../notification/FcmTokenRegisterRequest.java | 94 -- .../notification/PushBroadcastHistory.java | 49 - .../controller/FcmTokenController.java | 42 - .../notification/dto/FcmTokenResponse.java | 14 - .../AiRecommendationController.java | 6 +- .../ai/dto/AiRecommendationResponse.java | 8 +- .../ai/service/AiRecommendationService.java | 70 +- .../GeneralRecommendationController.java | 32 +- .../general/dto/GeneralRecommendationDto.java | 10 +- ...ralRecommendationRepositoryCustomImpl.java | 30 +- .../service/GeneralRecommendationService.java | 135 +-- .../search/controller/SearchController.java | 6 +- .../domain/search/dto/SearchListResponse.java | 2 +- .../domain/search/service/SearchService.java | 10 +- .../domain => domain/shared}/BaseEntity.java | 12 +- .../domain => domain/shared}/Coordinate.java | 2 +- .../controller/SouvenirController.java | 51 +- .../souvenir/dto/MySouvenirListResponse.java | 6 +- .../souvenir/dto/MySouvenirResponse.java | 26 +- .../souvenir/dto/SouvenirDetailResponse.java | 135 +-- .../souvenir/dto/SouvenirNearbyResponse.java | 105 +- .../domain/souvenir/entity/Souvenir.java | 123 +- .../repository/SouvenirRepository.java | 7 +- .../souvenir/service/SouvenirService.java | 48 +- .../domain/user/adapter/UserPortImpl.java | 32 - .../user/controller/UserController.java | 34 +- .../souzip/domain/user/entity/Provider.java | 23 + .../com/souzip/domain/user/entity/User.java | 73 +- .../domain/user/entity/UserAgreement.java | 40 +- .../repository/UserAgreementRepository.java | 2 - .../user/repository/UserRepository.java | 14 +- .../user/scheduler/UserCleanupScheduler.java | 22 - .../user/service/ProfileImageService.java | 18 +- .../domain/user/service/UserService.java | 43 +- .../controller/WishlistController.java | 45 - .../wishlist/dto/MyWishlistListResponse.java | 15 - .../wishlist/dto/MyWishlistResponse.java | 27 - .../domain/wishlist/dto/WishlistResponse.java | 10 - .../domain/wishlist/entity/Wishlist.java | 26 - .../repository/WishlistRepository.java | 48 - .../wishlist/service/WishlistService.java | 105 -- .../{shared => global}/aop/MdcTraceId.java | 6 +- .../aop/ServiceExceptionLoggingAspect.java | 13 +- .../{shared => global}/aop/TraceIdAspect.java | 2 +- .../audit/annotation/Audit.java | 3 +- .../audit/aspect/AuditAspect.java | 113 +- .../audit/dto/AuditContext.java | 4 +- .../clova/ClovaStudioClient.java | 43 +- .../clova/PromptLoader.java | 6 +- .../common/dto/ErrorResponse.java | 28 +- .../common/dto/SuccessResponse.java | 12 +- .../dto/pagination/PaginationRequest.java | 7 +- .../dto/pagination/PaginationResponse.java | 52 +- .../com/souzip/global/config/AppConfig.java | 19 + .../config/AppleMigrationConfig.java | 16 +- .../config/ClovaStudioProperties.java | 2 +- .../config/CorsProperties.java | 2 +- .../config/JpaAuditingConfig.java | 2 +- .../config/PasswordEncoderConfig.java | 2 +- .../config/QuerydslConfig.java | 2 +- .../config/RestTemplateConfig.java | 2 +- .../config/SchedulerConfig.java | 2 +- .../adapter => global}/config/WebConfig.java | 7 +- .../exception/BaseErrorCode.java | 4 +- .../exception/BusinessException.java | 2 +- .../exception/ErrorCode.java | 27 +- .../exception/GlobalExceptionHandler.java | 8 +- .../souzip/global/oauth/OAuthProperties.java | 26 + .../security/annotation/CurrentUserId.java | 2 +- .../security/annotation/RequireAuth.java | 2 +- .../security/config/SecurityConfig.java | 11 +- .../security/jwt/JwtAuthenticationFilter.java | 105 ++ .../security/jwt/JwtProperties.java | 5 +- .../global/security/jwt/JwtTokenProvider.java | 67 ++ .../CurrentUserIdArgumentResolver.java | 23 +- .../util/HttpRequestUtils.java | 20 +- .../com/souzip/shared/config/AppConfig.java | 18 - .../souzip/shared/config/FirebaseConfig.java | 44 - .../shared/config/FirebaseProperties.java | 15 - .../FirebasePropertiesConfiguration.java | 9 - .../souzip/shared/config/MailSmtpConfig.java | 42 - .../shared/config/MailSmtpProperties.java | 18 - .../com/souzip/shared/domain/Provider.java | 24 - src/main/resources/.env.example | 10 - src/main/resources/META-INF/orm.xml | 296 ++--- src/main/resources/application-dev.yaml | 16 - src/main/resources/application-local.yaml | 14 +- src/main/resources/application-prod.yaml | 24 +- src/main/resources/application.yaml | 20 +- .../V14__create_fcm_token_tablel.sql | 23 - .../migration/V15__create_wishlist_table.sql | 10 - .../V16__create_push_broadcast_history.sql | 14 - src/main/resources/logback-dev.xml | 72 -- src/main/resources/logback-local.xml | 19 - src/main/resources/logback-prod.xml | 55 - .../googlegeocoding/GoogleGeocodingTest.java | 2 +- .../adapter/storage/file/NcpStorageTest.java | 2 +- .../webapi/geocoding/GeocodingApiTest.java | 2 +- .../webapi/location/LocationApiTest.java | 2 +- .../location/LocationSearchServiceTest.java | 9 +- .../location/ReverseGeocodingServiceTest.java | 2 +- .../souzip/auth/controller/AuthApiTest.java | 372 ------ .../souzip/auth/service/AuthServiceTest.java | 168 --- .../java/com/souzip/docs/RestDocsSupport.java | 2 +- .../application/AdminAuthServiceTest.java | 28 +- .../AdminCityQueryServiceTest.java | 4 +- .../AdminRefreshTokenRepositoryTest.java | 3 +- .../persistence/AdminRepositoryTest.java | 4 +- .../admin/presentation/AdminAuthApiTest.java | 244 ---- .../presentation/AdminAuthControllerTest.java | 242 ++++ .../AdminManagementControllerTest.java | 4 +- .../auth/controller/AuthControllerTest.java | 593 ++++++++++ .../RefreshTokenCleanupSchedulerTest.java | 14 +- .../domain/auth/service/AuthServiceTest.java | 459 ++++++++ .../command/CityCommandServiceTest.java | 4 +- .../country/service/CountryServiceTest.java | 32 +- .../currency/service/CurrencyServiceTest.java | 4 +- .../FcmTokenRegisterRequestTest.java | 201 ---- .../domain/notification/FcmTokenTest.java | 210 ---- .../AiRecommendationControllerTest.java | 211 ++-- .../GeneralRecommendationControllerTest.java | 331 +++--- .../controller/SearchControllerTest.java | 8 +- .../souzip/domain/shared/CoordinateTest.java | 4 +- .../controller/SouvenirControllerTest.java | 1026 ++++++++--------- .../user/controller/UserControllerTest.java | 766 ++++++------ .../souzip/domain/user/entity/UserTest.java | 25 +- .../user/service/ProfileImageServiceTest.java | 30 +- .../domain/user/service/UserServiceTest.java | 209 ++-- .../controller/WishlistControllerTest.java | 315 ----- .../domain/wishlist/entity/WishlistTest.java | 27 - .../wishlist/service/WishlistServiceTest.java | 148 --- .../aop/MdcTraceIdTest.java | 2 +- .../dto/pagination/PaginationRequestTest.java | 2 +- .../pagination/PaginationResponseTest.java | 27 +- .../exception/BusinessExceptionTest.java | 2 +- src/test/resources/application-test.yaml | 8 +- 277 files changed, 5391 insertions(+), 7692 deletions(-) delete mode 100644 deploy/dev/docker-compose.monitoring.yaml delete mode 100644 deploy/dev/prometheus.yaml delete mode 100644 deploy/prod/docker-compose.db.yaml delete mode 100644 deploy/prod/docker-compose.logging.yaml delete mode 100644 deploy/prod/docker-compose.monitoring.yaml delete mode 100644 deploy/prod/loki-config.yaml delete mode 100644 deploy/prod/prometheus.yaml delete mode 100644 deploy/prod/promtail.yaml delete mode 100644 deploy/shared/db-backup.sh delete mode 100644 src/docs/asciidoc/app/wishlist.adoc delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java delete mode 100644 src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java delete mode 100644 src/main/java/com/souzip/application/email/EmailBroadcastService.java delete mode 100644 src/main/java/com/souzip/application/email/UserEmailQueryService.java delete mode 100644 src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java delete mode 100644 src/main/java/com/souzip/application/email/provided/UserEmailFinder.java delete mode 100644 src/main/java/com/souzip/application/notification/FcmNotificationService.java delete mode 100644 src/main/java/com/souzip/application/notification/FcmTokenCommandService.java delete mode 100644 src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java delete mode 100644 src/main/java/com/souzip/application/notification/FcmTokenQueryService.java delete mode 100644 src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java delete mode 100644 src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java delete mode 100644 src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java delete mode 100644 src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java delete mode 100644 src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java delete mode 100644 src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java delete mode 100644 src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java delete mode 100644 src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java delete mode 100644 src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java delete mode 100644 src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java delete mode 100644 src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java delete mode 100644 src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java delete mode 100644 src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java delete mode 100644 src/main/java/com/souzip/auth/adapter/social/SocialProperties.java delete mode 100644 src/main/java/com/souzip/auth/adapter/web/AuthApi.java delete mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java delete mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java delete mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java delete mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java delete mode 100644 src/main/java/com/souzip/auth/application/AuthService.java delete mode 100644 src/main/java/com/souzip/auth/application/SocialClientFactory.java delete mode 100644 src/main/java/com/souzip/auth/application/dto/LoginInfo.java delete mode 100644 src/main/java/com/souzip/auth/application/dto/RefreshInfo.java delete mode 100644 src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java delete mode 100644 src/main/java/com/souzip/auth/application/dto/UserInfo.java delete mode 100644 src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java delete mode 100644 src/main/java/com/souzip/auth/application/exception/AuthException.java delete mode 100644 src/main/java/com/souzip/auth/application/provided/Auth.java delete mode 100644 src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java delete mode 100644 src/main/java/com/souzip/auth/application/required/SocialClient.java delete mode 100644 src/main/java/com/souzip/auth/application/required/TokenProvider.java delete mode 100644 src/main/java/com/souzip/auth/application/required/UserPort.java delete mode 100644 src/main/java/com/souzip/auth/domain/RefreshToken.java create mode 100644 src/main/java/com/souzip/domain/auth/client/AppleApiClient.java create mode 100644 src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java create mode 100644 src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java create mode 100644 src/main/java/com/souzip/domain/auth/client/OAuthClient.java create mode 100644 src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java create mode 100644 src/main/java/com/souzip/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/LoginRequest.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/LoginResponse.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java create mode 100644 src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java create mode 100644 src/main/java/com/souzip/domain/auth/entity/RefreshToken.java create mode 100644 src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java create mode 100644 src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java rename src/main/java/com/souzip/{auth/adapter/integration => domain/auth}/scheduler/RefreshTokenCleanupScheduler.java (78%) create mode 100644 src/main/java/com/souzip/domain/auth/service/AuthService.java create mode 100644 src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java create mode 100644 src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java create mode 100644 src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java create mode 100644 src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java delete mode 100644 src/main/java/com/souzip/domain/notification/DeviceType.java delete mode 100644 src/main/java/com/souzip/domain/notification/FcmToken.java delete mode 100644 src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java delete mode 100644 src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java delete mode 100644 src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java delete mode 100644 src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java rename src/main/java/com/souzip/{shared/domain => domain/shared}/BaseEntity.java (68%) rename src/main/java/com/souzip/{shared/domain => domain/shared}/Coordinate.java (97%) delete mode 100644 src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java create mode 100644 src/main/java/com/souzip/domain/user/entity/Provider.java delete mode 100644 src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java delete mode 100644 src/main/java/com/souzip/domain/wishlist/service/WishlistService.java rename src/main/java/com/souzip/{shared => global}/aop/MdcTraceId.java (91%) rename src/main/java/com/souzip/{shared => global}/aop/ServiceExceptionLoggingAspect.java (75%) rename src/main/java/com/souzip/{shared => global}/aop/TraceIdAspect.java (96%) rename src/main/java/com/souzip/{shared => global}/audit/annotation/Audit.java (89%) rename src/main/java/com/souzip/{shared => global}/audit/aspect/AuditAspect.java (59%) rename src/main/java/com/souzip/{shared => global}/audit/dto/AuditContext.java (88%) rename src/main/java/com/souzip/{shared => global}/clova/ClovaStudioClient.java (76%) rename src/main/java/com/souzip/{shared => global}/clova/PromptLoader.java (84%) rename src/main/java/com/souzip/{shared => global}/common/dto/ErrorResponse.java (75%) rename src/main/java/com/souzip/{shared => global}/common/dto/SuccessResponse.java (80%) rename src/main/java/com/souzip/{shared => global}/common/dto/pagination/PaginationRequest.java (85%) rename src/main/java/com/souzip/{shared => global}/common/dto/pagination/PaginationResponse.java (67%) create mode 100644 src/main/java/com/souzip/global/config/AppConfig.java rename src/main/java/com/souzip/{shared => global}/config/AppleMigrationConfig.java (77%) rename src/main/java/com/souzip/{shared => global}/config/ClovaStudioProperties.java (89%) rename src/main/java/com/souzip/{shared => global}/config/CorsProperties.java (91%) rename src/main/java/com/souzip/{shared => global}/config/JpaAuditingConfig.java (85%) rename src/main/java/com/souzip/{shared => global}/config/PasswordEncoderConfig.java (92%) rename src/main/java/com/souzip/{shared => global}/config/QuerydslConfig.java (93%) rename src/main/java/com/souzip/{shared => global}/config/RestTemplateConfig.java (90%) rename src/main/java/com/souzip/{shared => global}/config/SchedulerConfig.java (85%) rename src/main/java/com/souzip/{auth/adapter => global}/config/WebConfig.java (87%) rename src/main/java/com/souzip/{shared => global}/exception/BaseErrorCode.java (79%) rename src/main/java/com/souzip/{shared => global}/exception/BusinessException.java (91%) rename src/main/java/com/souzip/{shared => global}/exception/ErrorCode.java (71%) rename src/main/java/com/souzip/{shared => global}/exception/GlobalExceptionHandler.java (96%) create mode 100644 src/main/java/com/souzip/global/oauth/OAuthProperties.java rename src/main/java/com/souzip/{auth/adapter => global}/security/annotation/CurrentUserId.java (83%) rename src/main/java/com/souzip/{auth/adapter => global}/security/annotation/RequireAuth.java (87%) rename src/main/java/com/souzip/{auth/adapter => global}/security/config/SecurityConfig.java (93%) create mode 100644 src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java rename src/main/java/com/souzip/{auth/adapter => global}/security/jwt/JwtProperties.java (88%) create mode 100644 src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java rename src/main/java/com/souzip/{auth/adapter => global}/security/resolver/CurrentUserIdArgumentResolver.java (71%) rename src/main/java/com/souzip/{shared => global}/util/HttpRequestUtils.java (90%) delete mode 100644 src/main/java/com/souzip/shared/config/AppConfig.java delete mode 100644 src/main/java/com/souzip/shared/config/FirebaseConfig.java delete mode 100644 src/main/java/com/souzip/shared/config/FirebaseProperties.java delete mode 100644 src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java delete mode 100644 src/main/java/com/souzip/shared/config/MailSmtpConfig.java delete mode 100644 src/main/java/com/souzip/shared/config/MailSmtpProperties.java delete mode 100644 src/main/java/com/souzip/shared/domain/Provider.java delete mode 100644 src/main/resources/db/migration/V14__create_fcm_token_tablel.sql delete mode 100644 src/main/resources/db/migration/V15__create_wishlist_table.sql delete mode 100644 src/main/resources/db/migration/V16__create_push_broadcast_history.sql delete mode 100644 src/main/resources/logback-dev.xml delete mode 100644 src/main/resources/logback-local.xml delete mode 100644 src/main/resources/logback-prod.xml delete mode 100644 src/test/java/com/souzip/auth/controller/AuthApiTest.java delete mode 100644 src/test/java/com/souzip/auth/service/AuthServiceTest.java delete mode 100644 src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java create mode 100644 src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java create mode 100644 src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java rename src/test/java/com/souzip/{ => domain}/auth/scheduler/RefreshTokenCleanupSchedulerTest.java (77%) create mode 100644 src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java delete mode 100644 src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java delete mode 100644 src/test/java/com/souzip/domain/notification/FcmTokenTest.java delete mode 100644 src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java delete mode 100644 src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java delete mode 100644 src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java rename src/test/java/com/souzip/{shared => global}/aop/MdcTraceIdTest.java (98%) rename src/test/java/com/souzip/{shared => global}/common/dto/pagination/PaginationRequestTest.java (97%) rename src/test/java/com/souzip/{shared => global}/common/dto/pagination/PaginationResponseTest.java (82%) rename src/test/java/com/souzip/{shared => global}/exception/BusinessExceptionTest.java (96%) diff --git a/.github/workflows/dev-cicd.yaml b/.github/workflows/dev-cicd.yaml index d10a900a..dd0932c4 100644 --- a/.github/workflows/dev-cicd.yaml +++ b/.github/workflows/dev-cicd.yaml @@ -84,10 +84,6 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest - environment: - name: Development - url: https://api-dev.souzip.com - steps: - name: Checkout uses: actions/checkout@v3 @@ -140,16 +136,6 @@ jobs: DEV_API_DOCS_URL=${{ secrets.DEV_API_DOCS_URL }} ADMIN_INITIAL_USERNAME=${{ secrets.ADMIN_INITIAL_USERNAME }} ADMIN_INITIAL_PASSWORD=${{ secrets.ADMIN_INITIAL_PASSWORD }} - GRAFANA_ADMIN_USER=${{ secrets.DEV_GRAFANA_ADMIN_USER }} - GRAFANA_ADMIN_PASSWORD=${{ secrets.DEV_GRAFANA_ADMIN_PASSWORD }} - FIREBASE_ENABLED=${{ secrets.FIREBASE_ENABLED }} - FIREBASE_CREDENTIALS_PATH=${{ secrets.DEV_FIREBASE_CREDENTIALS_PATH }} - MAIL_SMTP_ENABLED=${{ secrets.MAIL_SMTP_ENABLED }} - MAIL_SMTP_HOST=${{ secrets.MAIL_SMTP_HOST }} - MAIL_SMTP_PORT=${{ secrets.MAIL_SMTP_PORT }} - MAIL_SMTP_USERNAME=${{ secrets.MAIL_SMTP_USERNAME }} - MAIL_SMTP_PASSWORD=${{ secrets.MAIL_SMTP_PASSWORD }} - MAIL_SMTP_FROM=${{ secrets.MAIL_SMTP_FROM }} EOF echo "DEPLOYER=${{ github.actor }}" >> deploy/dev/.env @@ -159,4 +145,4 @@ jobs: gcloud auth configure-docker asia-northeast3-docker.pkg.dev - ./deploy/dev/scripts/deploy.sh + ./deploy/dev/scripts/deploy.sh \ No newline at end of file diff --git a/.github/workflows/prod-cicd.yaml b/.github/workflows/prod-cicd.yaml index 91d56424..442faca3 100644 --- a/.github/workflows/prod-cicd.yaml +++ b/.github/workflows/prod-cicd.yaml @@ -79,10 +79,6 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest - environment: - name: Production - url: https://api.souzip.com - steps: - uses: actions/checkout@v3 @@ -126,7 +122,6 @@ jobs: CLOVA_STUDIO_API_URL=${{ secrets.CLOVA_STUDIO_API_URL }} DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} PROD_DISCORD_WEBHOOK_URL=${{ secrets.PROD_DISCORD_WEBHOOK_URL }} - PROD_DB_DISCORD_WEBHOOK_URL=${{ secrets.PROD_DB_DISCORD_WEBHOOK_URL }} PROD_API_DOCS_URL=${{ secrets.PROD_API_DOCS_URL }} ADMIN_INITIAL_USERNAME=${{ secrets.ADMIN_INITIAL_USERNAME }} ADMIN_INITIAL_PASSWORD=${{ secrets.ADMIN_INITIAL_PASSWORD }} @@ -135,16 +130,6 @@ jobs: NCP_BUCKET=${{ secrets.NCP_BUCKET }} NCP_ACCESS_KEY=${{ secrets.NCP_ACCESS_KEY }} NCP_SECRET_KEY=${{ secrets.NCP_SECRET_KEY }} - GRAFANA_ADMIN_USER=${{ secrets.PROD_GRAFANA_ADMIN_USER }} - GRAFANA_ADMIN_PASSWORD=${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} - FIREBASE_ENABLED=${{ secrets.FIREBASE_ENABLED }} - FIREBASE_CREDENTIALS_PATH=${{ secrets.PROD_FIREBASE_CREDENTIALS_PATH }} - MAIL_SMTP_ENABLED=${{ secrets.MAIL_SMTP_ENABLED }} - MAIL_SMTP_HOST=${{ secrets.MAIL_SMTP_HOST }} - MAIL_SMTP_PORT=${{ secrets.MAIL_SMTP_PORT }} - MAIL_SMTP_USERNAME=${{ secrets.MAIL_SMTP_USERNAME }} - MAIL_SMTP_PASSWORD=${{ secrets.MAIL_SMTP_PASSWORD }} - MAIL_SMTP_FROM=${{ secrets.MAIL_SMTP_FROM }} EOF echo "DEPLOYER=${{ github.actor }}" >> deploy/prod/.env @@ -154,4 +139,4 @@ jobs: gcloud auth configure-docker asia-northeast3-docker.pkg.dev - ./deploy/prod/scripts/deploy.sh + ./deploy/prod/scripts/deploy.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a7e5f013..9cd07fe2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,9 @@ FROM eclipse-temurin:21-jdk WORKDIR /app -COPY build/libs/*.jar /souzip-api.jar +ARG JAR_FILE=build/libs/souzip-api-0.0.1-SNAPSHOT.jar +COPY ${JAR_FILE} /souzip-api.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "/souzip-api.jar"] +ENTRYPOINT ["java", "-jar", "/souzip-api.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3d2a6793..ff3abae8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,164 +1,155 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.8' - id 'io.spring.dependency-management' version '1.1.7' - id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'java' + id 'org.springframework.boot' version '3.5.8' + id 'io.spring.dependency-management' version '1.1.7' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'com.souzip' -version = '1.1.0-SNAPSHOT' -description = 'Souzip API - Travel Souvenir Recommendation Platform' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Spring Boot' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -jar { - enabled = false + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } - asciidoctorExt + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt } repositories { - mavenCentral() + mavenCentral() } ext { - snippetsDir = file('build/generated-snippets') + snippetsDir = file('build/generated-snippets') } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-aop' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'me.paulschwarz:spring-dotenv:4.0.0' + implementation 'me.paulschwarz:spring-dotenv:4.0.0' - runtimeOnly 'org.springframework.boot:spring-boot-docker-compose' + runtimeOnly 'org.springframework.boot:spring-boot-docker-compose' - runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'org.postgresql:postgresql' - implementation 'org.flywaydb:flyway-core' - implementation 'org.flywaydb:flyway-database-postgresql' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' - implementation 'io.micrometer:micrometer-registry-prometheus' - implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + implementation 'io.micrometer:micrometer-registry-prometheus' - implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' - annotationProcessor 'jakarta.annotation:jakarta.annotation-api' - annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' - implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529' + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529' - implementation 'com.google.firebase:firebase-admin:9.4.3' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'com.h2database:h2' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - testImplementation 'com.h2database:h2' - testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' } def generatedDir = 'build/generated/sources/annotationProcessor/java/main' tasks.withType(JavaCompile).configureEach { - options.getGeneratedSourceOutputDirectory().set(file(generatedDir)) + options.getGeneratedSourceOutputDirectory().set(file(generatedDir)) } sourceSets { - main.java.srcDirs += [generatedDir] + main.java.srcDirs += [generatedDir] } clean { - delete file(generatedDir) + delete file(generatedDir) } tasks.named('test') { - outputs.dir snippetsDir - useJUnitPlatform() - systemProperty 'spring.profiles.active', 'test' + outputs.dir snippetsDir + useJUnitPlatform() } def appDocsOutDir = file("$buildDir/docs/app") def adminDocsOutDir = file("$buildDir/docs/admin") tasks.register('asciidoctorApp', org.asciidoctor.gradle.jvm.AsciidoctorTask) { - group = 'documentation' - description = 'Generate App API docs (Asciidoctor)' + group = 'documentation' + description = 'Generate App API docs (Asciidoctor)' - inputs.dir snippetsDir - configurations 'asciidoctorExt' - dependsOn tasks.named('test') + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn tasks.named('test') - baseDirFollowsSourceFile() + baseDirFollowsSourceFile() - sourceDir = file('src/docs/asciidoc/app') - sources { include 'index.adoc' } + sourceDir = file('src/docs/asciidoc/app') + sources { include 'index.adoc' } - outputDir = appDocsOutDir + outputDir = appDocsOutDir } tasks.register('asciidoctorAdmin', org.asciidoctor.gradle.jvm.AsciidoctorTask) { - group = 'documentation' - description = 'Generate Admin API docs (Asciidoctor)' + group = 'documentation' + description = 'Generate Admin API docs (Asciidoctor)' - inputs.dir snippetsDir - configurations 'asciidoctorExt' - dependsOn tasks.named('test') + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn tasks.named('test') - baseDirFollowsSourceFile() + baseDirFollowsSourceFile() - sourceDir = file('src/docs/asciidoc/admin') - sources { include 'index.adoc' } + sourceDir = file('src/docs/asciidoc/admin') + sources { include 'index.adoc' } - outputDir = adminDocsOutDir + outputDir = adminDocsOutDir } tasks.named('bootJar') { - dependsOn tasks.named('asciidoctorApp'), tasks.named('asciidoctorAdmin') - - from(appDocsOutDir) { - into 'static/docs/app' - } - from(adminDocsOutDir) { - into 'static/docs/admin' - } + dependsOn tasks.named('asciidoctorApp'), tasks.named('asciidoctorAdmin') + + from(appDocsOutDir) { + into 'static/docs/app' + } + from(adminDocsOutDir) { + into 'static/docs/admin' + } } tasks.register('copyDocsApp', Copy) { - group = 'documentation' - description = 'Copy App docs to src/main/resources/static/docs/app' - dependsOn tasks.named('asciidoctorApp') - from appDocsOutDir - into file("src/main/resources/static/docs/app") + group = 'documentation' + description = 'Copy App docs to src/main/resources/static/docs/app' + dependsOn tasks.named('asciidoctorApp') + from appDocsOutDir + into file("src/main/resources/static/docs/app") } tasks.register('copyDocsAdmin', Copy) { - group = 'documentation' - description = 'Copy Admin docs to src/main/resources/static/docs/admin' - dependsOn tasks.named('asciidoctorAdmin') - from adminDocsOutDir - into file("src/main/resources/static/docs/admin") -} \ No newline at end of file + group = 'documentation' + description = 'Copy Admin docs to src/main/resources/static/docs/admin' + dependsOn tasks.named('asciidoctorAdmin') + from adminDocsOutDir + into file("src/main/resources/static/docs/admin") +} diff --git a/deploy/dev/docker-compose.app.yaml b/deploy/dev/docker-compose.app.yaml index 0bd70480..7caffcff 100644 --- a/deploy/dev/docker-compose.app.yaml +++ b/deploy/dev/docker-compose.app.yaml @@ -9,9 +9,6 @@ services: - souzip-network env_file: - .env - volumes: - - /home/souzip-dev/secrets:/home/souzip-dev/secrets:ro - - /home/souzip-dev/logs/souzip-api:/home/souzip-dev/logs/souzip-api ports: - "127.0.0.1:8080:8080" diff --git a/deploy/dev/docker-compose.db.yaml b/deploy/dev/docker-compose.db.yaml index 8083de54..ca1a12cc 100644 --- a/deploy/dev/docker-compose.db.yaml +++ b/deploy/dev/docker-compose.db.yaml @@ -8,7 +8,7 @@ services: networks: - souzip-network ports: - - "127.0.0.1:5432:5432" + - "5432:5432" env_file: - .env environment: diff --git a/deploy/dev/docker-compose.monitoring.yaml b/deploy/dev/docker-compose.monitoring.yaml deleted file mode 100644 index fc02ed0a..00000000 --- a/deploy/dev/docker-compose.monitoring.yaml +++ /dev/null @@ -1,44 +0,0 @@ -version: '3.8' - -services: - prometheus: - image: prom/prometheus:latest - container_name: souzip-prometheus-dev - ports: - - "127.0.0.1:9090:9090" - volumes: - - ./prometheus.yaml:/etc/prometheus/prometheus.yaml - - prometheus-data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yaml' - - '--storage.tsdb.retention.time=30d' - networks: - - souzip-network - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: souzip-grafana-dev - ports: - - "127.0.0.1:3000:3000" - volumes: - - grafana-data:/var/lib/grafana - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_USERS_ALLOW_SIGN_UP=false - - GF_USERS_DEFAULT_THEME=light - - GF_SERVER_ROOT_URL=https://dev-monitor.souzip.com - networks: - - souzip-network - restart: unless-stopped - depends_on: - - prometheus - -networks: - souzip-network: - external: true - -volumes: - prometheus-data: - grafana-data: diff --git a/deploy/dev/prometheus.yaml b/deploy/dev/prometheus.yaml deleted file mode 100644 index 5cdeca0f..00000000 --- a/deploy/dev/prometheus.yaml +++ /dev/null @@ -1,16 +0,0 @@ -global: - scrape_interval: 30s - evaluation_interval: 30s - -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'souzip-api-dev' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['souzip-api:8080'] - labels: - application: 'souzip-api' - environment: 'dev' diff --git a/deploy/dev/scripts/deploy.sh b/deploy/dev/scripts/deploy.sh index 73d68afb..747ade22 100644 --- a/deploy/dev/scripts/deploy.sh +++ b/deploy/dev/scripts/deploy.sh @@ -12,7 +12,6 @@ DEPLOY_DIR="$WORK_DIR/deploy/dev" APP_COMPOSE="docker-compose.app.yaml" DB_COMPOSE="docker-compose.db.yaml" -MONITORING_COMPOSE="docker-compose.monitoring.yaml" HEALTH_CHECK_URL="http://localhost:8080/actuator/health" MAX_RETRY=6 @@ -32,7 +31,7 @@ fi cd "$WORK_DIR" || exit 1 -echo -e "${YELLOW}[1/10] 현재 이미지를 롤백용으로 보관${NC}" +echo -e "${YELLOW}[1/9] 현재 이미지를 롤백용으로 보관${NC}" CURRENT_IMAGE=$(docker images ${REGISTRY}:latest -q || true) if [ ! -z "${CURRENT_IMAGE:-}" ]; then docker rmi ${REGISTRY}:previous 2>/dev/null || true @@ -42,7 +41,7 @@ else echo -e "${YELLOW}[INFO] 기존 이미지 없음 (첫 배포)${NC}" fi -echo -e "${YELLOW}[2/10] 오래된 이미지 정리${NC}" +echo -e "${YELLOW}[2/9] 오래된 이미지 정리${NC}" OLD_IMAGES=$(docker images ${REGISTRY} -q | tail -n +3 || true) if [ ! -z "${OLD_IMAGES:-}" ]; then echo "$OLD_IMAGES" | xargs docker rmi -f 2>/dev/null || true @@ -51,7 +50,7 @@ else echo -e "${YELLOW}[INFO] 삭제할 오래된 이미지 없음${NC}" fi -echo -e "${YELLOW}[3/10] 최신 이미지 다운로드${NC}" +echo -e "${YELLOW}[3/9] 최신 이미지 다운로드${NC}" docker pull ${REGISTRY}:latest NEW_IMAGE=$(docker images ${REGISTRY}:latest -q || true) if [ -z "${NEW_IMAGE:-}" ]; then @@ -67,7 +66,7 @@ if [ ! -f ".env" ]; then exit 1 fi -echo -e "${YELLOW}[4/10] DB 컨테이너 확인${NC}" +echo -e "${YELLOW}[4/9] DB 컨테이너 확인(없으면 시작)${NC}" if ! docker ps --format '{{.Names}}' | grep -q '^souzip-dev-db$'; then docker compose -f "$DB_COMPOSE" up -d if [ $? -ne 0 ]; then @@ -79,31 +78,11 @@ else echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}" fi -echo -e "${YELLOW}[5/10] 모니터링 스택 확인${NC}" -PROMETHEUS_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-prometheus-dev$' && echo "true" || echo "false") -GRAFANA_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-grafana-dev$' && echo "true" || echo "false") - -if [ "$PROMETHEUS_RUNNING" = "false" ] || [ "$GRAFANA_RUNNING" = "false" ]; then - if [ -f "$MONITORING_COMPOSE" ]; then - echo -e "${YELLOW}[INFO] 모니터링 스택 시작 중...${NC}" - docker compose -f "$MONITORING_COMPOSE" up -d - if [ $? -ne 0 ]; then - echo -e "${RED}[WARNING] 모니터링 스택 시작 실패 (계속 진행)${NC}" - else - echo -e "${GREEN}[SUCCESS] 모니터링 스택 시작 완료${NC}" - fi - else - echo -e "${YELLOW}[INFO] 모니터링 설정 파일 없음 ($MONITORING_COMPOSE)${NC}" - fi -else - echo -e "${GREEN}[SUCCESS] 모니터링 스택 이미 실행 중${NC}" -fi - -echo -e "${YELLOW}[6/10] 기존 APP 컨테이너 중지${NC}" +echo -e "${YELLOW}[5/9] 기존 APP 컨테이너 중지${NC}" docker compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f souzip-api 2>/dev/null || true echo -e "${GREEN}[SUCCESS] 기존 APP 컨테이너 중지 완료${NC}" -echo -e "${YELLOW}[7/10] 새 APP 컨테이너 시작${NC}" +echo -e "${YELLOW}[6/9] 새 APP 컨테이너 시작${NC}" docker compose -f "$APP_COMPOSE" up -d if [ $? -ne 0 ]; then echo -e "${RED}[ERROR] APP 컨테이너 시작 실패${NC}" @@ -113,10 +92,10 @@ if [ $? -ne 0 ]; then fi echo -e "${GREEN}[SUCCESS] 새 컨테이너 시작 완료${NC}" -echo -e "${YELLOW}[8/10] 애플리케이션 시작 대기${NC}" +echo -e "${YELLOW}[7/9] 애플리케이션 시작 대기${NC}" sleep 10 -echo -e "${YELLOW}[9/10] 헬스체크 (최대 ${MAX_RETRY}번 시도)${NC}" +echo -e "${YELLOW}[8/9] 헬스체크 (최대 ${MAX_RETRY}번 시도)${NC}" RETRY_COUNT=0 HEALTH_OK=false @@ -146,7 +125,7 @@ if [ "$HEALTH_OK" = false ]; then exit 1 fi -echo -e "${YELLOW}[10/10] 정리${NC}" +echo -e "${YELLOW}[9/9] 정리${NC}" docker image prune -f || true if [ ! -z "${DEVELOP_DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then diff --git a/deploy/dev/scripts/rollback.sh b/deploy/dev/scripts/rollback.sh index 177aa933..c3d32309 100644 --- a/deploy/dev/scripts/rollback.sh +++ b/deploy/dev/scripts/rollback.sh @@ -36,8 +36,7 @@ echo -e "${GREEN}[SUCCESS] 롤백 이미지 발견: ${PREVIOUS_IMAGE}${NC}" echo -e "${YELLOW}[2/6] 현재 APP 컨테이너 중지${NC}" cd "$DEPLOY_DIR" || exit 1 - -docker compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f souzip-api 2>/dev/null || true +docker-compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f souzip-api 2>/dev/null || true echo -e "${GREEN}[SUCCESS] APP 컨테이너 중지 완료${NC}" echo -e "${YELLOW}[3/6] 이전 버전으로 태그 변경${NC}" @@ -45,8 +44,7 @@ docker tag ${REGISTRY}:previous ${REGISTRY}:latest echo -e "${GREEN}[SUCCESS] 태그 변경 완료${NC}" echo -e "${YELLOW}[4/6] 이전 버전 APP 컨테이너 시작${NC}" - -docker compose -f "$APP_COMPOSE" up -d +docker-compose -f "$APP_COMPOSE" up -d echo -e "${GREEN}[SUCCESS] 이전 버전 컨테이너 시작 완료${NC}" echo -e "${YELLOW}[5/6] 애플리케이션 시작 대기${NC}" @@ -82,7 +80,7 @@ if [ "$HEALTH_OK" = true ]; then if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then source "$WORK_DIR/deploy/shared/discord-notify.sh" - notify_rollback_success "dev" + notify_rollback_success fi exit 0 else @@ -92,8 +90,4 @@ else docker logs --tail 50 souzip-api || true if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then - source "$WORK_DIR/deploy/shared/discord-notify.sh" - notify_rollback_failed "dev" - fi - exit 1 -fi + source "$WORK_DIR/d diff --git a/deploy/prod/docker-compose.blue.yaml b/deploy/prod/docker-compose.blue.yaml index 934dc837..bdeecb19 100644 --- a/deploy/prod/docker-compose.blue.yaml +++ b/deploy/prod/docker-compose.blue.yaml @@ -11,12 +11,9 @@ services: - TZ=Asia/Seoul env_file: - .env - volumes: - - /home/souzip-prod/secrets:/home/souzip-prod/secrets:ro - - /home/souzip-prod/logs/souzip-api-blue:/home/souzip-prod/logs/souzip-api restart: always networks: default: name: souzip-network - external: true + external: true \ No newline at end of file diff --git a/deploy/prod/docker-compose.db.yaml b/deploy/prod/docker-compose.db.yaml deleted file mode 100644 index e44a9ba2..00000000 --- a/deploy/prod/docker-compose.db.yaml +++ /dev/null @@ -1,34 +0,0 @@ -version: "3.8" - -services: - postgres: - image: postgis/postgis:16-3.4 - container_name: souzip-prod-db - restart: always - networks: - - souzip-network - ports: - - "127.0.0.1:5432:5432" - env_file: - - .env.prod - environment: - POSTGRES_DB: souzip_prod - POSTGRES_USER: ${PROD_POSTGRES_USER} - POSTGRES_PASSWORD: ${PROD_POSTGRES_PASSWORD} - TZ: Asia/Seoul - volumes: - - postgres-data:/var/lib/postgresql/data - - ./postgres/init:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${PROD_POSTGRES_USER} -d souzip_prod"] - interval: 10s - timeout: 5s - retries: 10 - -networks: - souzip-network: - external: true - -volumes: - postgres-data: - driver: local \ No newline at end of file diff --git a/deploy/prod/docker-compose.green.yaml b/deploy/prod/docker-compose.green.yaml index d62da854..dcddb22e 100644 --- a/deploy/prod/docker-compose.green.yaml +++ b/deploy/prod/docker-compose.green.yaml @@ -11,12 +11,9 @@ services: - TZ=Asia/Seoul env_file: - .env - volumes: - - /home/souzip-prod/secrets:/home/souzip-prod/secrets:ro - - /home/souzip-prod/logs/souzip-api-green:/home/souzip-prod/logs/souzip-api restart: always networks: default: name: souzip-network - external: true + external: true \ No newline at end of file diff --git a/deploy/prod/docker-compose.logging.yaml b/deploy/prod/docker-compose.logging.yaml deleted file mode 100644 index d0ab9aa2..00000000 --- a/deploy/prod/docker-compose.logging.yaml +++ /dev/null @@ -1,37 +0,0 @@ -version: '3.8' - -services: - loki: - image: grafana/loki:3.0.0 - container_name: souzip-loki-prod - ports: - - "127.0.0.1:3100:3100" - volumes: - - loki-data:/loki - - ./loki-config.yaml:/etc/loki/local-config.yaml - command: -config.file=/etc/loki/local-config.yaml - networks: - - souzip-network - restart: unless-stopped - - promtail: - image: grafana/promtail:3.0.0 - container_name: souzip-promtail-prod - volumes: - - /home/souzip-prod/logs:/logs:ro - - ./promtail.yaml:/etc/promtail/config.yaml:ro - - promtail-positions:/promtail - command: -config.file=/etc/promtail/config.yaml - networks: - - souzip-network - restart: unless-stopped - depends_on: - - loki - -networks: - souzip-network: - external: true - -volumes: - loki-data: - promtail-positions: diff --git a/deploy/prod/docker-compose.monitoring.yaml b/deploy/prod/docker-compose.monitoring.yaml deleted file mode 100644 index 2173d198..00000000 --- a/deploy/prod/docker-compose.monitoring.yaml +++ /dev/null @@ -1,78 +0,0 @@ -version: '3.8' - -services: - prometheus: - image: prom/prometheus:latest - container_name: souzip-prometheus-prod - ports: - - "127.0.0.1:9090:9090" - volumes: - - ./prometheus.yaml:/etc/prometheus/prometheus.yaml - - prometheus-data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yaml' - - '--storage.tsdb.retention.time=90d' - networks: - - souzip-network - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: souzip-grafana-prod - ports: - - "127.0.0.1:3000:3000" - volumes: - - grafana-data:/var/lib/grafana - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_USERS_ALLOW_SIGN_UP=false - - GF_SERVER_ROOT_URL=https://monitor.souzip.com - networks: - - souzip-network - restart: unless-stopped - depends_on: - - prometheus - - node-exporter: - image: prom/node-exporter:latest - container_name: souzip-node-exporter - ports: - - "127.0.0.1:9100:9100" - command: - - '--path.procfs=/host/proc' - - '--path.sysfs=/host/sys' - - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - networks: - - souzip-network - restart: unless-stopped - - cadvisor: - image: gcr.io/cadvisor/cadvisor:latest - container_name: souzip-cadvisor - ports: - - "127.0.0.1:8083:8080" - volumes: - - /:/rootfs:ro - - /var/run:/var/run:ro - - /sys:/sys:ro - - /var/lib/docker/:/var/lib/docker:ro - - /dev/disk/:/dev/disk:ro - privileged: true - devices: - - /dev/kmsg - networks: - - souzip-network - restart: unless-stopped - -networks: - souzip-network: - external: true - -volumes: - prometheus-data: - grafana-data: diff --git a/deploy/prod/loki-config.yaml b/deploy/prod/loki-config.yaml deleted file mode 100644 index 17f2cc37..00000000 --- a/deploy/prod/loki-config.yaml +++ /dev/null @@ -1,47 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - instance_addr: 127.0.0.1 - path_prefix: /loki - storage: - filesystem: - chunks_directory: /loki/chunks - rules_directory: /loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2024-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -limits_config: - retention_period: 90d - reject_old_samples: true - reject_old_samples_max_age: 168h - -compactor: - working_directory: /loki/compactor - compaction_interval: 10m - retention_enabled: true - retention_delete_delay: 2h - retention_delete_worker_count: 150 - delete_request_store: filesystem \ No newline at end of file diff --git a/deploy/prod/prometheus.yaml b/deploy/prod/prometheus.yaml deleted file mode 100644 index 33ad773f..00000000 --- a/deploy/prod/prometheus.yaml +++ /dev/null @@ -1,38 +0,0 @@ -global: - scrape_interval: 60s - evaluation_interval: 60s - -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'souzip-api-blue' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['souzip-api-blue:8080'] - labels: - application: 'souzip-api' - environment: 'prod' - deployment: 'blue' - - - job_name: 'souzip-api-green' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['souzip-api-green:8080'] - labels: - application: 'souzip-api' - environment: 'prod' - deployment: 'green' - - - job_name: 'node-exporter' - static_configs: - - targets: ['souzip-node-exporter:9100'] - labels: - environment: 'prod' - - - job_name: 'cadvisor' - static_configs: - - targets: ['souzip-cadvisor:8080'] - labels: - environment: 'prod' diff --git a/deploy/prod/promtail.yaml b/deploy/prod/promtail.yaml deleted file mode 100644 index 966bf2de..00000000 --- a/deploy/prod/promtail.yaml +++ /dev/null @@ -1,28 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /promtail/positions.yaml - -clients: - - url: http://souzip-loki-prod:3100/loki/api/v1/push - -scrape_configs: - - job_name: souzip-api-blue - static_configs: - - targets: [localhost] - labels: - job: souzip-api - environment: prod - deployment: blue - __path__: /logs/souzip-api-blue/*.log - - - job_name: souzip-api-green - static_configs: - - targets: [localhost] - labels: - job: souzip-api - environment: prod - deployment: green - __path__: /logs/souzip-api-green/*.log diff --git a/deploy/prod/scripts/deploy.sh b/deploy/prod/scripts/deploy.sh index ebfe38ff..370af3d1 100755 --- a/deploy/prod/scripts/deploy.sh +++ b/deploy/prod/scripts/deploy.sh @@ -13,9 +13,6 @@ DEPLOY_DIR="$WORK_DIR/deploy/prod" BLUE_COMPOSE="docker-compose.blue.yaml" GREEN_COMPOSE="docker-compose.green.yaml" -MONITORING_COMPOSE="docker-compose.monitoring.yaml" -LOGGING_COMPOSE="docker-compose.logging.yaml" -DB_COMPOSE="docker-compose.db.yaml" BLUE_PROJECT="souzip-blue" GREEN_PROJECT="souzip-green" @@ -25,23 +22,12 @@ NGINX_UPSTREAM_FILE="/etc/nginx/conf.d/upstream-souzip.conf" BLUE_PORT=8081 GREEN_PORT=8082 -MAX_RETRY=12 -RETRY_INTERVAL=15 - -ENV_FILE="$DEPLOY_DIR/.env" -if [ -f "$ENV_FILE" ]; then - while IFS='=' read -r key value; do - case "$key" in - DISCORD_WEBHOOK_URL|PROD_DISCORD_WEBHOOK_URL|PROD_API_DOCS_URL|COMMIT_MESSAGE|DEPLOYER) - export "$key=$value" - ;; - esac - done < "$ENV_FILE" -fi +MAX_RETRY=6 +RETRY_INTERVAL=10 cd "$WORK_DIR" || exit 1 -echo -e "${YELLOW}[1/9] 최신 이미지 다운로드${NC}" +echo -e "${YELLOW}[1/7] 최신 이미지 다운로드${NC}" docker pull ${REGISTRY}:latest @@ -61,7 +47,7 @@ cd "$DEPLOY_DIR" || exit 1 exit 1 } -echo -e "${YELLOW}[2/9] 현재 active 포트 확인${NC}" +echo -e "${YELLOW}[2/7] 현재 active 포트 확인${NC}" CURRENT_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$NGINX_UPSTREAM_FILE" | cut -d: -f2 | head -n 1 || true) @@ -78,6 +64,7 @@ if [ "$CURRENT_PORT" == "$BLUE_PORT" ]; then STOP_PROJECT=$BLUE_PROJECT STOP_COMPOSE=$BLUE_COMPOSE + else TARGET="blue" TARGET_PORT=$BLUE_PORT @@ -90,64 +77,12 @@ fi echo -e "${GREEN}[INFO] 현재:$CURRENT_PORT → 배포:$TARGET($TARGET_PORT)${NC}" -echo -e "${YELLOW}[3/9] DB 컨테이너 확인${NC}" -if ! docker ps --format '{{.Names}}' | grep -q '^souzip-prod-db$'; then - docker compose -f "$DB_COMPOSE" up -d - if [ $? -ne 0 ]; then - echo -e "${RED}[ERROR] DB 컨테이너 시작 실패${NC}" - exit 1 - fi - echo -e "${GREEN}[SUCCESS] DB 컨테이너 시작 완료${NC}" -else - echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}" -fi - -echo -e "${YELLOW}[4/9] 모니터링 스택 확인${NC}" -PROMETHEUS_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-prometheus-prod$' && echo "true" || echo "false") -GRAFANA_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-grafana-prod$' && echo "true" || echo "false") -NODE_EXPORTER_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-node-exporter$' && echo "true" || echo "false") -CADVISOR_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-cadvisor$' && echo "true" || echo "false") - -if [ "$PROMETHEUS_RUNNING" = "false" ] || [ "$GRAFANA_RUNNING" = "false" ] || [ "$NODE_EXPORTER_RUNNING" = "false" ] || [ "$CADVISOR_RUNNING" = "false" ]; then - if [ -f "$MONITORING_COMPOSE" ]; then - echo -e "${YELLOW}[INFO] 모니터링 스택 시작 중...${NC}" - if docker compose -f "$MONITORING_COMPOSE" up -d; then - echo -e "${GREEN}[SUCCESS] 모니터링 스택 시작 완료${NC}" - else - echo -e "${RED}[WARNING] 모니터링 스택 시작 실패 (계속 진행)${NC}" - fi - else - echo -e "${YELLOW}[INFO] 모니터링 설정 파일 없음 ($MONITORING_COMPOSE)${NC}" - fi -else - echo -e "${GREEN}[SUCCESS] 모니터링 스택 이미 실행 중${NC}" -fi - -echo -e "${YELLOW}[4.5/9] 로그 수집 스택 확인${NC}" -LOKI_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-loki-prod$' && echo "true" || echo "false") -PROMTAIL_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-promtail-prod$' && echo "true" || echo "false") - -if [ "$LOKI_RUNNING" = "false" ] || [ "$PROMTAIL_RUNNING" = "false" ]; then - if [ -f "$LOGGING_COMPOSE" ]; then - echo -e "${YELLOW}[INFO] 로그 수집 스택 시작 중...${NC}" - if docker compose -f "$LOGGING_COMPOSE" up -d; then - echo -e "${GREEN}[SUCCESS] 로그 수집 스택 시작 완료${NC}" - else - echo -e "${RED}[WARNING] 로그 수집 스택 시작 실패 (계속 진행)${NC}" - fi - else - echo -e "${YELLOW}[INFO] 로그 수집 설정 파일 없음 ($LOGGING_COMPOSE)${NC}" - fi -else - echo -e "${GREEN}[SUCCESS] 로그 수집 스택 이미 실행 중${NC}" -fi - -echo -e "${YELLOW}[5/9] $TARGET 컨테이너 실행${NC}" +echo -e "${YELLOW}[3/7] $TARGET 컨테이너 실행${NC}" docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" pull docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" up -d -echo -e "${YELLOW}[6/9] 헬스체크 시작${NC}" +echo -e "${YELLOW}[4/7] 헬스체크 시작${NC}" RETRY_COUNT=0 HEALTH_OK=false @@ -167,17 +102,12 @@ while [ $RETRY_COUNT -lt $MAX_RETRY ]; do done if [ "$HEALTH_OK" = false ]; then - echo -e "${RED}[ERROR] 헬스체크 실패 - 자동 롤백 완료 (nginx 전환 없음)${NC}" + echo -e "${RED}[ERROR] 헬스체크 실패${NC}" docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" down - - if [ ! -z "${PROD_DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then - source "$WORK_DIR/deploy/shared/discord-notify.sh" - notify_rollback_success "prod" - fi exit 1 fi -echo -e "${YELLOW}[7/9] nginx upstream 전환${NC}" +echo -e "${YELLOW}[5/7] nginx upstream 전환${NC}" sudo tee "$NGINX_UPSTREAM_FILE" > /dev/null < /dev/null \ No newline at end of file diff --git a/deploy/shared/discord-notify.sh b/deploy/shared/discord-notify.sh index 3ae2a0bb..1abcede0 100644 --- a/deploy/shared/discord-notify.sh +++ b/deploy/shared/discord-notify.sh @@ -260,4 +260,4 @@ notify_container_stopped() { }') curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null -} +} \ No newline at end of file diff --git a/deploy/shared/system-monitor.sh b/deploy/shared/system-monitor.sh index 014d0a1f..6d5c92b7 100644 --- a/deploy/shared/system-monitor.sh +++ b/deploy/shared/system-monitor.sh @@ -39,31 +39,20 @@ else CONTAINER_WARNING_SENT=false fi -NGINX_UPSTREAM_FILE="/etc/nginx/conf.d/upstream-souzip.conf" - if [ "$ENV" = "dev" ]; then HEALTH_CHECK_URL="http://localhost:8080/actuator/health" CONTAINER_NAME="souzip-api" else - if [ -f "$NGINX_UPSTREAM_FILE" ]; then - ACTIVE_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$NGINX_UPSTREAM_FILE" | cut -d: -f2 | head -n 1 || true) - else - ACTIVE_PORT="" - fi - - case "$ACTIVE_PORT" in - 8081) + CONTAINER_NAME="souzip-api-${CONTAINER_COLOR:-blue}" + case "$CONTAINER_COLOR" in + blue) HEALTH_CHECK_URL="http://localhost:8081/actuator/health" - CONTAINER_NAME="souzip-api-blue" ;; - 8082) + green) HEALTH_CHECK_URL="http://localhost:8082/actuator/health" - CONTAINER_NAME="souzip-api-green" ;; *) - echo "[WARN] nginx upstream에서 active 포트를 읽지 못했습니다. blue(8081)로 fallback합니다." HEALTH_CHECK_URL="http://localhost:8081/actuator/health" - CONTAINER_NAME="souzip-api-blue" ;; esac fi @@ -76,7 +65,7 @@ if curl -f -s --max-time 5 $HEALTH_CHECK_URL > /dev/null 2>&1; then DOWN_DURATION=$(( UP_TIMESTAMP - DOWN_TIMESTAMP )) DOWNTIME_MIN=$(( DOWN_DURATION / 60 )) if [ ! -z "$DISCORD_WEBHOOK_URL" ]; then - notify_server_up "$ENV" "${DOWNTIME_MIN}분" + notify_server_up "${DOWNTIME_MIN}분" "$ENV" fi fi HEALTH_STATUS="up" @@ -101,7 +90,7 @@ DISK_PERCENT=$(df / | awk 'NR==2 {print int($5)}') if [ $DISK_PERCENT -ge $DISK_THRESHOLD ]; then if [ "$DISK_WARNING_SENT" = "false" ]; then if [ ! -z "$DISCORD_WEBHOOK_URL" ]; then - notify_disk_warning "$ENV" "$DISK_USAGE" "$DISK_PERCENT" + notify_disk_warning "$DISK_USAGE" "$DISK_PERCENT" "$ENV" fi DISK_WARNING_SENT=true fi @@ -114,7 +103,7 @@ MEMORY_PERCENT=$(free | awk 'NR==2 {print int($3/$2 * 100)}') if [ $MEMORY_PERCENT -ge $MEMORY_THRESHOLD ]; then if [ "$MEMORY_WARNING_SENT" = "false" ]; then if [ ! -z "$DISCORD_WEBHOOK_URL" ]; then - notify_memory_warning "$ENV" "$MEMORY_USAGE" "$MEMORY_PERCENT" + notify_memory_warning "$MEMORY_USAGE" "$MEMORY_PERCENT" "$ENV" fi MEMORY_WARNING_SENT=true fi @@ -134,13 +123,11 @@ else CONTAINER_WARNING_SENT=false fi -TEMP_STATUS_FILE=$(mktemp "${STATUS_FILE}.tmp.XXXXXX") -cat > "$TEMP_STATUS_FILE" << EOF +cat > "$STATUS_FILE" << EOF HEALTH_STATUS="$HEALTH_STATUS" HEALTH_FAILURE_COUNT=$HEALTH_FAILURE_COUNT HEALTH_DOWN_SINCE="$HEALTH_DOWN_SINCE" DISK_WARNING_SENT=$DISK_WARNING_SENT MEMORY_WARNING_SENT=$MEMORY_WARNING_SENT CONTAINER_WARNING_SENT=$CONTAINER_WARNING_SENT -EOF -mv "$TEMP_STATUS_FILE" "$STATUS_FILE" +EOF \ No newline at end of file diff --git a/src/docs/asciidoc/app/index.adoc b/src/docs/asciidoc/app/index.adoc index bb5a6ec2..2095e924 100644 --- a/src/docs/asciidoc/app/index.adoc +++ b/src/docs/asciidoc/app/index.adoc @@ -15,7 +15,6 @@ include::../shared/overview.adoc[] include::auth.adoc[] include::user.adoc[] include::souvenir.adoc[] -include::wishlist.adoc[] include::category.adoc[] include::country.adoc[] include::discovery.adoc[] diff --git a/src/docs/asciidoc/app/souvenir.adoc b/src/docs/asciidoc/app/souvenir.adoc index 0e981db6..bc17c4f1 100644 --- a/src/docs/asciidoc/app/souvenir.adoc +++ b/src/docs/asciidoc/app/souvenir.adoc @@ -2,14 +2,13 @@ == Souvenir API [[souvenir-get]] -=== 기념품 조회 (new) +=== 기념품 조회 기념품 상세 정보를 조회하는 API입니다. * 인증 불필요 (Public API) * 삭제된(soft delete) 기념품은 조회할 수 없습니다. -* 인증 불필요하지만, 로그인한 사용자가 조회하면 내 기념품 여부(isOwned), 찜 여부(isWishlisted) 함께 반환 -* 비로그인 사용자의 경우 `isWishlisted` 는 `null` 로 반환 +* 인증 불필요하지만, 로그인한 사용자가 조회하면 내 기념품 여부(isOwned) 함께 반환 operation::souvenirs/get-souvenir[snippets='http-request,path-parameters,http-response,response-fields'] @@ -20,7 +19,7 @@ NOTE: v2 응답 구조 - `price` 필드에 원본 가격(original)과 등록 시 WARNING: 응답의 `localPrice`, `currencySymbol`, `krwPrice` 필드는 v1 호환성을 위해 임시로 제공되며, 향후 제거될 예정입니다. `price` 객체(`price.original.amount`, `price.original.symbol`, `price.converted.amount`) 사용을 권장합니다. [[souvenir-create]] -=== 기념품 생성 +=== 기념품 생성 (New) 사용자가 새로운 기념품을 등록하는 v2 API입니다. @@ -60,7 +59,7 @@ operation::souvenirs/create-souvenir[snippets='http-request,request-parts,reques NOTE: 기념품 가격은 현지 통화 또는 원화 기준으로 입력할 수 있으며, 서버에서 환율이 자동 적용됩니다. [[souvenir-update]] -=== 기념품 수정 +=== 기념품 수정 (New) v2 API를 사용한 기념품 수정입니다. @@ -107,7 +106,7 @@ operation::souvenirs/delete-souvenir[snippets='http-request,path-parameters,http NOTE: 삭제 성공 시 `data`는 반환되지 않으며, 메시지만 전달됩니다. [[souvenir-nearby]] -=== 근처 기념품 조회 (new) +=== 근처 기념품 조회 위도/경도를 통해 특정 반경 내 근처 기념품을 조회하는 API입니다. @@ -115,6 +114,5 @@ NOTE: 삭제 성공 시 `data`는 반환되지 않으며, 메시지만 전달됩 * 인증 불필요 (Public API) * 삭제된(soft delete) 기념품은 조회할 수 없습니다. * 조회 반경 단위는 미터(m) -* 로그인한 사용자가 조회하면 찜 여부(isWishlisted) 함께 반환, 비로그인 시 `null` operation::souvenirs/get-nearby-souvenirs[snippets='http-request,query-parameters,http-response,response-fields'] diff --git a/src/docs/asciidoc/app/user.adoc b/src/docs/asciidoc/app/user.adoc index 86d700f9..9a077318 100644 --- a/src/docs/asciidoc/app/user.adoc +++ b/src/docs/asciidoc/app/user.adoc @@ -94,24 +94,4 @@ operation::user/my-souvenirs-page-2[snippets='http-request,query-parameters,http ==== 등록한 기념품이 없는 경우 operation::user/my-souvenirs-empty[snippets='http-request,query-parameters,http-response,response-fields'] -NOTE: 기념품 목록은 생성일시 기준 내림차순으로 정렬됩니다. - -[[user-my-wishlists]] -==== 내가 찜한 기념품 목록 조회 (new) - -찜 탭에 표시할 기념품 목록을 조회합니다. - -* 인증 필요 -* 찜한 순서(최신순)로 정렬됩니다. - -operation::user/my-wishlists[snippets='http-request,query-parameters,http-response,response-fields'] - -==== 페이징 예시 - -===== 2페이지 조회 -operation::user/my-wishlists-page-2[snippets='http-request,query-parameters,http-response,response-fields'] - -==== 찜한 기념품이 없는 경우 -operation::user/my-wishlists-empty[snippets='http-request,query-parameters,http-response,response-fields'] - -NOTE: 기념품 목록은 찜한 시간 기준 내림차순으로 정렬됩니다. +NOTE: 기념품 목록은 생성일시 기준 내림차순으로 정렬됩니다. 찜 탭 기능은 추후 구현 예정입니다. diff --git a/src/docs/asciidoc/app/wishlist.adoc b/src/docs/asciidoc/app/wishlist.adoc deleted file mode 100644 index d21edd74..00000000 --- a/src/docs/asciidoc/app/wishlist.adoc +++ /dev/null @@ -1,24 +0,0 @@ -[[wishlist-api]] -== Wishlist API - -[[wishlist-add]] -=== 찜 등록 (new) - -기념품을 찜 목록에 추가하는 API입니다. - -* 엔드포인트: `POST /api/wishlists/{souvenirId}` -* 인증 필요 -* 이미 찜한 기념품에 다시 찜 요청을 보내면 에러가 반환됩니다. - -operation::wishlists/add[snippets='http-request,path-parameters,http-response,response-fields'] - -[[wishlist-remove]] -=== 찜 취소 (new) - -찜 목록에서 기념품을 제거하는 API입니다. - -* 엔드포인트: `DELETE /api/wishlists/{souvenirId}` -* 인증 필요 -* 찜하지 않은 기념품을 취소하려 하면 에러가 반환됩니다. - -operation::wishlists/remove[snippets='http-request,path-parameters,http-response,response-fields'] \ No newline at end of file diff --git a/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java b/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java index b21c9847..44735ac0 100644 --- a/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java +++ b/src/main/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocoding.java @@ -5,7 +5,7 @@ import com.souzip.adapter.integration.googlegeocoding.dto.GoogleGeocodingResponse.Result; import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.required.AddressProvider; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java b/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java index d5a3b24b..a7b5d913 100644 --- a/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java +++ b/src/main/java/com/souzip/adapter/integration/googleplaces/GooglePlaces.java @@ -4,7 +4,8 @@ import com.souzip.adapter.integration.googleplaces.dto.GooglePlacesSearchResponse.Result; import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.required.PlaceSearchProvider; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -13,7 +14,6 @@ import java.math.BigDecimal; import java.util.List; -import java.util.Optional; @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java b/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java index dee542f4..821a9b5d 100644 --- a/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java +++ b/src/main/java/com/souzip/adapter/storage/file/NcpStorage.java @@ -4,8 +4,8 @@ import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; import com.souzip.application.file.required.FileStorage; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java deleted file mode 100644 index 6c3f0f2d..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.webapi.admin.dto.EmailBroadcastRequest; -import com.souzip.adapter.webapi.admin.dto.EmailTestSendRequest; -import com.souzip.application.email.EmailBroadcastService; -import com.souzip.application.email.dto.EmailBroadcastResult; -import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; -import com.souzip.shared.common.dto.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequestMapping("/api/admin/email") -@RequiredArgsConstructor -@RestController -public class AdminEmailApi { - - private final EmailBroadcastService emailBroadcastService; - - // 한 주소로만 테스트 발송합니다(실회원 DB 조회 없음). - @AdminAccess - @PostMapping("/test-send") - public SuccessResponse testSend(@Valid @RequestBody EmailTestSendRequest request) { - EmailBroadcastResult result = emailBroadcastService.sendTestToSingleAddress( - request.to(), - request.subject(), - request.body() - ); - return SuccessResponse.of(result, buildTestMessage(result)); - } - - // 전체 회원(이메일 보유·탈퇴 제외) 고유 주소로 동일 메일을 발송합니다. - @AdminAccess - @PostMapping("/broadcast") - public SuccessResponse broadcast(@Valid @RequestBody EmailBroadcastRequest request) { - EmailBroadcastResult result = emailBroadcastService.broadcastToAllMemberEmails( - request.subject(), - request.body() - ); - return SuccessResponse.of(result, buildMessage(result)); - } - - private static String buildTestMessage(EmailBroadcastResult result) { - if (!result.smtpConfigured()) { - return "SMTP가 설정되지 않아 테스트 메일을 보내지 않았습니다."; - } - if (result.successCount() > 0) { - return "테스트 메일을 발송했습니다."; - } - return "테스트 메일 발송에 실패했습니다."; - } - - private static String buildMessage(EmailBroadcastResult result) { - if (result.totalTargets() == 0) { - return "발송 대상 이메일이 없어 전송하지 않았습니다."; - } - if (!result.smtpConfigured()) { - return "SMTP가 설정되지 않아 전송하지 않았습니다. (대상 " + result.totalTargets() + "건)"; - } - return String.format( - "이메일 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건", - result.totalTargets(), - result.successCount(), - result.failCount() - ); - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java index f843539f..6b7c940a 100644 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java +++ b/src/main/java/com/souzip/adapter/webapi/admin/AdminNoticeApi.java @@ -8,16 +8,13 @@ import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; import com.souzip.domain.notice.Notice; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; import jakarta.validation.Valid; - import java.util.Optional; - import lombok.RequiredArgsConstructor; import java.util.List; import java.util.UUID; - import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java deleted file mode 100644 index 7464dcd2..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.webapi.admin.dto.PushBroadcastRequest; -import com.souzip.application.notification.FcmNotificationService; -import com.souzip.application.notification.PushBroadcastHistoryCommandService; -import com.souzip.application.notification.PushBroadcastHistoryQueryService; -import com.souzip.application.notification.dto.MarketingConsentStats; -import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; -import com.souzip.application.notification.dto.PushBroadcastResult; -import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; -import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; -import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; -import com.souzip.domain.user.repository.UserAgreementRepository; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@RequestMapping("/api/admin/push") -@RequiredArgsConstructor -@RestController -public class AdminPushApi { - - private final FcmNotificationService fcmNotificationService; - private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService; - private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService; - private final UserAgreementRepository userAgreementRepository; - - // 마케팅 수신 동의한 사용자에게만 푸시를 발송합니다. - @AdminAccess - @PostMapping("/broadcast") - public SuccessResponse broadcast( - @CurrentAdminId UUID adminId, - @Valid @RequestBody PushBroadcastRequest request - ) { - PushBroadcastResult result = fcmNotificationService.broadcastToMarketingConsentedTokens( - request.title(), - request.body() - ); - pushBroadcastHistoryCommandService.record(adminId, request.title(), request.body(), result); - String message = buildMessage(result); - return SuccessResponse.of(result, message); - } - - // 마케팅 수신 동의 현황을 조회합니다. - @ViewerAccess - @GetMapping("/marketing-consent/stats") - public SuccessResponse marketingConsentStats() { - long total = userAgreementRepository.count(); - long consented = userAgreementRepository.countByMarketingConsentTrue(); - return SuccessResponse.of(new MarketingConsentStats(total, consented, total - consented)); - } - - // 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. - @ViewerAccess - @GetMapping("/broadcast/history") - public SuccessResponse> broadcastHistory( - @ModelAttribute PaginationRequest paginationRequest - ) { - PaginationResponse page = - pushBroadcastHistoryQueryService.findPage(paginationRequest); - return SuccessResponse.of(page); - } - - private static String buildMessage(PushBroadcastResult result) { - if (result.totalTargets() == 0) { - return "등록된 활성 기기가 없어 전송하지 않았습니다."; - } - if (!result.firebaseConfigured()) { - return "Firebase가 설정되지 않아 전송하지 않았습니다. (대상 기기 " + result.totalTargets() + "건)"; - } - return String.format( - "푸시 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건", - result.totalTargets(), - result.successCount(), - result.failCount() - ); - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java deleted file mode 100644 index 47402e5e..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record EmailBroadcastRequest( - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") - String subject, - - @NotBlank(message = "본문은 필수입니다.") - @Size(max = 20000, message = "본문은 20000자를 초과할 수 없습니다.") - String body -) { - public EmailBroadcastRequest { - subject = subject != null ? subject.trim() : null; - body = body != null ? body.trim() : null; - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java deleted file mode 100644 index 65299c32..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record EmailTestSendRequest( - @NotBlank(message = "수신 이메일은 필수입니다.") - @Email(message = "수신 이메일 형식이 올바르지 않습니다.") - @Size(max = 320, message = "수신 이메일이 너무 깁니다.") - String to, - - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") - String subject, - - @NotBlank(message = "본문은 필수입니다.") - @Size(max = 20000, message = "본문은 20000자를 초과할 수 없습니다.") - String body -) { - public EmailTestSendRequest { - to = to != null ? to.trim() : null; - subject = subject != null ? subject.trim() : null; - body = body != null ? body.trim() : null; - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java deleted file mode 100644 index cc63fc20..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record PushBroadcastRequest( - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") - String title, - - @NotBlank(message = "내용은 필수입니다.") - @Size(max = 1000, message = "내용은 1000자를 초과할 수 없습니다.") - String body -) { - public PushBroadcastRequest { - title = title != null ? title.trim() : null; - body = body != null ? body.trim() : null; - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java b/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java index d1c1227f..413e8589 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java +++ b/src/main/java/com/souzip/adapter/webapi/location/GeocodingApi.java @@ -3,14 +3,11 @@ import com.souzip.adapter.webapi.location.dto.AddressResponse; import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.RequireAuth; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; diff --git a/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java b/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java index b895cc21..6e38ab20 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java +++ b/src/main/java/com/souzip/adapter/webapi/location/LocationApi.java @@ -6,18 +6,15 @@ import com.souzip.application.location.dto.SearchResult; import com.souzip.application.location.provided.LocationSearch; import com.souzip.application.location.provided.ReverseGeocoding; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.RequireAuth; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; -import java.util.List; @Slf4j @RequestMapping("/api/location") diff --git a/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java b/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java index 542e5681..13a1d706 100644 --- a/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java +++ b/src/main/java/com/souzip/adapter/webapi/location/dto/SearchResponse.java @@ -6,8 +6,7 @@ import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.dto.SearchResult; import com.souzip.domain.city.entity.City; -import com.souzip.shared.domain.Coordinate; - +import com.souzip.domain.shared.Coordinate; import java.util.List; @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java b/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java index c7c6068c..57254c86 100644 --- a/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java +++ b/src/main/java/com/souzip/adapter/webapi/user/NoticeApi.java @@ -2,7 +2,7 @@ import com.souzip.application.notice.dto.NoticeResponse; import com.souzip.application.notice.provided.NoticeFinder; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/souzip/application/email/EmailBroadcastService.java b/src/main/java/com/souzip/application/email/EmailBroadcastService.java deleted file mode 100644 index 0a66817a..00000000 --- a/src/main/java/com/souzip/application/email/EmailBroadcastService.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.souzip.application.email; - -import com.souzip.application.email.dto.EmailBroadcastResult; -import com.souzip.application.email.provided.UserEmailFinder; -import com.souzip.shared.config.MailSmtpProperties; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; - -import java.util.List; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.mail.MailException; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EmailBroadcastService { - - private final UserEmailFinder userEmailFinder; - private final ObjectProvider javaMailSender; - private final MailSmtpProperties mailSmtpProperties; - - // 지정한 한 주소로만 발송합니다(연동·문구 검증용). - public EmailBroadcastResult sendTestToSingleAddress(String to, String subject, String body) { - JavaMailSender sender = javaMailSender.getIfAvailable(); - if (sender == null || !mailSmtpProperties.isEnabled()) { - log.warn("JavaMailSender 가 없거나 mail.smtp.enabled=false 입니다. 테스트 메일을 보내지 않습니다."); - return new EmailBroadcastResult(1, 0, 0, false); - } - String from = resolveFromAddress(); - try { - sendPlain(from, to, subject, body, sender); - return new EmailBroadcastResult(1, 1, 0, true); - } catch (MessagingException | MailException e) { - log.warn("테스트 이메일 발송 실패 toMasked={} error={}", maskEmail(to), e.getMessage()); - return new EmailBroadcastResult(1, 0, 1, true); - } - } - - // SMTP가 설정된 경우 전체(탈퇴 제외) 회원의 고유 이메일로 동일 제목·본문을 발송합니다. - public EmailBroadcastResult broadcastToAllMemberEmails(String subject, String body) { - List emails = userEmailFinder.findDistinctEmailsForActiveUsers(); - if (emails.isEmpty()) { - return new EmailBroadcastResult(0, 0, 0, isSmtpReady()); - } - JavaMailSender sender = javaMailSender.getIfAvailable(); - if (sender == null || !mailSmtpProperties.isEnabled()) { - log.warn("JavaMailSender 가 없거나 mail.smtp.enabled=false 입니다. 이메일 발송을 건너뜁니다. 대상={}건", emails.size()); - return new EmailBroadcastResult(emails.size(), 0, 0, false); - } - String from = resolveFromAddress(); - int success = 0; - int fail = 0; - for (String to : emails) { - try { - sendPlain(from, to, subject, body, sender); - success++; - } catch (MessagingException | MailException e) { - fail++; - log.warn("이메일 발송 실패(다음 주소로 계속) toDomain={} error={}", maskEmail(to), e.getMessage()); - } - } - return new EmailBroadcastResult(emails.size(), success, fail, true); - } - - private boolean isSmtpReady() { - return javaMailSender.getIfAvailable() != null && mailSmtpProperties.isEnabled(); - } - - private void sendPlain(String from, String to, String subject, String body, JavaMailSender sender) - throws MessagingException { - MimeMessage message = sender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); - helper.setFrom(from); - helper.setTo(to); - helper.setSubject(subject); - helper.setText(body, false); - sender.send(message); - } - - private String resolveFromAddress() { - if (StringUtils.hasText(mailSmtpProperties.getFrom())) { - return mailSmtpProperties.getFrom(); - } - if (StringUtils.hasText(mailSmtpProperties.getUsername())) { - return mailSmtpProperties.getUsername(); - } - throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED, "발신 주소(mail.smtp.from 또는 username)가 없습니다."); - } - - private static String maskEmail(String email) { - if (email == null || !email.contains("@")) { - return "***"; - } - int at = email.indexOf('@'); - if (at <= 2) { - return "***@" + email.substring(at + 1); - } - return email.substring(0, 2) + "***@" + email.substring(at + 1); - } -} diff --git a/src/main/java/com/souzip/application/email/UserEmailQueryService.java b/src/main/java/com/souzip/application/email/UserEmailQueryService.java deleted file mode 100644 index 397be644..00000000 --- a/src/main/java/com/souzip/application/email/UserEmailQueryService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.souzip.application.email; - -import com.souzip.application.email.provided.UserEmailFinder; -import com.souzip.domain.user.repository.UserRepository; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Service -public class UserEmailQueryService implements UserEmailFinder { - - private final UserRepository userRepository; - - @Override - public List findDistinctEmailsForActiveUsers() { - return userRepository.findDistinctEmailsByActiveUsers().stream() - .filter(e -> e != null && !e.isBlank()) - .distinct() - .toList(); - } -} diff --git a/src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java b/src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java deleted file mode 100644 index 3a3eee44..00000000 --- a/src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.application.email.dto; - -public record EmailBroadcastResult( - int totalTargets, - int successCount, - int failCount, - boolean smtpConfigured -) { -} diff --git a/src/main/java/com/souzip/application/email/provided/UserEmailFinder.java b/src/main/java/com/souzip/application/email/provided/UserEmailFinder.java deleted file mode 100644 index 5e815d0f..00000000 --- a/src/main/java/com/souzip/application/email/provided/UserEmailFinder.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.application.email.provided; - -import java.util.List; - -public interface UserEmailFinder { - - List findDistinctEmailsForActiveUsers(); -} diff --git a/src/main/java/com/souzip/application/location/ReverseGeocodingService.java b/src/main/java/com/souzip/application/location/ReverseGeocodingService.java index a878f4e1..1393c9a5 100644 --- a/src/main/java/com/souzip/application/location/ReverseGeocodingService.java +++ b/src/main/java/com/souzip/application/location/ReverseGeocodingService.java @@ -3,7 +3,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; import com.souzip.application.location.required.AddressProvider; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/souzip/application/location/dto/SearchPlace.java b/src/main/java/com/souzip/application/location/dto/SearchPlace.java index 37d5ba47..23f0c16b 100644 --- a/src/main/java/com/souzip/application/location/dto/SearchPlace.java +++ b/src/main/java/com/souzip/application/location/dto/SearchPlace.java @@ -1,6 +1,6 @@ package com.souzip.application.location.dto; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; public record SearchPlace( String name, diff --git a/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java b/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java index c5071de9..2b012523 100644 --- a/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java +++ b/src/main/java/com/souzip/application/location/provided/ReverseGeocoding.java @@ -1,7 +1,7 @@ package com.souzip.application.location.provided; import com.souzip.application.location.dto.AddressResult; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; public interface ReverseGeocoding { diff --git a/src/main/java/com/souzip/application/location/required/AddressProvider.java b/src/main/java/com/souzip/application/location/required/AddressProvider.java index 9fa20fb9..fb2666b4 100644 --- a/src/main/java/com/souzip/application/location/required/AddressProvider.java +++ b/src/main/java/com/souzip/application/location/required/AddressProvider.java @@ -1,7 +1,7 @@ package com.souzip.application.location.required; import com.souzip.application.location.dto.AddressResult; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; public interface AddressProvider { diff --git a/src/main/java/com/souzip/application/notification/FcmNotificationService.java b/src/main/java/com/souzip/application/notification/FcmNotificationService.java deleted file mode 100644 index 14207f0d..00000000 --- a/src/main/java/com/souzip/application/notification/FcmNotificationService.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.souzip.application.notification; - -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.Notification; -import com.souzip.application.notification.dto.PushBroadcastResult; -import com.souzip.application.notification.provided.FcmTokenFinder; -import com.souzip.domain.notification.FcmToken; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class FcmNotificationService { - - private final FcmTokenFinder fcmTokenFinder; - private final ObjectProvider firebaseMessaging; - - // 단일 기기 토큰으로 알림(제목·본문)을 전송합니다. - public void sendToToken(String registrationToken, String title, String body) { - sendToToken(registrationToken, title, body, Map.of()); - } - - // 데이터 페이로드를 포함해 전송합니다. - public void sendToToken(String registrationToken, String title, String body, Map data) { - FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); - if (messaging == null) { - log.warn("FirebaseMessaging 빈이 없습니다. firebase.enabled 와 credentials 를 확인하세요. 전송을 건너뜁니다."); - return; - } - try { - Notification notification = Notification.builder() - .setTitle(title) - .setBody(body) - .build(); - Message.Builder messageBuilder = Message.builder() - .setToken(registrationToken) - .setNotification(notification); - if (data != null && !data.isEmpty()) { - messageBuilder.putAllData(new HashMap<>(data)); - } - messaging.send(messageBuilder.build()); - } catch (FirebaseMessagingException e) { - log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), e); - throw new BusinessException(ErrorCode.FCM_SEND_FAILED, e.getMessage()); - } - } - - // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송은 트랜잭션 밖에서 수행합니다. - public void sendToUser(Long userId, String title, String body) { - List tokens = fcmTokenFinder.getActiveTokensByUserId(userId); - if (tokens.isEmpty()) { - return; - } - int successCount = 0; - int failCount = 0; - for (FcmToken token : tokens) { - try { - sendToToken(token.getToken(), title, body); - successCount++; - } catch (BusinessException e) { - failCount++; - log.warn( - "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}", - userId, - token.getId(), - e.getErrorCode() - ); - } - } - if (failCount > 0 && successCount == 0) { - throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다."); - } - if (failCount > 0) { - log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount); - } - } - - // 마케팅 수신 동의한 사용자의 활성 토큰에만 푸시를 발송합니다. - public PushBroadcastResult broadcastToMarketingConsentedTokens(String title, String body) { - List tokens = fcmTokenFinder.getAllActiveTokensWithMarketingConsent(); - return doBroadcast(tokens, title, body); - } - - // 활성 토큰 전체에 푸시를 발송합니다. - public PushBroadcastResult broadcastToAllActiveTokens(String title, String body) { - List tokens = fcmTokenFinder.getAllActiveTokens(); - return doBroadcast(tokens, title, body); - } - - private PushBroadcastResult doBroadcast(List tokens, String title, String body) { - if (tokens.isEmpty()) { - return new PushBroadcastResult(0, 0, 0, true); - } - FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); - if (messaging == null) { - log.warn("FirebaseMessaging 빈이 없습니다. 브로드캐스트를 건너뜁니다. 대상 기기 수={}", tokens.size()); - return new PushBroadcastResult(tokens.size(), 0, 0, false); - } - int successCount = 0; - int failCount = 0; - for (FcmToken token : tokens) { - try { - sendToToken(token.getToken(), title, body); - successCount++; - } catch (BusinessException e) { - failCount++; - log.warn( - "FCM 브로드캐스트 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}", - token.getId(), - e.getErrorCode() - ); - } - } - return new PushBroadcastResult(tokens.size(), successCount, failCount, true); - } - - private static String maskToken(String token) { - if (token == null || token.length() < 12) { - return "***"; - } - return token.substring(0, 6) + "..." + token.substring(token.length() - 4); - } -} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java b/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java deleted file mode 100644 index 826006ea..00000000 --- a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.required.FcmTokenRepository; -import com.souzip.domain.notification.FcmToken; -import com.souzip.domain.notification.FcmTokenRegisterRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional -public class FcmTokenCommandService { - - private final FcmTokenRepository fcmTokenRepository; - - // 로그인 사용자 기준으로 FCM 토큰을 등록하거나 동일 디바이스·토큰에 맞춰 갱신합니다. - public FcmToken registerOrUpdate(Long userId, FcmTokenRegisterRequest request) { - return fcmTokenRepository.findByToken(request.fcmToken()) - .map(existing -> syncExistingToken(existing, userId, request)) - .orElseGet(() -> fcmTokenRepository.findByUserIdAndDeviceId(userId, request.deviceId()) - .map(existing -> updateExistingDeviceRow(existing, request, userId)) - .orElseGet(() -> createNew(userId, request))); - } - - private FcmToken syncExistingToken(FcmToken existing, Long userId, FcmTokenRegisterRequest request) { - existing.linkUser(userId); - existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); - existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); - existing.activate(); - return fcmTokenRepository.save(existing); - } - - private FcmToken updateExistingDeviceRow(FcmToken existing, FcmTokenRegisterRequest request, Long userId) { - existing.updateToken(request.fcmToken()); - existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); - existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); - existing.linkUser(userId); - existing.activate(); - return fcmTokenRepository.save(existing); - } - - private FcmToken createNew(Long userId, FcmTokenRegisterRequest request) { - FcmToken created = FcmToken.register(request); - created.linkUser(userId); - return fcmTokenRepository.save(created); - } - - // 해당 디바이스의 푸시 토큰을 비활성화합니다. - public void deactivateByDevice(Long userId, String deviceId) { - fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId) - .ifPresent(token -> { - token.deactivate(); - fcmTokenRepository.save(token); - }); - } -} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java b/src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java deleted file mode 100644 index ac3b093f..00000000 --- a/src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.souzip.domain.notification; - -public class FcmTokenNotFoundException extends RuntimeException { - - public FcmTokenNotFoundException(String message) { - super(message); - } - - public static FcmTokenNotFoundException byDeviceId(String deviceId) { - return new FcmTokenNotFoundException( - String.format("디바이스 ID '%s'에 해당하는 FCM 토큰을 찾을 수 없습니다.", deviceId) - ); - } - - public static FcmTokenNotFoundException byToken(String token) { - return new FcmTokenNotFoundException( - String.format("FCM 토큰 '%s'를 찾을 수 없습니다.", token) - ); - } -} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java b/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java deleted file mode 100644 index 8bdf2fcd..00000000 --- a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.provided.FcmTokenFinder; -import com.souzip.application.notification.required.FcmTokenRepository; -import com.souzip.domain.notification.FcmToken; -import com.souzip.domain.notification.FcmTokenNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class FcmTokenQueryService implements FcmTokenFinder { - - private final FcmTokenRepository fcmTokenRepository; - - @Override - public FcmToken getByDeviceId(String deviceId) { - return fcmTokenRepository.findByDeviceId(deviceId) - .orElseThrow(() -> FcmTokenNotFoundException.byDeviceId(deviceId)); - } - - @Override - public FcmToken getByToken(String token) { - return fcmTokenRepository.findByToken(token) - .orElseThrow(() -> FcmTokenNotFoundException.byToken(token)); - } - - @Override - public List getActiveTokensByUserId(Long userId) { - return fcmTokenRepository.findByUserIdAndActiveTrue(userId); - } - - @Override - public List getAllActiveTokens() { - return fcmTokenRepository.findAllByActiveTrue(); - } - - @Override - public List getAllActiveTokensWithMarketingConsent() { - return fcmTokenRepository.findAllActiveWithMarketingConsent(); - } -} diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java deleted file mode 100644 index 49e02368..00000000 --- a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.dto.PushBroadcastResult; -import com.souzip.application.notification.required.PushBroadcastHistoryRepository; -import com.souzip.domain.notification.PushBroadcastHistory; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class PushBroadcastHistoryCommandService { - - private final PushBroadcastHistoryRepository pushBroadcastHistoryRepository; - - // 관리자 푸시 브로드캐스트 결과를 이력으로 저장합니다. - @Transactional - public void record(UUID adminId, String title, String body, PushBroadcastResult result) { - PushBroadcastHistory row = PushBroadcastHistory.record( - adminId, - title, - body, - result.totalTargets(), - result.successCount(), - result.failCount(), - result.firebaseConfigured() - ); - pushBroadcastHistoryRepository.save(row); - } -} diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java deleted file mode 100644 index 48df8f43..00000000 --- a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; -import com.souzip.application.notification.required.PushBroadcastHistoryRepository; -import com.souzip.domain.notification.PushBroadcastHistory; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - -import java.util.List; - -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class PushBroadcastHistoryQueryService { - - private final PushBroadcastHistoryRepository pushBroadcastHistoryRepository; - - // 푸시 브로드캐스트 이력을 최신순 페이지로 조회합니다. - public PaginationResponse findPage(PaginationRequest paginationRequest) { - Page page = - pushBroadcastHistoryRepository.findAllByOrderByCreatedAtDesc(paginationRequest.toPageable()); - List content = page.getContent().stream() - .map(PushBroadcastHistoryResponse::from) - .toList(); - return PaginationResponse.of(page, content); - } -} diff --git a/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java b/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java deleted file mode 100644 index 49d644de..00000000 --- a/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.application.notification.dto; - -public record MarketingConsentStats( - long totalUsers, - long consentedUsers, - long declinedUsers -) { -} diff --git a/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java b/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java deleted file mode 100644 index 958536ac..00000000 --- a/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.souzip.application.notification.dto; - -import com.souzip.domain.notification.PushBroadcastHistory; -import java.time.LocalDateTime; -import java.util.UUID; - -public record PushBroadcastHistoryResponse( - Long id, - UUID adminId, - String title, - String body, - int totalTargets, - int successCount, - int failCount, - boolean firebaseConfigured, - LocalDateTime createdAt -) { - - public static PushBroadcastHistoryResponse from(PushBroadcastHistory entity) { - return new PushBroadcastHistoryResponse( - entity.getId(), - entity.getAdminId(), - entity.getTitle(), - entity.getBody(), - entity.getTotalTargets(), - entity.getSuccessCount(), - entity.getFailCount(), - entity.isFirebaseConfigured(), - entity.getCreatedAt() - ); - } -} diff --git a/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java b/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java deleted file mode 100644 index 94c01244..00000000 --- a/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.application.notification.dto; - -public record PushBroadcastResult( - int totalTargets, - int successCount, - int failCount, - boolean firebaseConfigured -) { -} diff --git a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java b/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java deleted file mode 100644 index 99895949..00000000 --- a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.souzip.application.notification.provided; - -import com.souzip.domain.notification.FcmToken; -import java.util.List; - -public interface FcmTokenFinder { - - FcmToken getByDeviceId(String deviceId); - - FcmToken getByToken(String token); - - List getActiveTokensByUserId(Long userId); - - List getAllActiveTokens(); - - List getAllActiveTokensWithMarketingConsent(); -} diff --git a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java deleted file mode 100644 index 177aa1e9..00000000 --- a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.souzip.application.notification.required; - -import com.souzip.domain.notification.FcmToken; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; - -public interface FcmTokenRepository extends Repository { - - FcmToken save(FcmToken fcmToken); - - Optional findByToken(String token); - - Optional findByDeviceId(String deviceId); - - Optional findByUserIdAndDeviceId(Long userId, String deviceId); - - List findByUserIdAndActiveTrue(Long userId); - - List findAllByActiveTrue(); - - @Query("SELECT ft FROM FcmToken ft WHERE ft.active = true AND ft.userId IN " + - "(SELECT ua.user.id FROM UserAgreement ua WHERE ua.marketingConsent = true)") - List findAllActiveWithMarketingConsent(); - - void delete(FcmToken fcmToken); - - void deleteByDeviceId(String deviceId); - - boolean existsByToken(String token); - - boolean existsByDeviceId(String deviceId); -} diff --git a/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java b/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java deleted file mode 100644 index d9f4397b..00000000 --- a/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.souzip.application.notification.required; - -import com.souzip.domain.notification.PushBroadcastHistory; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.repository.Repository; - -public interface PushBroadcastHistoryRepository extends Repository { - - PushBroadcastHistory save(PushBroadcastHistory entity); - - Page findAllByOrderByCreatedAtDesc(Pageable pageable); -} diff --git a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java deleted file mode 100644 index df451c79..00000000 --- a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.souzip.auth.adapter.security.jwt; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Collections; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; - - private final JwtTokenProvider jwtTokenProvider; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - try { - String token = extractToken(request); - - if (token != null && jwtTokenProvider.validateToken(token)) { - Long userId = jwtTokenProvider.getUserIdFromToken(token); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } catch (Exception e) { - log.error("JWT 인증 처리 중 오류 발생", e); - } - - filterChain.doFilter(request, response); - } - - private String extractToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { - return bearerToken.substring(BEARER_PREFIX.length()); - } - - return null; - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java b/src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java deleted file mode 100644 index 62e58b2c..00000000 --- a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.souzip.auth.adapter.security.jwt; - -import com.souzip.auth.application.required.TokenProvider; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Date; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JwtTokenProvider implements TokenProvider { - - private final JwtProperties jwtProperties; - - @Override - public String generateAccessToken(Long userId) { - return createToken(String.valueOf(userId), jwtProperties.getExpiration()); - } - - @Override - public String generateRefreshToken() { - return createToken("refresh", jwtProperties.getRefreshExpiration()); - } - - @Override - public LocalDateTime getRefreshTokenExpiresAt() { - return LocalDateTime.now().plus(jwtProperties.getRefreshExpiration(), ChronoUnit.MILLIS); - } - - public Long getUserIdFromToken(String token) { - return Long.parseLong(parseClaims(token).getSubject()); - } - - public boolean validateToken(String token) { - try { - parseClaims(token); - - return true; - } catch (Exception e) { - log.debug("Invalid JWT token: {}", e.getMessage()); - - return false; - } - } - - public String generateToken(String subject) { - return createToken(subject, jwtProperties.getExpiration()); - } - - public String generateRefreshToken(String subject) { - return createToken(subject, jwtProperties.getRefreshExpiration()); - } - - private String createToken(String subject, Long expirationMs) { - Date now = new Date(); - - return Jwts.builder() - .subject(subject) - .issuedAt(now) - .expiration(new Date(now.getTime() + expirationMs)) - .signWith(getSigningKey()) - .compact(); - } - - private Claims parseClaims(String token) { - return Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - private SecretKey getSigningKey() { - byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8); - return Keys.hmacShaKeyFor(keyBytes); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java deleted file mode 100644 index cf9b1fd2..00000000 --- a/src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.auth.adapter.social; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.souzip.auth.application.dto.SocialUserInfo; -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.required.SocialClient; -import com.souzip.shared.domain.Provider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Base64; - -@Slf4j -@RequiredArgsConstructor -@Component -public class AppleSocialClient implements SocialClient { - - private final ObjectMapper objectMapper; - - @Override - public Provider getProvider() { - return Provider.APPLE; - } - - @Override - public SocialUserInfo getUserInfo(String identityToken) { - try { - String[] parts = identityToken.split("\\."); - if (parts.length < 2) throw AuthException.invalidJwtStructure(); - - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - AppleTokenPayload token = objectMapper.readValue(payload, AppleTokenPayload.class); - - return new SocialUserInfo(token.sub(), token.email()); - } catch (AuthException e) { - throw e; - } catch (Exception e) { - log.error("애플 사용자 정보 조회 중 오류 발생", e); - throw AuthException.appleApiError(); - } - } - - private record AppleTokenPayload(String sub, String email) { - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java deleted file mode 100644 index 14bcf4c0..00000000 --- a/src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.souzip.auth.adapter.social; - -import com.souzip.auth.application.dto.SocialUserInfo; -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.required.SocialClient; -import com.souzip.shared.domain.Provider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Slf4j -@RequiredArgsConstructor -@EnableConfigurationProperties(SocialProperties.class) -@Component -public class GoogleSocialClient implements SocialClient { - - private final RestTemplate restTemplate; - private final SocialProperties socialProperties; - - @Override - public Provider getProvider() { - return Provider.GOOGLE; - } - - @Override - public SocialUserInfo getUserInfo(String accessToken) { - try { - GoogleUserResponse response = restTemplate.exchange( - socialProperties.google().userInfoUrl(), - HttpMethod.GET, - new HttpEntity<>(createAuthHeaders(accessToken)), - GoogleUserResponse.class - ).getBody(); - - return new SocialUserInfo(response.id(), response.email()); - } catch (Exception e) { - log.error("구글 사용자 정보 조회 중 오류 발생", e); - throw AuthException.googleApiError(); - } - } - - private HttpHeaders createAuthHeaders(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - return headers; - } - - private record GoogleUserResponse(String id, String email) { - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java deleted file mode 100644 index ad4e3c79..00000000 --- a/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.souzip.auth.adapter.social; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.souzip.auth.application.dto.SocialUserInfo; -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.required.SocialClient; -import com.souzip.shared.domain.Provider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Slf4j -@RequiredArgsConstructor -@EnableConfigurationProperties(SocialProperties.class) -@Component -public class KakaoSocialClient implements SocialClient { - - private final RestTemplate restTemplate; - private final SocialProperties socialProperties; - - @Override - public Provider getProvider() { - return Provider.KAKAO; - } - - @Override - public SocialUserInfo getUserInfo(String accessToken) { - try { - KakaoUserResponse response = restTemplate.exchange( - socialProperties.kakao().userInfoUrl(), - HttpMethod.GET, - new HttpEntity<>(createAuthHeaders(accessToken)), - KakaoUserResponse.class - ).getBody(); - - return new SocialUserInfo( - String.valueOf(response.id()), - response.kakaoAccount().email() - ); - } catch (Exception e) { - log.error("카카오 사용자 정보 조회 중 오류 발생", e); - throw AuthException.kakaoApiError(); - } - } - - private HttpHeaders createAuthHeaders(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - return headers; - } - - private record KakaoUserResponse(Long id, @JsonProperty("kakao_account") KakaoAccount kakaoAccount) { - } - - private record KakaoAccount(String email) { - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java b/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java deleted file mode 100644 index ecbeef50..00000000 --- a/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.auth.adapter.social; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "oauth") -public record SocialProperties( - Kakao kakao, - Google google -) { - public record Kakao(String userInfoUrl) { - } - - public record Google(String userInfoUrl) { - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/AuthApi.java b/src/main/java/com/souzip/auth/adapter/web/AuthApi.java deleted file mode 100644 index 67b5dc71..00000000 --- a/src/main/java/com/souzip/auth/adapter/web/AuthApi.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.souzip.auth.adapter.web; - -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.auth.adapter.web.dto.LoginRequest; -import com.souzip.auth.adapter.web.dto.LoginResponse; -import com.souzip.auth.adapter.web.dto.RefreshRequest; -import com.souzip.auth.adapter.web.dto.RefreshResponse; -import com.souzip.auth.application.dto.LoginInfo; -import com.souzip.auth.application.dto.RefreshInfo; -import com.souzip.auth.application.provided.Auth; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.domain.Provider; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RequestMapping("/api/auth") -@RestController -public class AuthApi { - - private final Auth auth; - - @PostMapping("/login/{provider}") - public SuccessResponse login( - @PathVariable String provider, - @RequestBody LoginRequest request - ) { - LoginInfo info = auth.login(Provider.from(provider), request.accessToken()); - - return SuccessResponse.of(LoginResponse.from(info)); - } - - @PostMapping("/refresh") - public SuccessResponse refresh(@RequestBody RefreshRequest request) { - RefreshInfo info = auth.refresh(request.refreshToken()); - - return SuccessResponse.of(RefreshResponse.from(info)); - } - - @RequireAuth - @PostMapping("/logout") - public SuccessResponse logout(@CurrentUserId Long currentUserId) { - auth.logout(currentUserId); - - return SuccessResponse.of(null, "로그아웃 되었습니다."); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java b/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java deleted file mode 100644 index c1fd3e55..00000000 --- a/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.souzip.auth.adapter.web.dto; - -public record LoginRequest( - String accessToken -) { -} diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java b/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java deleted file mode 100644 index 8e4bcd8a..00000000 --- a/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.souzip.auth.adapter.web.dto; - -import com.souzip.auth.application.dto.LoginInfo; - -public record LoginResponse( - String accessToken, - String refreshToken, - boolean needsOnboarding, - UserDto user -) { - public record UserDto(String userId, String nickname) { - } - - public static LoginResponse from(LoginInfo info) { - return new LoginResponse( - info.accessToken(), - info.refreshToken(), - info.needsOnboarding(), - new UserDto(info.userId(), info.nickname()) - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java deleted file mode 100644 index e0204e54..00000000 --- a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.souzip.auth.adapter.web.dto; - -public record RefreshRequest( - String refreshToken -) { -} diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java deleted file mode 100644 index 2ed925a1..00000000 --- a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.auth.adapter.web.dto; - -import com.souzip.auth.application.dto.RefreshInfo; - -public record RefreshResponse( - String accessToken, - String refreshToken -) { - public static RefreshResponse from(RefreshInfo info) { - return new RefreshResponse( - info.accessToken(), - info.refreshToken() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/AuthService.java b/src/main/java/com/souzip/auth/application/AuthService.java deleted file mode 100644 index 73b9c2d9..00000000 --- a/src/main/java/com/souzip/auth/application/AuthService.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.souzip.auth.application; - -import com.souzip.auth.application.dto.LoginInfo; -import com.souzip.auth.application.dto.RefreshInfo; -import com.souzip.auth.application.dto.UserInfo; -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.provided.Auth; -import com.souzip.auth.application.required.RefreshTokenRepository; -import com.souzip.auth.application.required.TokenProvider; -import com.souzip.auth.application.required.UserPort; -import com.souzip.auth.domain.RefreshToken; -import com.souzip.shared.domain.Provider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@RequiredArgsConstructor -@Transactional(readOnly = true) -@Service -public class AuthService implements Auth { - - private final SocialClientFactory socialClientFactory; - private final UserPort userPort; - private final RefreshTokenRepository refreshTokenRepository; - private final TokenProvider tokenProvider; - - @Transactional - @Override - public LoginInfo login(Provider provider, String accessToken) { - var socialUserInfo = socialClientFactory.getClient(provider) - .getUserInfo(accessToken); - - UserInfo userInfo = userPort.findOrCreateUser( - provider, - socialUserInfo.providerId(), - socialUserInfo.email() - ); - - refreshTokenRepository.deleteByUserId(userInfo.id()); - - String newRefreshToken = tokenProvider.generateRefreshToken(); - refreshTokenRepository.save( - RefreshToken.create(userInfo.id(), newRefreshToken, tokenProvider.getRefreshTokenExpiresAt()) - ); - - log.info("로그인 성공 - userId: {}, provider: {}", userInfo.id(), provider); - - return new LoginInfo( - tokenProvider.generateAccessToken(userInfo.id()), - newRefreshToken, - userInfo.needsOnboarding(), - userInfo.userId(), - userInfo.nickname() - ); - } - - @Transactional - @Override - public RefreshInfo refresh(String refreshToken) { - RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken) - .orElseThrow(AuthException::invalidRefreshToken); - - if (storedToken.isExpired()) { - refreshTokenRepository.delete(storedToken); - - throw AuthException.expiredRefreshToken(); - } - - String newRefreshToken = tokenProvider.generateRefreshToken(); - - storedToken.rotate(newRefreshToken, tokenProvider.getRefreshTokenExpiresAt()); - - refreshTokenRepository.save(storedToken); - - log.debug("토큰 재발급 - userId: {}", storedToken.getUserId()); - - return new RefreshInfo( - tokenProvider.generateAccessToken(storedToken.getUserId()), - newRefreshToken - ); - } - - @Transactional - @Override - public void logout(Long userId) { - refreshTokenRepository.deleteByUserId(userId); - - log.info("로그아웃 - userId: {}", userId); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/SocialClientFactory.java b/src/main/java/com/souzip/auth/application/SocialClientFactory.java deleted file mode 100644 index 146b801b..00000000 --- a/src/main/java/com/souzip/auth/application/SocialClientFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.auth.application; - -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.required.SocialClient; -import com.souzip.shared.domain.Provider; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -@Component -public class SocialClientFactory { - - private final Map clients; - - public SocialClientFactory(List socialClients) { - this.clients = socialClients.stream() - .collect(Collectors.toMap(SocialClient::getProvider, c -> c)); - } - - public SocialClient getClient(Provider provider) { - return Optional.ofNullable(clients.get(provider)) - .orElseThrow(AuthException::unsupportedProvider); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/LoginInfo.java b/src/main/java/com/souzip/auth/application/dto/LoginInfo.java deleted file mode 100644 index cfb95297..00000000 --- a/src/main/java/com/souzip/auth/application/dto/LoginInfo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.auth.application.dto; - -public record LoginInfo( - String accessToken, - String refreshToken, - boolean needsOnboarding, - String userId, - String nickname -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java b/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java deleted file mode 100644 index 549433bb..00000000 --- a/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.souzip.auth.application.dto; - -public record RefreshInfo( - String accessToken, - String refreshToken -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java b/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java deleted file mode 100644 index 9ab39b14..00000000 --- a/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.souzip.auth.application.dto; - -public record SocialUserInfo(String providerId, String email) { -} diff --git a/src/main/java/com/souzip/auth/application/dto/UserInfo.java b/src/main/java/com/souzip/auth/application/dto/UserInfo.java deleted file mode 100644 index 5fc2421d..00000000 --- a/src/main/java/com/souzip/auth/application/dto/UserInfo.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.auth.application.dto; - -public record UserInfo( - Long id, - String userId, - String nickname, - boolean needsOnboarding -) { -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java b/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java deleted file mode 100644 index e32f6cd1..00000000 --- a/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.auth.application.exception; - -import com.souzip.shared.exception.BaseErrorCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum AuthErrorCode implements BaseErrorCode { - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), - EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), - - INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "토큰 형식이 올바르지 않습니다."), - INVALID_JWT_STRUCTURE(HttpStatus.BAD_REQUEST, "JWT 구조가 올바르지 않습니다."), - TOKEN_DECODE_FAILED(HttpStatus.BAD_REQUEST, "토큰 디코딩에 실패했습니다."), - TOKEN_PARSE_FAILED(HttpStatus.BAD_REQUEST, "토큰 파싱에 실패했습니다."), - UNSUPPORTED_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인입니다."), - - KAKAO_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API 호출에 실패했습니다."), - GOOGLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 API 호출에 실패했습니다."), - APPLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 API 호출에 실패했습니다."); - - private final HttpStatus status; - - private final String message; -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/exception/AuthException.java b/src/main/java/com/souzip/auth/application/exception/AuthException.java deleted file mode 100644 index 8cb237a7..00000000 --- a/src/main/java/com/souzip/auth/application/exception/AuthException.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.auth.application.exception; - -import com.souzip.shared.exception.BusinessException; - -public class AuthException extends BusinessException { - - public AuthException(AuthErrorCode errorCode) { - super(errorCode); - } - - public static AuthException invalidRefreshToken() { - return new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); - } - - public static AuthException expiredRefreshToken() { - return new AuthException(AuthErrorCode.EXPIRED_REFRESH_TOKEN); - } - - public static AuthException unsupportedProvider() { - return new AuthException(AuthErrorCode.UNSUPPORTED_PROVIDER); - } - - public static AuthException invalidJwtStructure() { - return new AuthException(AuthErrorCode.INVALID_JWT_STRUCTURE); - } - - public static AuthException tokenDecodeFailed() { - return new AuthException(AuthErrorCode.TOKEN_DECODE_FAILED); - } - - public static AuthException tokenParseFailed() { - return new AuthException(AuthErrorCode.TOKEN_PARSE_FAILED); - } - - public static AuthException kakaoApiError() { - return new AuthException(AuthErrorCode.KAKAO_API_ERROR); - } - - public static AuthException googleApiError() { - return new AuthException(AuthErrorCode.GOOGLE_API_ERROR); - } - - public static AuthException appleApiError() { - return new AuthException(AuthErrorCode.APPLE_API_ERROR); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/provided/Auth.java b/src/main/java/com/souzip/auth/application/provided/Auth.java deleted file mode 100644 index 3659c1b5..00000000 --- a/src/main/java/com/souzip/auth/application/provided/Auth.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.souzip.auth.application.provided; - -import com.souzip.auth.application.dto.LoginInfo; -import com.souzip.auth.application.dto.RefreshInfo; -import com.souzip.shared.domain.Provider; - -public interface Auth { - LoginInfo login(Provider provider, String accessToken); - - RefreshInfo refresh(String refreshToken); - - void logout(Long userId); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java b/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java deleted file mode 100644 index c2bcf69f..00000000 --- a/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.souzip.auth.application.required; - -import com.souzip.auth.domain.RefreshToken; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; -import org.springframework.data.repository.query.Param; - -import java.time.LocalDateTime; -import java.util.Optional; - -public interface RefreshTokenRepository extends Repository { - RefreshToken save(RefreshToken token); - - Optional findByToken(String token); - - void deleteByUserId(Long userId); - - void delete(RefreshToken token); - - @Modifying - @Query("DELETE FROM RefreshToken t WHERE t.expiresAt < :now") - int deleteAllExpiredBefore(@Param("now") LocalDateTime now); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/SocialClient.java b/src/main/java/com/souzip/auth/application/required/SocialClient.java deleted file mode 100644 index 1fb80087..00000000 --- a/src/main/java/com/souzip/auth/application/required/SocialClient.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.auth.application.required; - -import com.souzip.auth.application.dto.SocialUserInfo; -import com.souzip.shared.domain.Provider; - -public interface SocialClient { - SocialUserInfo getUserInfo(String accessToken); - - Provider getProvider(); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/TokenProvider.java b/src/main/java/com/souzip/auth/application/required/TokenProvider.java deleted file mode 100644 index 6757ee11..00000000 --- a/src/main/java/com/souzip/auth/application/required/TokenProvider.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.souzip.auth.application.required; - -import java.time.LocalDateTime; - -public interface TokenProvider { - String generateAccessToken(Long userId); - - String generateRefreshToken(); - - LocalDateTime getRefreshTokenExpiresAt(); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/UserPort.java b/src/main/java/com/souzip/auth/application/required/UserPort.java deleted file mode 100644 index 144e14d6..00000000 --- a/src/main/java/com/souzip/auth/application/required/UserPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.auth.application.required; - -import com.souzip.auth.application.dto.UserInfo; -import com.souzip.shared.domain.Provider; - -public interface UserPort { - UserInfo findOrCreateUser(Provider provider, String providerId, String email); -} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/domain/RefreshToken.java b/src/main/java/com/souzip/auth/domain/RefreshToken.java deleted file mode 100644 index 3cb51e67..00000000 --- a/src/main/java/com/souzip/auth/domain/RefreshToken.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.souzip.auth.domain; - -import com.souzip.shared.domain.BaseEntity; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class RefreshToken extends BaseEntity { - - private Long userId; - - private String token; - - private LocalDateTime expiresAt; - - public static RefreshToken create(Long userId, String token, LocalDateTime expiresAt) { - return new RefreshToken(userId, token, expiresAt); - } - - public boolean isExpired() { - return LocalDateTime.now().isAfter(expiresAt); - } - - public void rotate(String newToken, LocalDateTime newExpiresAt) { - this.token = newToken; - this.expiresAt = newExpiresAt; - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java index 7447ceb9..73c9282c 100644 --- a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java +++ b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java @@ -1,6 +1,5 @@ package com.souzip.domain.admin.application; -import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.application.command.AdminLoginCommand; import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; @@ -12,6 +11,7 @@ import com.souzip.domain.admin.model.Username; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; import com.souzip.domain.admin.repository.AdminRepository; +import com.souzip.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -37,7 +37,7 @@ public class AdminAuthService { @Transactional public AdminLoginResult login(AdminLoginCommand command) { Admin admin = adminRepository.findByUsername(new Username(command.username())) - .orElseThrow(AdminNotFoundException::new); + .orElseThrow(AdminNotFoundException::new); validatePassword(admin, command.password()); @@ -58,7 +58,7 @@ public RefreshResult refresh(String refreshTokenValue) { validateRefreshToken(refreshToken); Admin admin = adminRepository.findById(refreshToken.getAdminId()) - .orElseThrow(AdminNotFoundException::new); + .orElseThrow(AdminNotFoundException::new); String newAccessToken = jwtTokenProvider.generateToken(admin.getId().toString()); @@ -72,7 +72,7 @@ public RefreshResult refresh(String refreshTokenValue) { @Transactional public void logout(UUID adminId) { refreshTokenRepository.findByAdminId(adminId) - .ifPresent(refreshTokenRepository::delete); + .ifPresent(refreshTokenRepository::delete); } private void validatePassword(Admin admin, String password) { @@ -85,10 +85,10 @@ private void saveRefreshToken(Admin admin, String tokenValue) { LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); refreshTokenRepository.findByAdminId(admin.getId()) - .ifPresentOrElse( - token -> updateExistingToken(token, tokenValue, expiresAt), - () -> createNewToken(admin.getId(), tokenValue, expiresAt) - ); + .ifPresentOrElse( + token -> updateExistingToken(token, tokenValue, expiresAt), + () -> createNewToken(admin.getId(), tokenValue, expiresAt) + ); } private void updateExistingToken(AdminRefreshToken token, String tokenValue, LocalDateTime expiresAt) { @@ -103,7 +103,7 @@ private void createNewToken(UUID adminId, String tokenValue, LocalDateTime expir private AdminRefreshToken findRefreshToken(String tokenValue) { return refreshTokenRepository.findByToken(tokenValue) - .orElseThrow(AdminInvalidRefreshTokenException::new); + .orElseThrow(AdminInvalidRefreshTokenException::new); } private void validateRefreshToken(AdminRefreshToken refreshToken) { @@ -133,15 +133,13 @@ private RefreshResult renewRefreshToken(AdminRefreshToken refreshToken, Admin ad } public record AdminLoginResult( - Admin admin, - String accessToken, - String refreshToken - ) { - } + Admin admin, + String accessToken, + String refreshToken + ) {} public record RefreshResult( - String accessToken, - String refreshToken - ) { - } + String accessToken, + String refreshToken + ) {} } diff --git a/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java index df2894aa..0000b793 100644 --- a/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java +++ b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java @@ -2,7 +2,7 @@ import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; import com.souzip.domain.admin.application.query.CitySearchQuery; -import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.global.common.dto.pagination.PaginationResponse; public interface AdminCityQueryUseCase { diff --git a/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java index b5a385b2..49ac7784 100644 --- a/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java +++ b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java @@ -1,7 +1,6 @@ package com.souzip.domain.admin.application.port; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - +import com.souzip.global.common.dto.pagination.PaginationResponse; import java.time.LocalDateTime; public interface CityQueryPort { @@ -14,6 +13,5 @@ record CityQueryResult( String nameEn, Integer priority, LocalDateTime updatedAt - ) { - } + ) {} } diff --git a/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java index baf66efa..e59ca11e 100644 --- a/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java +++ b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java @@ -3,7 +3,7 @@ import com.souzip.domain.admin.application.AdminCityQueryUseCase; import com.souzip.domain.admin.application.port.CityQueryPort; import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; -import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.global.common.dto.pagination.PaginationResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +18,7 @@ public class AdminCityQueryService implements AdminCityQueryUseCase { @Override public PaginationResponse getCities(CitySearchQuery query) { return cityQueryPort.getCities( - query.countryId(), query.keyword(), query.pageNo(), query.pageSize() + query.countryId(), query.keyword(), query.pageNo(), query.pageSize() ); } } diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java index 808d0030..d71940ce 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BaseErrorCode; +import com.souzip.global.exception.BaseErrorCode; import org.springframework.http.HttpStatus; public enum AdminErrorCode implements BaseErrorCode { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminException.java b/src/main/java/com/souzip/domain/admin/exception/AdminException.java index 632db616..1645bfad 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; public class AdminException extends BusinessException { public AdminException(AdminErrorCode errorCode) { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java b/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java index f65ed114..50414923 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; public class AdminExpiredRefreshTokenException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java b/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java index 2c6de256..caca6044 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; public class AdminInvalidRefreshTokenException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java b/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java index ef35020d..1b753143 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; public class AdminLoginFailedException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java b/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java index da896ed5..6a6a54cd 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; public class AdminNotFoundException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java b/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java index 57218b9a..b24b2167 100644 --- a/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java +++ b/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; public class InvalidUsernameException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java index 94006b43..4bd49b64 100644 --- a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java @@ -2,10 +2,8 @@ import com.souzip.domain.admin.application.port.CityQueryPort; import com.souzip.domain.city.application.port.CityAdminPort; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - +import com.souzip.global.common.dto.pagination.PaginationResponse; import java.util.List; - import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -19,26 +17,26 @@ public class CityQueryAdapter implements CityQueryPort { @Override public PaginationResponse getCities( - Long countryId, - String keyword, - int pageNo, - int pageSize + Long countryId, + String keyword, + int pageNo, + int pageSize ) { Page page = cityAdminPort.getCities( - countryId, - keyword, - PageRequest.of(pageNo - 1, pageSize) + countryId, + keyword, + PageRequest.of(pageNo - 1, pageSize) ); List content = page.getContent().stream() - .map(c -> new CityQueryResult( - c.id(), - c.nameKr(), - c.nameEn(), - c.priority(), - c.updatedAt() - )) - .toList(); + .map(c -> new CityQueryResult( + c.id(), + c.nameKr(), + c.nameEn(), + c.priority(), + c.updatedAt() + )) + .toList(); return PaginationResponse.of(page, content); } diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java index bf59e5c8..f99ddbd2 100644 --- a/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java @@ -1,8 +1,8 @@ package com.souzip.domain.admin.infrastructure.security.jwt; -import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.model.Admin; import com.souzip.domain.admin.repository.AdminRepository; +import com.souzip.global.security.jwt.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -10,8 +10,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.GrantedAuthority; // ← 추가 +import org.springframework.security.core.authority.SimpleGrantedAuthority; // ← 추가 import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -19,7 +19,7 @@ import java.io.IOException; import java.util.Collections; -import java.util.List; +import java.util.List; // ← 추가 import java.util.UUID; @Slf4j @@ -101,8 +101,8 @@ private boolean isTokenInvalid(String token) { } private Admin getAdminFromToken(String token) { - Long adminId = jwtTokenProvider.getUserIdFromToken(token); - return adminRepository.findById(UUID.fromString(String.valueOf(adminId))).orElse(null); + String adminId = jwtTokenProvider.getUserIdFromToken(token); + return adminRepository.findById(UUID.fromString(adminId)).orElse(null); } private boolean isAdminAbsent(Admin admin) { @@ -111,11 +111,11 @@ private boolean isAdminAbsent(Admin admin) { private void setAuthentication(Admin admin) { List authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_" + admin.getRole().name()) + new SimpleGrantedAuthority("ROLE_" + admin.getRole().name()) ); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(admin, null, authorities); + new UsernamePasswordAuthenticationToken(admin, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java index e79925b8..8e3ca7d0 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java @@ -1,24 +1,23 @@ package com.souzip.domain.admin.presentation; -import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.admin.application.AdminAuthService; import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; -import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; import com.souzip.domain.admin.presentation.request.AdminLoginRequest; import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; import com.souzip.domain.admin.presentation.response.AdminLoginResponse; import com.souzip.domain.admin.presentation.response.AdminRefreshResponse; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; +import com.souzip.global.security.annotation.RequireAuth; import jakarta.validation.Valid; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.UUID; - @RequiredArgsConstructor @RequestMapping("/api/admin/auth") @RestController diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java index 7bdd64cf..3a5b1359 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java @@ -21,14 +21,12 @@ import com.souzip.domain.admin.presentation.request.UpdateCityRequest; import com.souzip.domain.admin.presentation.response.AdminResponse; import com.souzip.domain.admin.presentation.response.InviteAdminResponse; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.common.dto.pagination.PaginationRequest; +import com.souzip.global.common.dto.pagination.PaginationResponse; import jakarta.validation.Valid; - import java.util.List; import java.util.UUID; - import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -53,12 +51,12 @@ public class AdminManagementController { @SuperAdminOnly @PostMapping("/invite") public SuccessResponse inviteAdmin( - @Valid @RequestBody InviteAdminRequest request + @Valid @RequestBody InviteAdminRequest request ) { Admin admin = adminManagementUseCase.inviteAdmin(new InviteAdminCommand( - request.username(), - request.password(), - request.role() + request.username(), + request.password(), + request.role() )); return SuccessResponse.of(InviteAdminResponse.from(admin), "관리자 초대가 완료되었습니다."); } @@ -66,21 +64,21 @@ public SuccessResponse inviteAdmin( @SuperAdminOnly @GetMapping("/list") public SuccessResponse> getAdmins( - @ModelAttribute PaginationRequest paginationRequest + @ModelAttribute PaginationRequest paginationRequest ) { return SuccessResponse.of(AdminResponse.ofPageResult( - adminManagementUseCase.getAdmins( - paginationRequest.getPageNo(), - paginationRequest.getPageSize() - ) + adminManagementUseCase.getAdmins( + paginationRequest.getPageNo(), + paginationRequest.getPageSize() + ) )); } @SuperAdminOnly @DeleteMapping("/{adminId}") public SuccessResponse deleteAdmin( - @PathVariable UUID adminId, - @CurrentAdminId UUID requesterId + @PathVariable UUID adminId, + @CurrentAdminId UUID requesterId ) { adminManagementUseCase.deleteAdmin(adminId, requesterId); return SuccessResponse.of(null, "관리자가 삭제되었습니다."); @@ -97,15 +95,15 @@ public SuccessResponse> getCountries( @ViewerAccess @GetMapping("/cities") public SuccessResponse> getCities( - @RequestParam Long countryId, - @RequestParam(required = false) String keyword, - @ModelAttribute PaginationRequest paginationRequest + @RequestParam Long countryId, + @RequestParam(required = false) String keyword, + @ModelAttribute PaginationRequest paginationRequest ) { CitySearchQuery query = CitySearchQuery.of( - countryId, - keyword, - paginationRequest.getPageNo(), - paginationRequest.getPageSize() + countryId, + keyword, + paginationRequest.getPageNo(), + paginationRequest.getPageSize() ); return SuccessResponse.of(adminCityQueryUseCase.getCities(query)); } @@ -113,14 +111,14 @@ public SuccessResponse> getCities( @AdminAccess @PostMapping("/cities") public SuccessResponse createCity( - @Valid @RequestBody CreateCityRequest request + @Valid @RequestBody CreateCityRequest request ) { adminManagementUseCase.createCity(new AdminCreateCityCommand( - request.nameEn(), - request.nameKr(), - request.latitude(), - request.longitude(), - request.countryId() + request.nameEn(), + request.nameKr(), + request.latitude(), + request.longitude(), + request.countryId() )); return SuccessResponse.of(null, "도시가 추가되었습니다."); } @@ -128,7 +126,7 @@ public SuccessResponse createCity( @AdminAccess @DeleteMapping("/cities/{cityId}") public SuccessResponse deleteCity( - @PathVariable Long cityId + @PathVariable Long cityId ) { adminManagementUseCase.deleteCity(new AdminDeleteCityCommand(cityId)); return SuccessResponse.of(null, "도시가 삭제되었습니다."); @@ -137,8 +135,8 @@ public SuccessResponse deleteCity( @AdminAccess @PatchMapping("/cities/{cityId}/priority") public SuccessResponse updateCityPriority( - @PathVariable Long cityId, - @RequestParam(required = false) Integer priority + @PathVariable Long cityId, + @RequestParam(required = false) Integer priority ) { adminManagementUseCase.updateCityPriority(new AdminUpdateCityPriorityCommand(cityId, priority)); return SuccessResponse.of(null, "우선순위가 업데이트되었습니다."); diff --git a/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java index 33cd56e3..37db88ea 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java +++ b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java @@ -3,40 +3,39 @@ import com.souzip.domain.admin.application.AdminManagementService.AdminPageResult; import com.souzip.domain.admin.model.Admin; import com.souzip.domain.admin.model.AdminRole; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - +import com.souzip.global.common.dto.pagination.PaginationResponse; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; public record AdminResponse( - UUID id, - String username, - AdminRole role, - LocalDateTime lastLoginAt, - LocalDateTime createdAt + UUID id, + String username, + AdminRole role, + LocalDateTime lastLoginAt, + LocalDateTime createdAt ) { public static AdminResponse from(Admin admin) { return new AdminResponse( - admin.getId(), - admin.getUsername().value(), - admin.getRole(), - admin.getLastLoginAt(), - admin.getCreatedAt() + admin.getId(), + admin.getUsername().value(), + admin.getRole(), + admin.getLastLoginAt(), + admin.getCreatedAt() ); } public static PaginationResponse ofPageResult(AdminPageResult result) { List content = result.admins().stream() - .map(AdminResponse::from) - .toList(); + .map(AdminResponse::from) + .toList(); return PaginationResponse.of( - content, - result.pageNo(), - result.pageSize(), - result.total(), - result.totalPages() + content, + result.pageNo(), + result.pageSize(), + result.total(), + result.totalPages() ); } } diff --git a/src/main/java/com/souzip/domain/audit/entity/AuditLog.java b/src/main/java/com/souzip/domain/audit/entity/AuditLog.java index 84aa6bab..76f4bf3d 100644 --- a/src/main/java/com/souzip/domain/audit/entity/AuditLog.java +++ b/src/main/java/com/souzip/domain/audit/entity/AuditLog.java @@ -1,7 +1,7 @@ package com.souzip.domain.audit.entity; -import com.souzip.shared.domain.BaseEntity; -import com.souzip.shared.domain.Provider; +import com.souzip.domain.user.entity.Provider; +import com.souzip.domain.shared.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -11,13 +11,13 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity @Table( - indexes = { - @Index(name = "idx_audit_user_id", columnList = "user_id"), - @Index(name = "idx_audit_action", columnList = "action, created_at"), - @Index(name = "idx_audit_category", columnList = "category, created_at"), - @Index(name = "idx_audit_target", columnList = "target_type, target_id"), - @Index(name = "idx_audit_created_at", columnList = "created_at DESC") - } + indexes = { + @Index(name = "idx_audit_user_id", columnList = "user_id"), + @Index(name = "idx_audit_action", columnList = "action, created_at"), + @Index(name = "idx_audit_category", columnList = "category, created_at"), + @Index(name = "idx_audit_target", columnList = "target_type, target_id"), + @Index(name = "idx_audit_created_at", columnList = "created_at DESC") + } ) public class AuditLog extends BaseEntity { @@ -58,45 +58,45 @@ public class AuditLog extends BaseEntity { private String metadata; public static AuditLog success( - String userId, - AuditAction action, - String ipAddress, - String userAgent, - String appVersion, - String metadata + String userId, + AuditAction action, + String ipAddress, + String userAgent, + String appVersion, + String metadata ) { return AuditLog.builder() - .userId(userId) - .action(action) - .category(action.getCategory()) - .success(true) - .ipAddress(ipAddress) - .deviceType(DeviceType.fromUserAgent(userAgent)) - .appVersion(appVersion) - .userAgent(userAgent) - .metadata(metadata) - .build(); + .userId(userId) + .action(action) + .category(action.getCategory()) + .success(true) + .ipAddress(ipAddress) + .deviceType(DeviceType.fromUserAgent(userAgent)) + .appVersion(appVersion) + .userAgent(userAgent) + .metadata(metadata) + .build(); } public static AuditLog failure( - AuditAction action, - String failureReason, - String ipAddress, - String userAgent, - String appVersion, - String metadata + AuditAction action, + String failureReason, + String ipAddress, + String userAgent, + String appVersion, + String metadata ) { return AuditLog.builder() - .action(action) - .category(action.getCategory()) - .success(false) - .failureReason(failureReason) - .ipAddress(ipAddress) - .deviceType(DeviceType.fromUserAgent(userAgent)) - .appVersion(appVersion) - .userAgent(userAgent) - .metadata(metadata) - .build(); + .action(action) + .category(action.getCategory()) + .success(false) + .failureReason(failureReason) + .ipAddress(ipAddress) + .deviceType(DeviceType.fromUserAgent(userAgent)) + .appVersion(appVersion) + .userAgent(userAgent) + .metadata(metadata) + .build(); } public void setOAuthProvider(Provider provider) { diff --git a/src/main/java/com/souzip/domain/audit/service/AuditService.java b/src/main/java/com/souzip/domain/audit/service/AuditService.java index 5d4a835a..f0ad70b7 100644 --- a/src/main/java/com/souzip/domain/audit/service/AuditService.java +++ b/src/main/java/com/souzip/domain/audit/service/AuditService.java @@ -3,7 +3,7 @@ import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.audit.entity.AuditLog; import com.souzip.domain.audit.repository.AuditLogRepository; -import com.souzip.shared.audit.dto.AuditContext; +import com.souzip.global.audit.dto.AuditContext; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; @@ -46,7 +46,7 @@ public void logFailure(AuditAction action, String failureReason, String ipAddress, String userAgent, String appVersion) { try { AuditLog auditLog = createFailureLog(action, failureReason, - ipAddress, userAgent, appVersion); + ipAddress, userAgent, appVersion); saveLog(auditLog); incrementFailureMetric(action); logFailureWarning(action, failureReason); @@ -58,12 +58,12 @@ public void logFailure(AuditAction action, String failureReason, private AuditLog createSuccessLog(AuditContext context) { AuditLog auditLog = AuditLog.success( - context.getUserId(), - context.getAction(), - context.getIpAddress(), - context.getUserAgent(), - context.getAppVersion(), - context.getMetadata() + context.getUserId(), + context.getAction(), + context.getIpAddress(), + context.getUserAgent(), + context.getAppVersion(), + context.getMetadata() ); enrichLogWithOptionalFields(auditLog, context); @@ -84,12 +84,12 @@ private void enrichLogWithOptionalFields(AuditLog auditLog, AuditContext context private AuditLog createFailureLog(AuditAction action, String failureReason, String ipAddress, String userAgent, String appVersion) { return AuditLog.failure( - action, - failureReason, - ipAddress, - userAgent, - appVersion, - null + action, + failureReason, + ipAddress, + userAgent, + appVersion, + null ); } @@ -111,24 +111,24 @@ private void incrementMetric(AuditAction action, boolean success) { private Counter buildCounter(AuditAction action, boolean success) { return meterRegistry.counter( - METRIC_NAME, - TAG_ACTION, action.name(), - TAG_CATEGORY, action.getCategory().name(), - TAG_SUCCESS, String.valueOf(success) + METRIC_NAME, + TAG_ACTION, action.name(), + TAG_CATEGORY, action.getCategory().name(), + TAG_SUCCESS, String.valueOf(success) ); } private void logSuccessInfo(AuditContext context) { log.info("[Audit success] - userId: {}, action: {}, category: {}", - context.getUserId(), - context.getAction(), - context.getAction().getCategory()); + context.getUserId(), + context.getAction(), + context.getAction().getCategory()); } private void logFailureWarning(AuditAction action, String failureReason) { log.warn("[Audit failure] - action: {}, reason: {}", - action, - failureReason); + action, + failureReason); } private void logSaveError(Exception e) { diff --git a/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java b/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java new file mode 100644 index 00000000..d831bd87 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java @@ -0,0 +1,59 @@ +package com.souzip.domain.auth.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.souzip.domain.auth.dto.AppleUserInfo; +import com.souzip.domain.auth.dto.OAuthUserInfo; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Base64; + +@Slf4j +@RequiredArgsConstructor +@Component +public class AppleApiClient implements OAuthClient { + + private final ObjectMapper objectMapper; + + @Override + public OAuthUserInfo getUserInfo(String identityToken) { + try { + return parseIdentityToken(identityToken); + } catch (Exception e) { + throw new BusinessException(ErrorCode.APPLE_API_ERROR); + } + } + + private AppleUserInfo parseIdentityToken(String identityToken) { + String[] parts = splitToken(identityToken); + String payload = decodePayload(parts[1]); + return parseJson(payload); + } + + private String[] splitToken(String identityToken) { + String[] parts = identityToken.split("\\."); + if (parts.length < 2) { + throw new BusinessException(ErrorCode.INVALID_JWT_STRUCTURE); + } + return parts; + } + + private String decodePayload(String encodedPayload) { + try { + return new String(Base64.getUrlDecoder().decode(encodedPayload)); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.TOKEN_DECODE_FAILED); + } + } + + private AppleUserInfo parseJson(String payload) { + try { + return objectMapper.readValue(payload, AppleUserInfo.class); + } catch (Exception e) { + throw new BusinessException(ErrorCode.TOKEN_PARSE_FAILED); + } + } +} diff --git a/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java b/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java new file mode 100644 index 00000000..7b803b43 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java @@ -0,0 +1,55 @@ +package com.souzip.domain.auth.client; + +import com.souzip.domain.auth.dto.GoogleUserInfo; +import com.souzip.domain.auth.dto.OAuthUserInfo; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.oauth.OAuthProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@RequiredArgsConstructor +@Component +public class GoogleApiClient implements OAuthClient { + + private final RestTemplate restTemplate; + private final OAuthProperties oAuthProperties; + + @Override + public OAuthUserInfo getUserInfo(String accessToken) { + try { + return callGoogleUserInfoApi(accessToken); + } catch (Exception e) { + log.error("구글 사용자 정보 조회 중 오류 발생", e); + throw new BusinessException(ErrorCode.GOOGLE_API_ERROR); + } + } + + private GoogleUserInfo callGoogleUserInfoApi(String accessToken) { + String url = oAuthProperties.getGoogle().getUserInfoUrl(); + HttpHeaders headers = createAuthHeaders(accessToken); + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + request, + GoogleUserInfo.class + ); + + return response.getBody(); + } + + private HttpHeaders createAuthHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } +} diff --git a/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java b/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java new file mode 100644 index 00000000..e9a57c2f --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java @@ -0,0 +1,55 @@ +package com.souzip.domain.auth.client; + +import com.souzip.domain.auth.dto.KakaoUserInfo; +import com.souzip.domain.auth.dto.OAuthUserInfo; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.oauth.OAuthProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KakaoApiClient implements OAuthClient { + + private final RestTemplate restTemplate; + private final OAuthProperties oAuthProperties; + + @Override + public OAuthUserInfo getUserInfo(String accessToken) { + try { + return callKakaoUserInfoApi(accessToken); + } catch (Exception e) { + log.error("카카오 사용자 정보 조회 중 오류 발생", e); + throw new BusinessException(ErrorCode.KAKAO_API_ERROR); + } + } + + private KakaoUserInfo callKakaoUserInfoApi(String accessToken) { + String url = oAuthProperties.getKakao().getUserInfoUrl(); + HttpHeaders headers = createAuthHeaders(accessToken); + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + request, + KakaoUserInfo.class + ); + + return response.getBody(); + } + + private HttpHeaders createAuthHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } +} diff --git a/src/main/java/com/souzip/domain/auth/client/OAuthClient.java b/src/main/java/com/souzip/domain/auth/client/OAuthClient.java new file mode 100644 index 00000000..0322a6e6 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/client/OAuthClient.java @@ -0,0 +1,7 @@ +package com.souzip.domain.auth.client; + +import com.souzip.domain.auth.dto.OAuthUserInfo; + +public interface OAuthClient { + OAuthUserInfo getUserInfo(String accessToken); +} diff --git a/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java b/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java new file mode 100644 index 00000000..32acde69 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java @@ -0,0 +1,27 @@ +package com.souzip.domain.auth.client; + +import com.souzip.domain.user.entity.Provider; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +@Component +public class OAuthClientFactory { + + private final Map clients; + + public OAuthClientFactory( + KakaoApiClient kakaoApiClient, + GoogleApiClient googleApiClient, + AppleApiClient appleApiClient + ) { + this.clients = new EnumMap<>(Provider.class); + clients.put(Provider.KAKAO, kakaoApiClient); + clients.put(Provider.GOOGLE, googleApiClient); + clients.put(Provider.APPLE, appleApiClient); + } + public OAuthClient getClient(Provider provider) { + return clients.get(provider); + } +} diff --git a/src/main/java/com/souzip/domain/auth/controller/AuthController.java b/src/main/java/com/souzip/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..3010fa9f --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/controller/AuthController.java @@ -0,0 +1,44 @@ +package com.souzip.domain.auth.controller; + +import com.souzip.domain.auth.dto.LoginRequest; +import com.souzip.domain.auth.dto.LoginResponse; +import com.souzip.domain.auth.dto.RefreshRequest; +import com.souzip.domain.auth.dto.RefreshResponse; +import com.souzip.domain.auth.service.AuthService; +import com.souzip.domain.user.entity.Provider; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.CurrentUserId; +import com.souzip.global.security.annotation.RequireAuth; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@RestController +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login/{provider}") + public SuccessResponse login(@PathVariable String provider, @RequestBody LoginRequest request) { + LoginResponse response = authService.login(Provider.from(provider), request.accessToken()); + return SuccessResponse.of(response); + } + + @PostMapping("/refresh") + public SuccessResponse refresh(@RequestBody RefreshRequest request) { + RefreshResponse response = authService.refresh(request.refreshToken()); + return SuccessResponse.of(response); + } + + @PostMapping("/logout") + @RequireAuth + public SuccessResponse logout(@CurrentUserId Long currentUserId) { + authService.logout(currentUserId); + return SuccessResponse.of(null, "로그아웃 되었습니다."); + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java new file mode 100644 index 00000000..4315a7e3 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java @@ -0,0 +1,45 @@ +package com.souzip.domain.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class AppleUserInfo implements OAuthUserInfo { + + private String sub; + private String email; + + @JsonProperty("email_verified") + private Boolean emailVerified; + + @JsonProperty("transfer_sub") + private String transferSub; + + @Override + public String getProviderId() { + return sub; + } + + @Override + public String getName() { + if (isValidEmail(email)) { + return email.split("@")[0]; + } + return "apple.user"; + } + + @Override + public String getEmail() { + return email; + } + + public String getTransferSub() { + return transferSub; + } + + private boolean isValidEmail(String email) { + return email != null && email.contains("@"); + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java new file mode 100644 index 00000000..979277ca --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java @@ -0,0 +1,34 @@ +package com.souzip.domain.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleUserInfo implements OAuthUserInfo { + + @JsonProperty("id") + private String id; + + @JsonProperty("email") + private String email; + + @JsonProperty("name") + private String name; + + @Override + public String getProviderId() { + return id; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getEmail() { + return email; + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java new file mode 100644 index 00000000..ecde2a47 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java @@ -0,0 +1,44 @@ +package com.souzip.domain.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoUserInfo implements OAuthUserInfo { + + private Long id; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + @Getter + @NoArgsConstructor + public static class KakaoAccount { + + private Profile profile; + private String email; + + @Getter + @NoArgsConstructor + public static class Profile { + private String nickname; + } + } + + @Override + public String getProviderId() { + return String.valueOf(id); + } + + @Override + public String getName() { + return kakaoAccount.profile.nickname; + } + + @Override + public String getEmail() { + return kakaoAccount.email; + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java b/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java new file mode 100644 index 00000000..2dff0d95 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java @@ -0,0 +1,5 @@ +package com.souzip.domain.auth.dto; + +public record LoginRequest( + String accessToken +) {} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java b/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java new file mode 100644 index 00000000..294821b0 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java @@ -0,0 +1,27 @@ +package com.souzip.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LoginResponse { + private String accessToken; + private String refreshToken; + private LoginUserInfo user; + private boolean needsOnboarding; + + public static LoginResponse of( + String accessToken, + String refreshToken, + LoginUserInfo user, + boolean needsOnboarding + ) { + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .user(user) + .needsOnboarding(needsOnboarding) + .build(); + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java new file mode 100644 index 00000000..8067d298 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java @@ -0,0 +1,15 @@ +package com.souzip.domain.auth.dto; + +import com.souzip.domain.user.entity.User; + +public record LoginUserInfo( + String userId, + String nickname +) { + public static LoginUserInfo from(User user) { + return new LoginUserInfo( + user.getUserId(), + user.getNickname() + ); + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java new file mode 100644 index 00000000..ee0818d2 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java @@ -0,0 +1,10 @@ +package com.souzip.domain.auth.dto; + +public interface OAuthUserInfo { + String getProviderId(); + String getName(); + String getEmail(); + default String getProfileImageUrl() { + return null; + } +} diff --git a/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java b/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java new file mode 100644 index 00000000..b410f1cb --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java @@ -0,0 +1,6 @@ +package com.souzip.domain.auth.dto; + +public record RefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java b/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java new file mode 100644 index 00000000..8588c3e7 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java @@ -0,0 +1,10 @@ +package com.souzip.domain.auth.dto; + +public record RefreshResponse( + String accessToken, + String refreshToken +) { + public static RefreshResponse of(String accessToken, String refreshToken) { + return new RefreshResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java b/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..7808a7b8 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java @@ -0,0 +1,49 @@ +package com.souzip.domain.auth.entity; + +import com.souzip.domain.user.entity.User; +import com.souzip.domain.shared.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "refresh_token", + indexes = { + @Index(name = "idx_refresh_token_user_id", columnList = "user_id"), + @Index(name = "idx_refresh_token_token", columnList = "token") + } +) +@Entity +public class RefreshToken extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private User user; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + public static RefreshToken of(User user, String token, LocalDateTime expiresAt) { + return RefreshToken.builder() + .user(user) + .token(token) + .expiresAt(expiresAt) + .build(); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public void updateToken(String newToken, LocalDateTime newExpiresAt) { + this.token = newToken; + this.expiresAt = newExpiresAt; + } +} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..afd50707 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.souzip.domain.auth.repository; + +import com.souzip.domain.auth.entity.RefreshToken; +import com.souzip.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository, RefreshTokenRepositoryCustom { + + Optional findByUser(User user); + + Optional findByToken(String token); +} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java new file mode 100644 index 00000000..d3d0eb70 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java @@ -0,0 +1,8 @@ +package com.souzip.domain.auth.repository; + +import java.time.LocalDateTime; + +public interface RefreshTokenRepositoryCustom { + + int deleteAllByExpiresAtBefore(LocalDateTime now); +} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java new file mode 100644 index 00000000..27153247 --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.souzip.domain.auth.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import static com.souzip.domain.auth.entity.QRefreshToken.refreshToken; + +@RequiredArgsConstructor +@Repository +public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public int deleteAllByExpiresAtBefore(LocalDateTime now) { + long deletedCount = queryFactory + .delete(refreshToken) + .where(refreshToken.expiresAt.lt(now)) + .execute(); + + return (int) deletedCount; + } +} diff --git a/src/main/java/com/souzip/auth/adapter/integration/scheduler/RefreshTokenCleanupScheduler.java b/src/main/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupScheduler.java similarity index 78% rename from src/main/java/com/souzip/auth/adapter/integration/scheduler/RefreshTokenCleanupScheduler.java rename to src/main/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupScheduler.java index 1b2da7f0..320eb016 100644 --- a/src/main/java/com/souzip/auth/adapter/integration/scheduler/RefreshTokenCleanupScheduler.java +++ b/src/main/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupScheduler.java @@ -1,14 +1,13 @@ -package com.souzip.auth.adapter.integration.scheduler; +package com.souzip.domain.auth.scheduler; -import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.domain.auth.repository.RefreshTokenRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - @Slf4j @RequiredArgsConstructor @Component @@ -20,9 +19,7 @@ public class RefreshTokenCleanupScheduler { @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") public void cleanUpExpiredRefreshTokens() { LocalDateTime now = LocalDateTime.now(); - - int deletedCount = refreshTokenRepository.deleteAllExpiredBefore(now); - + int deletedCount = refreshTokenRepository.deleteAllByExpiresAtBefore(now); log.info("[Token Scheduler] 만료된 Refresh Token {}개 삭제 완료 (실행 시각: {})", deletedCount, now); } -} \ No newline at end of file +} diff --git a/src/main/java/com/souzip/domain/auth/service/AuthService.java b/src/main/java/com/souzip/domain/auth/service/AuthService.java new file mode 100644 index 00000000..639b09ce --- /dev/null +++ b/src/main/java/com/souzip/domain/auth/service/AuthService.java @@ -0,0 +1,241 @@ +package com.souzip.domain.auth.service; + +import com.souzip.domain.audit.entity.AuditAction; +import com.souzip.domain.auth.dto.AppleUserInfo; +import com.souzip.global.audit.annotation.Audit; +import com.souzip.domain.auth.client.OAuthClient; +import com.souzip.domain.auth.client.OAuthClientFactory; +import com.souzip.domain.auth.dto.LoginResponse; +import com.souzip.domain.auth.dto.LoginUserInfo; +import com.souzip.domain.auth.dto.OAuthUserInfo; +import com.souzip.domain.auth.dto.RefreshResponse; +import com.souzip.domain.auth.entity.RefreshToken; +import com.souzip.domain.auth.repository.RefreshTokenRepository; +import com.souzip.domain.user.entity.Provider; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.security.jwt.JwtTokenProvider; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor +@Service +public class AuthService { + + private static final int REFRESH_TOKEN_VALIDITY_DAYS = 30; + private static final int REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS = 10; + + private final OAuthClientFactory oauthClientFactory; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + + @Audit(action = AuditAction.LOGIN) + @Transactional + public LoginResponse login(Provider provider, String oauthAccessToken) { + OAuthClient oauthClient = oauthClientFactory.getClient(provider); + OAuthUserInfo oauthUserInfo = oauthClient.getUserInfo(oauthAccessToken); + + User user = findOrCreateUser(provider, oauthUserInfo); + + user.updateLastLoginAt(); + + String accessToken = jwtTokenProvider.generateToken(user.getUserId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId()); + + saveRefreshToken(user, refreshToken); + + LoginUserInfo userInfo = LoginUserInfo.from(user); + boolean needsOnboarding = user.needsOnboarding(); + + return LoginResponse.of(accessToken, refreshToken, userInfo, needsOnboarding); + } + + @Transactional + public RefreshResponse refresh(String refreshTokenValue) { + RefreshToken refreshToken = findRefreshToken(refreshTokenValue); + validateRefreshToken(refreshToken); + + User user = refreshToken.getUser(); + String newAccessToken = jwtTokenProvider.generateToken(user.getUserId()); + + if (isRefreshTokenExpiringSoon(refreshToken)) { + return renewRefreshToken(refreshToken, user, newAccessToken); + } + + return RefreshResponse.of(newAccessToken, refreshToken.getToken()); + } + + @Audit(action = AuditAction.LOGOUT) + @Transactional + public void logout(Long currentUserId) { + User user = userRepository.findById(currentUserId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + refreshTokenRepository.findByUser(user) + .ifPresent(refreshTokenRepository::delete); + } + + private User findOrCreateUser(Provider provider, OAuthUserInfo oauthUserInfo) { + if (isAppleProvider(provider)) { + return findOrCreateAppleUser(oauthUserInfo); + } + + return findOrCreateGeneralUser(provider, oauthUserInfo); + } + + private User findOrCreateGeneralUser(Provider provider, OAuthUserInfo oauthUserInfo) { + Optional existingUser = userRepository + .findByProviderAndProviderId(provider, oauthUserInfo.getProviderId()); + + return existingUser.map(user -> restoreIfDeleted(user, oauthUserInfo)) + .orElseGet(() -> createUser(provider, oauthUserInfo)); + } + + private User findOrCreateAppleUser(OAuthUserInfo oauthUserInfo) { + String newProviderId = oauthUserInfo.getProviderId(); + String transferSub = extractTransferSub(oauthUserInfo); + + Optional existingUser = findByProviderId(newProviderId); + if (existingUser.isPresent()) { + return handleExistingUser(existingUser.get(), oauthUserInfo, newProviderId); + } + + if (hasTransferSub(transferSub)) { + Optional migratedUser = attemptMigration(transferSub, newProviderId, oauthUserInfo); + + return migratedUser.orElseGet(() -> createNewAppleUser(newProviderId, oauthUserInfo)); + } + + return createNewAppleUser(newProviderId, oauthUserInfo); + } + + private Optional findByProviderId(String providerId) { + return userRepository.findByProviderAndProviderId(Provider.APPLE, providerId); + } + + private String extractTransferSub(OAuthUserInfo oauthUserInfo) { + if (oauthUserInfo instanceof AppleUserInfo appleUserInfo) { + return appleUserInfo.getTransferSub(); + } + return null; + } + + private boolean hasTransferSub(String transferSub) { + return transferSub != null; + } + + private User handleExistingUser(User user, OAuthUserInfo oauthUserInfo, String providerId) { + log.info("Apple 사용자 로그인 - providerId로 찾음: {}", providerId); + return restoreIfDeleted(user, oauthUserInfo); + } + + private Optional attemptMigration(String transferSub, String newProviderId, OAuthUserInfo oauthUserInfo) { + Optional existingUser = userRepository.findByTransferIdentifier(transferSub); + + return existingUser.map(user -> migrateUser(user, transferSub, newProviderId, oauthUserInfo)); + } + + private User migrateUser(User user, String transferSub, String newProviderId, OAuthUserInfo oauthUserInfo) { + log.info("Apple 앱 이전 감지 - 기존 providerId: {}, transfer_sub: {}, 새 providerId: {}", + user.getProviderId(), transferSub, newProviderId); + + user.updateProviderId(newProviderId); + userRepository.save(user); + + User restoredUser = restoreIfDeleted(user, oauthUserInfo); + + log.info("Apple 사용자 마이그레이션 완료: userId={}", user.getId()); + return restoredUser; + } + + private User createNewAppleUser(String providerId, OAuthUserInfo oauthUserInfo) { + log.info("새로운 Apple 사용자 생성: providerId={}", providerId); + return createUser(Provider.APPLE, oauthUserInfo); + } + + private User restoreIfDeleted(User user, OAuthUserInfo oauthUserInfo) { + if (user.isDeleted()) { + String name = oauthUserInfo.getName(); + user.restore(name); + } + return user; + } + + private User createUser(Provider provider, OAuthUserInfo oauthUserInfo) { + User user = User.of(provider, oauthUserInfo); + return userRepository.save(user); + } + + private boolean isAppleProvider(Provider provider) { + return provider == Provider.APPLE; + } + + private RefreshResponse renewRefreshToken(RefreshToken refreshToken, User user, String newAccessToken) { + String newRefreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId()); + updateRefreshToken(refreshToken, newRefreshToken); + + log.info("Refresh Token 갱신: userId={}, remainingDays={}", + user.getUserId(), getRemainingDays(refreshToken)); + + return RefreshResponse.of(newAccessToken, newRefreshToken); + } + + private void saveRefreshToken(User user, String tokenValue) { + LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); + + refreshTokenRepository.findByUser(user) + .ifPresentOrElse( + token -> updateExistingToken(token, tokenValue, expiresAt), + () -> createNewToken(user, tokenValue, expiresAt) + ); + } + + private void updateExistingToken(RefreshToken token, String tokenValue, LocalDateTime expiresAt) { + token.updateToken(tokenValue, expiresAt); + } + + private void createNewToken(User user, String tokenValue, LocalDateTime expiresAt) { + RefreshToken newToken = RefreshToken.of(user, tokenValue, expiresAt); + refreshTokenRepository.save(newToken); + } + + private RefreshToken findRefreshToken(String tokenValue) { + return refreshTokenRepository.findByToken(tokenValue) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN)); + } + + private void validateRefreshToken(RefreshToken refreshToken) { + if (refreshToken.isExpired()) { + deleteExpiredToken(refreshToken); + throw new BusinessException(ErrorCode.EXPIRED_REFRESH_TOKEN); + } + } + + private void deleteExpiredToken(RefreshToken refreshToken) { + refreshTokenRepository.delete(refreshToken); + log.info("만료된 Refresh Token 삭제: token={}", refreshToken.getId()); + } + + private boolean isRefreshTokenExpiringSoon(RefreshToken refreshToken) { + LocalDateTime threshold = LocalDateTime.now().plusDays(REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS); + return refreshToken.getExpiresAt().isBefore(threshold); + } + + private void updateRefreshToken(RefreshToken refreshToken, String newTokenValue) { + LocalDateTime newExpiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); + refreshToken.updateToken(newTokenValue, newExpiresAt); + } + + private long getRemainingDays(RefreshToken refreshToken) { + return java.time.Duration.between(LocalDateTime.now(), refreshToken.getExpiresAt()).toDays(); + } +} diff --git a/src/main/java/com/souzip/domain/category/controller/CategoryController.java b/src/main/java/com/souzip/domain/category/controller/CategoryController.java index 65408844..7218f073 100644 --- a/src/main/java/com/souzip/domain/category/controller/CategoryController.java +++ b/src/main/java/com/souzip/domain/category/controller/CategoryController.java @@ -2,7 +2,7 @@ import com.souzip.domain.category.dto.CategoriesResponse; import com.souzip.domain.category.service.CategoryService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java b/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java index 45b184b7..08eb790e 100644 --- a/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java +++ b/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java @@ -9,11 +9,9 @@ import com.souzip.domain.city.service.CityPriorityDomainService; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; - +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import java.math.BigDecimal; - import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/souzip/domain/city/entity/City.java b/src/main/java/com/souzip/domain/city/entity/City.java index 83c936c0..6d116753 100644 --- a/src/main/java/com/souzip/domain/city/entity/City.java +++ b/src/main/java/com/souzip/domain/city/entity/City.java @@ -1,9 +1,9 @@ package com.souzip.domain.city.entity; import com.souzip.domain.country.entity.Country; -import com.souzip.shared.domain.BaseEntity; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.domain.shared.BaseEntity; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; @@ -15,9 +15,9 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity @Table(name = "city", indexes = { - @Index(name = "idx_city_country", columnList = "country_id"), - @Index(name = "idx_city_name", columnList = "name_en, name_kr"), - @Index(name = "idx_city_priority", columnList = "country_id, priority") + @Index(name = "idx_city_country", columnList = "country_id"), + @Index(name = "idx_city_name", columnList = "name_en, name_kr"), + @Index(name = "idx_city_priority", columnList = "country_id, priority") }) public class City extends BaseEntity { @@ -41,20 +41,20 @@ public class City extends BaseEntity { private Country country; public static City create( - String nameEn, - String nameKr, - BigDecimal latitude, - BigDecimal longitude, - Country country + String nameEn, + String nameKr, + BigDecimal latitude, + BigDecimal longitude, + Country country ) { return City.builder() - .nameEn(nameEn) - .nameKr(nameKr) - .latitude(latitude) - .longitude(longitude) - .country(country) - .priority(null) - .build(); + .nameEn(nameEn) + .nameKr(nameKr) + .latitude(latitude) + .longitude(longitude) + .country(country) + .priority(null) + .build(); } public void updatePriority(Integer priority) { diff --git a/src/main/java/com/souzip/domain/country/controller/CountryController.java b/src/main/java/com/souzip/domain/country/controller/CountryController.java index 6d804ddc..b93a154c 100644 --- a/src/main/java/com/souzip/domain/country/controller/CountryController.java +++ b/src/main/java/com/souzip/domain/country/controller/CountryController.java @@ -2,7 +2,7 @@ import com.souzip.domain.country.dto.CountryListResponse; import com.souzip.domain.country.service.CountryService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/souzip/domain/country/entity/Country.java b/src/main/java/com/souzip/domain/country/entity/Country.java index d6fb33a0..4412709e 100644 --- a/src/main/java/com/souzip/domain/country/entity/Country.java +++ b/src/main/java/com/souzip/domain/country/entity/Country.java @@ -1,11 +1,21 @@ package com.souzip.domain.country.entity; import com.souzip.domain.currency.entity.Currency; -import com.souzip.shared.domain.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - +import com.souzip.domain.shared.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import java.math.BigDecimal; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -44,26 +54,26 @@ public class Country extends BaseEntity { private Currency currency; public static Country of( - String nameEn, - String nameKr, - String code, - String capital, - Region region, - String imageUrl, - BigDecimal latitude, - BigDecimal longitude, - Currency currency + String nameEn, + String nameKr, + String code, + String capital, + Region region, + String imageUrl, + BigDecimal latitude, + BigDecimal longitude, + Currency currency ) { return Country.builder() - .nameEn(nameEn) - .nameKr(nameKr) - .code(code) - .capital(capital) - .region(region) - .imageUrl(imageUrl) - .latitude(latitude) - .longitude(longitude) - .currency(currency) - .build(); + .nameEn(nameEn) + .nameKr(nameKr) + .code(code) + .capital(capital) + .region(region) + .imageUrl(imageUrl) + .latitude(latitude) + .longitude(longitude) + .currency(currency) + .build(); } } diff --git a/src/main/java/com/souzip/domain/country/service/CountryService.java b/src/main/java/com/souzip/domain/country/service/CountryService.java index 75ccd41f..d0916061 100644 --- a/src/main/java/com/souzip/domain/country/service/CountryService.java +++ b/src/main/java/com/souzip/domain/country/service/CountryService.java @@ -9,8 +9,8 @@ import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.entity.Currency; import com.souzip.domain.currency.repository.CurrencyRepository; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,7 +38,6 @@ public CountryListResponse getAllCountries() { public CountryResponseDto getCountryByCode(String code) { Country country = findCountryByCodeOrThrow(code); - return CountryResponseDto.from(country); } diff --git a/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java b/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java index 51cafe0e..f025016d 100644 --- a/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java +++ b/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java @@ -1,9 +1,9 @@ package com.souzip.domain.currency.controller; -import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.currency.dto.CurrencyResponse; import com.souzip.domain.currency.service.CurrencyService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.RequireAuth; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/src/main/java/com/souzip/domain/currency/service/CurrencyService.java b/src/main/java/com/souzip/domain/currency/service/CurrencyService.java index 202740c3..060668ed 100644 --- a/src/main/java/com/souzip/domain/currency/service/CurrencyService.java +++ b/src/main/java/com/souzip/domain/currency/service/CurrencyService.java @@ -3,8 +3,8 @@ import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.dto.CurrencyResponse; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java b/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java index 7434a624..892a91fc 100644 --- a/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java +++ b/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java @@ -14,8 +14,8 @@ import com.souzip.domain.souvenir.dto.PriceData; import com.souzip.domain.souvenir.dto.PriceResponse; import com.souzip.domain.souvenir.vo.PriceInfo; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -42,9 +42,9 @@ public class ExchangeRateService { private final CurrencyRepository currencyRepository; public ExchangeCalculatedPrice calculatePrice( - String countryCode, - Integer localPrice, - Integer krwPrice + String countryCode, + Integer localPrice, + Integer krwPrice ) { CountryResponseDto country = countryService.getCountryByCode(countryCode); ExchangeRateResponseDto rate = getRate(country.currency().code()); @@ -61,9 +61,9 @@ public ExchangeCalculatedPrice calculatePrice( } public PriceData calculatePriceData( - Integer price, - String currency, - String countryCode + Integer price, + String currency, + String countryCode ) { if (hasInvalidPriceInput(price, currency)) { return createEmptyPriceData(); @@ -75,16 +75,16 @@ public PriceData calculatePriceData( String localCurrency = getLocalCurrency(countryCode); PriceInfo convertedPrice = calculateConvertedPrice( - originalPrice, - localCurrency, - exchangeAmount + originalPrice, + localCurrency, + exchangeAmount ); return new PriceData( - originalPrice, - exchangeAmount, - currencySymbol, - convertedPrice + originalPrice, + exchangeAmount, + currencySymbol, + convertedPrice ); } @@ -97,10 +97,10 @@ public PriceResponse createPriceResponse(PriceInfo originalPrice, PriceInfo conv String convertedSymbol = getCurrencySymbol(convertedPrice.getCurrency()); return PriceResponse.of( - originalPrice.getAmount(), - originalSymbol, - convertedPrice.getAmount(), - convertedSymbol + originalPrice.getAmount(), + originalSymbol, + convertedPrice.getAmount(), + convertedSymbol ); } @@ -114,37 +114,37 @@ private boolean hasKrwPrice(Integer krwPrice) { private ExchangeCalculatedPrice createEmptyPrice(CountryResponseDto country) { return new ExchangeCalculatedPrice( - ZERO_PRICE, - ZERO_PRICE, - country.currency().symbol() + ZERO_PRICE, + ZERO_PRICE, + country.currency().symbol() ); } private ExchangeCalculatedPrice fromLocalPrice( - Integer localPrice, - ExchangeRateResponseDto rate, - CountryResponseDto country + Integer localPrice, + ExchangeRateResponseDto rate, + CountryResponseDto country ) { int krw = multiply(localPrice, rate.rate()); return new ExchangeCalculatedPrice( - localPrice, - krw, - country.currency().symbol() + localPrice, + krw, + country.currency().symbol() ); } private ExchangeCalculatedPrice fromKrwPrice( - Integer krwPrice, - ExchangeRateResponseDto rate, - CountryResponseDto country + Integer krwPrice, + ExchangeRateResponseDto rate, + CountryResponseDto country ) { int local = divide(krwPrice, rate.rate()); return new ExchangeCalculatedPrice( - local, - krwPrice, - country.currency().symbol() + local, + krwPrice, + country.currency().symbol() ); } @@ -181,9 +181,9 @@ private PriceData createEmptyPriceData() { } private PriceInfo calculateConvertedPrice( - PriceInfo originalPrice, - String localCurrency, - Integer exchangeAmount + PriceInfo originalPrice, + String localCurrency, + Integer exchangeAmount ) { if (isKrwInput(originalPrice)) { return convertKrwToLocal(originalPrice.getAmount(), localCurrency); @@ -207,8 +207,8 @@ private boolean hasNoOriginalPrice(PriceInfo originalPrice) { private String getLocalCurrency(String countryCode) { return countryRepository.findByCodeWithCurrency(countryCode) - .map(country -> country.getCurrency().getCode()) - .orElseThrow(() -> new BusinessException(ErrorCode.COUNTRY_NOT_FOUND)); + .map(country -> country.getCurrency().getCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.COUNTRY_NOT_FOUND)); } private String getCurrencySymbol(String currencyCode) { @@ -217,8 +217,8 @@ private String getCurrencySymbol(String currencyCode) { } return currencyRepository.findByCode(currencyCode) - .map(Currency::getSymbol) - .orElseThrow(() -> new BusinessException(ErrorCode.CURRENCY_NOT_FOUND)); + .map(Currency::getSymbol) + .orElseThrow(() -> new BusinessException(ErrorCode.CURRENCY_NOT_FOUND)); } private boolean isNullCurrency(String currencyCode) { @@ -245,21 +245,21 @@ private Integer calculateForeignAmount(Integer krwAmount, String toCurrency) { private int multiply(Integer value, BigDecimal rate) { return BigDecimal.valueOf(value) - .multiply(rate) - .setScale(0, RoundingMode.HALF_UP) - .intValue(); + .multiply(rate) + .setScale(0, RoundingMode.HALF_UP) + .intValue(); } private int divide(Integer value, BigDecimal rate) { return BigDecimal.valueOf(value) - .divide(rate, 0, RoundingMode.HALF_UP) - .intValue(); + .divide(rate, 0, RoundingMode.HALF_UP) + .intValue(); } public ExchangeRateResponseDto getRate(String currencyCode) { ExchangeRate rate = exchangeRateRepository - .findByCurrencyCodeAndBaseCode(currencyCode, DEFAULT_BASE_CURRENCY) - .orElseThrow(() -> new BusinessException(ErrorCode.EXCHANGE_RATE_NOT_FOUND)); + .findByCurrencyCodeAndBaseCode(currencyCode, DEFAULT_BASE_CURRENCY) + .orElseThrow(() -> new BusinessException(ErrorCode.EXCHANGE_RATE_NOT_FOUND)); return ExchangeRateResponseDto.from(rate); } @@ -307,8 +307,8 @@ private void saveOrUpdate(ExchangeRate newRate) { private Optional findExistingRate(ExchangeRate newRate) { return exchangeRateRepository.findByCurrencyCodeAndBaseCode( - newRate.getCurrencyCode(), - newRate.getBaseCode() + newRate.getCurrencyCode(), + newRate.getBaseCode() ); } diff --git a/src/main/java/com/souzip/domain/file/File.java b/src/main/java/com/souzip/domain/file/File.java index ff312cb7..f515ad1d 100644 --- a/src/main/java/com/souzip/domain/file/File.java +++ b/src/main/java/com/souzip/domain/file/File.java @@ -1,6 +1,6 @@ package com.souzip.domain.file; -import com.souzip.shared.domain.BaseEntity; +import com.souzip.domain.shared.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/location/Location.java b/src/main/java/com/souzip/domain/location/Location.java index 44ba5686..5ac59312 100644 --- a/src/main/java/com/souzip/domain/location/Location.java +++ b/src/main/java/com/souzip/domain/location/Location.java @@ -1,7 +1,7 @@ package com.souzip.domain.location; -import com.souzip.shared.domain.BaseEntity; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.BaseEntity; +import com.souzip.domain.shared.Coordinate; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java b/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java new file mode 100644 index 00000000..b0e70fac --- /dev/null +++ b/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java @@ -0,0 +1,28 @@ +package com.souzip.domain.migration.apple.controller; + +import com.souzip.domain.migration.apple.dto.AppleMigrationPrepareResponse; +import com.souzip.domain.migration.apple.service.AppleMigrationPrepService; +import com.souzip.domain.migration.apple.service.AppleMigrationPrepService.MigrationResult; +import com.souzip.global.common.dto.SuccessResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/migration/apple") +@RequiredArgsConstructor +public class AppleMigrationController { + + private final AppleMigrationPrepService migrationPrepService; + + @PostMapping("/prepare") + public SuccessResponse prepare() { + log.info("Apple 마이그레이션 준비 API 호출"); + + MigrationResult result = migrationPrepService.prepareAllAppleUsers(); + AppleMigrationPrepareResponse data = AppleMigrationPrepareResponse.from(result); + + return SuccessResponse.of(data, "마이그레이션 준비 완료"); + } +} diff --git a/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java b/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java new file mode 100644 index 00000000..7a42ab3c --- /dev/null +++ b/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java @@ -0,0 +1,23 @@ +package com.souzip.domain.migration.apple.dto; + +import com.souzip.domain.migration.apple.service.AppleMigrationPrepService.MigrationResult; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AppleMigrationPrepareResponse { + private final int total; + private final int successCount; + private final int failCount; + private final int skipCount; + + public static AppleMigrationPrepareResponse from(MigrationResult result) { + return AppleMigrationPrepareResponse.builder() + .total(result.total()) + .successCount(result.success()) + .failCount(result.fail()) + .skipCount(result.skip()) + .build(); + } +} diff --git a/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java b/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java new file mode 100644 index 00000000..b0663713 --- /dev/null +++ b/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java @@ -0,0 +1,177 @@ +package com.souzip.domain.migration.apple.service; + +import com.souzip.domain.user.entity.Provider; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleMigrationPrepService { + + private final UserRepository userRepository; + private final AppleTransferService appleTransferService; + + private static final int RATE_LIMIT_DELAY_MILLIS = 100; + + @Transactional + public MigrationResult prepareAllAppleUsers() { + log.info("=== Apple 사용자 마이그레이션 준비 시작 ==="); + + List appleUsers = findAllAppleUsers(); + + if (hasNoAppleUsers(appleUsers)) { + throw new BusinessException(ErrorCode.NO_APPLE_USERS_FOUND); + } + + MigrationCounters counters = processAllUsers(appleUsers); + + return logAndReturnResult(appleUsers.size(), counters); + } + + private List findAllAppleUsers() { + List appleUsers = userRepository.findByProvider(Provider.APPLE); + log.info("총 {} 명의 Apple 사용자 발견", appleUsers.size()); + return appleUsers; + } + + private boolean hasNoAppleUsers(List appleUsers) { + return appleUsers.isEmpty(); + } + + private MigrationCounters processAllUsers(List appleUsers) { + MigrationCounters counters = new MigrationCounters(); + appleUsers.forEach(user -> processUser(user, counters)); + return counters; + } + + private void processUser(User user, MigrationCounters counters) { + try { + if (alreadyHasTransferIdentifier(user)) { + handleAlreadyMigrated(user, counters); + return; + } + + attemptMigration(user, counters); + waitForRateLimit(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + handleMigrationFailure(user, counters, e); + } + } + + private boolean alreadyHasTransferIdentifier(User user) { + return user.getTransferIdentifier() != null; + } + + private void handleAlreadyMigrated(User user, MigrationCounters counters) { + log.info("이미 저장됨 - userId: {}, transferId: {}", + user.getId(), user.getTransferIdentifier()); + counters.incrementSkip(); + } + + private void attemptMigration(User user, MigrationCounters counters) { + String transferIdentifier = appleTransferService.getTransferIdentifier(user.getProviderId()); + + if (hasTransferIdentifier(transferIdentifier)) { + saveMigration(user, transferIdentifier, counters); + } else { + handleNoTransferIdentifier(user, counters); + } + } + + private boolean hasTransferIdentifier(String transferIdentifier) { + return transferIdentifier != null; + } + + private void saveMigration(User user, String transferIdentifier, MigrationCounters counters) { + user.updateTransferIdentifier(transferIdentifier); + userRepository.save(user); + counters.incrementSuccess(); + + log.info("저장 성공 - userId: {}, providerId: {}, transferId: {}", + user.getId(), user.getProviderId(), transferIdentifier); + } + + private void handleNoTransferIdentifier(User user, MigrationCounters counters) { + counters.incrementFail(); + log.warn("transfer_identifier 없음 - userId: {}, providerId: {}", + user.getId(), user.getProviderId()); + } + + private void waitForRateLimit() { + try { + Thread.sleep(RATE_LIMIT_DELAY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Rate limit 대기 중 인터럽트 발생", e); + } + } + + private void handleMigrationFailure(User user, MigrationCounters counters, Exception e) { + counters.incrementFail(); + log.error("실패 - userId: {}, providerId: {}", user.getId(), user.getProviderId(), e); + } + + private MigrationResult logAndReturnResult(int total, MigrationCounters counters) { + MigrationResult result = new MigrationResult( + total, + counters.getSuccess(), + counters.getFail(), + counters.getSkip() + ); + + log.info("=== 마이그레이션 준비 완료 ==="); + log.info("총: {}, 성공: {}, 실패: {}, 스킵: {}", + result.total(), result.success(), result.fail(), result.skip()); + + return result; + } + + private static class MigrationCounters { + private final AtomicInteger successCount = new AtomicInteger(0); + private final AtomicInteger failCount = new AtomicInteger(0); + private final AtomicInteger skipCount = new AtomicInteger(0); + + public void incrementSuccess() { + successCount.incrementAndGet(); + } + + public void incrementFail() { + failCount.incrementAndGet(); + } + + public void incrementSkip() { + skipCount.incrementAndGet(); + } + + public int getSuccess() { + return successCount.get(); + } + + public int getFail() { + return failCount.get(); + } + + public int getSkip() { + return skipCount.get(); + } + } + + public record MigrationResult( + int total, + int success, + int fail, + int skip + ) {} +} diff --git a/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java b/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java new file mode 100644 index 00000000..3ca8f75f --- /dev/null +++ b/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java @@ -0,0 +1,198 @@ +package com.souzip.domain.migration.apple.service; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.souzip.global.config.AppleMigrationConfig; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import io.jsonwebtoken.Jwts; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleTransferService { + + private final AppleMigrationConfig appleConfig; + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String APPLE_AUTH_URL = "https://appleid.apple.com/auth/token"; + private static final String APPLE_MIGRATION_URL = "https://appleid.apple.com/auth/usermigrationinfo"; + private static final long JWT_EXPIRATION_MILLIS = 3600000L; + + public String getTransferIdentifier(String userId) { + try { + TransferResponse response = callAppleMigrationApi(userId); + return extractTransferSub(response, userId); + } catch (Exception e) { + log.error("Apple transfer identifier 조회 실패 - userId: {}", userId, e); + throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); + } + } + + private TransferResponse callAppleMigrationApi(String userId) { + String clientSecret = generateClientSecret(); + String accessToken = getAccessToken(clientSecret); + + HttpHeaders headers = createHeaders(); + headers.setBearerAuth(accessToken); + + MultiValueMap params = createParams(userId, clientSecret); + HttpEntity> request = new HttpEntity<>(params, headers); + + ResponseEntity response = restTemplate.postForEntity( + APPLE_MIGRATION_URL, + request, + TransferResponse.class + ); + + return response.getBody(); + } + + private String getAccessToken(String clientSecret) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + params.add("scope", "user.migration"); + params.add("client_id", appleConfig.getClientId()); + params.add("client_secret", clientSecret); + + ResponseEntity response = restTemplate.postForEntity( + APPLE_AUTH_URL, + new HttpEntity<>(params, headers), + AccessTokenResponse.class + ); + + log.info("Apple access token 발급 성공"); + return response.getBody().getAccessToken(); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + return headers; + } + + private MultiValueMap createParams(String userId, String clientSecret) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("sub", userId); + params.add("target", appleConfig.getTeamId()); + params.add("client_id", appleConfig.getClientId()); + params.add("client_secret", clientSecret); + return params; + } + + private String extractTransferSub(TransferResponse response, String userId) { + if (hasValidTransferSub(response)) { + return logAndReturnTransferSub(response, userId); + } + + logNoTransferSub(userId); + return null; + } + + private boolean hasValidTransferSub(TransferResponse response) { + return response != null && response.getTransferSub() != null; + } + + private String logAndReturnTransferSub(TransferResponse response, String userId) { + String transferSub = response.getTransferSub(); + log.info("transfer_identifier 조회 성공 - userId: {}, transferId: {}", userId, transferSub); + return transferSub; + } + + private void logNoTransferSub(String userId) { + log.warn("transfer_identifier 없음 - userId: {}", userId); + } + + private String generateClientSecret() { + try { + PrivateKey privateKey = getPrivateKey(); + String jwt = buildJwt(privateKey); + logJwtGeneration(jwt); + return jwt; + } catch (Exception e) { + log.error("client_secret 생성 실패", e); + throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); + } + } + + private String buildJwt(PrivateKey privateKey) { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date expiryDate = new Date(nowMillis + JWT_EXPIRATION_MILLIS); + + return Jwts.builder() + .header() + .add("kid", appleConfig.getOldKeyId()) + .add("alg", "ES256") + .and() + .issuer(appleConfig.getOldTeamId()) + .issuedAt(now) + .expiration(expiryDate) + .audience() + .add("https://appleid.apple.com") + .and() + .subject(appleConfig.getClientId()) + .signWith(privateKey, Jwts.SIG.ES256) + .compact(); + } + + private void logJwtGeneration(String jwt) { + log.info("Generated JWT: {}", jwt); + log.info("Old Team ID: {}", appleConfig.getOldTeamId()); + log.info("Old Key ID: {}", appleConfig.getOldKeyId()); + log.info("Client ID: {}", appleConfig.getClientId()); + } + + private PrivateKey getPrivateKey() { + try { + String cleanedKey = cleanPrivateKey(appleConfig.getOldPrivateKey()); // 이전 팀 Private Key + byte[] keyBytes = Base64.getDecoder().decode(cleanedKey); + return generatePrivateKey(keyBytes); + } catch (Exception e) { + log.error("Private Key 파싱 실패", e); + throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); + } + } + + private String cleanPrivateKey(String privateKey) { + return privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\n", "") + .replaceAll("\\s+", ""); + } + + private PrivateKey generatePrivateKey(byte[] keyBytes) throws Exception { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } + + @Getter + public static class AccessTokenResponse { + @JsonProperty("access_token") + private String accessToken; + } + + @Getter + public static class TransferResponse { + @JsonProperty("transfer_sub") + private String transferSub; + } +} diff --git a/src/main/java/com/souzip/domain/notice/Notice.java b/src/main/java/com/souzip/domain/notice/Notice.java index 76656d0f..e0d2836e 100644 --- a/src/main/java/com/souzip/domain/notice/Notice.java +++ b/src/main/java/com/souzip/domain/notice/Notice.java @@ -1,12 +1,11 @@ package com.souzip.domain.notice; -import com.souzip.shared.domain.BaseEntity; +import com.souzip.domain.shared.BaseEntity; +import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import java.util.UUID; - import static java.util.Objects.requireNonNull; @Getter diff --git a/src/main/java/com/souzip/domain/notification/DeviceType.java b/src/main/java/com/souzip/domain/notification/DeviceType.java deleted file mode 100644 index f75060a6..00000000 --- a/src/main/java/com/souzip/domain/notification/DeviceType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.souzip.domain.notification; - -public enum DeviceType { - ANDROID, - IOS -} diff --git a/src/main/java/com/souzip/domain/notification/FcmToken.java b/src/main/java/com/souzip/domain/notification/FcmToken.java deleted file mode 100644 index 3268edf5..00000000 --- a/src/main/java/com/souzip/domain/notification/FcmToken.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.souzip.domain.notification; - -import com.souzip.shared.domain.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -import static java.util.Objects.requireNonNull; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class FcmToken extends BaseEntity { - - private Long userId; - - private String token; - - private DeviceType deviceType; - - private String deviceId; - - private String deviceModel; - - private String osVersion; - - private String appVersion; - - private boolean active; - - private LocalDateTime lastUsedAt; - - public static FcmToken register(FcmTokenRegisterRequest request) { - FcmToken fcmToken = new FcmToken(); - - fcmToken.token = requireNonNull(request.fcmToken(), "FCM 토큰은 필수입니다."); - fcmToken.deviceType = requireNonNull(request.deviceType(), "디바이스 타입은 필수입니다."); - fcmToken.deviceId = requireNonNull(request.deviceId(), "디바이스 ID는 필수입니다."); - fcmToken.deviceModel = request.deviceModel(); - fcmToken.osVersion = request.osVersion(); - fcmToken.appVersion = request.appVersion(); - fcmToken.active = true; - fcmToken.lastUsedAt = LocalDateTime.now(); - - return fcmToken; - } - - public void linkUser(Long userId) { - this.userId = requireNonNull(userId, "사용자 ID는 필수입니다."); - this.lastUsedAt = LocalDateTime.now(); - } - - public void unlinkUser() { - this.userId = null; - this.lastUsedAt = LocalDateTime.now(); - } - - public void updateToken(String token) { - this.token = requireNonNull(token, "FCM 토큰은 필수입니다."); - this.lastUsedAt = LocalDateTime.now(); - } - - public void updateDeviceInfo(String deviceModel, String osVersion, String appVersion) { - this.deviceModel = deviceModel; - this.osVersion = osVersion; - this.appVersion = appVersion; - this.lastUsedAt = LocalDateTime.now(); - } - - public void syncDeviceIdentity(DeviceType deviceType, String deviceId) { - this.deviceType = requireNonNull(deviceType, "디바이스 타입은 필수입니다."); - this.deviceId = requireNonNull(deviceId, "디바이스 ID는 필수입니다."); - this.lastUsedAt = LocalDateTime.now(); - } - - public void deactivate() { - this.active = false; - } - - public void activate() { - this.active = true; - this.lastUsedAt = LocalDateTime.now(); - } - - public boolean isActive() { - return this.active; - } - - public boolean isLoggedIn() { - return this.userId != null; - } -} diff --git a/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java b/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java deleted file mode 100644 index dd8cc355..00000000 --- a/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.souzip.domain.notification; - -import static java.util.Objects.requireNonNull; - -public record FcmTokenRegisterRequest( - String fcmToken, - DeviceType deviceType, - String deviceId, - String deviceModel, - String osVersion, - String appVersion -) { - private static final int MAX_FCM_TOKEN_LENGTH = 255; - private static final int MAX_DEVICE_ID_LENGTH = 100; - private static final int MAX_DEVICE_MODEL_LENGTH = 100; - private static final int MAX_OS_VERSION_LENGTH = 50; - private static final int MAX_APP_VERSION_LENGTH = 50; - - public FcmTokenRegisterRequest { - fcmToken = validateFcmToken(fcmToken); - requireNonNull(deviceType, "디바이스 타입은 필수입니다."); - deviceId = validateDeviceId(deviceId); - deviceModel = validateOptionalField(deviceModel, MAX_DEVICE_MODEL_LENGTH, "디바이스 모델"); - osVersion = validateOptionalField(osVersion, MAX_OS_VERSION_LENGTH, "OS 버전"); - appVersion = validateOptionalField(appVersion, MAX_APP_VERSION_LENGTH, "앱 버전"); - } - - public static FcmTokenRegisterRequest of( - String fcmToken, - DeviceType deviceType, - String deviceId, - String deviceModel, - String osVersion, - String appVersion - ) { - return new FcmTokenRegisterRequest( - fcmToken, - deviceType, - deviceId, - deviceModel, - osVersion, - appVersion - ); - } - - private static String validateFcmToken(String fcmToken) { - requireNonNull(fcmToken, "FCM 토큰은 필수입니다."); - - String trimmed = fcmToken.trim(); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException("FCM 토큰은 비어있을 수 없습니다."); - } - - if (trimmed.length() > MAX_FCM_TOKEN_LENGTH) { - throw new IllegalArgumentException( - String.format("FCM 토큰은 %d자를 초과할 수 없습니다.", MAX_FCM_TOKEN_LENGTH) - ); - } - - return trimmed; - } - - private static String validateDeviceId(String deviceId) { - requireNonNull(deviceId, "디바이스 ID는 필수입니다."); - - String trimmed = deviceId.trim(); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException("디바이스 ID는 비어있을 수 없습니다."); - } - - if (trimmed.length() > MAX_DEVICE_ID_LENGTH) { - throw new IllegalArgumentException( - String.format("디바이스 ID는 %d자를 초과할 수 없습니다.", MAX_DEVICE_ID_LENGTH) - ); - } - - return trimmed; - } - - private static String validateOptionalField(String value, int maxLength, String fieldName) { - if (value == null || value.trim().isEmpty()) { - return null; - } - - String trimmed = value.trim(); - if (trimmed.length() > maxLength) { - throw new IllegalArgumentException( - String.format("%s는 %d자를 초과할 수 없습니다.", fieldName, maxLength) - ); - } - - return trimmed; - } -} diff --git a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java b/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java deleted file mode 100644 index c8d7e98d..00000000 --- a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.souzip.domain.notification; - -import com.souzip.shared.domain.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -import static java.util.Objects.requireNonNull; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PushBroadcastHistory extends BaseEntity { - - private UUID adminId; - - private String title; - - private String body; - - private int totalTargets; - - private int successCount; - - private int failCount; - - private boolean firebaseConfigured; - - public static PushBroadcastHistory record( - UUID adminId, - String title, - String body, - int totalTargets, - int successCount, - int failCount, - boolean firebaseConfigured - ) { - PushBroadcastHistory row = new PushBroadcastHistory(); - row.adminId = requireNonNull(adminId, "관리자 ID는 필수입니다."); - row.title = requireNonNull(title, "제목은 필수입니다."); - row.body = requireNonNull(body, "본문은 필수입니다."); - row.totalTargets = totalTargets; - row.successCount = successCount; - row.failCount = failCount; - row.firebaseConfigured = firebaseConfigured; - return row; - } -} diff --git a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java b/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java deleted file mode 100644 index 14c51ae8..00000000 --- a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.souzip.domain.notification.controller; - -import com.souzip.application.notification.FcmTokenCommandService; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.domain.notification.FcmToken; -import com.souzip.domain.notification.FcmTokenRegisterRequest; -import com.souzip.domain.notification.dto.FcmTokenResponse; -import com.souzip.shared.common.dto.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RequestMapping("/api/users/me/fcm-tokens") -@RestController -public class FcmTokenController { - - private final FcmTokenCommandService fcmTokenCommandService; - - // 로그인 사용자의 FCM 디바이스 토큰을 등록하거나 갱신합니다. - @PostMapping - @RequireAuth - public SuccessResponse register( - @CurrentUserId Long userId, - @Valid @RequestBody FcmTokenRegisterRequest request - ) { - FcmToken saved = fcmTokenCommandService.registerOrUpdate(userId, request); - return SuccessResponse.of(FcmTokenResponse.from(saved), "FCM 토큰이 등록되었습니다."); - } - - // 로그아웃 등으로 해당 디바이스 토큰을 비활성화합니다. - @DeleteMapping - @RequireAuth - public SuccessResponse deactivate( - @CurrentUserId Long userId, - @RequestParam String deviceId - ) { - fcmTokenCommandService.deactivateByDevice(userId, deviceId); - return SuccessResponse.of("FCM 토큰이 비활성화되었습니다."); - } -} diff --git a/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java b/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java deleted file mode 100644 index 8807399f..00000000 --- a/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.souzip.domain.notification.dto; - -import com.souzip.domain.notification.FcmToken; - -public record FcmTokenResponse( - Long id, - String deviceId, - boolean active -) { - - public static FcmTokenResponse from(FcmToken token) { - return new FcmTokenResponse(token.getId(), token.getDeviceId(), token.isActive()); - } -} diff --git a/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java b/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java index daf1d462..1efa3284 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java +++ b/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java @@ -1,10 +1,10 @@ package com.souzip.domain.recommend.ai.controller; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.service.AiRecommendationService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.CurrentUserId; +import com.souzip.global.security.annotation.RequireAuth; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java b/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java index e200c858..6b553c6b 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java +++ b/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java @@ -10,12 +10,10 @@ public record RecommendedSouvenir( String name, String category, String countryCode, - String thumbnailUrl, - long wishlistCount, - boolean isWishlisted + String thumbnailUrl ) { - public static RecommendedSouvenir from(Long id, String name, String category, String countryCode, String thumbnailUrl, long wishlistCount, boolean isWishlisted) { - return new RecommendedSouvenir(id, name, category, countryCode, thumbnailUrl, wishlistCount, isWishlisted); + public static RecommendedSouvenir from(Long id, String name, String category, String countryCode, String thumbnailUrl) { + return new RecommendedSouvenir(id, name, category, countryCode, thumbnailUrl); } } } diff --git a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java index c933ae64..85a1df0d 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java @@ -11,13 +11,8 @@ import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.repository.AiRecommendationRepositoryCustom; import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.shared.clova.ClovaStudioClient; -import com.souzip.shared.clova.PromptLoader; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.clova.ClovaStudioClient; +import com.souzip.global.clova.PromptLoader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; @@ -38,8 +33,6 @@ public class AiRecommendationService { private final ObjectMapper objectMapper; private final FileQueryService fileQueryService; private final FileStorage fileStorage; - private final WishlistRepository wishlistRepository; - private final UserRepository userRepository; public AiRecommendationResponse getCategoryRecommendationsForUser(Long userId) { log.info("getCategoryRecommendationsForUser 시작, userId={}", userId); @@ -71,10 +64,8 @@ public AiRecommendationResponse getCategoryRecommendationsForUser(Long userId) { Map> recommendedNamesByCategory = parseClovaResponse(clovaResponse); log.info("추천 이름 파싱: {}", recommendedNamesByCategory); - String userUuid = findUserUuid(userId); - return new AiRecommendationResponse( - mapToRecommendedSouvenirs(recommendedNamesByCategory, userUuid) + mapToRecommendedSouvenirs(recommendedNamesByCategory) ); } @@ -116,29 +107,20 @@ public AiRecommendationResponse getRecentSouvenirRecommendations(Long userId) { Map> recommendedNamesByCategory = parseClovaResponse(clovaResponse); log.info("추천 이름 파싱: {}", recommendedNamesByCategory); - List souvenirs = recommendedNamesByCategory.values().stream() - .flatMap(List::stream) - .map(aiRecommendationRepository::findByName) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList()); - - String userUuid = findUserUuid(userId); - List souvenirIds = souvenirs.stream().map(Souvenir::getId).toList(); - Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(userUuid); - Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); - - List finalSouvenirs = souvenirs.stream() - .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( - s.getId(), - s.getName(), - s.getCategory().name(), - s.getCountryCode(), - getThumbnailUrl(s.getId()), - wishlistCountMap.getOrDefault(s.getId(), 0L), - wishlistedIds.contains(s.getId()) - )) - .collect(Collectors.toList()); + List finalSouvenirs = + recommendedNamesByCategory.values().stream() + .flatMap(List::stream) + .map(aiRecommendationRepository::findByName) + .filter(Optional::isPresent) + .map(Optional::get) + .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( + s.getId(), + s.getName(), + s.getCategory().name(), + s.getCountryCode(), + getThumbnailUrl(s.getId()) + )) + .collect(Collectors.toList()); log.info("최종 추천 souvenirs 개수: {}", finalSouvenirs.size()); return new AiRecommendationResponse(finalSouvenirs); @@ -187,8 +169,7 @@ private Map> parseClovaResponse(String clovaResponse) { try { return objectMapper.readValue( clovaResponse, - new TypeReference>>>() { - } + new TypeReference>>>() {} ).entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, @@ -202,8 +183,7 @@ private Map> parseClovaResponse(String clovaResponse) { } private List mapToRecommendedSouvenirs( - Map> recommendedNamesByCategory, - String userUuid + Map> recommendedNamesByCategory ) { List souvenirs = recommendedNamesByCategory.values().stream() .flatMap(List::stream) @@ -217,8 +197,6 @@ private List mapToRecommendedSouve .toList(); Map thumbnailMap = getThumbnails(souvenirIds); - Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(userUuid); - Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); return souvenirs.stream() .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( @@ -228,19 +206,11 @@ private List mapToRecommendedSouve s.getCountryCode(), Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null), - wishlistCountMap.getOrDefault(s.getId(), 0L), - wishlistedIds.contains(s.getId()) + .orElse(null) )) .collect(Collectors.toList()); } - private String findUserUuid(Long userId) { - return userRepository.findById(userId) - .map(User::getUserId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - } - private String getThumbnailUrl(Long souvenirId) { File file = fileQueryService.findFirst(EntityType.SOUVENIR, souvenirId); return fileStorage.generateUrl(file.getStorageKey()); diff --git a/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java b/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java index ac52dadc..ba261e52 100644 --- a/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java +++ b/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java @@ -4,9 +4,12 @@ import com.souzip.domain.recommend.general.dto.GeneralRecommendationDto; import com.souzip.domain.recommend.general.dto.GeneralRecommendationStatsDto; import com.souzip.domain.recommend.general.service.GeneralRecommendationService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -18,19 +21,13 @@ public class GeneralRecommendationController { private final GeneralRecommendationService generalRecommendationService; @GetMapping("/discovery/general/country/{countryCode}") - public SuccessResponse> getCountryTop10( - @PathVariable String countryCode, - @RequestHeader(value = "Authorization", required = false) String authorizationHeader - ) { - return SuccessResponse.of(generalRecommendationService.getTop10ByCountry(countryCode, authorizationHeader)); + public SuccessResponse> getCountryTop10(@PathVariable String countryCode) { + return SuccessResponse.of(generalRecommendationService.getTop10ByCountry(countryCode)); } @GetMapping("/discovery/general/category/{categoryName}") - public SuccessResponse> getCategoryTop10( - @PathVariable String categoryName, - @RequestHeader(value = "Authorization", required = false) String authorizationHeader - ) { - return SuccessResponse.of(generalRecommendationService.getTop10ByCategory(categoryName, authorizationHeader)); + public SuccessResponse> getCategoryTop10(@PathVariable String categoryName) { + return SuccessResponse.of(generalRecommendationService.getTop10ByCategory(categoryName)); } @GetMapping("/discovery/general/stats") @@ -39,14 +36,15 @@ public SuccessResponse> getTopCountriesAllTi } @GetMapping("/countries/souvenirs") - public SuccessResponse> getTopCountriesWithTop10Souvenirs( - @RequestHeader(value = "Authorization", required = false) String authorizationHeader - ) { - return SuccessResponse.of(generalRecommendationService.getTopCountriesWithTop10Souvenirs(authorizationHeader)); + public SuccessResponse> getTopCountriesWithTop10Souvenirs() { + return SuccessResponse.of(generalRecommendationService.getTopCountriesWithTop10Souvenirs()); } @GetMapping("/discovery/general/countries/top10") public SuccessResponse> getTopCountriesAllTimeTop10() { - return SuccessResponse.of(generalRecommendationService.getTop10CountriesBySouvenirCount()); + return SuccessResponse.of( + generalRecommendationService.getTop10CountriesBySouvenirCount() + ); } + } diff --git a/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java b/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java index e6eb9490..1ad7b732 100644 --- a/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java +++ b/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java @@ -8,19 +8,15 @@ public record GeneralRecommendationDto( String name, Category category, String countryCode, - String thumbnailUrl, - long wishlistCount, - Boolean isWishlisted + String thumbnailUrl ) { - public static GeneralRecommendationDto of(Souvenir souvenir, String thumbnailUrl, long wishlistCount, Boolean isWishlisted) { + public static GeneralRecommendationDto of(Souvenir souvenir, String thumbnailUrl) { return new GeneralRecommendationDto( souvenir.getId(), souvenir.getName(), souvenir.getCategory(), souvenir.getCountryCode(), - thumbnailUrl, - wishlistCount, - isWishlisted + thumbnailUrl ); } } diff --git a/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java b/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java index cb3ef67b..ec8a91eb 100644 --- a/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java +++ b/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java @@ -7,8 +7,8 @@ import com.souzip.domain.souvenir.entity.QSouvenir; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.user.entity.QUser; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -25,15 +25,15 @@ public List findTop10ByCategoryRecent(String categoryName) { QUser u = QUser.user; Category category = Category.from(categoryName) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CATEGORY)); + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CATEGORY)); return queryFactory.selectFrom(s) - .leftJoin(s.user, u).fetchJoin() - .where(s.category.eq(category) - .and(s.deleted.eq(false))) - .orderBy(s.createdAt.desc()) - .limit(10) - .fetch(); + .leftJoin(s.user, u).fetchJoin() + .where(s.category.eq(category) + .and(s.deleted.eq(false))) + .orderBy(s.createdAt.desc()) + .limit(10) + .fetch(); } @Override @@ -42,12 +42,12 @@ public List findTop10ByCountry(String countryCode) { QUser u = QUser.user; return queryFactory.selectFrom(s) - .leftJoin(s.user, u).fetchJoin() - .where(s.countryCode.eq(countryCode) - .and(s.deleted.eq(false))) - .orderBy(s.createdAt.desc()) - .limit(10) - .fetch(); + .leftJoin(s.user, u).fetchJoin() + .where(s.countryCode.eq(countryCode) + .and(s.deleted.eq(false))) + .orderBy(s.createdAt.desc()) + .limit(10) + .fetch(); } @Override diff --git a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java index 4267815e..e74ca2e8 100644 --- a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java @@ -3,7 +3,6 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; -import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.recommend.general.dto.CountryRecommendationDto; @@ -11,40 +10,50 @@ import com.souzip.domain.recommend.general.dto.GeneralRecommendationStatsDto; import com.souzip.domain.recommend.general.repository.GeneralRecommendationRepositoryCustom; import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.wishlist.repository.WishlistRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; -@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class GeneralRecommendationService { - private static final String BEARER_PREFIX = "Bearer "; - private static final int BEARER_PREFIX_LENGTH = 7; private final GeneralRecommendationRepositoryCustom generalRecommendationRepository; private final FileQueryService fileQueryService; private final FileStorage fileStorage; - private final WishlistRepository wishlistRepository; - private final JwtTokenProvider jwtTokenProvider; - public List getTop10ByCategory(String categoryName, @Nullable String authorizationHeader) { - List souvenirs = generalRecommendationRepository.findTop10ByCategoryRecent(categoryName); - return toDto(souvenirs, authorizationHeader); + public List getTop10ByCategory(String categoryName) { + List souvenirs = generalRecommendationRepository + .findTop10ByCategoryRecent(categoryName); + + List souvenirIds = souvenirs.stream() + .map(Souvenir::getId) + .toList(); + + Map thumbnailMap = getThumbnails(souvenirIds); + + return souvenirs.stream() + .map(s -> new GeneralRecommendationDto( + s.getId(), + s.getName(), + s.getCategory(), + s.getCountryCode(), + Optional.ofNullable(thumbnailMap.get(s.getId())) + .map(FileResponse::url) + .orElse(null) + )) + .toList(); } public List getTop10CountriesBySouvenirCount() { return generalRecommendationRepository.findTop10CountriesBySouvenirCount(); } - public List getTopCountriesWithTop10Souvenirs(@Nullable String authorizationHeader) { + public List getTopCountriesWithTop10Souvenirs() { List topCountries = generalRecommendationRepository.findTop10CountriesBySouvenirCount(); @@ -65,14 +74,16 @@ public List getTopCountriesWithTop10Souvenirs(@Nullabl .toList(); Map thumbnailMap = allSouvenirIds.isEmpty() - ? Map.of() - : getThumbnails(allSouvenirIds); + ? Map.of() + : getThumbnails(allSouvenirIds); - String userId = extractUserId(authorizationHeader); - Set wishlistedIds = userId != null - ? wishlistRepository.findSouvenirIdsByUserId(userId) - : Collections.emptySet(); - Map wishlistCountMap = wishlistRepository.countBySouvenirIds(allSouvenirIds); + Map statsMap = topCountries.stream() + .collect(Collectors.toMap( + GeneralRecommendationStatsDto::countryCode, + s -> s, + (a, b) -> a, + LinkedHashMap::new + )); List result = new ArrayList<>(); for (GeneralRecommendationStatsDto stats : topCountries) { @@ -84,9 +95,7 @@ public List getTopCountriesWithTop10Souvenirs(@Nullabl s, Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null), - wishlistCountMap.getOrDefault(s.getId(), 0L), - userId != null ? wishlistedIds.contains(s.getId()) : null + .orElse(null) )) .toList(); @@ -101,81 +110,31 @@ public List getTopCountriesWithTop10Souvenirs(@Nullabl return result; } - public List getTop10ByCountry(String countryCode, @Nullable String authorizationHeader) { - List souvenirs = generalRecommendationRepository.findTop10ByCountry(countryCode); - return toDto(souvenirs, authorizationHeader); - } - - public List getTop3CountriesBySouvenirCount() { - return generalRecommendationRepository.findTop3CountriesBySouvenirCount(); - } + public List getTop10ByCountry(String countryCode) { + List souvenirs = generalRecommendationRepository + .findTop10ByCountry(countryCode); - private List toDto(List souvenirs, @Nullable String authorizationHeader) { - List souvenirIds = souvenirs.stream().map(Souvenir::getId).toList(); - Map thumbnailMap = souvenirIds.isEmpty() ? Map.of() : getThumbnails(souvenirIds); + List souvenirIds = souvenirs.stream() + .map(Souvenir::getId) + .toList(); - String userId = extractUserId(authorizationHeader); - Set wishlistedIds = userId != null - ? wishlistRepository.findSouvenirIdsByUserId(userId) - : Collections.emptySet(); - Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); + Map thumbnailMap = getThumbnails(souvenirIds); return souvenirs.stream() - .map(s -> GeneralRecommendationDto.of( - s, + .map(s -> new GeneralRecommendationDto( + s.getId(), + s.getName(), + s.getCategory(), + s.getCountryCode(), Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null), - wishlistCountMap.getOrDefault(s.getId(), 0L), - userId != null ? wishlistedIds.contains(s.getId()) : null + .orElse(null) )) .toList(); } - @Nullable - private String extractUserId(@Nullable String authorizationHeader) { - if (hasNoAuthorizationHeader(authorizationHeader)) { - return null; - } - - if (isNotBearerToken(authorizationHeader)) { - return null; - } - - String token = extractToken(authorizationHeader); - - if (isEmptyToken(token)) { - return null; - } - - return parseUserIdFromToken(token); - } - - private boolean hasNoAuthorizationHeader(String authorizationHeader) { - return authorizationHeader == null; - } - - private boolean isNotBearerToken(String authorizationHeader) { - return !authorizationHeader.startsWith(BEARER_PREFIX); - } - - private String extractToken(String authorizationHeader) { - return authorizationHeader.substring(BEARER_PREFIX_LENGTH).trim(); - } - - private boolean isEmptyToken(String token) { - return token.isEmpty(); - } - - @Nullable - private String parseUserIdFromToken(String token) { - try { - Long userId = jwtTokenProvider.getUserIdFromToken(token); - return userId != null ? String.valueOf(userId) : null; - } catch (Exception e) { - log.debug("Failed to parse token", e); - return null; - } + public List getTop3CountriesBySouvenirCount() { + return generalRecommendationRepository.findTop3CountriesBySouvenirCount(); } private Map getThumbnails(List souvenirIds) { diff --git a/src/main/java/com/souzip/domain/search/controller/SearchController.java b/src/main/java/com/souzip/domain/search/controller/SearchController.java index bbe63a27..a3728e39 100644 --- a/src/main/java/com/souzip/domain/search/controller/SearchController.java +++ b/src/main/java/com/souzip/domain/search/controller/SearchController.java @@ -2,9 +2,9 @@ import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.service.SearchService; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.common.dto.pagination.PaginationRequest; +import com.souzip.global.common.dto.pagination.PaginationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java b/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java index ced1bc4a..856ee8ad 100644 --- a/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java +++ b/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java @@ -1,6 +1,6 @@ package com.souzip.domain.search.dto; -import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.global.common.dto.pagination.PaginationResponse; public class SearchListResponse extends PaginationResponse { diff --git a/src/main/java/com/souzip/domain/search/service/SearchService.java b/src/main/java/com/souzip/domain/search/service/SearchService.java index a30b0f0e..0ad3a601 100644 --- a/src/main/java/com/souzip/domain/search/service/SearchService.java +++ b/src/main/java/com/souzip/domain/search/service/SearchService.java @@ -3,13 +3,11 @@ import com.souzip.domain.city.entity.City; import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.repository.LocationSearchRepository; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; - +import com.souzip.global.common.dto.pagination.PaginationRequest; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import java.util.List; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/souzip/shared/domain/BaseEntity.java b/src/main/java/com/souzip/domain/shared/BaseEntity.java similarity index 68% rename from src/main/java/com/souzip/shared/domain/BaseEntity.java rename to src/main/java/com/souzip/domain/shared/BaseEntity.java index 7a64119d..3ee96c6f 100644 --- a/src/main/java/com/souzip/shared/domain/BaseEntity.java +++ b/src/main/java/com/souzip/domain/shared/BaseEntity.java @@ -1,13 +1,17 @@ -package com.souzip.shared.domain; +package com.souzip.domain.shared; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.LocalDateTime; - @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/souzip/shared/domain/Coordinate.java b/src/main/java/com/souzip/domain/shared/Coordinate.java similarity index 97% rename from src/main/java/com/souzip/shared/domain/Coordinate.java rename to src/main/java/com/souzip/domain/shared/Coordinate.java index 75ebc318..a9be6135 100644 --- a/src/main/java/com/souzip/shared/domain/Coordinate.java +++ b/src/main/java/com/souzip/domain/shared/Coordinate.java @@ -1,4 +1,4 @@ -package com.souzip.shared.domain; +package com.souzip.domain.shared; import lombok.AccessLevel; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java b/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java index a213ca24..ade8ffc1 100644 --- a/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java +++ b/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java @@ -1,10 +1,15 @@ package com.souzip.domain.souvenir.controller; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.domain.souvenir.dto.*; +import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; +import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; +import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; +import com.souzip.domain.souvenir.dto.SouvenirRequest; +import com.souzip.domain.souvenir.dto.SouvenirResponse; +import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; import com.souzip.domain.souvenir.service.SouvenirService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.CurrentUserId; +import com.souzip.global.security.annotation.RequireAuth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -20,22 +25,20 @@ public class SouvenirController { @GetMapping("/api/souvenirs/nearby") public SuccessResponse getNearbySouvenirs( - @RequestParam double latitude, - @RequestParam double longitude, - @RequestParam(required = false, defaultValue = "5000") double radiusMeter, - @RequestHeader(value = "Authorization", required = false) String authorizationHeader + @RequestParam double latitude, + @RequestParam double longitude, + @RequestParam(required = false, defaultValue = "5000") double radiusMeter ) { - return SuccessResponse.of(souvenirService.getNearbySouvenirs(latitude, longitude, radiusMeter, authorizationHeader)); + return SuccessResponse.of(souvenirService.getNearbySouvenirs(latitude, longitude, radiusMeter)); } @GetMapping("/api/souvenirs/{id}") public SuccessResponse getSouvenir( - @PathVariable Long id, - @RequestHeader(value = "Authorization", required = false) String authorizationHeader + @PathVariable Long id, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader ) { SouvenirDetailResponse response = souvenirService.getSouvenir(id, authorizationHeader); return SuccessResponse.of(response); - } @RequireAuth @@ -48,9 +51,9 @@ public SuccessResponse deleteSouvenir(@PathVariable Long id, @CurrentUserI @RequireAuth @PostMapping("/api/souvenirs") public SuccessResponse createSouvenir( - @Valid @RequestPart("souvenir") SouvenirCreateRequest request, - @RequestPart(value = "files") List files, - @CurrentUserId Long userId + @Valid @RequestPart("souvenir") SouvenirCreateRequest request, + @RequestPart(value = "files") List files, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.createSouvenir(request, userId, files); return SuccessResponse.of(response); @@ -59,9 +62,9 @@ public SuccessResponse createSouvenir( @RequireAuth @PutMapping("/api/souvenirs/{id}") public SuccessResponse updateSouvenir( - @Valid @PathVariable Long id, - @RequestPart("souvenir") SouvenirUpdateRequest request, - @CurrentUserId Long userId + @Valid @PathVariable Long id, + @RequestPart("souvenir") SouvenirUpdateRequest request, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.updateSouvenir(id, request, userId); return SuccessResponse.of(response); @@ -70,9 +73,9 @@ public SuccessResponse updateSouvenir( @RequireAuth @PostMapping("/api/v2/souvenirs") public SuccessResponse createSouvenirV2( - @Valid @RequestPart("souvenir") SouvenirRequest request, - @RequestPart(value = "files") List files, - @CurrentUserId Long userId + @Valid @RequestPart("souvenir") SouvenirRequest request, + @RequestPart(value = "files") List files, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.createSouvenirV2(request, userId, files); return SuccessResponse.of(response); @@ -81,9 +84,9 @@ public SuccessResponse createSouvenirV2( @RequireAuth @PutMapping("/api/v2/souvenirs/{id}") public SuccessResponse updateSouvenirV2( - @PathVariable Long id, - @Valid @RequestPart("souvenir") SouvenirRequest request, - @CurrentUserId Long userId + @PathVariable Long id, + @Valid @RequestPart("souvenir") SouvenirRequest request, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.updateSouvenirV2(id, request, userId); return SuccessResponse.of(response); diff --git a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java index d0d372d1..ba115e49 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java @@ -1,13 +1,13 @@ package com.souzip.domain.souvenir.dto; -import com.souzip.shared.common.dto.pagination.PaginationResponse.PageInfo; +import com.souzip.global.common.dto.pagination.PaginationResponse.PageInfo; import org.springframework.data.domain.Page; import java.util.List; public record MySouvenirListResponse( - List content, - PageInfo pagination + List content, + PageInfo pagination ) { public static MySouvenirListResponse from(Page page) { PageInfo pagination = PageInfo.of(page); diff --git a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java index abd40340..f737331e 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java @@ -5,23 +5,19 @@ import java.time.LocalDateTime; public record MySouvenirResponse( - Long id, - String thumbnailUrl, - String countryCode, - LocalDateTime createdAt, - LocalDateTime updatedAt, - long wishlistCount, - boolean isWishlisted + Long id, + String thumbnailUrl, + String countryCode, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { - public static MySouvenirResponse of(Souvenir souvenir, String thumbnailUrl, boolean isWishlisted, long wishlistCount) { + public static MySouvenirResponse of(Souvenir souvenir, String thumbnailUrl) { return new MySouvenirResponse( - souvenir.getId(), - thumbnailUrl, - souvenir.getCountryCode(), - souvenir.getCreatedAt(), - souvenir.getUpdatedAt(), - wishlistCount, - isWishlisted + souvenir.getId(), + thumbnailUrl, + souvenir.getCountryCode(), + souvenir.getCreatedAt(), + souvenir.getUpdatedAt() ); } } diff --git a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java index d209e21b..46185b1d 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java @@ -9,87 +9,80 @@ import java.util.List; public record SouvenirDetailResponse( - Long id, - String name, - Integer localPrice, - String currencySymbol, - Integer krwPrice, - PriceResponse price, - String description, - String address, - String locationDetail, - BigDecimal latitude, - BigDecimal longitude, - Category category, - Purpose purpose, - String countryCode, - String userNickname, - String userProfileImageUrl, - Boolean isOwned, - Boolean isWishlisted, - long wishlistCount, - List files + Long id, + String name, + + Integer localPrice, + String currencySymbol, + Integer krwPrice, + + PriceResponse price, + + String description, + String address, + String locationDetail, + BigDecimal latitude, + BigDecimal longitude, + Category category, + Purpose purpose, + String countryCode, + String userNickname, + String userProfileImageUrl, + Boolean isOwned, + List files ) { public static SouvenirDetailResponse of( - Souvenir souvenir, - List files, - Boolean isOwned, - Boolean isWishlisted, - long wishlistCount + Souvenir souvenir, + List files, + Boolean isOwned ) { return new SouvenirDetailResponse( - souvenir.getId(), - souvenir.getName(), - souvenir.getLocalPrice(), - souvenir.getCurrencySymbol(), - souvenir.getKrwPrice(), - null, - souvenir.getDescription(), - souvenir.getAddress(), - souvenir.getLocationDetail(), - souvenir.getLatitude(), - souvenir.getLongitude(), - souvenir.getCategory(), - souvenir.getPurpose(), - souvenir.getCountryCode(), - souvenir.getUser().getNickname(), - souvenir.getUser().getProfileImageUrl(), - isOwned, - isWishlisted, - wishlistCount, - files + souvenir.getId(), + souvenir.getName(), + souvenir.getLocalPrice(), + souvenir.getCurrencySymbol(), + souvenir.getKrwPrice(), + null, + souvenir.getDescription(), + souvenir.getAddress(), + souvenir.getLocationDetail(), + souvenir.getLatitude(), + souvenir.getLongitude(), + souvenir.getCategory(), + souvenir.getPurpose(), + souvenir.getCountryCode(), + souvenir.getUser().getNickname(), + souvenir.getUser().getProfileImageUrl(), + isOwned, + files ); } public static SouvenirDetailResponse of( - Souvenir souvenir, - List files, - Boolean isOwned, - Boolean isWishlisted, - long wishlistCount, - PriceResponse priceResponse + Souvenir souvenir, + List files, + Boolean isOwned, + PriceResponse priceResponse ) { return new SouvenirDetailResponse( - souvenir.getId(), - souvenir.getName(), - souvenir.getLocalPrice(), - souvenir.getCurrencySymbol(), - souvenir.getKrwPrice(), - priceResponse, - souvenir.getDescription(), - souvenir.getAddress(), - souvenir.getLocationDetail(), - souvenir.getLatitude(), - souvenir.getLongitude(), - souvenir.getCategory(), - souvenir.getPurpose(), - souvenir.getCountryCode(), - souvenir.getUser().getNickname(), - souvenir.getUser().getProfileImageUrl(), - isOwned, - isWishlisted, - wishlistCount, - files + souvenir.getId(), + souvenir.getName(), + souvenir.getLocalPrice(), + souvenir.getCurrencySymbol(), + souvenir.getKrwPrice(), + priceResponse, + souvenir.getDescription(), + souvenir.getAddress(), + souvenir.getLocationDetail(), + souvenir.getLatitude(), + souvenir.getLongitude(), + souvenir.getCategory(), + souvenir.getPurpose(), + souvenir.getCountryCode(), + souvenir.getUser().getNickname(), + souvenir.getUser().getProfileImageUrl(), + isOwned, + files ); } } diff --git a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java index 0a58ee20..190c0674 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java @@ -4,23 +4,20 @@ import com.souzip.domain.souvenir.entity.Purpose; import java.math.BigDecimal; -import java.util.Set; import java.util.function.Function; public record SouvenirNearbyResponse( - Long id, - String name, - Category category, - Purpose purpose, - int localPrice, - int krwPrice, - String currencySymbol, - String thumbnail, - BigDecimal latitude, - BigDecimal longitude, - String address, - long wishlistCount, - Boolean isWishlisted + Long id, + String name, + Category category, + Purpose purpose, + int localPrice, + int krwPrice, + String currencySymbol, + String thumbnail, + BigDecimal latitude, + BigDecimal longitude, + String address ) { private static final int INDEX_ID = 0; private static final int INDEX_NAME = 1; @@ -33,44 +30,38 @@ public record SouvenirNearbyResponse( private static final int INDEX_LATITUDE = 8; private static final int INDEX_LONGITUDE = 9; private static final int INDEX_ADDRESS = 10; - private static final int INDEX_WISHLIST_COUNT = 11; public static SouvenirNearbyResponse from( - Long id, - String name, - Category category, - Purpose purpose, - int localPrice, - int krwPrice, - String currencySymbol, - String thumbnail, - BigDecimal latitude, - BigDecimal longitude, - String address, - long wishlistCount, - Boolean isWishlisted + Long id, + String name, + Category category, + Purpose purpose, + int localPrice, + int krwPrice, + String currencySymbol, + String thumbnail, + BigDecimal latitude, + BigDecimal longitude, + String address ) { return new SouvenirNearbyResponse( - id, - name, - category, - purpose, - localPrice, - krwPrice, - currencySymbol, - thumbnail, - latitude, - longitude, - address, - wishlistCount, - isWishlisted + id, + name, + category, + purpose, + localPrice, + krwPrice, + currencySymbol, + thumbnail, + latitude, + longitude, + address ); } public static SouvenirNearbyResponse fromObjectArray( - Object[] row, - Function urlGenerator, - Set wishlistedIds + Object[] row, + Function urlGenerator ) { Long id = extractId(row); String name = extractName(row); @@ -85,23 +76,19 @@ public static SouvenirNearbyResponse fromObjectArray( String address = extractAddress(row); String imageUrl = generateImageUrl(thumbnail, urlGenerator); - long wishlistCount = ((Number) row[INDEX_WISHLIST_COUNT]).longValue(); - Boolean isWishlisted = wishlistedIds.isEmpty() ? null : wishlistedIds.contains(id); return new SouvenirNearbyResponse( - id, - name, - category, - purpose, - localPrice, - krwPrice, - currencySymbol, - imageUrl, - latitude, - longitude, - address, - wishlistCount, - isWishlisted + id, + name, + category, + purpose, + localPrice, + krwPrice, + currencySymbol, + imageUrl, + latitude, + longitude, + address ); } diff --git a/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java b/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java index 60771fd9..a6cfe50f 100644 --- a/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java +++ b/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java @@ -6,11 +6,20 @@ import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; import com.souzip.domain.souvenir.vo.PriceInfo; import com.souzip.domain.user.entity.User; -import com.souzip.shared.domain.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - +import com.souzip.domain.shared.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import java.math.BigDecimal; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -80,65 +89,65 @@ public class Souvenir extends BaseEntity { @Deprecated public static Souvenir of( - SouvenirCreateRequest request, - User user, - Integer calculatedLocalPrice, - Integer calculatedKrwPrice + SouvenirCreateRequest request, + User user, + Integer calculatedLocalPrice, + Integer calculatedKrwPrice ) { return Souvenir.builder() - .name(request.name()) - .localPrice(calculatedLocalPrice) - .currencySymbol(request.currencySymbol()) - .krwPrice(calculatedKrwPrice) - .description(request.description()) - .address(request.address()) - .locationDetail(request.locationDetail()) - .latitude(request.latitude()) - .longitude(request.longitude()) - .category(request.category()) - .purpose(request.purpose()) - .countryCode(request.countryCode()) - .user(user) - .deleted(false) - .build(); + .name(request.name()) + .localPrice(calculatedLocalPrice) + .currencySymbol(request.currencySymbol()) + .krwPrice(calculatedKrwPrice) + .description(request.description()) + .address(request.address()) + .locationDetail(request.locationDetail()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .category(request.category()) + .purpose(request.purpose()) + .countryCode(request.countryCode()) + .user(user) + .deleted(false) + .build(); } public static Souvenir ofV2( - SouvenirRequest request, - User user, - PriceInfo originalPrice, - Integer exchangeAmount, - String currencySymbol, - PriceInfo convertedPrice + SouvenirRequest request, + User user, + PriceInfo originalPrice, + Integer exchangeAmount, + String currencySymbol, + PriceInfo convertedPrice ) { return Souvenir.builder() - .name(request.name()) - .originalAmount(extractAmount(originalPrice)) - .originalCurrency(extractCurrency(originalPrice)) - .exchangeAmount(exchangeAmount) - .convertedAmount(extractAmount(convertedPrice)) - .convertedCurrency(extractCurrency(convertedPrice)) - .localPrice(extractAmount(originalPrice)) - .currencySymbol(currencySymbol) - .krwPrice(exchangeAmount) - .description(request.description()) - .address(request.address()) - .locationDetail(request.locationDetail()) - .latitude(request.latitude()) - .longitude(request.longitude()) - .category(request.category()) - .purpose(request.purpose()) - .countryCode(request.countryCode()) - .user(user) - .deleted(false) - .build(); + .name(request.name()) + .originalAmount(extractAmount(originalPrice)) + .originalCurrency(extractCurrency(originalPrice)) + .exchangeAmount(exchangeAmount) + .convertedAmount(extractAmount(convertedPrice)) + .convertedCurrency(extractCurrency(convertedPrice)) + .localPrice(extractAmount(originalPrice)) + .currencySymbol(currencySymbol) + .krwPrice(exchangeAmount) + .description(request.description()) + .address(request.address()) + .locationDetail(request.locationDetail()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .category(request.category()) + .purpose(request.purpose()) + .countryCode(request.countryCode()) + .user(user) + .deleted(false) + .build(); } @Deprecated public void update( - SouvenirUpdateRequest request, - Integer calculatedLocalPrice, - Integer calculatedKrwPrice + SouvenirUpdateRequest request, + Integer calculatedLocalPrice, + Integer calculatedKrwPrice ) { this.name = request.name(); this.localPrice = calculatedLocalPrice; @@ -155,11 +164,11 @@ public void update( } public void updateV2( - SouvenirRequest request, - PriceInfo originalPrice, - Integer exchangeAmount, - String currencySymbol, - PriceInfo convertedPrice + SouvenirRequest request, + PriceInfo originalPrice, + Integer exchangeAmount, + String currencySymbol, + PriceInfo convertedPrice ) { this.name = request.name(); this.originalAmount = extractAmount(originalPrice); diff --git a/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java b/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java index 00a68033..e86519c5 100644 --- a/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java +++ b/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java @@ -27,23 +27,18 @@ public interface SouvenirRepository extends JpaRepository, Souve f.storage_key, s.latitude, s.longitude, - s.address, - COUNT(w.id) AS wishlist_count + s.address FROM souvenir s LEFT JOIN file f ON f.entity_type = 'Souvenir' AND f.entity_id = s.id AND f.display_order = 1 - LEFT JOIN wishlist w ON w.souvenir_id = s.id WHERE s.deleted = false AND ST_DWithin( ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)::geography, :radiusMeter ) - GROUP BY s.id, s.name, s.category, s.purpose, s.local_price, - s.krw_price, s.currency_symbol, f.storage_key, - s.latitude, s.longitude, s.address ORDER BY ST_Distance( ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)::geography diff --git a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java index 111a11a8..337fe4c7 100644 --- a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java +++ b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java @@ -5,21 +5,28 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; -import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.exchangerate.dto.ExchangeCalculatedPrice; import com.souzip.domain.exchangerate.service.ExchangeRateService; import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.souvenir.dto.*; +import com.souzip.domain.souvenir.dto.PriceData; +import com.souzip.domain.souvenir.dto.PriceResponse; +import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; +import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; +import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; +import com.souzip.domain.souvenir.dto.SouvenirNearbyResponse; +import com.souzip.domain.souvenir.dto.SouvenirRequest; +import com.souzip.domain.souvenir.dto.SouvenirResponse; +import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.souvenir.repository.SouvenirRepository; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.shared.audit.annotation.Audit; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.audit.annotation.Audit; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.security.jwt.JwtTokenProvider; import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,7 +36,6 @@ import java.util.Collections; import java.util.List; -import java.util.Set; @Slf4j @Transactional(readOnly = true) @@ -42,7 +48,6 @@ public class SouvenirService { private final SouvenirRepository souvenirRepository; private final UserRepository userRepository; - private final WishlistRepository wishlistRepository; private final FileModifyService fileModifyService; private final FileQueryService fileQueryService; private final FileStorage fileStorage; @@ -53,22 +58,18 @@ public class SouvenirService { public SouvenirNearbyListResponse getNearbySouvenirs( double latitude, double longitude, - double radiusMeter, - @Nullable String authorizationHeader + double radiusMeter ) { - String userId = extractUserId(authorizationHeader); - - List results = souvenirRepository.findNearbySouvenirs(latitude, longitude, radiusMeter); - - Set wishlistedIds = userId != null - ? wishlistRepository.findSouvenirIdsByUserId(userId) - : Collections.emptySet(); + List results = souvenirRepository.findNearbySouvenirs( + latitude, + longitude, + radiusMeter + ); List list = results.stream() .map(row -> SouvenirNearbyResponse.fromObjectArray( row, - ncpStorage::generateUrl, - wishlistedIds + ncpStorage::generateUrl )) .toList(); @@ -80,13 +81,13 @@ public SouvenirDetailResponse getSouvenir( @Nullable String authorizationHeader ) { String userId = extractUserId(authorizationHeader); + Souvenir souvenir = findSouvenirById(souvenirId); List files = getFiles(souvenirId); boolean isOwned = souvenir.isOwnedBy(userId); - boolean isWishlisted = userId != null && wishlistRepository.existsByUserUserIdAndSouvenirId(userId, souvenirId); - long wishlistCount = wishlistRepository.countBySouvenirId(souvenirId); PriceResponse priceResponse = createPriceResponse(souvenir); - return SouvenirDetailResponse.of(souvenir, files, isOwned, isWishlisted, wishlistCount, priceResponse); + + return SouvenirDetailResponse.of(souvenir, files, isOwned, priceResponse); } @Audit(action = AuditAction.SOUVENIR_DELETED) @@ -257,8 +258,7 @@ private boolean isEmptyToken(String token) { @Nullable private String parseUserIdFromToken(String token) { try { - Long userId = jwtTokenProvider.getUserIdFromToken(token); - return userId != null ? String.valueOf(userId) : null; + return jwtTokenProvider.getUserIdFromToken(token); } catch (Exception e) { log.debug("Failed to parse token", e); return null; diff --git a/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java b/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java deleted file mode 100644 index 346e548c..00000000 --- a/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.souzip.domain.user.adapter; - -import com.souzip.auth.application.dto.UserInfo; -import com.souzip.auth.application.required.UserPort; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.shared.domain.Provider; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class UserPortImpl implements UserPort { - - private final UserRepository userRepository; - - @Override - public UserInfo findOrCreateUser(Provider provider, String providerId, String email) { - User user = userRepository - .findByProviderAndProviderId(provider, providerId) - .orElseGet(() -> userRepository.save( - User.of(provider, providerId, email) - )); - - return new UserInfo( - user.getId(), - user.getUserId(), - user.getNickname(), - user.needsOnboarding() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/user/controller/UserController.java b/src/main/java/com/souzip/domain/user/controller/UserController.java index d3b26061..e981e75c 100644 --- a/src/main/java/com/souzip/domain/user/controller/UserController.java +++ b/src/main/java/com/souzip/domain/user/controller/UserController.java @@ -1,14 +1,24 @@ package com.souzip.domain.user.controller; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.souvenir.dto.MySouvenirListResponse; -import com.souzip.domain.user.dto.*; +import com.souzip.domain.user.dto.NicknameCheckRequest; +import com.souzip.domain.user.dto.NicknameCheckResponse; +import com.souzip.domain.user.dto.OnboardingRequest; +import com.souzip.domain.user.dto.OnboardingResponse; +import com.souzip.domain.user.dto.UserProfileResponse; import com.souzip.domain.user.service.UserService; -import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.CurrentUserId; +import com.souzip.global.security.annotation.RequireAuth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RequestMapping("/api/users") @@ -20,7 +30,7 @@ public class UserController { @PostMapping("/check-nickname") @RequireAuth public SuccessResponse checkNickname( - @Valid @RequestBody NicknameCheckRequest request + @Valid @RequestBody NicknameCheckRequest request ) { NicknameCheckResponse response = userService.checkNickname(request.nickname()); return SuccessResponse.of(response); @@ -29,8 +39,8 @@ public SuccessResponse checkNickname( @PostMapping("/onboarding") @RequireAuth public SuccessResponse completeOnboarding( - @CurrentUserId Long currentUserId, - @Valid @RequestBody OnboardingRequest request + @CurrentUserId Long currentUserId, + @Valid @RequestBody OnboardingRequest request ) { OnboardingResponse response = userService.completeOnboarding(currentUserId, request); return SuccessResponse.of(response); @@ -39,7 +49,7 @@ public SuccessResponse completeOnboarding( @GetMapping("/me") @RequireAuth public SuccessResponse getMyProfile( - @CurrentUserId Long currentUserId + @CurrentUserId Long currentUserId ) { UserProfileResponse profile = userService.getUserProfile(currentUserId); return SuccessResponse.of(profile); @@ -48,9 +58,9 @@ public SuccessResponse getMyProfile( @GetMapping("/me/souvenirs") @RequireAuth public SuccessResponse getMySouvenirs( - @CurrentUserId Long currentUserId, - @RequestParam int page, - @RequestParam int size + @CurrentUserId Long currentUserId, + @RequestParam int page, + @RequestParam int size ) { MySouvenirListResponse souvenirs = userService.getMySouvenirs(currentUserId, page, size); return SuccessResponse.of(souvenirs); diff --git a/src/main/java/com/souzip/domain/user/entity/Provider.java b/src/main/java/com/souzip/domain/user/entity/Provider.java new file mode 100644 index 00000000..d4399633 --- /dev/null +++ b/src/main/java/com/souzip/domain/user/entity/Provider.java @@ -0,0 +1,23 @@ +package com.souzip.domain.user.entity; + +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import java.util.Arrays; + +public enum Provider { + KAKAO, + APPLE, + GOOGLE; + + public static Provider from(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "Provider는 필수입니다."); + } + + return Arrays.stream(values()) + .filter(provider -> provider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new BusinessException( + ErrorCode.INVALID_INPUT, "지원하지 않는 로그인 방식입니다: " + value)); + } +} diff --git a/src/main/java/com/souzip/domain/user/entity/User.java b/src/main/java/com/souzip/domain/user/entity/User.java index 49bbf9b3..b69f64df 100644 --- a/src/main/java/com/souzip/domain/user/entity/User.java +++ b/src/main/java/com/souzip/domain/user/entity/User.java @@ -1,15 +1,15 @@ package com.souzip.domain.user.entity; +import com.souzip.domain.auth.dto.OAuthUserInfo; import com.souzip.domain.category.entity.Category; -import com.souzip.shared.domain.BaseEntity; -import com.souzip.shared.domain.Provider; +import com.souzip.domain.shared.BaseEntity; import jakarta.persistence.*; +import java.util.HashSet; +import java.util.Set; import lombok.*; import org.hibernate.annotations.SQLDelete; import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; import java.util.UUID; @Getter @@ -17,15 +17,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Table(name = "\"user\"", - uniqueConstraints = { - @UniqueConstraint(columnNames = {"provider", "provider_id"}) - }, - indexes = { - @Index(name = "idx_user_id", columnList = "user_id"), - @Index(name = "idx_provider", columnList = "provider, provider_id"), - @Index(name = "idx_deleted", columnList = "deleted"), - @Index(name = "idx_email", columnList = "email") - } + uniqueConstraints = { + @UniqueConstraint(columnNames = {"provider", "provider_id"}) + }, + indexes = { + @Index(name = "idx_user_id", columnList = "user_id"), + @Index(name = "idx_provider", columnList = "provider, provider_id"), + @Index(name = "idx_deleted", columnList = "deleted"), + @Index(name = "idx_email", columnList = "email") + } ) @SQLDelete(sql = "UPDATE \"user\" SET deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @Entity @@ -61,8 +61,8 @@ public class User extends BaseEntity { @ElementCollection(fetch = FetchType.LAZY) @CollectionTable( - name = "user_category", - joinColumns = @JoinColumn(name = "user_id") + name = "user_category", + joinColumns = @JoinColumn(name = "user_id") ) @Column(name = "category") @Enumerated(EnumType.STRING) @@ -81,18 +81,37 @@ protected void onCreate() { ensureUserId(); } - public static User of(Provider provider, String providerId, String email) { + public static User of( + Provider provider, + String providerId, + String name, + String nickname, + String email, + String profileImageUrl + ) { return User.builder() - .userId(UUID.randomUUID().toString()) - .provider(provider) - .providerId(providerId) - .name("") - .nickname("") - .email(email) - .onboardingCompleted(false) - .categories(new HashSet<>()) - .deleted(false) - .build(); + .userId(UUID.randomUUID().toString()) + .provider(provider) + .providerId(providerId) + .name(name) + .nickname(nickname) + .email(email) + .profileImageUrl(profileImageUrl) + .onboardingCompleted(false) + .categories(new HashSet<>()) + .deleted(false) + .build(); + } + + public static User of(Provider provider, OAuthUserInfo oauthUserInfo) { + return User.of( + provider, + oauthUserInfo.getProviderId(), + oauthUserInfo.getName(), + "", + oauthUserInfo.getEmail(), + oauthUserInfo.getProfileImageUrl() + ); } private void ensureUserId() { @@ -142,4 +161,4 @@ public void updateProviderId(String providerId) { public void updateLastLoginAt() { this.lastLoginAt = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/souzip/domain/user/entity/UserAgreement.java b/src/main/java/com/souzip/domain/user/entity/UserAgreement.java index 2467b398..a38f787a 100644 --- a/src/main/java/com/souzip/domain/user/entity/UserAgreement.java +++ b/src/main/java/com/souzip/domain/user/entity/UserAgreement.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.entity; import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.shared.domain.BaseEntity; +import com.souzip.domain.shared.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -32,31 +32,31 @@ public class UserAgreement extends BaseEntity { private Boolean marketingConsent; public static UserAgreement of( - User user, - Boolean ageVerified, - Boolean serviceTerms, - Boolean privacyRequired, - Boolean locationService, - Boolean marketingConsent + User user, + Boolean ageVerified, + Boolean serviceTerms, + Boolean privacyRequired, + Boolean locationService, + Boolean marketingConsent ) { return UserAgreement.builder() - .user(user) - .ageVerified(ageVerified) - .serviceTerms(serviceTerms) - .privacyRequired(privacyRequired) - .locationService(locationService) - .marketingConsent(marketingConsent) - .build(); + .user(user) + .ageVerified(ageVerified) + .serviceTerms(serviceTerms) + .privacyRequired(privacyRequired) + .locationService(locationService) + .marketingConsent(marketingConsent) + .build(); } public static UserAgreement of(User user, OnboardingRequest request) { return of( - user, - request.ageVerified(), - request.serviceTerms(), - request.privacyRequired(), - request.locationService(), - request.marketingConsent() + user, + request.ageVerified(), + request.serviceTerms(), + request.privacyRequired(), + request.locationService(), + request.marketingConsent() ); } } diff --git a/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java b/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java index bba83483..62a32cce 100644 --- a/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java +++ b/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java @@ -11,6 +11,4 @@ public interface UserAgreementRepository extends JpaRepository findByUser(User user); boolean existsByUser(User user); - - long countByMarketingConsentTrue(); } diff --git a/src/main/java/com/souzip/domain/user/repository/UserRepository.java b/src/main/java/com/souzip/domain/user/repository/UserRepository.java index ab86490f..dae37a66 100644 --- a/src/main/java/com/souzip/domain/user/repository/UserRepository.java +++ b/src/main/java/com/souzip/domain/user/repository/UserRepository.java @@ -1,12 +1,10 @@ package com.souzip.domain.user.repository; +import com.souzip.domain.user.entity.Provider; import com.souzip.domain.user.entity.User; -import com.souzip.shared.domain.Provider; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { @@ -19,12 +17,4 @@ public interface UserRepository extends JpaRepository { Optional findByTransferIdentifier(String transferIdentifier); List findByProvider(Provider provider); - - @Query(""" - SELECT DISTINCT u.email FROM User u - WHERE u.deleted = false AND u.email IS NOT NULL - """) - List findDistinctEmailsByActiveUsers(); - - long deleteByDeletedTrue(); } diff --git a/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java b/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java deleted file mode 100644 index a608c812..00000000 --- a/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.souzip.domain.user.scheduler; - -import com.souzip.domain.user.service.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class UserCleanupScheduler { - - private final UserService userService; - - @Scheduled(cron = "0 0 3 * * *") - public void runUserCleanup() { - log.info("탈퇴 유저 삭제 배치 스케줄러 실행 시작"); - long deletedCount = userService.deleteWithdrawnUsers(); - log.info("탈퇴 유저 {}명 삭제 완료", deletedCount); - } -} diff --git a/src/main/java/com/souzip/domain/user/service/ProfileImageService.java b/src/main/java/com/souzip/domain/user/service/ProfileImageService.java index f0336d60..b8dd95f0 100644 --- a/src/main/java/com/souzip/domain/user/service/ProfileImageService.java +++ b/src/main/java/com/souzip/domain/user/service/ProfileImageService.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.service; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import com.souzip.adapter.config.ObjectStorageProperties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -20,7 +20,7 @@ public class ProfileImageService { private static final String URL_FORMAT = "%s/%s/%s/%s%s"; private static final Set AVAILABLE_COLORS = Set.of( - "red", "blue", "yellow", "purple" + "red", "blue", "yellow", "purple" ); public String resolveProfileImageUrl(String input) { @@ -71,12 +71,12 @@ private boolean isValidColor(String color) { private String buildColorUrl(String color) { return String.format( - URL_FORMAT, - storageProperties.getEndpoint(), - storageProperties.getBucket(), - PROFILE_FOLDER, - color, - IMAGE_EXTENSION + URL_FORMAT, + storageProperties.getEndpoint(), + storageProperties.getBucket(), + PROFILE_FOLDER, + color, + IMAGE_EXTENSION ); } } diff --git a/src/main/java/com/souzip/domain/user/service/UserService.java b/src/main/java/com/souzip/domain/user/service/UserService.java index 92cb7665..04087f71 100644 --- a/src/main/java/com/souzip/domain/user/service/UserService.java +++ b/src/main/java/com/souzip/domain/user/service/UserService.java @@ -3,8 +3,8 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; -import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.audit.entity.AuditAction; +import com.souzip.domain.auth.repository.RefreshTokenRepository; import com.souzip.domain.category.dto.CategoryDto; import com.souzip.domain.category.entity.Category; import com.souzip.domain.file.EntityType; @@ -21,10 +21,14 @@ import com.souzip.domain.user.entity.UserAgreement; import com.souzip.domain.user.repository.UserAgreementRepository; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.shared.audit.annotation.Audit; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.audit.annotation.Audit; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -34,13 +38,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) @@ -52,7 +49,6 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; private final ProfileImageService profileImageService; private final SouvenirRepository souvenirRepository; - private final WishlistRepository wishlistRepository; private final FileQueryService fileQueryService; private final FileStorage fileStorage; @@ -89,20 +85,12 @@ public OnboardingResponse completeOnboarding(Long userId, OnboardingRequest requ @Transactional public void withdraw(Long userId) { User user = findUserById(userId); - - deleteRefreshTokenIfExists(userId); + deleteRefreshTokenIfExists(user); deleteUserAgreementIfExists(user); - user.anonymize(); - userRepository.delete(user); } - @Transactional - public long deleteWithdrawnUsers() { - return userRepository.deleteByDeletedTrue(); - } - public UserProfileResponse getUserProfile(Long userId) { User user = findUserById(userId); return UserProfileResponse.from(user); @@ -119,16 +107,12 @@ public MySouvenirListResponse getMySouvenirs(Long userId, int page, int size) { .toList(); Map thumbnailMap = getThumbnails(souvenirIds); - Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(user.getUserId()); - Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); Page responsePage = souvenirPage.map(souvenir -> { String thumbnailUrl = Optional.ofNullable(thumbnailMap.get(souvenir.getId())) .map(FileResponse::url) .orElse(null); - boolean isWishlisted = wishlistedIds.contains(souvenir.getId()); - long wishlistCount = wishlistCountMap.getOrDefault(souvenir.getId(), 0L); - return MySouvenirResponse.of(souvenir, thumbnailUrl, isWishlisted, wishlistCount); + return MySouvenirResponse.of(souvenir, thumbnailUrl); }); return MySouvenirListResponse.from(responsePage); @@ -190,8 +174,9 @@ private boolean isRequiredAgreementNotAccepted(OnboardingRequest request) { || !request.locationService(); } - private void deleteRefreshTokenIfExists(Long userId) { - refreshTokenRepository.deleteByUserId(userId); + private void deleteRefreshTokenIfExists(User user) { + refreshTokenRepository.findByUser(user) + .ifPresent(refreshTokenRepository::delete); } private void deleteUserAgreementIfExists(User user) { diff --git a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java deleted file mode 100644 index f4247470..00000000 --- a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.souzip.domain.wishlist.controller; - -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.domain.wishlist.dto.MyWishlistListResponse; -import com.souzip.domain.wishlist.dto.WishlistResponse; -import com.souzip.domain.wishlist.service.WishlistService; -import com.souzip.shared.common.dto.SuccessResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -public class WishlistController { - - private final WishlistService wishlistService; - - @PostMapping("/api/wishlists/{souvenirId}") - @RequireAuth - public SuccessResponse addWishlist( - @CurrentUserId Long currentUserId, - @PathVariable Long souvenirId - ) { - return SuccessResponse.of(wishlistService.addWishlist(currentUserId, souvenirId)); - } - - @DeleteMapping("/api/wishlists/{souvenirId}") - @RequireAuth - public SuccessResponse removeWishlist( - @CurrentUserId Long currentUserId, - @PathVariable Long souvenirId - ) { - return SuccessResponse.of(wishlistService.removeWishlist(currentUserId, souvenirId)); - } - - @GetMapping("/api/users/me/wishlists") - @RequireAuth - public SuccessResponse getMyWishlist( - @CurrentUserId Long currentUserId, - Pageable pageable - ) { - return SuccessResponse.of(wishlistService.getMyWishlist(currentUserId, pageable)); - } -} diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java deleted file mode 100644 index f4edc416..00000000 --- a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.domain.wishlist.dto; - -import com.souzip.shared.common.dto.pagination.PaginationResponse.PageInfo; -import org.springframework.data.domain.Page; - -import java.util.List; - -public record MyWishlistListResponse( - List content, - PageInfo pagination -) { - public static MyWishlistListResponse from(Page page) { - return new MyWishlistListResponse(page.getContent(), PageInfo.of(page)); - } -} diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java deleted file mode 100644 index 2582f008..00000000 --- a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.domain.wishlist.dto; - -import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.wishlist.entity.Wishlist; - -import java.time.LocalDateTime; - -public record MyWishlistResponse( - Long souvenirId, - String name, - String countryCode, - String thumbnailUrl, - LocalDateTime wishedAt, - boolean isWishlisted -) { - public static MyWishlistResponse of(Wishlist wishlist, String thumbnailUrl) { - Souvenir s = wishlist.getSouvenir(); - return new MyWishlistResponse( - s.getId(), - s.getName(), - s.getCountryCode(), - thumbnailUrl, - wishlist.getCreatedAt(), - true - ); - } -} diff --git a/src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java deleted file mode 100644 index 770ac825..00000000 --- a/src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.domain.wishlist.dto; - -public record WishlistResponse( - Long souvenirId, - boolean wishlisted -) { - public static WishlistResponse of(Long souvenirId, boolean wishlisted) { - return new WishlistResponse(souvenirId, wishlisted); - } -} diff --git a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java deleted file mode 100644 index dc24fb1a..00000000 --- a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.souzip.domain.wishlist.entity; - -import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.user.entity.User; -import com.souzip.shared.domain.BaseEntity; -import jakarta.persistence.Entity; -import lombok.*; - -@Getter -@Builder(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Entity -public class Wishlist extends BaseEntity { - - private User user; - - private Souvenir souvenir; - - public static Wishlist of(User user, Souvenir souvenir) { - return Wishlist.builder() - .user(user) - .souvenir(souvenir) - .build(); - } -} diff --git a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java deleted file mode 100644 index c4de3895..00000000 --- a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.souzip.domain.wishlist.repository; - -import com.souzip.domain.wishlist.entity.Wishlist; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@Repository -public interface WishlistRepository extends JpaRepository { - - boolean existsByUserUserIdAndSouvenirId(String userId, Long souvenirId); - - @Modifying - @Query("DELETE FROM Wishlist w WHERE w.user.id = :userId AND w.souvenir.id = :souvenirId") - void deleteByUserIdAndSouvenirId(@Param("userId") Long userId, @Param("souvenirId") Long souvenirId); - - @Query( - value = "SELECT w FROM Wishlist w JOIN FETCH w.souvenir WHERE w.user.id = :userId ORDER BY w.createdAt DESC", - countQuery = "SELECT COUNT(w) FROM Wishlist w WHERE w.user.id = :userId" - ) - Page findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); - - @Query("SELECT w.souvenir.id FROM Wishlist w WHERE w.user.userId = :userId") - Set findSouvenirIdsByUserId(@Param("userId") String userId); - - @Query("SELECT COUNT(w) FROM Wishlist w WHERE w.souvenir.id = :souvenirId") - long countBySouvenirId(@Param("souvenirId") Long souvenirId); - - @Query("SELECT w.souvenir.id, COUNT(w) FROM Wishlist w WHERE w.souvenir.id IN :souvenirIds GROUP BY w.souvenir.id") - List countBySouvenirIdsRaw(@Param("souvenirIds") List souvenirIds); - - default Map countBySouvenirIds(List souvenirIds) { - return countBySouvenirIdsRaw(souvenirIds).stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); - } -} diff --git a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java deleted file mode 100644 index 73c1ed6f..00000000 --- a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.souzip.domain.wishlist.service; - -import com.souzip.application.file.FileQueryService; -import com.souzip.application.file.dto.FileResponse; -import com.souzip.application.file.required.FileStorage; -import com.souzip.domain.file.EntityType; -import com.souzip.domain.file.File; -import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.souvenir.repository.SouvenirRepository; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.domain.wishlist.dto.MyWishlistListResponse; -import com.souzip.domain.wishlist.dto.MyWishlistResponse; -import com.souzip.domain.wishlist.dto.WishlistResponse; -import com.souzip.domain.wishlist.entity.Wishlist; -import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class WishlistService { - - private final WishlistRepository wishlistRepository; - private final UserRepository userRepository; - private final SouvenirRepository souvenirRepository; - private final FileQueryService fileQueryService; - private final FileStorage fileStorage; - - @Transactional - public WishlistResponse addWishlist(Long userId, Long souvenirId) { - User user = findUserById(userId); - Souvenir souvenir = findSouvenirById(souvenirId); - try { - wishlistRepository.save(Wishlist.of(user, souvenir)); - } catch (DataIntegrityViolationException e) { - throw new BusinessException(ErrorCode.ALREADY_WISHLISTED); - } - return WishlistResponse.of(souvenirId, true); - } - - @Transactional - public WishlistResponse removeWishlist(Long userId, Long souvenirId) { - User user = findUserById(userId); - if (!wishlistRepository.existsByUserUserIdAndSouvenirId(user.getUserId(), souvenirId)) { - throw new BusinessException(ErrorCode.WISHLIST_NOT_FOUND); - } - wishlistRepository.deleteByUserIdAndSouvenirId(userId, souvenirId); - return WishlistResponse.of(souvenirId, false); - } - - public MyWishlistListResponse getMyWishlist(Long userId, Pageable pageable) { - Page wishlistPage = wishlistRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); - - List souvenirIds = wishlistPage.getContent().stream() - .map(w -> w.getSouvenir().getId()) - .toList(); - - Map thumbnailMap = getThumbnails(souvenirIds); - - Page responsePage = wishlistPage.map(wishlist -> { - String thumbnailUrl = Optional.ofNullable(thumbnailMap.get(wishlist.getSouvenir().getId())) - .map(FileResponse::url) - .orElse(null); - return MyWishlistResponse.of(wishlist, thumbnailUrl); - }); - - return MyWishlistListResponse.from(responsePage); - } - - private Map getThumbnails(List souvenirIds) { - Map fileMap = fileQueryService.findThumbnailsByEntityIds(EntityType.SOUVENIR, souvenirIds); - - return fileMap.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - entry -> FileResponse.of( - entry.getValue(), - fileStorage.generateUrl(entry.getValue().getStorageKey()) - ) - )); - } - - private User findUserById(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - } - - private Souvenir findSouvenirById(Long souvenirId) { - return souvenirRepository.findByIdAndDeletedFalse(souvenirId) - .orElseThrow(() -> new BusinessException(ErrorCode.SOUVENIR_NOT_FOUND)); - } -} diff --git a/src/main/java/com/souzip/shared/aop/MdcTraceId.java b/src/main/java/com/souzip/global/aop/MdcTraceId.java similarity index 91% rename from src/main/java/com/souzip/shared/aop/MdcTraceId.java rename to src/main/java/com/souzip/global/aop/MdcTraceId.java index a6231c29..344a4a3f 100644 --- a/src/main/java/com/souzip/shared/aop/MdcTraceId.java +++ b/src/main/java/com/souzip/global/aop/MdcTraceId.java @@ -1,15 +1,13 @@ -package com.souzip.shared.aop; +package com.souzip.global.aop; import java.util.UUID; - import org.slf4j.MDC; public class MdcTraceId { private static final String TRACE_ID = "traceId"; - private MdcTraceId() { - } + private MdcTraceId() {} public static String generate() { return UUID.randomUUID().toString().substring(0, 8); diff --git a/src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java b/src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java similarity index 75% rename from src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java rename to src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java index a6459721..2111e769 100644 --- a/src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java +++ b/src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java @@ -1,6 +1,6 @@ -package com.souzip.shared.aop; +package com.souzip.global.aop; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterThrowing; @@ -16,17 +16,16 @@ public class ServiceExceptionLoggingAspect { @Pointcut("execution(* com.souzip.api..service..*(..))") - public void serviceLayer() { - } + public void serviceLayer() {} @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex") public void logServiceException(JoinPoint joinPoint, Exception ex) { if (ex instanceof BusinessException) return; log.error("[SERVICE-ERROR] method={} message={}", - joinPoint.getSignature().toShortString(), - ex.getMessage(), - ex + joinPoint.getSignature().toShortString(), + ex.getMessage(), + ex ); } } diff --git a/src/main/java/com/souzip/shared/aop/TraceIdAspect.java b/src/main/java/com/souzip/global/aop/TraceIdAspect.java similarity index 96% rename from src/main/java/com/souzip/shared/aop/TraceIdAspect.java rename to src/main/java/com/souzip/global/aop/TraceIdAspect.java index 913289fe..18670887 100644 --- a/src/main/java/com/souzip/shared/aop/TraceIdAspect.java +++ b/src/main/java/com/souzip/global/aop/TraceIdAspect.java @@ -1,4 +1,4 @@ -package com.souzip.shared.aop; +package com.souzip.global.aop; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; diff --git a/src/main/java/com/souzip/shared/audit/annotation/Audit.java b/src/main/java/com/souzip/global/audit/annotation/Audit.java similarity index 89% rename from src/main/java/com/souzip/shared/audit/annotation/Audit.java rename to src/main/java/com/souzip/global/audit/annotation/Audit.java index 96435ed9..e4c9fb49 100644 --- a/src/main/java/com/souzip/shared/audit/annotation/Audit.java +++ b/src/main/java/com/souzip/global/audit/annotation/Audit.java @@ -1,7 +1,6 @@ -package com.souzip.shared.audit.annotation; +package com.souzip.global.audit.annotation; import com.souzip.domain.audit.entity.AuditAction; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java b/src/main/java/com/souzip/global/audit/aspect/AuditAspect.java similarity index 59% rename from src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java rename to src/main/java/com/souzip/global/audit/aspect/AuditAspect.java index 2cfd071e..8cb6d87e 100644 --- a/src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java +++ b/src/main/java/com/souzip/global/audit/aspect/AuditAspect.java @@ -1,15 +1,17 @@ -package com.souzip.shared.audit.aspect; +package com.souzip.global.audit.aspect; -import com.souzip.auth.application.dto.LoginInfo; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.audit.service.AuditService; +import com.souzip.domain.auth.dto.LoginResponse; +import com.souzip.domain.auth.dto.LoginUserInfo; import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.shared.audit.annotation.Audit; -import com.souzip.shared.audit.dto.AuditContext; -import com.souzip.shared.domain.Provider; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; -import com.souzip.shared.util.HttpRequestUtils; +import com.souzip.domain.user.entity.Provider; +import com.souzip.domain.user.entity.User; +import com.souzip.global.audit.annotation.Audit; +import com.souzip.global.audit.dto.AuditContext; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.util.HttpRequestUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,7 +34,7 @@ public class AuditAspect { private static final String ONBOARDING_METADATA_FORMAT = - "{\"serviceTerms\": %s, \"privacyRequired\": %s, \"marketingConsent\": %s, \"locationService\": %s}"; + "{\"serviceTerms\": %s, \"privacyRequired\": %s, \"marketingConsent\": %s, \"locationService\": %s}"; private final AuditService auditService; @@ -63,11 +65,11 @@ private void logSuccess(HttpServletRequest request, private void logFailure(HttpServletRequest request, AuditAction action, Exception e) { auditService.logFailure( - action, - e.getClass().getSimpleName(), - HttpRequestUtils.extractClientIp(request), - HttpRequestUtils.extractUserAgent(request), - HttpRequestUtils.extractAppVersion(request) + action, + e.getClass().getSimpleName(), + HttpRequestUtils.extractClientIp(request), + HttpRequestUtils.extractUserAgent(request), + HttpRequestUtils.extractAppVersion(request) ); } @@ -78,14 +80,14 @@ private AuditContext buildSuccessContext(HttpServletRequest request, Audit audit ) { return AuditContext.builder() - .userId(extractUserId(result, joinPoint, audit)) - .action(action) - .ipAddress(HttpRequestUtils.extractClientIp(request)) - .userAgent(HttpRequestUtils.extractUserAgent(request)) - .appVersion(HttpRequestUtils.extractAppVersion(request)) - .oauthProvider(extractOAuthProvider(joinPoint)) - .metadata(buildMetadata(joinPoint)) - .build(); + .userId(extractUserId(result, joinPoint, audit)) + .action(action) + .ipAddress(HttpRequestUtils.extractClientIp(request)) + .userAgent(HttpRequestUtils.extractUserAgent(request)) + .appVersion(HttpRequestUtils.extractAppVersion(request)) + .oauthProvider(extractOAuthProvider(joinPoint)) + .metadata(buildMetadata(joinPoint)) + .build(); } private String extractUserId(Object result, ProceedingJoinPoint joinPoint, Audit audit) { @@ -93,8 +95,8 @@ private String extractUserId(Object result, ProceedingJoinPoint joinPoint, Audit return extractUserIdFromArgs(joinPoint); } - if (result instanceof LoginInfo loginInfo) { - return loginInfo.userId(); + if (isLoginResponse(result)) { + return extractUserIdFromLoginResponse((LoginResponse) result); } return extractUserIdFromSecurity(); @@ -106,50 +108,65 @@ private boolean hasUserIdParam(Audit audit) { private String extractUserIdFromArgs(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(this::isUserIdArg) - .map(String.class::cast) - .findFirst() - .orElse(null); + .filter(this::isUserIdArg) + .map(String.class::cast) + .findFirst() + .orElse(null); + } + + private boolean isLoginResponse(Object result) { + return result instanceof LoginResponse; + } + + private String extractUserIdFromLoginResponse(LoginResponse loginResponse) { + return Optional.ofNullable(loginResponse.getUser()) + .map(LoginUserInfo::userId) + .orElse(null); } private String extractUserIdFromSecurity() { return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) - .map(Authentication::getPrincipal) - .filter(Long.class::isInstance) - .map(principal -> String.valueOf((Long) principal)) - .orElse(null); + .filter(this::isAuthenticatedUser) + .map(Authentication::getPrincipal) + .map(User.class::cast) + .map(User::getUserId) + .orElse(null); + } + + private boolean isAuthenticatedUser(Authentication authentication) { + return authentication.getPrincipal() instanceof User; } private Provider extractOAuthProvider(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(Provider.class::isInstance) - .map(Provider.class::cast) - .findFirst() - .orElse(null); + .filter(Provider.class::isInstance) + .map(Provider.class::cast) + .findFirst() + .orElse(null); } private String buildMetadata(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(OnboardingRequest.class::isInstance) - .map(OnboardingRequest.class::cast) - .findFirst() - .map(this::formatOnboardingMetadata) - .orElse(null); + .filter(OnboardingRequest.class::isInstance) + .map(OnboardingRequest.class::cast) + .findFirst() + .map(this::formatOnboardingMetadata) + .orElse(null); } private String formatOnboardingMetadata(OnboardingRequest req) { return String.format( - ONBOARDING_METADATA_FORMAT, - req.serviceTerms(), - req.privacyRequired(), - req.marketingConsent(), - req.locationService() + ONBOARDING_METADATA_FORMAT, + req.serviceTerms(), + req.privacyRequired(), + req.marketingConsent(), + req.locationService() ); } private HttpServletRequest getCurrentRequest() { ServletRequestAttributes attributes = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); validateRequestAttributes(attributes); return attributes.getRequest(); } @@ -167,4 +184,4 @@ private boolean isUserIdArg(Object arg) { private boolean isMissingAttributes(ServletRequestAttributes attributes) { return attributes == null; } -} \ No newline at end of file +} diff --git a/src/main/java/com/souzip/shared/audit/dto/AuditContext.java b/src/main/java/com/souzip/global/audit/dto/AuditContext.java similarity index 88% rename from src/main/java/com/souzip/shared/audit/dto/AuditContext.java rename to src/main/java/com/souzip/global/audit/dto/AuditContext.java index 09e93183..0f63ec8a 100644 --- a/src/main/java/com/souzip/shared/audit/dto/AuditContext.java +++ b/src/main/java/com/souzip/global/audit/dto/AuditContext.java @@ -1,7 +1,7 @@ -package com.souzip.shared.audit.dto; +package com.souzip.global.audit.dto; import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.shared.domain.Provider; +import com.souzip.domain.user.entity.Provider; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/souzip/shared/clova/ClovaStudioClient.java b/src/main/java/com/souzip/global/clova/ClovaStudioClient.java similarity index 76% rename from src/main/java/com/souzip/shared/clova/ClovaStudioClient.java rename to src/main/java/com/souzip/global/clova/ClovaStudioClient.java index d326d8f0..2a516550 100644 --- a/src/main/java/com/souzip/shared/clova/ClovaStudioClient.java +++ b/src/main/java/com/souzip/global/clova/ClovaStudioClient.java @@ -1,8 +1,8 @@ -package com.souzip.shared.clova; +package com.souzip.global.clova; -import com.souzip.shared.config.ClovaStudioProperties; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.config.ClovaStudioProperties; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; @@ -41,11 +41,10 @@ private String chat(String userMessage) { try { HttpEntity> request = createRequest(userMessage); ResponseEntity> response = restTemplate.exchange( - properties.getApiUrl(), - HttpMethod.POST, - request, - new ParameterizedTypeReference>() { - } + properties.getApiUrl(), + HttpMethod.POST, + request, + new ParameterizedTypeReference>() {} ); return parseResponse(response.getBody()); @@ -72,27 +71,27 @@ private HttpHeaders createHeaders() { private Map createRequestBody(String userMessage) { return Map.of( - "messages", createMessages(userMessage), - "topP", TOP_P, - "topK", TOP_K, - "maxTokens", MAX_TOKENS, - "temperature", TEMPERATURE, - "repeatPenalty", REPEAT_PENALTY, - "includeAiFilters", true + "messages", createMessages(userMessage), + "topP", TOP_P, + "topK", TOP_K, + "maxTokens", MAX_TOKENS, + "temperature", TEMPERATURE, + "repeatPenalty", REPEAT_PENALTY, + "includeAiFilters", true ); } private List> createMessages(String userMessage) { return List.of( - createMessage("system", CURATOR_SYSTEM_PROMPT), - createMessage("user", userMessage) + createMessage("system", CURATOR_SYSTEM_PROMPT), + createMessage("user", userMessage) ); } private Map createMessage(String role, String text) { return Map.of( - "role", role, - "content", List.of(Map.of("type", "text", "text", text)) + "role", role, + "content", List.of(Map.of("type", "text", "text", text)) ); } @@ -114,7 +113,7 @@ private String parseResponse(Map responseBody) { private String parseListContent(List> contentList) { return contentList.stream() - .map(item -> (String) item.get("text")) - .reduce("", String::concat); + .map(item -> (String) item.get("text")) + .reduce("", String::concat); } } diff --git a/src/main/java/com/souzip/shared/clova/PromptLoader.java b/src/main/java/com/souzip/global/clova/PromptLoader.java similarity index 84% rename from src/main/java/com/souzip/shared/clova/PromptLoader.java rename to src/main/java/com/souzip/global/clova/PromptLoader.java index e9b265a1..51b2c135 100644 --- a/src/main/java/com/souzip/shared/clova/PromptLoader.java +++ b/src/main/java/com/souzip/global/clova/PromptLoader.java @@ -1,7 +1,7 @@ -package com.souzip.shared.clova; +package com.souzip.global.clova; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/souzip/shared/common/dto/ErrorResponse.java b/src/main/java/com/souzip/global/common/dto/ErrorResponse.java similarity index 75% rename from src/main/java/com/souzip/shared/common/dto/ErrorResponse.java rename to src/main/java/com/souzip/global/common/dto/ErrorResponse.java index bd47f359..07cb6312 100644 --- a/src/main/java/com/souzip/shared/common/dto/ErrorResponse.java +++ b/src/main/java/com/souzip/global/common/dto/ErrorResponse.java @@ -1,10 +1,8 @@ -package com.souzip.shared.common.dto; +package com.souzip.global.common.dto; import com.fasterxml.jackson.annotation.JsonInclude; -import com.souzip.shared.aop.MdcTraceId; - +import com.souzip.global.aop.MdcTraceId; import java.util.List; - import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -26,17 +24,17 @@ private ErrorResponse(String traceId, String message, List errors) { public static ErrorResponse of(String message) { return ErrorResponse.builder() - .traceId(MdcTraceId.get()) - .message(message) - .build(); + .traceId(MdcTraceId.get()) + .message(message) + .build(); } public static ErrorResponse of(String message, List errors) { return ErrorResponse.builder() - .traceId(MdcTraceId.get()) - .message(message) - .errors(errors) - .build(); + .traceId(MdcTraceId.get()) + .message(message) + .errors(errors) + .build(); } @Getter @@ -55,10 +53,10 @@ private FieldError(String field, String value, String reason) { public static FieldError of(String field, String value, String reason) { return FieldError.builder() - .field(field) - .value(value) - .reason(reason) - .build(); + .field(field) + .value(value) + .reason(reason) + .build(); } } } diff --git a/src/main/java/com/souzip/shared/common/dto/SuccessResponse.java b/src/main/java/com/souzip/global/common/dto/SuccessResponse.java similarity index 80% rename from src/main/java/com/souzip/shared/common/dto/SuccessResponse.java rename to src/main/java/com/souzip/global/common/dto/SuccessResponse.java index 94fea218..aacf8e03 100644 --- a/src/main/java/com/souzip/shared/common/dto/SuccessResponse.java +++ b/src/main/java/com/souzip/global/common/dto/SuccessResponse.java @@ -1,4 +1,4 @@ -package com.souzip.shared.common.dto; +package com.souzip.global.common.dto; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AccessLevel; @@ -20,9 +20,9 @@ private SuccessResponse(T data, String message) { public static SuccessResponse of(T data, String message) { return SuccessResponse.builder() - .data(data) - .message(message) - .build(); + .data(data) + .message(message) + .build(); } public static SuccessResponse of(T data) { @@ -31,7 +31,7 @@ public static SuccessResponse of(T data) { public static SuccessResponse of(String message) { return SuccessResponse.builder() - .message(message) - .build(); + .message(message) + .build(); } } diff --git a/src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java b/src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java similarity index 85% rename from src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java rename to src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java index 55f5ff21..ff9c9a08 100644 --- a/src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java +++ b/src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java @@ -1,7 +1,6 @@ -package com.souzip.shared.common.dto.pagination; +package com.souzip.global.common.dto.pagination; import java.util.Optional; - import lombok.Getter; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -20,10 +19,10 @@ public class PaginationRequest { public PaginationRequest(Integer pageNo, Integer pageSize) { this.pageNo = validatePageNo( - Optional.ofNullable(pageNo).orElse(DEFAULT_PAGE_NO) + Optional.ofNullable(pageNo).orElse(DEFAULT_PAGE_NO) ); this.pageSize = validatePageSize( - Optional.ofNullable(pageSize).orElse(DEFAULT_PAGE_SIZE) + Optional.ofNullable(pageSize).orElse(DEFAULT_PAGE_SIZE) ); } diff --git a/src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java b/src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java similarity index 67% rename from src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java rename to src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java index f1e8774c..7b3050e4 100644 --- a/src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java +++ b/src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java @@ -1,9 +1,7 @@ -package com.souzip.shared.common.dto.pagination; +package com.souzip.global.common.dto.pagination; import com.fasterxml.jackson.annotation.JsonInclude; - import java.util.List; - import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -24,16 +22,16 @@ protected PaginationResponse(List content, PageInfo pagination) { public static PaginationResponse of(Page page, List content) { return PaginationResponse.builder() - .content(content) - .pagination(PageInfo.of(page)) - .build(); + .content(content) + .pagination(PageInfo.of(page)) + .build(); } public static PaginationResponse of(List content, int pageNo, int pageSize, long total, int totalPages) { return PaginationResponse.builder() - .content(content) - .pagination(PageInfo.of(pageNo, pageSize, total, totalPages)) - .build(); + .content(content) + .pagination(PageInfo.of(pageNo, pageSize, total, totalPages)) + .build(); } @Getter @@ -66,28 +64,28 @@ private PageInfo(int currentPage, int totalPages, public static PageInfo of(Page page) { return PageInfo.builder() - .currentPage(toDisplayPageNumber(page.getNumber())) - .totalPages(page.getTotalPages()) - .totalItems(page.getTotalElements()) - .pageSize(page.getSize()) - .first(page.isFirst()) - .last(page.isLast()) - .hasNext(page.hasNext()) - .hasPrevious(page.hasPrevious()) - .build(); + .currentPage(toDisplayPageNumber(page.getNumber())) + .totalPages(page.getTotalPages()) + .totalItems(page.getTotalElements()) + .pageSize(page.getSize()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); } public static PageInfo of(int pageNo, int pageSize, long total, int totalPages) { return PageInfo.builder() - .currentPage(pageNo) - .totalPages(totalPages) - .totalItems(total) - .pageSize(pageSize) - .first(pageNo == 1) - .last(pageNo >= totalPages) - .hasNext(pageNo < totalPages) - .hasPrevious(pageNo > 1) - .build(); + .currentPage(pageNo) + .totalPages(totalPages) + .totalItems(total) + .pageSize(pageSize) + .first(pageNo == 1) + .last(pageNo >= totalPages) + .hasNext(pageNo < totalPages) + .hasPrevious(pageNo > 1) + .build(); } private static int toDisplayPageNumber(int zeroBasedPageNumber) { diff --git a/src/main/java/com/souzip/global/config/AppConfig.java b/src/main/java/com/souzip/global/config/AppConfig.java new file mode 100644 index 00000000..232db199 --- /dev/null +++ b/src/main/java/com/souzip/global/config/AppConfig.java @@ -0,0 +1,19 @@ +package com.souzip.global.config; + +import com.souzip.global.oauth.OAuthProperties; +import com.souzip.global.security.jwt.JwtProperties; +import com.souzip.adapter.config.ObjectStorageProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({ + JwtProperties.class, + OAuthProperties.class, + ObjectStorageProperties.class, + ClovaStudioProperties.class, + AppleMigrationConfig.class, + CorsProperties.class +}) +public class AppConfig { +} diff --git a/src/main/java/com/souzip/shared/config/AppleMigrationConfig.java b/src/main/java/com/souzip/global/config/AppleMigrationConfig.java similarity index 77% rename from src/main/java/com/souzip/shared/config/AppleMigrationConfig.java rename to src/main/java/com/souzip/global/config/AppleMigrationConfig.java index b92c2020..c094b100 100644 --- a/src/main/java/com/souzip/shared/config/AppleMigrationConfig.java +++ b/src/main/java/com/souzip/global/config/AppleMigrationConfig.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -18,13 +18,13 @@ public class AppleMigrationConfig { @ConstructorBinding public AppleMigrationConfig( - String clientId, - String teamId, - String keyId, - String privateKey, - String oldTeamId, - String oldKeyId, - String oldPrivateKey + String clientId, + String teamId, + String keyId, + String privateKey, + String oldTeamId, + String oldKeyId, + String oldPrivateKey ) { this.clientId = clientId; this.teamId = teamId; diff --git a/src/main/java/com/souzip/shared/config/ClovaStudioProperties.java b/src/main/java/com/souzip/global/config/ClovaStudioProperties.java similarity index 89% rename from src/main/java/com/souzip/shared/config/ClovaStudioProperties.java rename to src/main/java/com/souzip/global/config/ClovaStudioProperties.java index 6611a072..8134e860 100644 --- a/src/main/java/com/souzip/shared/config/ClovaStudioProperties.java +++ b/src/main/java/com/souzip/global/config/ClovaStudioProperties.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/souzip/shared/config/CorsProperties.java b/src/main/java/com/souzip/global/config/CorsProperties.java similarity index 91% rename from src/main/java/com/souzip/shared/config/CorsProperties.java rename to src/main/java/com/souzip/global/config/CorsProperties.java index ddaf55c9..e4b5cba0 100644 --- a/src/main/java/com/souzip/shared/config/CorsProperties.java +++ b/src/main/java/com/souzip/global/config/CorsProperties.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/souzip/shared/config/JpaAuditingConfig.java b/src/main/java/com/souzip/global/config/JpaAuditingConfig.java similarity index 85% rename from src/main/java/com/souzip/shared/config/JpaAuditingConfig.java rename to src/main/java/com/souzip/global/config/JpaAuditingConfig.java index 2662ebc7..147284b8 100644 --- a/src/main/java/com/souzip/shared/config/JpaAuditingConfig.java +++ b/src/main/java/com/souzip/global/config/JpaAuditingConfig.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java b/src/main/java/com/souzip/global/config/PasswordEncoderConfig.java similarity index 92% rename from src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java rename to src/main/java/com/souzip/global/config/PasswordEncoderConfig.java index 47035212..25774aef 100644 --- a/src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java +++ b/src/main/java/com/souzip/global/config/PasswordEncoderConfig.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/shared/config/QuerydslConfig.java b/src/main/java/com/souzip/global/config/QuerydslConfig.java similarity index 93% rename from src/main/java/com/souzip/shared/config/QuerydslConfig.java rename to src/main/java/com/souzip/global/config/QuerydslConfig.java index 0a894a51..6eae15b0 100644 --- a/src/main/java/com/souzip/shared/config/QuerydslConfig.java +++ b/src/main/java/com/souzip/global/config/QuerydslConfig.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; diff --git a/src/main/java/com/souzip/shared/config/RestTemplateConfig.java b/src/main/java/com/souzip/global/config/RestTemplateConfig.java similarity index 90% rename from src/main/java/com/souzip/shared/config/RestTemplateConfig.java rename to src/main/java/com/souzip/global/config/RestTemplateConfig.java index c8522ac9..6c17ff42 100644 --- a/src/main/java/com/souzip/shared/config/RestTemplateConfig.java +++ b/src/main/java/com/souzip/global/config/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/shared/config/SchedulerConfig.java b/src/main/java/com/souzip/global/config/SchedulerConfig.java similarity index 85% rename from src/main/java/com/souzip/shared/config/SchedulerConfig.java rename to src/main/java/com/souzip/global/config/SchedulerConfig.java index 6f8bd45a..ae48e2ab 100644 --- a/src/main/java/com/souzip/shared/config/SchedulerConfig.java +++ b/src/main/java/com/souzip/global/config/SchedulerConfig.java @@ -1,4 +1,4 @@ -package com.souzip.shared.config; +package com.souzip.global.config; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/src/main/java/com/souzip/auth/adapter/config/WebConfig.java b/src/main/java/com/souzip/global/config/WebConfig.java similarity index 87% rename from src/main/java/com/souzip/auth/adapter/config/WebConfig.java rename to src/main/java/com/souzip/global/config/WebConfig.java index 951a2751..74c45b31 100644 --- a/src/main/java/com/souzip/auth/adapter/config/WebConfig.java +++ b/src/main/java/com/souzip/global/config/WebConfig.java @@ -1,14 +1,13 @@ -package com.souzip.auth.adapter.config; +package com.souzip.global.config; -import com.souzip.auth.adapter.security.resolver.CurrentUserIdArgumentResolver; import com.souzip.domain.admin.infrastructure.security.resolver.CurrentAdminIdArgumentResolver; +import com.souzip.global.security.resolver.CurrentUserIdArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.util.List; - @RequiredArgsConstructor @Configuration public class WebConfig implements WebMvcConfigurer { diff --git a/src/main/java/com/souzip/shared/exception/BaseErrorCode.java b/src/main/java/com/souzip/global/exception/BaseErrorCode.java similarity index 79% rename from src/main/java/com/souzip/shared/exception/BaseErrorCode.java rename to src/main/java/com/souzip/global/exception/BaseErrorCode.java index 35d168e6..b834c33f 100644 --- a/src/main/java/com/souzip/shared/exception/BaseErrorCode.java +++ b/src/main/java/com/souzip/global/exception/BaseErrorCode.java @@ -1,11 +1,9 @@ -package com.souzip.shared.exception; +package com.souzip.global.exception; import org.springframework.http.HttpStatus; public interface BaseErrorCode { HttpStatus getStatus(); - String getMessage(); - String name(); } diff --git a/src/main/java/com/souzip/shared/exception/BusinessException.java b/src/main/java/com/souzip/global/exception/BusinessException.java similarity index 91% rename from src/main/java/com/souzip/shared/exception/BusinessException.java rename to src/main/java/com/souzip/global/exception/BusinessException.java index 587875cb..3ba7e2dc 100644 --- a/src/main/java/com/souzip/shared/exception/BusinessException.java +++ b/src/main/java/com/souzip/global/exception/BusinessException.java @@ -1,4 +1,4 @@ -package com.souzip.shared.exception; +package com.souzip.global.exception; import lombok.Getter; diff --git a/src/main/java/com/souzip/shared/exception/ErrorCode.java b/src/main/java/com/souzip/global/exception/ErrorCode.java similarity index 71% rename from src/main/java/com/souzip/shared/exception/ErrorCode.java rename to src/main/java/com/souzip/global/exception/ErrorCode.java index 25db9642..2f06c698 100644 --- a/src/main/java/com/souzip/shared/exception/ErrorCode.java +++ b/src/main/java/com/souzip/global/exception/ErrorCode.java @@ -1,4 +1,4 @@ -package com.souzip.shared.exception; +package com.souzip.global.exception; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -20,18 +20,29 @@ public enum ErrorCode implements BaseErrorCode { INVALID_PROFILE_IMAGE_COLOR(HttpStatus.BAD_REQUEST, "유효하지 않은 프로필 이미지 색상입니다."), NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), + INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "토큰 형식이 올바르지 않습니다."), + INVALID_JWT_STRUCTURE(HttpStatus.BAD_REQUEST, "JWT 구조가 올바르지 않습니다."), + TOKEN_DECODE_FAILED(HttpStatus.BAD_REQUEST, "토큰 디코딩에 실패했습니다."), + TOKEN_PARSE_FAILED(HttpStatus.BAD_REQUEST, "토큰 파싱에 실패했습니다."), + + KAKAO_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API 호출에 실패했습니다."), + GOOGLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 API 호출에 실패했습니다."), + APPLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 API 호출에 실패했습니다."), + COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 국가 정보를 찾을 수 없습니다."), COUNTRY_REGION_INVALID(HttpStatus.BAD_REQUEST, "국가의 지역 코드가 유효하지 않습니다."), + CITY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 도시 정보를 찾을 수 없습니다."), + CURRENCY_NOT_FOUND(HttpStatus.BAD_REQUEST, "통화를 찾을 수 없습니다."), + EXCHANGE_RATE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 환율 정보를 찾을 수 없습니다."), SOUVENIR_NOT_FOUND(HttpStatus.NOT_FOUND, "기념품을 찾을 수 없습니다."), INVALID_SOUVENIR_LOCATION(HttpStatus.BAD_REQUEST, "기념품의 위치 정보가 유효하지 않습니다."), - WISHLIST_NOT_FOUND(HttpStatus.NOT_FOUND, "찜 목록에 없는 기념품입니다."), - ALREADY_WISHLISTED(HttpStatus.BAD_REQUEST, "이미 찜한 기념품입니다."), - FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "파일 크기가 50MB를 초과합니다."), INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "허용되지 않은 파일 형식입니다."), @@ -49,9 +60,11 @@ public enum ErrorCode implements BaseErrorCode { AI_RECOMMENDATION_NOT_READY(HttpStatus.BAD_REQUEST, "추천 시스템을 위해 기념품 업로드 이력이 있어야 합니다."), - FCM_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 푸시 전송에 실패했습니다."), - EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다."); + APPLE_MIGRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple 마이그레이션 준비 중 오류가 발생했습니다."), + APPLE_TRANSFER_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "App Transfer가 아직 완료되지 않았습니다."), + NO_APPLE_USERS_FOUND(HttpStatus.NOT_FOUND, "마이그레이션할 Apple 사용자가 없습니다.") + ; private final HttpStatus status; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java b/src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java similarity index 96% rename from src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java rename to src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java index 6423ef1c..43e9e57a 100644 --- a/src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java @@ -1,12 +1,10 @@ -package com.souzip.shared.exception; - -import com.souzip.shared.aop.MdcTraceId; -import com.souzip.shared.common.dto.ErrorResponse; +package com.souzip.global.exception; +import com.souzip.global.aop.MdcTraceId; +import com.souzip.global.common.dto.ErrorResponse; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; - import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; diff --git a/src/main/java/com/souzip/global/oauth/OAuthProperties.java b/src/main/java/com/souzip/global/oauth/OAuthProperties.java new file mode 100644 index 00000000..a4807eee --- /dev/null +++ b/src/main/java/com/souzip/global/oauth/OAuthProperties.java @@ -0,0 +1,26 @@ +package com.souzip.global.oauth; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "oauth") +public class OAuthProperties { + + private final Kakao kakao; + private final Google google; + + @Getter + @RequiredArgsConstructor + public static class Kakao { + private final String userInfoUrl; + } + + @Getter + @RequiredArgsConstructor + public static class Google { + private final String userInfoUrl; + } +} diff --git a/src/main/java/com/souzip/auth/adapter/security/annotation/CurrentUserId.java b/src/main/java/com/souzip/global/security/annotation/CurrentUserId.java similarity index 83% rename from src/main/java/com/souzip/auth/adapter/security/annotation/CurrentUserId.java rename to src/main/java/com/souzip/global/security/annotation/CurrentUserId.java index b91da375..24393502 100644 --- a/src/main/java/com/souzip/auth/adapter/security/annotation/CurrentUserId.java +++ b/src/main/java/com/souzip/global/security/annotation/CurrentUserId.java @@ -1,4 +1,4 @@ -package com.souzip.auth.adapter.security.annotation; +package com.souzip.global.security.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/souzip/auth/adapter/security/annotation/RequireAuth.java b/src/main/java/com/souzip/global/security/annotation/RequireAuth.java similarity index 87% rename from src/main/java/com/souzip/auth/adapter/security/annotation/RequireAuth.java rename to src/main/java/com/souzip/global/security/annotation/RequireAuth.java index fd7bb22e..c1941934 100644 --- a/src/main/java/com/souzip/auth/adapter/security/annotation/RequireAuth.java +++ b/src/main/java/com/souzip/global/security/annotation/RequireAuth.java @@ -1,4 +1,4 @@ -package com.souzip.auth.adapter.security.annotation; +package com.souzip.global.security.annotation; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java b/src/main/java/com/souzip/global/security/config/SecurityConfig.java similarity index 93% rename from src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java rename to src/main/java/com/souzip/global/security/config/SecurityConfig.java index c2bdb9f0..a8955637 100644 --- a/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java +++ b/src/main/java/com/souzip/global/security/config/SecurityConfig.java @@ -1,11 +1,11 @@ -package com.souzip.auth.adapter.security.config; +package com.souzip.global.security.config; import com.fasterxml.jackson.databind.ObjectMapper; -import com.souzip.auth.adapter.security.jwt.JwtAuthenticationFilter; +import com.souzip.global.common.dto.ErrorResponse; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.config.CorsProperties; import com.souzip.domain.admin.infrastructure.security.jwt.AdminJwtAuthenticationFilter; -import com.souzip.shared.common.dto.ErrorResponse; -import com.souzip.shared.config.CorsProperties; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.security.jwt.JwtAuthenticationFilter; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -59,7 +59,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/souvenirs/nearby", "/api/discovery/**", "/actuator/health", - "/actuator/prometheus", "/api/admin/auth/login", "/api/admin/auth/refresh", "/docs/**" diff --git a/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..5eda49e3 --- /dev/null +++ b/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,105 @@ +package com.souzip.global.security.jwt; + +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + authenticateRequest(request); + } catch (Exception e) { + log.error("사용자 인증 정보를 SecurityContext에 설정할 수 없습니다.", e); + } + + filterChain.doFilter(request, response); + } + + private void authenticateRequest(HttpServletRequest request) { + String token = extractTokenFromRequest(request); + + if (isTokenAbsent(token)) { + return; + } + + User user = getUserFromToken(token); + + if (isUserAbsent(user)) { + return; + } + + setAuthentication(user); + } + + private String extractTokenFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (hasBearerToken(bearerToken)) { + return extractToken(bearerToken); + } + + return null; + } + + private boolean hasBearerToken(String bearerToken) { + return StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX); + } + + private String extractToken(String bearerToken) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + + private boolean isTokenAbsent(String token) { + return isTokenNull(token) || isTokenInvalid(token); + } + + private boolean isTokenNull(String token) { + return token == null; + } + + private boolean isTokenInvalid(String token) { + return !jwtTokenProvider.validateToken(token); + } + + private User getUserFromToken(String token) { + String userId = jwtTokenProvider.getUserIdFromToken(token); + return userRepository.findByUserId(userId).orElse(null); + } + + private boolean isUserAbsent(User user) { + return user == null; + } + + private void setAuthentication(User user) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtProperties.java b/src/main/java/com/souzip/global/security/jwt/JwtProperties.java similarity index 88% rename from src/main/java/com/souzip/auth/adapter/security/jwt/JwtProperties.java rename to src/main/java/com/souzip/global/security/jwt/JwtProperties.java index c9d4ea76..f7369eeb 100644 --- a/src/main/java/com/souzip/auth/adapter/security/jwt/JwtProperties.java +++ b/src/main/java/com/souzip/global/security/jwt/JwtProperties.java @@ -1,4 +1,4 @@ -package com.souzip.auth.adapter.security.jwt; +package com.souzip.global.security.jwt; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -8,9 +8,8 @@ @RequiredArgsConstructor @ConfigurationProperties(prefix = "jwt") public class JwtProperties { - private final String secret; + private final String secret; private final Long expiration; - private final Long refreshExpiration; } diff --git a/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..b09f64c8 --- /dev/null +++ b/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,67 @@ +package com.souzip.global.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + public String generateToken(String userId) { + return createToken(userId, jwtProperties.getExpiration()); + } + + public String generateRefreshToken(String userId) { + return createToken(userId, jwtProperties.getRefreshExpiration()); + } + + private String createToken(String userId, Long expirationMs) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expirationMs); + + return Jwts.builder() + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + public String getUserIdFromToken(String token) { + return parseClaims(token).getSubject(); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (Exception e) { + log.debug("Invalid JWT token: {}", e.getMessage()); + return false; + } + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey getSigningKey() { + byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/main/java/com/souzip/auth/adapter/security/resolver/CurrentUserIdArgumentResolver.java b/src/main/java/com/souzip/global/security/resolver/CurrentUserIdArgumentResolver.java similarity index 71% rename from src/main/java/com/souzip/auth/adapter/security/resolver/CurrentUserIdArgumentResolver.java rename to src/main/java/com/souzip/global/security/resolver/CurrentUserIdArgumentResolver.java index b7c065d1..9ea64811 100644 --- a/src/main/java/com/souzip/auth/adapter/security/resolver/CurrentUserIdArgumentResolver.java +++ b/src/main/java/com/souzip/global/security/resolver/CurrentUserIdArgumentResolver.java @@ -1,8 +1,10 @@ -package com.souzip.auth.adapter.security.resolver; +package com.souzip.global.security.resolver; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.domain.user.entity.User; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import com.souzip.global.security.annotation.CurrentUserId; +import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -12,26 +14,27 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +@RequiredArgsConstructor @Component public class CurrentUserIdArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); + && parameter.getParameterType().equals(Long.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { + WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !(authentication.getPrincipal() instanceof Long)) { + if (authentication == null || !(authentication.getPrincipal() instanceof User)) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } - - return (Long) authentication.getPrincipal(); + User user = (User) authentication.getPrincipal(); + return user.getId(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/souzip/shared/util/HttpRequestUtils.java b/src/main/java/com/souzip/global/util/HttpRequestUtils.java similarity index 90% rename from src/main/java/com/souzip/shared/util/HttpRequestUtils.java rename to src/main/java/com/souzip/global/util/HttpRequestUtils.java index f590ae7f..040ed251 100644 --- a/src/main/java/com/souzip/shared/util/HttpRequestUtils.java +++ b/src/main/java/com/souzip/global/util/HttpRequestUtils.java @@ -1,4 +1,4 @@ -package com.souzip.shared.util; +package com.souzip.global.util; import jakarta.servlet.http.HttpServletRequest; import lombok.AccessLevel; @@ -21,7 +21,7 @@ public class HttpRequestUtils { private static final String UNKNOWN_IP = "unknown"; private static final Pattern APP_VERSION_PATTERN = - Pattern.compile("Souzip-(iOS|Android)/(\\d+\\.\\d+\\.\\d+)"); + Pattern.compile("Souzip-(iOS|Android)/(\\d+\\.\\d+\\.\\d+)"); public static String extractClientIp(HttpServletRequest request) { String ip = getFirstValidIp(request); @@ -44,14 +44,14 @@ public static String extractAppVersion(HttpServletRequest request) { private static String getFirstValidIp(HttpServletRequest request) { return Arrays.stream(new String[]{ - request.getHeader(HEADER_X_FORWARDED_FOR), - request.getHeader(HEADER_X_REAL_IP), - request.getHeader(HEADER_PROXY_CLIENT_IP), - request.getRemoteAddr() - }) - .filter(HttpRequestUtils::isValidIp) - .findFirst() - .orElse(UNKNOWN); + request.getHeader(HEADER_X_FORWARDED_FOR), + request.getHeader(HEADER_X_REAL_IP), + request.getHeader(HEADER_PROXY_CLIENT_IP), + request.getRemoteAddr() + }) + .filter(HttpRequestUtils::isValidIp) + .findFirst() + .orElse(UNKNOWN); } private static String parseVersionFromUserAgent(HttpServletRequest request) { diff --git a/src/main/java/com/souzip/shared/config/AppConfig.java b/src/main/java/com/souzip/shared/config/AppConfig.java deleted file mode 100644 index 5db0dfbe..00000000 --- a/src/main/java/com/souzip/shared/config/AppConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.souzip.shared.config; - -import com.souzip.adapter.config.ObjectStorageProperties; -import com.souzip.auth.adapter.security.jwt.JwtProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties({ - JwtProperties.class, - ObjectStorageProperties.class, - ClovaStudioProperties.class, - AppleMigrationConfig.class, - CorsProperties.class, - MailSmtpProperties.class -}) -public class AppConfig { -} diff --git a/src/main/java/com/souzip/shared/config/FirebaseConfig.java b/src/main/java/com/souzip/shared/config/FirebaseConfig.java deleted file mode 100644 index 4eccec0d..00000000 --- a/src/main/java/com/souzip/shared/config/FirebaseConfig.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.souzip.shared.config; - -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import com.google.firebase.messaging.FirebaseMessaging; - -import java.io.FileInputStream; -import java.io.IOException; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.util.StringUtils; - -@Configuration -@RequiredArgsConstructor -@ConditionalOnProperty(name = "firebase.enabled", havingValue = "true") -public class FirebaseConfig { - - private final FirebaseProperties firebaseProperties; - - @Bean - public FirebaseApp firebaseApp() throws IOException { - if (!StringUtils.hasText(firebaseProperties.getCredentialsPath())) { - throw new IllegalStateException("firebase.enabled=true 일 때 firebase.credentials-path 가 필요합니다."); - } - try (FileInputStream serviceAccount = new FileInputStream(firebaseProperties.getCredentialsPath())) { - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(serviceAccount)) - .build(); - if (FirebaseApp.getApps().isEmpty()) { - return FirebaseApp.initializeApp(options); - } - return FirebaseApp.getInstance(); - } - } - - @Bean - public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { - return FirebaseMessaging.getInstance(firebaseApp); - } -} diff --git a/src/main/java/com/souzip/shared/config/FirebaseProperties.java b/src/main/java/com/souzip/shared/config/FirebaseProperties.java deleted file mode 100644 index d5c57fa7..00000000 --- a/src/main/java/com/souzip/shared/config/FirebaseProperties.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.shared.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "firebase") -public class FirebaseProperties { - - private boolean enabled = false; - - private String credentialsPath = ""; -} diff --git a/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java b/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java deleted file mode 100644 index 53ce0cbd..00000000 --- a/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.shared.config; - -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(FirebaseProperties.class) -public class FirebasePropertiesConfiguration { -} diff --git a/src/main/java/com/souzip/shared/config/MailSmtpConfig.java b/src/main/java/com/souzip/shared/config/MailSmtpConfig.java deleted file mode 100644 index 4a9de9d9..00000000 --- a/src/main/java/com/souzip/shared/config/MailSmtpConfig.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.souzip.shared.config; - -import java.util.Properties; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.JavaMailSenderImpl; -import org.springframework.util.StringUtils; - -@Configuration -@RequiredArgsConstructor -@ConditionalOnProperty(name = "mail.smtp.enabled", havingValue = "true") -public class MailSmtpConfig { - - private final MailSmtpProperties mailSmtpProperties; - - @Bean - public JavaMailSender javaMailSender() { - if (!StringUtils.hasText(mailSmtpProperties.getUsername()) - || !StringUtils.hasText(mailSmtpProperties.getPassword())) { - throw new IllegalStateException( - "mail.smtp.enabled=true 일 때 mail.smtp.username 과 mail.smtp.password 가 필요합니다." - ); - } - JavaMailSenderImpl sender = new JavaMailSenderImpl(); - sender.setHost(mailSmtpProperties.getHost()); - sender.setPort(mailSmtpProperties.getPort()); - sender.setUsername(mailSmtpProperties.getUsername()); - sender.setPassword(mailSmtpProperties.getPassword()); - - Properties props = sender.getJavaMailProperties(); - props.put("mail.transport.protocol", "smtp"); - props.put("mail.smtp.auth", "true"); - props.put("mail.smtp.starttls.enable", "true"); - props.put("mail.smtp.starttls.required", "true"); - props.put("mail.debug", "false"); - return sender; - } -} diff --git a/src/main/java/com/souzip/shared/config/MailSmtpProperties.java b/src/main/java/com/souzip/shared/config/MailSmtpProperties.java deleted file mode 100644 index 5767db37..00000000 --- a/src/main/java/com/souzip/shared/config/MailSmtpProperties.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.souzip.shared.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "mail.smtp") -public class MailSmtpProperties { - - private boolean enabled = false; - private String host = "smtp.gmail.com"; - private int port = 587; - private String username = ""; - private String password = ""; - private String from = ""; -} diff --git a/src/main/java/com/souzip/shared/domain/Provider.java b/src/main/java/com/souzip/shared/domain/Provider.java deleted file mode 100644 index 16d7778b..00000000 --- a/src/main/java/com/souzip/shared/domain/Provider.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.souzip.shared.domain; - -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; - -import java.util.Arrays; - -public enum Provider { - KAKAO, - APPLE, - GOOGLE; - - public static Provider from(String value) { - if (value == null || value.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "Provider는 필수입니다."); - } - - return Arrays.stream(values()) - .filter(provider -> provider.name().equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new BusinessException( - ErrorCode.INVALID_INPUT, "지원하지 않는 로그인 방식입니다: " + value)); - } -} diff --git a/src/main/resources/.env.example b/src/main/resources/.env.example index aa3ad102..b2ab1dd4 100644 --- a/src/main/resources/.env.example +++ b/src/main/resources/.env.example @@ -25,13 +25,3 @@ CLOVA_STUDIO_API_URL= ADMIN_INITIAL_USERNAME= ADMIN_INITIAL_PASSWORD= - -FIREBASE_ENABLED= -FIREBASE_CREDENTIALS_PATH= - -MAIL_SMTP_ENABLED= -MAIL_SMTP_HOST= -MAIL_SMTP_PORT= -MAIL_SMTP_USERNAME= -MAIL_SMTP_PASSWORD= -MAIL_SMTP_FROM= diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index a690286c..ead65aed 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -1,211 +1,105 @@ - FIELD + FIELD - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - -
- - - - - - - - - - - -
+ + + + name + address + +
+ + + + + + + + + +
- - - - name - address - -
- - - - - - - - - -
+ + + + entity_type + entity_id + display_order + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
- - - - entity_type - entity_id - display_order - -
- - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + + + + + + + + + + + + STRING + + + - -
- - - - - - - - - - - - - STRING - - - - - -
- - token - - - user_id - device_id - -
- - - - - - - - - - STRING - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - user_id - souvenir_id - -
- - - - - - - - - - -
- - - - - - - - - - - + + + + + + + + + +
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 9d28d17c..ce23510a 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -41,19 +41,3 @@ management: endpoint: health: show-details: when-authorized - probes: - enabled: true - metrics: - tags: - application: ${spring.application.name} - environment: dev - distribution: - percentiles-histogram: - http.server.requests: true - prometheus: - metrics: - export: - enabled: true - -logging: - config: classpath:logback-dev.xml diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 99598bbe..6f27257b 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -22,19 +22,7 @@ management: endpoints: web: exposure: - include: "*" - base-path: /actuator + include: "loggers,env" endpoint: health: show-details: always - metrics: - tags: - application: ${spring.application.name} - environment: local - prometheus: - metrics: - export: - enabled: true - -logging: - config: classpath:logback-local.xml diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 7b6b401f..41b7619a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -16,7 +16,7 @@ spring: open-in-view: false properties: hibernate: - format_sql: false + format_sql: true default_schema: public flyway: @@ -36,25 +36,7 @@ management: endpoints: web: exposure: - include: "health,metrics,prometheus" + include: "health,info,metrics,prometheus" endpoint: health: - show-details: never - probes: - enabled: true - metrics: - tags: - application: ${spring.application.name} - environment: prod - distribution: - percentiles-histogram: - http.server.requests: true - percentiles: - http.server.requests: 0.95, 0.99 - prometheus: - metrics: - export: - enabled: true - -logging: - config: classpath:logback-prod.xml + show-details: when-authorized diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5359718a..19b728c0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -35,6 +35,13 @@ management: tags: application: ${spring.application.name} +logging: + pattern: + console: "[%d{yyyy-MM-dd HH:mm:ss}] [%X{traceId}] [%-5level] %logger{36} - %msg%n" + level: + root: INFO + com.souzip.api: DEBUG + jwt: expiration: 3600000 refresh-expiration: 2592000000 @@ -80,16 +87,3 @@ admin: initial: username: ${ADMIN_INITIAL_USERNAME} password: ${ADMIN_INITIAL_PASSWORD} - -firebase: - enabled: ${FIREBASE_ENABLED:false} - credentials-path: ${FIREBASE_CREDENTIALS_PATH:} - -mail: - smtp: - enabled: ${MAIL_SMTP_ENABLED:false} - host: ${MAIL_SMTP_HOST:smtp.gmail.com} - port: ${MAIL_SMTP_PORT:587} - username: ${MAIL_SMTP_USERNAME:} - password: ${MAIL_SMTP_PASSWORD:} - from: ${MAIL_SMTP_FROM:} diff --git a/src/main/resources/db/migration/V14__create_fcm_token_tablel.sql b/src/main/resources/db/migration/V14__create_fcm_token_tablel.sql deleted file mode 100644 index 06c6bc02..00000000 --- a/src/main/resources/db/migration/V14__create_fcm_token_tablel.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE fcm_tokens ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT, - token VARCHAR(255) NOT NULL, - device_type VARCHAR(20) NOT NULL, - device_id VARCHAR(100) NOT NULL, - device_model VARCHAR(100), - os_version VARCHAR(50), - app_version VARCHAR(50), - is_active BOOLEAN DEFAULT true NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - last_used_at TIMESTAMP -); - --- 인덱스 생성 -CREATE INDEX idx_fcm_tokens_user_id ON fcm_tokens(user_id) WHERE user_id IS NOT NULL; -CREATE INDEX idx_fcm_tokens_device_id ON fcm_tokens(device_id); -CREATE INDEX idx_fcm_tokens_device_type ON fcm_tokens(device_type); -CREATE INDEX idx_fcm_tokens_is_active ON fcm_tokens(is_active); -CREATE UNIQUE INDEX idx_fcm_tokens_token ON fcm_tokens(token); -CREATE UNIQUE INDEX idx_fcm_tokens_user_device ON fcm_tokens(user_id, device_id) - WHERE user_id IS NOT NULL; diff --git a/src/main/resources/db/migration/V15__create_wishlist_table.sql b/src/main/resources/db/migration/V15__create_wishlist_table.sql deleted file mode 100644 index ee999898..00000000 --- a/src/main/resources/db/migration/V15__create_wishlist_table.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE wishlist ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL, - souvenir_id BIGINT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL -); - -CREATE UNIQUE INDEX idx_wishlist_user_souvenir ON wishlist(user_id, souvenir_id); -CREATE INDEX idx_wishlist_user_created ON wishlist(user_id, created_at); diff --git a/src/main/resources/db/migration/V16__create_push_broadcast_history.sql b/src/main/resources/db/migration/V16__create_push_broadcast_history.sql deleted file mode 100644 index d1d791f4..00000000 --- a/src/main/resources/db/migration/V16__create_push_broadcast_history.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE push_broadcast_histories ( - id BIGSERIAL PRIMARY KEY, - admin_id UUID NOT NULL, - title VARCHAR(200) NOT NULL, - body VARCHAR(1000) NOT NULL, - total_targets INT NOT NULL, - success_count INT NOT NULL, - fail_count INT NOT NULL, - firebase_configured BOOLEAN NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL -); - -CREATE INDEX idx_push_broadcast_histories_created_at ON push_broadcast_histories (created_at DESC); diff --git a/src/main/resources/logback-dev.xml b/src/main/resources/logback-dev.xml deleted file mode 100644 index 0a0e8336..00000000 --- a/src/main/resources/logback-dev.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - ${LOG_FILE}.log - - INFO - - - ${LOG_FILE}.%d{yyyy-MM-dd}.log - 30 - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - ${LOG_PATH}/souzip-api-debug.log - - INFO - DENY - ACCEPT - - - ${LOG_PATH}/souzip-api-debug.%d{yyyy-MM-dd}.log - 3 - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - ${LOG_PATH}/souzip-api-error.log - - ERROR - - - ${LOG_PATH}/souzip-api-error.%d{yyyy-MM-dd}.log - 30 - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/logback-local.xml b/src/main/resources/logback-local.xml deleted file mode 100644 index 29e4e170..00000000 --- a/src/main/resources/logback-local.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %cyan(%logger{36}) - %msg%n - - - - - - - - - - - - - - diff --git a/src/main/resources/logback-prod.xml b/src/main/resources/logback-prod.xml deleted file mode 100644 index a1b9d3ac..00000000 --- a/src/main/resources/logback-prod.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - ${LOG_FILE}.log - - INFO - - - ${LOG_FILE}.%d{yyyy-MM-dd}.log - 60 - - - - - - - ${LOG_PATH}/souzip-api-warn.log - - WARN - - - ${LOG_PATH}/souzip-api-warn.%d{yyyy-MM-dd}.log - 90 - - - - - - - ${LOG_PATH}/souzip-api-error.log - - ERROR - - - ${LOG_PATH}/souzip-api-error.%d{yyyy-MM-dd}.log - 180 - - - - - - - - - - - - - - - - diff --git a/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java b/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java index 360434ad..82f1efe1 100644 --- a/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java +++ b/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java @@ -2,7 +2,7 @@ import com.souzip.adapter.integration.googlegeocoding.dto.GoogleGeocodingResponse; import com.souzip.application.location.dto.AddressResult; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java index 0209ec4b..85a0c4b8 100644 --- a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java +++ b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java @@ -4,7 +4,7 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java b/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java index 214f4282..f9dd32d0 100644 --- a/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java @@ -4,7 +4,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; import com.souzip.docs.RestDocsSupport; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java b/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java index cc635b27..a6b536e2 100644 --- a/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java @@ -9,7 +9,7 @@ import com.souzip.docs.RestDocsSupport; import com.souzip.domain.city.entity.City; import com.souzip.domain.country.entity.Country; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java b/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java index 8a487cba..9bcbe87f 100644 --- a/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java +++ b/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java @@ -1,15 +1,17 @@ package com.souzip.application.location; import com.souzip.application.location.dto.CitySearchResult; -import com.souzip.application.location.dto.PlaceSearchResult; import com.souzip.application.location.dto.SearchPlace; +import com.souzip.application.location.dto.PlaceSearchResult; import com.souzip.application.location.dto.SearchResult; import com.souzip.application.location.required.CitySearchRepository; import com.souzip.application.location.required.PlaceSearchProvider; import com.souzip.domain.city.entity.City; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.entity.Region; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; +import java.math.BigDecimal; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,9 +23,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; -import java.math.BigDecimal; -import java.util.List; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java b/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java index 613cb329..9a8923df 100644 --- a/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java +++ b/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java @@ -2,7 +2,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.required.AddressProvider; -import com.souzip.shared.domain.Coordinate; +import com.souzip.domain.shared.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/auth/controller/AuthApiTest.java b/src/test/java/com/souzip/auth/controller/AuthApiTest.java deleted file mode 100644 index b8abd2dc..00000000 --- a/src/test/java/com/souzip/auth/controller/AuthApiTest.java +++ /dev/null @@ -1,372 +0,0 @@ -package com.souzip.auth.controller; - -import com.souzip.auth.adapter.web.AuthApi; -import com.souzip.auth.adapter.web.dto.LoginRequest; -import com.souzip.auth.adapter.web.dto.RefreshRequest; -import com.souzip.auth.application.dto.LoginInfo; -import com.souzip.auth.application.dto.RefreshInfo; -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.provided.Auth; -import com.souzip.docs.CommonDocumentation; -import com.souzip.docs.RestDocsSupport; -import com.souzip.shared.domain.Provider; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AuthApiTest extends RestDocsSupport { - - private static final String PROVIDER_DESC = "OAuth Provider (kakao, google, apple)"; - - private final Auth auth = mock(Auth.class); - - @Override - protected Object initController() { - return new AuthApi(auth); - } - - private LoginInfo createLoginInfo(boolean needsOnboarding) { - return new LoginInfo( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - needsOnboarding, - "20251204", - "수집" - ); - } - - private static org.springframework.restdocs.payload.FieldDescriptor[] loginResponseFields() { - return new org.springframework.restdocs.payload.FieldDescriptor[]{ - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - }; - } - - @Test - @DisplayName("카카오 로그인 - 신규 사용자") - void loginWithKakao_newUser() throws Exception { - LoginRequest request = new LoginRequest("kakao_access_token"); - given(auth.login(eq(Provider.KAKAO), anyString())).willReturn(createLoginInfo(true)); - - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").exists()) - .andExpect(jsonPath("$.data.refreshToken").exists()) - .andExpect(jsonPath("$.data.user.userId").value("20251204")) - .andExpect(jsonPath("$.data.user.nickname").value("수집")) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-kakao-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("카카오 Access Token")), - apiResponseFields(loginResponseFields()) - )); - } - - @Test - @DisplayName("카카오 로그인 - 기존 사용자") - void loginWithKakao_existingUser() throws Exception { - LoginRequest request = new LoginRequest("kakao_access_token"); - given(auth.login(eq(Provider.KAKAO), anyString())).willReturn(createLoginInfo(false)); - - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-kakao-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("카카오 Access Token")), - apiResponseFields(loginResponseFields()) - )); - } - - @Test - @DisplayName("카카오 로그인 - 카카오 API 에러") - void loginWithKakao_kakaoApiError() throws Exception { - LoginRequest request = new LoginRequest("invalid_kakao_token"); - given(auth.login(eq(Provider.KAKAO), anyString())).willThrow(AuthException.kakaoApiError()); - - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("카카오 API 호출에 실패했습니다.")) - .andDo(document("auth/login-kakao-api-error", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("유효하지 않은 카카오 Access Token")), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("Google 로그인 - 신규 사용자") - void loginWithGoogle_newUser() throws Exception { - LoginRequest request = new LoginRequest("google_access_token"); - given(auth.login(eq(Provider.GOOGLE), anyString())).willReturn(createLoginInfo(true)); - - mockMvc.perform(post("/api/auth/login/{provider}", "google") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-google-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("구글 Access Token")), - apiResponseFields(loginResponseFields()) - )); - } - - @Test - @DisplayName("Google 로그인 - 기존 사용자") - void loginWithGoogle_existingUser() throws Exception { - LoginRequest request = new LoginRequest("google_access_token"); - given(auth.login(eq(Provider.GOOGLE), anyString())).willReturn(createLoginInfo(false)); - - mockMvc.perform(post("/api/auth/login/{provider}", "google") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-google-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("구글 Access Token")), - apiResponseFields(loginResponseFields()) - )); - } - - @Test - @DisplayName("Apple 로그인 - 신규 사용자") - void loginWithApple_newUser() throws Exception { - LoginRequest request = new LoginRequest("apple_id_token"); - given(auth.login(eq(Provider.APPLE), anyString())).willReturn(createLoginInfo(true)); - - mockMvc.perform(post("/api/auth/login/{provider}", "apple") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-apple-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("애플 ID Token")), - apiResponseFields(loginResponseFields()) - )); - } - - @Test - @DisplayName("Apple 로그인 - 기존 사용자") - void loginWithApple_existingUser() throws Exception { - LoginRequest request = new LoginRequest("apple_id_token"); - given(auth.login(eq(Provider.APPLE), anyString())).willReturn(createLoginInfo(false)); - - mockMvc.perform(post("/api/auth/login/{provider}", "apple") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-apple-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("애플 ID Token")), - apiResponseFields(loginResponseFields()) - )); - } - - @Test - @DisplayName("지원하지 않는 Provider - 400 에러") - void loginWithUnsupportedProvider_returns400() throws Exception { - LoginRequest request = new LoginRequest("naver_access_token"); - given(auth.login(any(), anyString())).willThrow(AuthException.unsupportedProvider()); - - mockMvc.perform(post("/api/auth/login/{provider}", "naver") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(document("auth/login-unsupported-provider", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("OAuth Access Token")), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("잘못된 Provider 형식 - 400 에러") - void loginWithInvalidProvider_returns400() throws Exception { - LoginRequest request = new LoginRequest("invalid_token"); - given(auth.login(any(), anyString())).willThrow(AuthException.unsupportedProvider()); - - mockMvc.perform(post("/api/auth/login/{provider}", "invalid123") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(document("auth/login-invalid-provider", - getDocumentRequest(), - getDocumentResponse(), - pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), - requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("OAuth Access Token")), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("Refresh Token 재발급") - void refresh_withValidToken() throws Exception { - RefreshRequest request = new RefreshRequest("valid_refresh_token"); - given(auth.refresh(anyString())).willReturn( - new RefreshInfo("new_access_token", "valid_refresh_token") - ); - - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("new_access_token")) - .andExpect(jsonPath("$.data.refreshToken").value("valid_refresh_token")) - .andDo(document("auth/refresh-valid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token")), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새 Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("Refresh Token"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Refresh Token 만료 임박 - 둘 다 재발급") - void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { - RefreshRequest request = new RefreshRequest("expiring_soon_token"); - given(auth.refresh(anyString())).willReturn( - new RefreshInfo("new_access_token", "new_refresh_token") - ); - - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.refreshToken").value("new_refresh_token")) - .andDo(document("auth/refresh-expiring-soon", - getDocumentRequest(), - getDocumentResponse(), - requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token (만료 10일 이하)")), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새 Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새 Refresh Token"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("만료된 Refresh Token - 401 에러") - void refresh_withExpiredToken_returns401() throws Exception { - RefreshRequest request = new RefreshRequest("expired_refresh_token"); - given(auth.refresh(anyString())).willThrow(AuthException.expiredRefreshToken()); - - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("만료된 Refresh Token입니다.")) - .andDo(document("auth/refresh-expired-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("만료된 Refresh Token")), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token - 401 에러") - void refresh_withInvalidToken_returns401() throws Exception { - RefreshRequest request = new RefreshRequest("invalid_refresh_token"); - given(auth.refresh(anyString())).willThrow(AuthException.invalidRefreshToken()); - - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("유효하지 않은 Refresh Token입니다.")) - .andDo(document("auth/refresh-invalid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("유효하지 않은 Refresh Token")), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("로그아웃 성공") - void logout_success() throws Exception { - doNothing().when(auth).logout(anyLong()); - - mockMvc.perform(post("/api/auth/logout") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) - .andDo(document("auth/logout", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/auth/service/AuthServiceTest.java b/src/test/java/com/souzip/auth/service/AuthServiceTest.java deleted file mode 100644 index 1be66e0f..00000000 --- a/src/test/java/com/souzip/auth/service/AuthServiceTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.souzip.auth.service; - -import com.souzip.auth.application.AuthService; -import com.souzip.auth.application.SocialClientFactory; -import com.souzip.auth.application.dto.LoginInfo; -import com.souzip.auth.application.dto.RefreshInfo; -import com.souzip.auth.application.dto.SocialUserInfo; -import com.souzip.auth.application.dto.UserInfo; -import com.souzip.auth.application.exception.AuthException; -import com.souzip.auth.application.required.RefreshTokenRepository; -import com.souzip.auth.application.required.SocialClient; -import com.souzip.auth.application.required.TokenProvider; -import com.souzip.auth.application.required.UserPort; -import com.souzip.auth.domain.RefreshToken; -import com.souzip.shared.domain.Provider; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.verify; - -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @Mock - private SocialClientFactory socialClientFactory; - - @Mock - private UserPort userPort; - - @Mock - private RefreshTokenRepository refreshTokenRepository; - - @Mock - private TokenProvider tokenProvider; - - @Mock - private SocialClient socialClient; - - @InjectMocks - private AuthService authService; - - private static final Long USER_ID = 1L; - private static final String USER_STRING_ID = "550e8400"; - private static final String NICKNAME = "수집"; - private static final String ACCESS_TOKEN = "access_token"; - private static final String REFRESH_TOKEN = "refresh_token"; - - private SocialUserInfo createSocialUserInfo() { - return new SocialUserInfo("kakao123", "test@gmail.com"); - } - - private UserInfo createUserInfo(boolean needsOnboarding) { - return new UserInfo(USER_ID, USER_STRING_ID, NICKNAME, needsOnboarding); - } - - @Test - @DisplayName("신규 사용자 로그인 시 토큰을 발급한다.") - void login_newUser_issuesTokens() { - // given - given(socialClientFactory.getClient(Provider.KAKAO)).willReturn(socialClient); - given(socialClient.getUserInfo("kakao_token")).willReturn(createSocialUserInfo()); - given(userPort.findOrCreateUser(eq(Provider.KAKAO), anyString(), anyString())) - .willReturn(createUserInfo(true)); - given(tokenProvider.generateAccessToken(USER_ID)).willReturn(ACCESS_TOKEN); - given(tokenProvider.generateRefreshToken()).willReturn(REFRESH_TOKEN); - given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); - - // when - LoginInfo result = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(result.accessToken()).isEqualTo(ACCESS_TOKEN); - assertThat(result.refreshToken()).isEqualTo(REFRESH_TOKEN); - assertThat(result.needsOnboarding()).isTrue(); - assertThat(result.userId()).isEqualTo(USER_STRING_ID); - assertThat(result.nickname()).isEqualTo(NICKNAME); - - verify(refreshTokenRepository).deleteByUserId(USER_ID); - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("기존 사용자 로그인 시 needsOnboarding은 false다.") - void login_existingUser_needsOnboardingFalse() { - // given - given(socialClientFactory.getClient(Provider.KAKAO)).willReturn(socialClient); - given(socialClient.getUserInfo("kakao_token")).willReturn(createSocialUserInfo()); - given(userPort.findOrCreateUser(eq(Provider.KAKAO), anyString(), anyString())) - .willReturn(createUserInfo(false)); - given(tokenProvider.generateAccessToken(USER_ID)).willReturn(ACCESS_TOKEN); - given(tokenProvider.generateRefreshToken()).willReturn(REFRESH_TOKEN); - given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); - - // when - LoginInfo result = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(result.needsOnboarding()).isFalse(); - } - - @Test - @DisplayName("유효한 Refresh Token으로 재발급 시 새 Access Token을 발급한다.") - void refresh_withValidToken_returnsNewAccessToken() { - // given - RefreshToken storedToken = RefreshToken.create(USER_ID, REFRESH_TOKEN, LocalDateTime.now().plusDays(20)); - - given(refreshTokenRepository.findByToken(REFRESH_TOKEN)).willReturn(Optional.of(storedToken)); - given(tokenProvider.generateAccessToken(USER_ID)).willReturn("new_access_token"); - given(tokenProvider.generateRefreshToken()).willReturn("new_refresh_token"); - given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); - - // when - RefreshInfo result = authService.refresh(REFRESH_TOKEN); - - // then - assertThat(result.accessToken()).isEqualTo("new_access_token"); - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("만료된 Refresh Token으로 재발급 시 토큰을 삭제하고 예외가 발생한다.") - void refresh_withExpiredToken_throwsException() { - // given - RefreshToken expiredToken = RefreshToken.create(USER_ID, REFRESH_TOKEN, LocalDateTime.now().minusDays(1)); - - given(refreshTokenRepository.findByToken(REFRESH_TOKEN)).willReturn(Optional.of(expiredToken)); - - // when & then - assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN)) - .isInstanceOf(AuthException.class) - .hasMessageContaining("만료된 Refresh Token입니다."); - - verify(refreshTokenRepository).delete(expiredToken); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token으로 재발급 시 예외가 발생한다.") - void refresh_withInvalidToken_throwsException() { - // given - given(refreshTokenRepository.findByToken("invalid_token")).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> authService.refresh("invalid_token")) - .isInstanceOf(AuthException.class) - .hasMessageContaining("유효하지 않은 Refresh Token입니다."); - } - - @Test - @DisplayName("로그아웃 시 Refresh Token을 삭제한다.") - void logout_success() { - // when - authService.logout(USER_ID); - - // then - verify(refreshTokenRepository).deleteByUserId(USER_ID); - } -} \ No newline at end of file diff --git a/src/test/java/com/souzip/docs/RestDocsSupport.java b/src/test/java/com/souzip/docs/RestDocsSupport.java index ecd31b9e..52646a1b 100644 --- a/src/test/java/com/souzip/docs/RestDocsSupport.java +++ b/src/test/java/com/souzip/docs/RestDocsSupport.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; -import com.souzip.shared.exception.GlobalExceptionHandler; +import com.souzip.global.exception.GlobalExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.core.MethodParameter; diff --git a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java index d9d12ca7..251c0d06 100644 --- a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java +++ b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java @@ -1,22 +1,26 @@ package com.souzip.domain.admin.application; -import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; import com.souzip.domain.admin.application.command.AdminLoginCommand; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; import com.souzip.domain.admin.exception.AdminLoginFailedException; import com.souzip.domain.admin.exception.AdminNotFoundException; -import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; -import com.souzip.domain.admin.model.*; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminPasswordEncoder; +import com.souzip.domain.admin.model.AdminRefreshToken; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.model.Username; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; import com.souzip.domain.admin.repository.AdminRepository; +import com.souzip.global.security.jwt.JwtTokenProvider; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; @@ -50,7 +54,7 @@ void login_success() { // given AdminLoginCommand command = new AdminLoginCommand("admin123", "password123"); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); given(passwordEncoder.matches(anyString(), anyString())).willReturn(true); @@ -79,7 +83,7 @@ void login_fail_not_found() { // when & then assertThatThrownBy(() -> adminAuthService.login(command)) - .isInstanceOf(AdminNotFoundException.class); + .isInstanceOf(AdminNotFoundException.class); } @DisplayName("비밀번호 불일치 시 예외가 발생한다.") @@ -88,14 +92,14 @@ void login_fail_password_mismatch() { // given AdminLoginCommand command = new AdminLoginCommand("admin123", "wrongpassword"); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); given(passwordEncoder.matches(anyString(), anyString())).willReturn(false); // when & then assertThatThrownBy(() -> adminAuthService.login(command)) - .isInstanceOf(AdminLoginFailedException.class); + .isInstanceOf(AdminLoginFailedException.class); } @DisplayName("리프레시 토큰 갱신에 성공한다.") @@ -104,7 +108,7 @@ void refresh_success() { // given UUID adminId = UUID.randomUUID(); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); @@ -128,7 +132,7 @@ void refresh_fail_invalid_token() { // when & then assertThatThrownBy(() -> adminAuthService.refresh("invalid-token")) - .isInstanceOf(AdminInvalidRefreshTokenException.class); + .isInstanceOf(AdminInvalidRefreshTokenException.class); } @DisplayName("만료된 리프레시 토큰으로 갱신 시 예외가 발생한다.") @@ -143,7 +147,7 @@ void refresh_fail_expired_token() { // when & then assertThatThrownBy(() -> adminAuthService.refresh("refresh-token")) - .isInstanceOf(AdminExpiredRefreshTokenException.class); + .isInstanceOf(AdminExpiredRefreshTokenException.class); verify(refreshTokenRepository, times(1)).delete(refreshToken); } @@ -154,7 +158,7 @@ void refresh_renew_expiring_token() { // given UUID adminId = UUID.randomUUID(); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); LocalDateTime expiresAt = LocalDateTime.now().plusDays(5); // 10일 미만 AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "old-refresh-token", expiresAt); diff --git a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java index 1315ee8f..f1953947 100644 --- a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java +++ b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java @@ -4,11 +4,9 @@ import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; import com.souzip.domain.admin.application.query.AdminCityQueryService; import com.souzip.domain.admin.application.query.CitySearchQuery; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - +import com.souzip.global.common.dto.pagination.PaginationResponse; import java.time.LocalDateTime; import java.util.List; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java index aaec602e..23208d4e 100644 --- a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java @@ -2,7 +2,7 @@ import com.souzip.domain.admin.model.AdminRefreshToken; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; -import com.souzip.shared.config.QuerydslConfig; +import com.souzip.global.config.QuerydslConfig; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,7 +12,6 @@ import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; - import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java index d1ef86ee..b89dacef 100644 --- a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java @@ -6,11 +6,9 @@ import com.souzip.domain.admin.model.AdminRole; import com.souzip.domain.admin.model.Username; import com.souzip.domain.admin.repository.AdminRepository; -import com.souzip.shared.config.QuerydslConfig; - +import com.souzip.global.config.QuerydslConfig; import java.util.List; import java.util.Optional; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java deleted file mode 100644 index 47beb932..00000000 --- a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.souzip.domain.admin.presentation; - -import com.souzip.docs.CommonDocumentation; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.admin.application.AdminAuthService; -import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; -import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; -import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; -import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; -import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; -import com.souzip.domain.admin.model.Admin; -import com.souzip.domain.admin.model.AdminRole; -import com.souzip.domain.admin.presentation.request.AdminLoginRequest; -import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.Collections; -import java.util.UUID; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AdminAuthApiTest extends RestDocsSupport { - - private final AdminAuthService adminAuthService = mock(AdminAuthService.class); - - @Override - protected Object initController() { - return new AdminAuthController(adminAuthService); - } - - @Test - @DisplayName("어드민 로그인 성공") - void login_success() throws Exception { - // given - AdminLoginRequest request = new AdminLoginRequest("admin123", "password123"); - - Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); - - AdminLoginResult result = new AdminLoginResult(mockAdmin, "access-token", "refresh-token"); - - given(adminAuthService.login(any())).willReturn(result); - - // when & then - mockMvc.perform(post("/api/admin/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")) - .andExpect(jsonPath("$.data.username").value("admin123")) - .andExpect(jsonPath("$.data.role").value("SUPER_ADMIN")) - .andDo(document("admin/login", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("username").type(JsonFieldType.STRING).description("어드민 아이디 (4~10자)"), - fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.id").type(JsonFieldType.STRING).description("어드민 ID (UUID)"), - fieldWithPath("data.username").type(JsonFieldType.STRING).description("어드민 아이디"), - fieldWithPath("data.role").type(JsonFieldType.STRING).description("권한 (SUPER_ADMIN)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("어드민 토큰 갱신 - Access Token만") - void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("valid-refresh-token"); - RefreshResult result = new RefreshResult("new-access-token", "valid-refresh-token"); - - given(adminAuthService.refresh(anyString())).willReturn(result); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("valid-refresh-token")) - .andDo(document("admin/refresh-valid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token (유효기간 10일 초과 시 그대로 유지)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("어드민 토큰 갱신 - 만료 임박 시 둘 다 재발급") - void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("expiring-soon-token"); - RefreshResult result = new RefreshResult("new-access-token", "new-refresh-token"); - - given(adminAuthService.refresh(anyString())).willReturn(result); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")) - .andDo(document("admin/refresh-expiring-soon", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token (만료 10일 이하)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Refresh Token"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("만료된 Refresh Token - 401 에러") - void refresh_withExpiredToken_returns401() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("expired-refresh-token"); - given(adminAuthService.refresh(anyString())) - .willThrow(new AdminExpiredRefreshTokenException()); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("만료된 리프레시 토큰입니다.")) - .andDo(document("admin/refresh-expired-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("만료된 Refresh Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token - 401 에러") - void refresh_withInvalidToken_returns401() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("invalid-refresh-token"); - given(adminAuthService.refresh(anyString())) - .willThrow(new AdminInvalidRefreshTokenException()); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("유효하지 않은 리프레시 토큰입니다.")) - .andDo(document("admin/refresh-invalid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("유효하지 않은 Refresh Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("어드민 로그아웃 성공") - void logout_success() throws Exception { - // given - Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(mockAdmin, null, Collections.emptyList()); - SecurityContextHolder.getContext().setAuthentication(authentication); - - doNothing().when(adminAuthService).logout(any(UUID.class)); - - // when & then - mockMvc.perform(post("/api/admin/auth/logout") - .header("Authorization", "Bearer valid-access-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃되었습니다.")) - .andDo(document("admin/logout", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer {accessToken}") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.NULL).description("응답 데이터 (없음)").optional(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") - ) - )); - - SecurityContextHolder.clearContext(); - } -} diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java new file mode 100644 index 00000000..fdd1809b --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java @@ -0,0 +1,242 @@ +package com.souzip.domain.admin.presentation; + +import com.souzip.docs.CommonDocumentation; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.admin.application.AdminAuthService; +import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; +import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; +import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; +import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.presentation.request.AdminLoginRequest; +import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.UUID; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminAuthControllerTest extends RestDocsSupport { + + private final AdminAuthService adminAuthService = mock(AdminAuthService.class); + + @Override + protected Object initController() { + return new AdminAuthController(adminAuthService); + } + + @Test + @DisplayName("어드민 로그인 성공") + void login_success() throws Exception { + // given + AdminLoginRequest request = new AdminLoginRequest("admin123", "password123"); + + Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + AdminLoginResult result = new AdminLoginResult(mockAdmin, "access-token", "refresh-token"); + + given(adminAuthService.login(any())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")) + .andExpect(jsonPath("$.data.username").value("admin123")) + .andExpect(jsonPath("$.data.role").value("SUPER_ADMIN")) + .andDo(document("admin/login", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("username").type(JsonFieldType.STRING).description("어드민 아이디 (4~10자)"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.id").type(JsonFieldType.STRING).description("어드민 ID (UUID)"), + fieldWithPath("data.username").type(JsonFieldType.STRING).description("어드민 아이디"), + fieldWithPath("data.role").type(JsonFieldType.STRING).description("권한 (SUPER_ADMIN)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("어드민 토큰 갱신 - Access Token만") + void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("valid-refresh-token"); + RefreshResult result = new RefreshResult("new-access-token", "valid-refresh-token"); + + given(adminAuthService.refresh(anyString())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("valid-refresh-token")) + .andDo(document("admin/refresh-valid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (유효기간 10일 초과 시 그대로 유지)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("어드민 토큰 갱신 - 만료 임박 시 둘 다 재발급") + void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("expiring-soon-token"); + RefreshResult result = new RefreshResult("new-access-token", "new-refresh-token"); + + given(adminAuthService.refresh(anyString())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")) + .andDo(document("admin/refresh-expiring-soon", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (만료 10일 이하)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("만료된 Refresh Token - 401 에러") + void refresh_withExpiredToken_returns401() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("expired-refresh-token"); + given(adminAuthService.refresh(anyString())) + .willThrow(new AdminExpiredRefreshTokenException()); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 리프레시 토큰입니다.")) + .andDo(document("admin/refresh-expired-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("만료된 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token - 401 에러") + void refresh_withInvalidToken_returns401() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("invalid-refresh-token"); + given(adminAuthService.refresh(anyString())) + .willThrow(new AdminInvalidRefreshTokenException()); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("유효하지 않은 리프레시 토큰입니다.")) + .andDo(document("admin/refresh-invalid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("유효하지 않은 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("어드민 로그아웃 성공") + void logout_success() throws Exception { + // given + Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(mockAdmin, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + doNothing().when(adminAuthService).logout(any(UUID.class)); + + // when & then + mockMvc.perform(post("/api/admin/auth/logout") + .header("Authorization", "Bearer valid-access-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃되었습니다.")) + .andDo(document("admin/logout", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken}") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응답 데이터 (없음)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + + SecurityContextHolder.clearContext(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java index 2934390c..7fd3aba3 100644 --- a/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java @@ -19,13 +19,11 @@ import com.souzip.domain.admin.presentation.request.CreateCityRequest; import com.souzip.domain.admin.presentation.request.InviteAdminRequest; import com.souzip.domain.admin.presentation.request.UpdateCityRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - +import com.souzip.global.common.dto.pagination.PaginationResponse; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.UUID; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; diff --git a/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..6834d1b2 --- /dev/null +++ b/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java @@ -0,0 +1,593 @@ +package com.souzip.domain.auth.controller; + +import com.souzip.docs.CommonDocumentation; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.auth.dto.LoginRequest; +import com.souzip.domain.auth.dto.LoginResponse; +import com.souzip.domain.auth.dto.LoginUserInfo; +import com.souzip.domain.auth.dto.RefreshRequest; +import com.souzip.domain.auth.dto.RefreshResponse; +import com.souzip.domain.auth.service.AuthService; +import com.souzip.domain.user.entity.Provider; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AuthControllerTest extends RestDocsSupport { + + private static final String PROVIDER_DESC = "OAuth Provider (kakao, google, apple)"; + + private final AuthService authService = mock(AuthService.class); + + @Override + protected Object initController() { + return new AuthController(authService); + } + + @Test + @DisplayName("카카오 로그인 - 신규 사용자") + void loginWithKakao_newUser() throws Exception { + // given + LoginRequest request = new LoginRequest("kakao_access_token"); + + LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); + LoginResponse response = LoginResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + userInfo, + true + ); + + given(authService.login(eq(Provider.KAKAO), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()) + .andExpect(jsonPath("$.data.refreshToken").exists()) + .andExpect(jsonPath("$.data.user.userId").value("20251204")) + .andExpect(jsonPath("$.data.user.nickname").value("수집")) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-kakao-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("카카오 Access Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID 앞 8자리)"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN) + .description("온보딩 필요 여부 (true = 온보딩 필요, false = 완료)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("카카오 로그인 - 기존 사용자") + void loginWithKakao_existingUser() throws Exception { + // given + LoginRequest request = new LoginRequest("kakao_access_token"); + + LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); + LoginResponse response = LoginResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + userInfo, + false + ); + + given(authService.login(eq(Provider.KAKAO), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-kakao-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("카카오 Access Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN) + .description("온보딩 필요 여부 (false = 온보딩 완료, true = 필요)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("카카오 로그인 - 카카오 API 에러") + void loginWithKakao_kakaoApiError() throws Exception { + // given + LoginRequest request = new LoginRequest("invalid_kakao_token"); + + given(authService.login(eq(Provider.KAKAO), anyString())) + .willThrow(new BusinessException(ErrorCode.KAKAO_API_ERROR)); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("카카오 API 호출에 실패했습니다.")) + .andDo(document("auth/login-kakao-api-error", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("유효하지 않은 카카오 Access Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("Google 로그인 - 신규 사용자") + void loginWithGoogle_newUser() throws Exception { + // given + LoginRequest request = new LoginRequest("google_access_token"); + + LoginUserInfo userInfo = new LoginUserInfo("abcd1234", "구글유저"); + LoginResponse response = LoginResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + userInfo, + true + ); + + given(authService.login(eq(Provider.GOOGLE), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "google") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-google-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("구글 Access Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("Google 로그인 - 기존 사용자") + void loginWithGoogle_existingUser() throws Exception { + // given + LoginRequest request = new LoginRequest("google_access_token"); + + LoginUserInfo userInfo = new LoginUserInfo("abcd1234", "구글유저"); + LoginResponse response = LoginResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + userInfo, + false + ); + + given(authService.login(eq(Provider.GOOGLE), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "google") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-google-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("구글 Access Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("Apple 로그인 - 신규 사용자") + void loginWithApple_newUser() throws Exception { + // given + LoginRequest request = new LoginRequest("apple_id_token"); + + LoginUserInfo userInfo = new LoginUserInfo("xyz98765", "애플유저"); + LoginResponse response = LoginResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + userInfo, + true + ); + + given(authService.login(eq(Provider.APPLE), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-apple-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("애플 ID Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("Apple 로그인 - 기존 사용자") + void loginWithApple_existingUser() throws Exception { + // given + LoginRequest request = new LoginRequest("apple_id_token"); + + LoginUserInfo userInfo = new LoginUserInfo("xyz98765", "애플유저"); + LoginResponse response = LoginResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + userInfo, + false + ); + + given(authService.login(eq(Provider.APPLE), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-apple-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("애플 ID Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("지원하지 않는 Provider - 400 에러") + void loginWithUnsupportedProvider_returns400() throws Exception { + // given + LoginRequest request = new LoginRequest("naver_access_token"); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "naver") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("지원하지 않는 로그인 방식입니다: naver")) + .andDo(document("auth/login-unsupported-provider", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("OAuth Access Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("잘못된 Provider 형식 - 400 에러") + void loginWithInvalidProvider_returns400() throws Exception { + // given + LoginRequest request = new LoginRequest("invalid_token"); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "invalid123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andDo(document("auth/login-invalid-provider", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("provider").description(PROVIDER_DESC) + ), + requestFields( + fieldWithPath("accessToken").type(JsonFieldType.STRING) + .description("OAuth Access Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("대소문자 구분 없이 Provider 처리") + void loginWithCaseInsensitiveProvider_success() throws Exception { + // given + LoginRequest request = new LoginRequest("kakao_token"); + + LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); + LoginResponse response = LoginResponse.of( + "access_token", + "refresh_token", + userInfo, + true + ); + + given(authService.login(eq(Provider.KAKAO), anyString())) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/login/{provider}", "KAKAO") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()); + // NOTE: 이 테스트는 “동작 검증”용으로 남기고 문서화는 생략(실무에서도 보통 이렇게 함) + } + + @Test + @DisplayName("Refresh Token 재발급 - Access Token만") + void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { + // given + RefreshRequest request = new RefreshRequest("valid_refresh_token"); + RefreshResponse response = RefreshResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token", + "valid_refresh_token" + ); + + given(authService.refresh(anyString())).willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token")) + .andExpect(jsonPath("$.data.refreshToken").value("valid_refresh_token")) + .andDo(document("auth/refresh-valid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (유효기간 10일 초과 시 그대로 유지)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("Refresh Token 만료 임박 - 둘 다 재발급") + void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { + // given + RefreshRequest request = new RefreshRequest("expiring_soon_token"); + RefreshResponse response = RefreshResponse.of( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_refresh_token" + ); + + given(authService.refresh(anyString())).willReturn(response); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.refreshToken").value("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_refresh_token")) + .andDo(document("auth/refresh-expiring-soon", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (만료 10일 이하)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("만료된 Refresh Token - 401 에러") + void refresh_withExpiredToken_returns401() throws Exception { + // given + RefreshRequest request = new RefreshRequest("expired_refresh_token"); + given(authService.refresh(anyString())) + .willThrow(new BusinessException(ErrorCode.EXPIRED_REFRESH_TOKEN)); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 Refresh Token입니다.")) + .andDo(document("auth/refresh-expired-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("만료된 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token - 401 에러") + void refresh_withInvalidToken_returns401() throws Exception { + // given + RefreshRequest request = new RefreshRequest("invalid_refresh_token"); + given(authService.refresh(anyString())) + .willThrow(new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN)); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("유효하지 않은 Refresh Token입니다.")) + .andDo(document("auth/refresh-invalid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("유효하지 않은 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("로그아웃 성공") + void logout_success() throws Exception { + // given + doNothing().when(authService).logout(anyLong()); + + // when & then + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) + .andDo(document("auth/logout", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); + } +} diff --git a/src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java b/src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java similarity index 77% rename from src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java rename to src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java index e9d0fa4c..e57e548f 100644 --- a/src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java +++ b/src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java @@ -1,7 +1,6 @@ -package com.souzip.auth.scheduler; +package com.souzip.domain.auth.scheduler; -import com.souzip.auth.adapter.integration.scheduler.RefreshTokenCleanupScheduler; -import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.domain.auth.repository.RefreshTokenRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,8 +11,8 @@ import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class RefreshTokenCleanupSchedulerTest { @@ -27,11 +26,14 @@ class RefreshTokenCleanupSchedulerTest { @Test @DisplayName("만료된 Refresh Token을 삭제한다.") void cleanupExpiredTokens() { + // given + LocalDateTime now = LocalDateTime.now(); + // when scheduler.cleanUpExpiredRefreshTokens(); // then verify(refreshTokenRepository, times(1)) - .deleteAllExpiredBefore(any(LocalDateTime.class)); + .deleteAllByExpiresAtBefore(any(LocalDateTime.class)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java b/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..2934b0ff --- /dev/null +++ b/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,459 @@ +package com.souzip.domain.auth.service; + +import com.souzip.domain.auth.client.OAuthClient; +import com.souzip.domain.auth.client.OAuthClientFactory; +import com.souzip.domain.auth.dto.LoginResponse; +import com.souzip.domain.auth.dto.OAuthUserInfo; +import com.souzip.domain.auth.dto.RefreshResponse; +import com.souzip.domain.auth.entity.RefreshToken; +import com.souzip.domain.auth.repository.RefreshTokenRepository; +import com.souzip.domain.user.entity.Provider; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.security.jwt.JwtTokenProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private OAuthClientFactory oauthClientFactory; + + @Mock + private UserRepository userRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private OAuthClient oauthClient; + + @InjectMocks + private AuthService authService; + + private OAuthUserInfo createOAuthUserInfo() { + return new OAuthUserInfo() { + @Override + public String getProviderId() { + return "kakao123"; + } + + @Override + public String getName() { + return "수집"; + } + + @Override + public String getEmail() { + return "test@gmail.com"; + } + }; + } + + @Test + @DisplayName("신규 사용자 로그인 시 User를 생성하고 토큰을 발급한다.") + void login_newUser_createsUserAndIssuesTokens() { + // given + OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); + User newUser = User.of(Provider.KAKAO, oauthUserInfo); + User spyUser = spy(newUser); + + given(spyUser.getUserId()).willReturn("550e8400"); + given(spyUser.needsOnboarding()).willReturn(true); + + given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); + given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); + given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willReturn(spyUser); + given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); + given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); + given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); + + // when + LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(response.getAccessToken()).isEqualTo("access_token"); + assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); + assertThat(response.getUser().nickname()).isEmpty(); + assertThat(response.isNeedsOnboarding()).isTrue(); + + verify(spyUser).updateLastLoginAt(); + + verify(userRepository).save(any(User.class)); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("온보딩 완료한 기존 사용자 로그인 시 needsOnboarding은 false다.") + void login_existingUserWithOnboardingCompleted_needsOnboardingFalse() { + // given + OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); + User existingUser = User.of(Provider.KAKAO, oauthUserInfo); + User spyUser = spy(existingUser); + + given(spyUser.getUserId()).willReturn("550e8400"); + given(spyUser.needsOnboarding()).willReturn(false); + + RefreshToken existingToken = RefreshToken.of( + spyUser, + "old_refresh_token", + LocalDateTime.now().plusDays(20) + ); + + given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); + given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); + given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) + .willReturn(Optional.of(spyUser)); + given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); + given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); + given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); + + // when + LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(response.getAccessToken()).isEqualTo("access_token"); + assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); + assertThat(response.isNeedsOnboarding()).isFalse(); // ← 온보딩 완료! + + verify(userRepository, never()).save(any(User.class)); + verify(spyUser, never()).restore(anyString()); + } + + @Test + @DisplayName("온보딩 안 한 기존 사용자 로그인 시 needsOnboarding은 true다.") + void login_existingUserWithoutOnboarding_needsOnboardingTrue() { + // given + OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); + User existingUser = User.of(Provider.KAKAO, oauthUserInfo); + User spyUser = spy(existingUser); + + given(spyUser.getUserId()).willReturn("550e8400"); + given(spyUser.needsOnboarding()).willReturn(true); + + RefreshToken existingToken = RefreshToken.of( + spyUser, + "old_refresh_token", + LocalDateTime.now().plusDays(20) + ); + + given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); + given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); + given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) + .willReturn(Optional.of(spyUser)); + given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); + given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); + given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); + + // when + LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(response.getAccessToken()).isEqualTo("access_token"); + assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); + assertThat(response.isNeedsOnboarding()).isTrue(); + + verify(userRepository, never()).save(any(User.class)); + verify(spyUser, never()).restore(anyString()); + } + + @Test + @DisplayName("탈퇴 회원 재로그인 시 User를 복구하고 needsOnboarding은 true다.") + void login_withdrawnUser_restoresUserAndNeedsOnboardingTrue() { + // given + OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); + User withdrawnUser = User.of(Provider.KAKAO, oauthUserInfo); + User spyUser = spy(withdrawnUser); + + given(spyUser.isDeleted()).willReturn(true); + given(spyUser.getUserId()).willReturn("550e8400"); + given(spyUser.needsOnboarding()).willReturn(true); + + doCallRealMethod().when(spyUser).restore(anyString()); + + given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); + given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); + given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) + .willReturn(Optional.of(spyUser)); + given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); + given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); + given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); + + // when + LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); + + // then + verify(spyUser).restore("수집"); + assertThat(response.isNeedsOnboarding()).isTrue(); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("Refresh Token이 없으면 새로 생성한다.") + void login_withoutRefreshToken_createsNewToken() { + // given + OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); + User newUser = User.of(Provider.KAKAO, oauthUserInfo); + User spyUser = spy(newUser); + + given(spyUser.getUserId()).willReturn("550e8400"); + given(spyUser.needsOnboarding()).willReturn(true); + + given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); + given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); + given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) + .willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willReturn(spyUser); + given(jwtTokenProvider.generateToken(anyString())).willReturn("access_token"); + given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("refresh_token"); + given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); + + // when + authService.login(Provider.KAKAO, "kakao_token"); + + // then + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("Refresh Token이 있으면 업데이트한다.") + void login_withExistingRefreshToken_updatesToken() { + // given + OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); + User existingUser = User.of(Provider.KAKAO, oauthUserInfo); + User spyUser = spy(existingUser); + + given(spyUser.getUserId()).willReturn("550e8400"); + given(spyUser.needsOnboarding()).willReturn(false); + + RefreshToken existingToken = spy(RefreshToken.of( + spyUser, + "old_refresh_token", + LocalDateTime.now().plusDays(20) + )); + + given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); + given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); + given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) + .willReturn(Optional.of(spyUser)); + given(jwtTokenProvider.generateToken(anyString())).willReturn("access_token"); + given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("new_refresh_token"); + given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); + + // when + authService.login(Provider.KAKAO, "kakao_token"); + + // then + verify(existingToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); + verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("Refresh Token이 유효하고 만료 임박하지 않으면 Access Token만 재발급한다.") + void refresh_withValidToken_returnsNewAccessTokenOnly() { + // given + User user = User.of(Provider.KAKAO, createOAuthUserInfo()); + User spyUser = spy(user); + given(spyUser.getUserId()).willReturn("550e8400"); + + RefreshToken refreshToken = RefreshToken.of( + spyUser, + "valid_refresh_token", + LocalDateTime.now().plusDays(20) + ); + RefreshToken spyToken = spy(refreshToken); + given(spyToken.isExpired()).willReturn(false); + given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(20)); + + given(refreshTokenRepository.findByToken("valid_refresh_token")) + .willReturn(Optional.of(spyToken)); + given(jwtTokenProvider.generateToken("550e8400")) + .willReturn("new_access_token"); + + // when + RefreshResponse response = authService.refresh("valid_refresh_token"); + + // then + assertThat(response.accessToken()).isEqualTo("new_access_token"); + assertThat(response.refreshToken()).isEqualTo("valid_refresh_token"); + verify(jwtTokenProvider, never()).generateRefreshToken(anyString()); + } + + @Test + @DisplayName("Refresh Token이 10일 이하로 남으면 둘 다 재발급한다.") + void refresh_withExpiringSoon_returnsBothNewTokens() { + // given + User user = User.of(Provider.KAKAO, createOAuthUserInfo()); + User spyUser = spy(user); + given(spyUser.getUserId()).willReturn("550e8400"); + + RefreshToken refreshToken = RefreshToken.of( + spyUser, + "expiring_token", + LocalDateTime.now().plusDays(5) + ); + RefreshToken spyToken = spy(refreshToken); + given(spyToken.isExpired()).willReturn(false); + given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(5)); + + given(refreshTokenRepository.findByToken("expiring_token")) + .willReturn(Optional.of(spyToken)); + given(jwtTokenProvider.generateToken("550e8400")) + .willReturn("new_access_token"); + given(jwtTokenProvider.generateRefreshToken("550e8400")) + .willReturn("new_refresh_token"); + + // when + RefreshResponse response = authService.refresh("expiring_token"); + + // then + assertThat(response.accessToken()).isEqualTo("new_access_token"); + assertThat(response.refreshToken()).isEqualTo("new_refresh_token"); + verify(spyToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); + } + + @Test + @DisplayName("만료된 Refresh Token으로 재발급 시 토큰을 삭제하고 에러가 발생한다.") + void refresh_withExpiredToken_deletesTokenAndThrowsException() { + // given + User user = User.of(Provider.KAKAO, createOAuthUserInfo()); + + RefreshToken expiredToken = RefreshToken.of( + user, + "expired_token", + LocalDateTime.now().minusDays(1) + ); + RefreshToken spyToken = spy(expiredToken); + given(spyToken.isExpired()).willReturn(true); + + given(refreshTokenRepository.findByToken("expired_token")) + .willReturn(Optional.of(spyToken)); + + // when & then + assertThatThrownBy(() -> authService.refresh("expired_token")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("만료된 Refresh Token입니다."); + + verify(refreshTokenRepository).delete(spyToken); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token으로 재발급 시 에러가 발생한다.") + void refresh_withInvalidToken_throwsException() { + // given + given(refreshTokenRepository.findByToken("invalid_token")) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.refresh("invalid_token")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("유효하지 않은 Refresh Token입니다."); + } + + @ParameterizedTest + @DisplayName("Refresh Token 만료 임계값 경계 조건 테스트") + @CsvSource({ + "11, false, 11일 남음 - 갱신 안 함", + "10, true, 정확히 10일 남음 - 갱신", + "9, true, 9일 남음 - 갱신" + }) + void refresh_boundaryCondition_test(int daysRemaining, boolean shouldRenew, String description) { + // given + User user = User.of(Provider.KAKAO, createOAuthUserInfo()); + User spyUser = spy(user); + given(spyUser.getUserId()).willReturn("550e8400"); + + String tokenValue = "token_" + daysRemaining + "_days"; + RefreshToken refreshToken = RefreshToken.of( + spyUser, + tokenValue, + LocalDateTime.now().plusDays(daysRemaining) + ); + RefreshToken spyToken = spy(refreshToken); + given(spyToken.isExpired()).willReturn(false); + given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(daysRemaining)); + + given(refreshTokenRepository.findByToken(tokenValue)) + .willReturn(Optional.of(spyToken)); + given(jwtTokenProvider.generateToken("550e8400")) + .willReturn("new_access_token"); + + if (shouldRenew) { + given(jwtTokenProvider.generateRefreshToken("550e8400")) + .willReturn("new_refresh_token"); + } + + // when + RefreshResponse response = authService.refresh(tokenValue); + + // then + assertThat(response.accessToken()).isEqualTo("new_access_token"); + + if (shouldRenew) { + assertThat(response.refreshToken()).isEqualTo("new_refresh_token"); + verify(spyToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); + } else { + assertThat(response.refreshToken()).isEqualTo(tokenValue); + verify(jwtTokenProvider, never()).generateRefreshToken(anyString()); + verify(spyToken, never()).updateToken(anyString(), any(LocalDateTime.class)); + } + } + + @Test + @DisplayName("로그아웃 시 Refresh Token을 삭제한다.") + void logout_success() { + // given + User user = User.of(Provider.KAKAO, createOAuthUserInfo()); + User spyUser = spy(user); + + RefreshToken refreshToken = RefreshToken.of( + spyUser, + "refresh_token", + LocalDateTime.now().plusDays(30) + ); + + given(userRepository.findById(1L)) + .willReturn(Optional.of(spyUser)); + given(refreshTokenRepository.findByUser(spyUser)) + .willReturn(Optional.of(refreshToken)); + + // when + authService.logout(1L); + + // then + verify(refreshTokenRepository).delete(refreshToken); + } + + @Test + @DisplayName("존재하지 않는 사용자로 로그아웃 시 에러가 발생한다.") + void logout_withNotExistUser_throwsException() { + // given + given(userRepository.findById(999L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.logout(999L)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); + } +} diff --git a/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java b/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java index a5a967cb..9d5dd681 100644 --- a/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java +++ b/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java @@ -8,11 +8,9 @@ import com.souzip.domain.city.service.CityPriorityDomainService; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; -import com.souzip.shared.exception.BusinessException; - +import com.souzip.global.exception.BusinessException; import java.math.BigDecimal; import java.util.Optional; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java b/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java index 5412cd5b..d9112cf6 100644 --- a/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java +++ b/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java @@ -8,13 +8,11 @@ import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.entity.Currency; import com.souzip.domain.currency.repository.CurrencyRepository; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; - +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import java.math.BigDecimal; import java.util.List; import java.util.Optional; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -43,15 +41,15 @@ class CountryServiceTest { private Country createCountry(String nameEn, String nameKr, String code, Currency currency) { return Country.of( - nameEn, - nameKr, - code, - "Seoul", - Region.ASIA, - "https://flagcdn.com/w320/kr.png", - new BigDecimal("37.0"), - new BigDecimal("127.0"), - currency + nameEn, + nameKr, + code, + "Seoul", + Region.ASIA, + "https://flagcdn.com/w320/kr.png", + new BigDecimal("37.0"), + new BigDecimal("127.0"), + currency ); } @@ -76,8 +74,8 @@ void getAllCountries() { // then assertThat(response.countries()).hasSize(2); assertThat(response.countries()) - .extracting(CountryResponseDto::code) - .containsExactly("KR", "JP"); + .extracting(CountryResponseDto::code) + .containsExactly("KR", "JP"); } @Test @@ -106,7 +104,7 @@ void getCountryByCode_throwsException_whenCountryNotFound() { // when & then assertThatThrownBy(() -> countryService.getCountryByCode("INVALID")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining(ErrorCode.COUNTRY_NOT_FOUND.getMessage()); + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUNTRY_NOT_FOUND.getMessage()); } } diff --git a/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java b/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java index a1ffa896..b79e5541 100644 --- a/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java +++ b/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java @@ -4,8 +4,8 @@ import com.souzip.domain.currency.dto.CurrencyResponse; import com.souzip.domain.country.entity.Country; import com.souzip.domain.currency.entity.Currency; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; diff --git a/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java b/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java deleted file mode 100644 index 615f0474..00000000 --- a/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.souzip.domain.notification; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.*; - -class FcmTokenRegisterRequestTest { - - @DisplayName("정상적으로 요청 객체를 생성한다") - @Test - void create_success() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - "device-id-123", - "Galaxy S23", - "Android 14", - "1.0.0" - ); - - // then - assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); - assertThat(request.deviceType()).isEqualTo(DeviceType.ANDROID); - assertThat(request.deviceId()).isEqualTo("device-id-123"); - assertThat(request.deviceModel()).isEqualTo("Galaxy S23"); - assertThat(request.osVersion()).isEqualTo("Android 14"); - assertThat(request.appVersion()).isEqualTo("1.0.0"); - } - - @DisplayName("선택 필드 없이 요청 객체를 생성한다") - @Test - void create_withoutOptionalFields() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.IOS, - "device-id-456", - null, - null, - null - ); - - // then - assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); - assertThat(request.deviceType()).isEqualTo(DeviceType.IOS); - assertThat(request.deviceModel()).isNull(); - assertThat(request.osVersion()).isNull(); - assertThat(request.appVersion()).isNull(); - } - - @DisplayName("FCM 토큰이 null이면 예외가 발생한다") - @Test - void create_withNullFcmToken_throwsException() { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - null, - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - )).isInstanceOf(NullPointerException.class) - .hasMessage("FCM 토큰은 필수입니다."); - } - - @DisplayName("FCM 토큰이 비어있으면 예외가 발생한다") - @ValueSource(strings = {"", " ", " "}) - @ParameterizedTest - void create_withEmptyFcmToken_throwsException(String emptyToken) { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - emptyToken, - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessage("FCM 토큰은 비어있을 수 없습니다."); - } - - @DisplayName("FCM 토큰이 최대 길이를 초과하면 예외가 발생한다") - @Test - void create_withTooLongFcmToken_throwsException() { - // given - String tooLongToken = "a".repeat(256); - - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - tooLongToken, - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("FCM 토큰은 255자를 초과할 수 없습니다"); - } - - @DisplayName("디바이스 타입이 null이면 예외가 발생한다") - @Test - void create_withNullDeviceType_throwsException() { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - null, - "device-id-123", - null, - null, - null - )).isInstanceOf(NullPointerException.class) - .hasMessage("디바이스 타입은 필수입니다."); - } - - @DisplayName("디바이스 ID가 null이면 예외가 발생한다") - @Test - void create_withNullDeviceId_throwsException() { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - null, - null, - null, - null - )).isInstanceOf(NullPointerException.class) - .hasMessage("디바이스 ID는 필수입니다."); - } - - @DisplayName("디바이스 ID가 비어있으면 예외가 발생한다") - @ValueSource(strings = {"", " ", " "}) - @ParameterizedTest - void create_withEmptyDeviceId_throwsException(String emptyDeviceId) { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - emptyDeviceId, - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessage("디바이스 ID는 비어있을 수 없습니다."); - } - - @DisplayName("디바이스 ID가 최대 길이를 초과하면 예외가 발생한다") - @Test - void create_withTooLongDeviceId_throwsException() { - // given - String tooLongDeviceId = "a".repeat(101); - - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - tooLongDeviceId, - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("디바이스 ID는 100자를 초과할 수 없습니다"); - } - - @DisplayName("FCM 토큰 앞뒤 공백을 제거한다") - @Test - void create_trimsFcmToken() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - " fcm-token-123 ", - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - ); - - // then - assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); - } - - @DisplayName("디바이스 ID 앞뒤 공백을 제거한다") - @Test - void create_trimsDeviceId() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - " device-id-123 ", - null, - null, - null - ); - - // then - assertThat(request.deviceId()).isEqualTo("device-id-123"); - } -} diff --git a/src/test/java/com/souzip/domain/notification/FcmTokenTest.java b/src/test/java/com/souzip/domain/notification/FcmTokenTest.java deleted file mode 100644 index 90dc5cbd..00000000 --- a/src/test/java/com/souzip/domain/notification/FcmTokenTest.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.souzip.domain.notification; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.*; - -class FcmTokenTest { - - @DisplayName("FCM 토큰을 정상적으로 등록한다") - @Test - void register_success() { - // given - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - "device-id-123", - "Galaxy S23", - "Android 14", - "1.0.0" - ); - - // when - FcmToken fcmToken = FcmToken.register(request); - - // then - assertThat(fcmToken.getToken()).isEqualTo("fcm-token-123"); - assertThat(fcmToken.getDeviceType()).isEqualTo(DeviceType.ANDROID); - assertThat(fcmToken.getDeviceId()).isEqualTo("device-id-123"); - assertThat(fcmToken.getDeviceModel()).isEqualTo("Galaxy S23"); - assertThat(fcmToken.getOsVersion()).isEqualTo("Android 14"); - assertThat(fcmToken.getAppVersion()).isEqualTo("1.0.0"); - assertThat(fcmToken.isActive()).isTrue(); - assertThat(fcmToken.getUserId()).isNull(); - assertThat(fcmToken.getLastUsedAt()).isNotNull(); - } - - @DisplayName("선택 필드 없이 FCM 토큰을 등록한다") - @Test - void register_withoutOptionalFields() { - // given - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.IOS, - "device-id-456", - null, - null, - null - ); - - // when - FcmToken fcmToken = FcmToken.register(request); - - // then - assertThat(fcmToken.getToken()).isEqualTo("fcm-token-123"); - assertThat(fcmToken.getDeviceType()).isEqualTo(DeviceType.IOS); - assertThat(fcmToken.getDeviceModel()).isNull(); - assertThat(fcmToken.getOsVersion()).isNull(); - assertThat(fcmToken.getAppVersion()).isNull(); - } - - @DisplayName("로그인 시 사용자 ID를 연결한다") - @Test - void linkUser_success() { - // given - FcmToken fcmToken = createFcmToken(); - Long userId = 1L; - - // when - fcmToken.linkUser(userId); - - // then - assertThat(fcmToken.getUserId()).isEqualTo(userId); - assertThat(fcmToken.isLoggedIn()).isTrue(); - assertThat(fcmToken.getLastUsedAt()).isNotNull(); - } - - @Test - @DisplayName("null 사용자 ID로 연결하면 예외가 발생한다") - void linkUser_withNull_throwsException() { - // given - FcmToken fcmToken = createFcmToken(); - - // when & then - assertThatThrownBy(() -> fcmToken.linkUser(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("사용자 ID는 필수입니다."); - } - - @DisplayName("로그아웃 시 사용자 ID 연결을 해제한다") - @Test - void unlinkUser_success() { - // given - FcmToken fcmToken = createFcmToken(); - fcmToken.linkUser(1L); - - // when - fcmToken.unlinkUser(); - - // then - assertThat(fcmToken.getUserId()).isNull(); - assertThat(fcmToken.isLoggedIn()).isFalse(); - } - - @DisplayName("FCM 토큰을 갱신한다") - @Test - void updateToken_success() { - // given - FcmToken fcmToken = createFcmToken(); - String newToken = "new-fcm-token"; - LocalDateTime beforeUpdate = fcmToken.getLastUsedAt(); - - // when - fcmToken.updateToken(newToken); - - // then - assertThat(fcmToken.getToken()).isEqualTo(newToken); - assertThat(fcmToken.getLastUsedAt()).isAfter(beforeUpdate); - } - - @DisplayName("null 토큰으로 갱신하면 예외가 발생한다") - @Test - void updateToken_withNull_throwsException() { - // given - FcmToken fcmToken = createFcmToken(); - - // when & then - assertThatThrownBy(() -> fcmToken.updateToken(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("FCM 토큰은 필수입니다."); - } - - @DisplayName("디바이스 정보를 업데이트한다") - @Test - void updateDeviceInfo_success() { - // given - FcmToken fcmToken = createFcmToken(); - - // when - fcmToken.updateDeviceInfo("iPhone 15 Pro", "iOS 17.2", "1.1.0"); - - // then - assertThat(fcmToken.getDeviceModel()).isEqualTo("iPhone 15 Pro"); - assertThat(fcmToken.getOsVersion()).isEqualTo("iOS 17.2"); - assertThat(fcmToken.getAppVersion()).isEqualTo("1.1.0"); - } - - @DisplayName("토큰을 비활성화한다") - @Test - void deactivate_success() { - // given - FcmToken fcmToken = createFcmToken(); - - // when - fcmToken.deactivate(); - - // then - assertThat(fcmToken.isActive()).isFalse(); - } - - @DisplayName("토큰을 활성화한다") - @Test - void activate_success() { - // given - FcmToken fcmToken = createFcmToken(); - fcmToken.deactivate(); - - // when - fcmToken.activate(); - - // then - assertThat(fcmToken.isActive()).isTrue(); - } - - @DisplayName("비로그인 상태를 확인한다") - @Test - void isLoggedIn_whenNotLoggedIn_returnsFalse() { - // given - FcmToken fcmToken = createFcmToken(); - - // when & then - assertThat(fcmToken.isLoggedIn()).isFalse(); - } - - @DisplayName("로그인 상태를 확인한다") - @Test - void isLoggedIn_whenLoggedIn_returnsTrue() { - // given - FcmToken fcmToken = createFcmToken(); - fcmToken.linkUser(1L); - - // when & then - assertThat(fcmToken.isLoggedIn()).isTrue(); - } - - private FcmToken createFcmToken() { - return FcmToken.register( - FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - "device-id-123", - "Galaxy S23", - "Android 14", - "1.0.0" - ) - ); - } -} diff --git a/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java b/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java index 11cfe65e..6e2f5963 100644 --- a/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java +++ b/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java @@ -1,9 +1,9 @@ package com.souzip.domain.recommend.ai.controller; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; import com.souzip.docs.RestDocsSupport; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.service.AiRecommendationService; +import com.souzip.global.security.annotation.CurrentUserId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,13 +29,12 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; class AiRecommendationControllerTest extends RestDocsSupport { private final AiRecommendationService aiRecommendationService = - org.mockito.Mockito.mock(AiRecommendationService.class); + org.mockito.Mockito.mock(AiRecommendationService.class); @Override protected Object initController() { @@ -45,136 +44,138 @@ protected Object initController() { @BeforeEach void setUpWithResolver(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { - return 1L; - } - }) - .apply(org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration(provider)) - .build(); + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return 1L; + } + }) + .apply(org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration(provider)) + .build(); } @Test @DisplayName("사용자 선호 카테고리 기반 AI 추천 기념품 조회") void getAiCategoryRecommendations() throws Exception { + // given List souvenirs = List.of( - AiRecommendationResponse.RecommendedSouvenir.from(1L, "초코파이", "FOOD_SNACK", "KR", "https://example.com/thumb1.jpg", 3L, true), - AiRecommendationResponse.RecommendedSouvenir.from(2L, "삼다수", "FOOD_SNACK", "KR", "https://example.com/thumb2.jpg", 1L, false), - AiRecommendationResponse.RecommendedSouvenir.from(3L, "설화수 스킨", "BEAUTY_HEALTH", "KR", "https://example.com/thumb3.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(4L, "헤라 립스틱", "BEAUTY_HEALTH", "KR", "https://example.com/thumb4.jpg", 2L, true), - AiRecommendationResponse.RecommendedSouvenir.from(5L, "한복 인형", "CULTURE_TRADITION", "KR", "https://example.com/thumb5.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(6L, "전통 부채", "CULTURE_TRADITION", "KR", "https://example.com/thumb6.jpg", 4L, false), - AiRecommendationResponse.RecommendedSouvenir.from(7L, "여행용 파우치", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb7.jpg", 1L, false), - AiRecommendationResponse.RecommendedSouvenir.from(8L, "휴대용 보조배터리", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb8.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(9L, "패션 마스크", "FASHION_ACCESSORY", "KR", "https://example.com/thumb9.jpg", 2L, true), - AiRecommendationResponse.RecommendedSouvenir.from(10L, "손목 시계", "FASHION_ACCESSORY", "KR", "https://example.com/thumb10.jpg", 0L, false) + AiRecommendationResponse.RecommendedSouvenir.from(1L, "초코파이", "FOOD_SNACK", "KR", "https://example.com/thumb1.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(2L, "삼다수", "FOOD_SNACK", "KR", "https://example.com/thumb2.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(3L, "설화수 스킨", "BEAUTY_HEALTH", "KR", "https://example.com/thumb3.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(4L, "헤라 립스틱", "BEAUTY_HEALTH", "KR", "https://example.com/thumb4.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(5L, "한복 인형", "CULTURE_TRADITION", "KR", "https://example.com/thumb5.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(6L, "전통 부채", "CULTURE_TRADITION", "KR", "https://example.com/thumb6.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(7L, "여행용 파우치", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb7.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(8L, "휴대용 보조배터리", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb8.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(9L, "패션 마스크", "FASHION_ACCESSORY", "KR", "https://example.com/thumb9.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(10L, "손목 시계", "FASHION_ACCESSORY", "KR", "https://example.com/thumb10.jpg") ); given(aiRecommendationService.getCategoryRecommendationsForUser(eq(1L))) - .willReturn(new AiRecommendationResponse(souvenirs)); + .willReturn(new AiRecommendationResponse(souvenirs)); + // when & then mockMvc.perform(get("/api/discovery/ai/preference-category") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("초코파이")) - .andDo(document("recommend/ai/category", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("초코파이")) + .andDo(document("recommend/ai/category", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("사용자 최근 업로드 기념품 기반 AI 추천 기념품 조회") void getAiRecentUploadRecommendations() throws Exception { + // given List souvenirs = List.of( - AiRecommendationResponse.RecommendedSouvenir.from(1L, "모나카", "FOOD_SNACK", "JP", "https://example.com/recent/thumb1.jpg", 2L, true), - AiRecommendationResponse.RecommendedSouvenir.from(2L, "센베이", "FOOD_SNACK", "JP", "https://example.com/recent/thumb2.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(3L, "다이후쿠", "FOOD_SNACK", "JP", "https://example.com/recent/thumb3.jpg", 1L, false), - AiRecommendationResponse.RecommendedSouvenir.from(4L, "가나코", "FOOD_SNACK", "JP", "https://example.com/recent/thumb4.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(5L, "유바", "FOOD_SNACK", "JP", "https://example.com/recent/thumb5.jpg", 3L, true), - AiRecommendationResponse.RecommendedSouvenir.from(6L, "아마자케", "FOOD_SNACK", "JP", "https://example.com/recent/thumb6.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(7L, "호지차", "FOOD_SNACK", "JP", "https://example.com/recent/thumb7.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(8L, "말차초콜릿", "FOOD_SNACK", "JP", "https://example.com/recent/thumb8.jpg", 1L, false), - AiRecommendationResponse.RecommendedSouvenir.from(9L, "시세이도 스킨케어", "BEAUTY_HEALTH", "JP", "https://example.com/recent/thumb9.jpg", 0L, false), - AiRecommendationResponse.RecommendedSouvenir.from(10L, "유젠 손수건", "CULTURE_TRADITION", "JP", "https://example.com/recent/thumb10.jpg", 0L, false) + AiRecommendationResponse.RecommendedSouvenir.from(1L, "모나카", "FOOD_SNACK", "JP", "https://example.com/recent/thumb1.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(2L, "센베이", "FOOD_SNACK", "JP", "https://example.com/recent/thumb2.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(3L, "다이후쿠", "FOOD_SNACK", "JP", "https://example.com/recent/thumb3.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(4L, "가나코", "FOOD_SNACK", "JP", "https://example.com/recent/thumb4.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(5L, "유바", "FOOD_SNACK", "JP", "https://example.com/recent/thumb5.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(6L, "아마자케", "FOOD_SNACK", "JP", "https://example.com/recent/thumb6.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(7L, "호지차", "FOOD_SNACK", "JP", "https://example.com/recent/thumb7.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(8L, "말차초콜릿", "FOOD_SNACK", "JP", "https://example.com/recent/thumb8.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(9L, "시세이도 스킨케어", "BEAUTY_HEALTH", "JP", "https://example.com/recent/thumb9.jpg"), + AiRecommendationResponse.RecommendedSouvenir.from(10L, "유젠 손수건", "CULTURE_TRADITION", "JP", "https://example.com/recent/thumb10.jpg") ); given(aiRecommendationService.getRecentSouvenirRecommendations(eq(1L))) - .willReturn(new AiRecommendationResponse(souvenirs)); + .willReturn(new AiRecommendationResponse(souvenirs)); + // when & then mockMvc.perform(get("/api/discovery/ai/preference-upload") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("모나카")) - .andExpect(jsonPath("$.data.souvenirs[0].category").value("FOOD_SNACK")) - .andExpect(jsonPath("$.data.souvenirs[0].countryCode").value("JP")) - .andExpect(jsonPath("$.data.souvenirs[0].thumbnailUrl").value("https://example.com/recent/thumb1.jpg")) - .andDo(document("recommend/ai/recent-upload", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("모나카")) + .andExpect(jsonPath("$.data.souvenirs[0].category").value("FOOD_SNACK")) + .andExpect(jsonPath("$.data.souvenirs[0].countryCode").value("JP")) + .andExpect(jsonPath("$.data.souvenirs[0].thumbnailUrl").value("https://example.com/recent/thumb1.jpg")) + .andDo(document("recommend/ai/recent-upload", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("사용자 최근 업로드 기념품이 없으면 AI 추천은 빈 배열을 반환한다") void getAiRecentUploadRecommendations_empty() throws Exception { + // given given(aiRecommendationService.getRecentSouvenirRecommendations(1L)) - .willReturn(new AiRecommendationResponse(Collections.emptyList())); + .willReturn(new AiRecommendationResponse(Collections.emptyList())); + // when & then mockMvc.perform(get("/api/discovery/ai/preference-upload") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs").isArray()) - .andExpect(jsonPath("$.data.souvenirs").isEmpty()) - .andDo(document("recommend/ai/recent-upload-empty", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY) - .description("최근 업로드 이력이 없으면 빈 배열이 반환됩니다"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs").isArray()) + .andExpect(jsonPath("$.data.souvenirs").isEmpty()) + .andDo(document("recommend/ai/recent-upload-empty", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY) + .description("최근 업로드 이력이 없으면 빈 배열이 반환됩니다"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java b/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java index 55f3b901..f10780ed 100644 --- a/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java +++ b/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java @@ -19,8 +19,6 @@ import static com.souzip.docs.CommonDocumentation.apiResponseFields; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -42,224 +40,211 @@ protected Object initController() { @DisplayName("카테고리별 추천 기념품 Top10 조회") @Test void getCategoryTop10() throws Exception { + // given List responseList = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - (long) i, - "기념품 " + i, - Category.SOUVENIR_BASIC, - "JP", - "https://example.com/thumb" + i + ".jpg", - 3L, - null - )) - .collect(Collectors.toList()); - - given(generalRecommendationService.getTop10ByCategory("FOOD_SNACK", null)) - .willReturn(responseList); - + .mapToObj(i -> new GeneralRecommendationDto( + (long) i, + "기념품 " + i, + Category.SOUVENIR_BASIC, + "JP", + "https://example.com/thumb" + i + ".jpg" + )) + .collect(Collectors.toList()); + + given(generalRecommendationService.getTop10ByCategory("FOOD_SNACK")) + .willReturn(responseList); + + // when & then mockMvc.perform(get("/api/discovery/general/category/{categoryName}", "FOOD_SNACK")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andDo(document("recommend/category-top10", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("categoryName").description("조회할 카테고리 이름 (예: FOOD_SNACK)") - ), - requestHeaders( - headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andDo(document("recommend/category-top10", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("categoryName").description("조회할 카테고리 이름 (예: FOOD_SNACK)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("나라별 추천 기념품 Top10 조회") @Test void getCountryTop10() throws Exception { + // given List responseList = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - (long) i, - "기념품 " + i, - Category.SOUVENIR_BASIC, - "KR", - "https://example.com/thumb" + i + ".jpg", - 2L, - null - )) - .collect(Collectors.toList()); - - given(generalRecommendationService.getTop10ByCountry("KR", null)) - .willReturn(responseList); - + .mapToObj(i -> new GeneralRecommendationDto( + (long) i, + "기념품 " + i, + Category.SOUVENIR_BASIC, + "KR", + "https://example.com/thumb" + i + ".jpg" + )) + .collect(Collectors.toList()); + + given(generalRecommendationService.getTop10ByCountry("KR")) + .willReturn(responseList); + + // when & then mockMvc.perform(get("/api/discovery/general/country/{countryCode}", "KR")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andExpect(jsonPath("$.data[0].name").value("기념품 1")) - .andDo(document("recommend/country-top10", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("countryCode").description("조회할 국가 코드 (예: KR, JP)") - ), - requestHeaders( - headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].name").value("기념품 1")) + .andDo(document("recommend/country-top10", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("countryCode").description("조회할 국가 코드 (예: KR, JP)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("기념품 최다등록 국가 Top10 조회(통계만)") @Test void getTopCountriesTop10() throws Exception { + // given List statsList = List.of( - new GeneralRecommendationStatsDto("JP", "일본", 20L), - new GeneralRecommendationStatsDto("US", "미국", 15L), - new GeneralRecommendationStatsDto("TW", "대만", 10L), - new GeneralRecommendationStatsDto("HK", "홍콩", 8L), - new GeneralRecommendationStatsDto("VN", "베트남", 4L), - new GeneralRecommendationStatsDto("KR", "한국", 4L), - new GeneralRecommendationStatsDto("DE", "독일", 4L), - new GeneralRecommendationStatsDto("FR", "프랑스", 4L), - new GeneralRecommendationStatsDto("CN", "중국", 3L), - new GeneralRecommendationStatsDto("PH", "필리핀", 3L) + new GeneralRecommendationStatsDto("JP", "일본", 20L), + new GeneralRecommendationStatsDto("US", "미국", 15L), + new GeneralRecommendationStatsDto("TW", "대만", 10L), + new GeneralRecommendationStatsDto("HK", "홍콩", 8L), + new GeneralRecommendationStatsDto("VN", "베트남", 4L), + new GeneralRecommendationStatsDto("KR", "한국", 4L), + new GeneralRecommendationStatsDto("DE", "독일", 4L), + new GeneralRecommendationStatsDto("FR", "프랑스", 4L), + new GeneralRecommendationStatsDto("CN", "중국", 3L), + new GeneralRecommendationStatsDto("PH", "필리핀", 3L) ); given(generalRecommendationService.getTop10CountriesBySouvenirCount()) - .willReturn(statsList); + .willReturn(statsList); + // when & then mockMvc.perform(get("/api/discovery/general/countries/top10")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(10)) - .andDo(document("recommend/stats-top10", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top10)"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(10)) + .andDo(document("recommend/stats-top10", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top10)"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("기념품 최다등록 국가 Top3 조회(통계만)") @Test void getTopCountriesTop3() throws Exception { + // given List statsList = List.of( - new GeneralRecommendationStatsDto("JP", "일본", 20L), - new GeneralRecommendationStatsDto("US", "미국", 15L), - new GeneralRecommendationStatsDto("TW", "대만", 10L) + new GeneralRecommendationStatsDto("JP", "일본", 20L), + new GeneralRecommendationStatsDto("US", "미국", 15L), + new GeneralRecommendationStatsDto("TW", "대만", 10L) ); given(generalRecommendationService.getTop3CountriesBySouvenirCount()) - .willReturn(statsList); + .willReturn(statsList); + // when & then mockMvc.perform(get("/api/discovery/general/stats")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(3)) - .andDo(document("recommend/stats-top3", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top3)"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(3)) + .andDo(document("recommend/stats-top3", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top3)"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 최다등록 Top10 국가 + 국가별 기념품 Top10 조회") @Test void getTopCountriesWithTop10Souvenirs() throws Exception { + // given List jpSouvenirs = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - 200L + i, - "일본 기념품 " + i, - Category.SOUVENIR_BASIC, - "JP", - "https://example.com/jp/thumb" + i + ".jpg", - 5L, - null - )) - .collect(Collectors.toList()); + .mapToObj(i -> new GeneralRecommendationDto( + 200L + i, + "일본 기념품 " + i, + Category.SOUVENIR_BASIC, + "JP", + "https://example.com/jp/thumb" + i + ".jpg" + )) + .collect(Collectors.toList()); List usSouvenirs = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - 300L + i, - "미국 기념품 " + i, - Category.SOUVENIR_BASIC, - "US", - "https://example.com/us/thumb" + i + ".jpg", - 2L, - null - )) - .collect(Collectors.toList()); + .mapToObj(i -> new GeneralRecommendationDto( + 300L + i, + "미국 기념품 " + i, + Category.SOUVENIR_BASIC, + "US", + "https://example.com/us/thumb" + i + ".jpg" + )) + .collect(Collectors.toList()); List response = List.of( - new CountryRecommendationDto("JP", "일본", 20L, jpSouvenirs), - new CountryRecommendationDto("US", "미국", 15L, usSouvenirs) + new CountryRecommendationDto("JP", "일본", 20L, jpSouvenirs), + new CountryRecommendationDto("US", "미국", 15L, usSouvenirs) ); - given(generalRecommendationService.getTopCountriesWithTop10Souvenirs(null)) - .willReturn(response); + given(generalRecommendationService.getTopCountriesWithTop10Souvenirs()) + .willReturn(response); + // when & then mockMvc.perform(get("/api/countries/souvenirs")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andDo(document("recommend/countries-top10-souvenirs-top10", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 최다등록 국가 Top10 및 국가별 기념품 Top10"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("data[].souvenirs").type(JsonFieldType.ARRAY).description("해당 국가의 추천 기념품 리스트(최대 10개)"), - fieldWithPath("data[].souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].souvenirs[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data[].souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data[].souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andDo(document("recommend/countries-top10-souvenirs-top10", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 최다등록 국가 Top10 및 국가별 기념품 Top10"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("data[].souvenirs").type(JsonFieldType.ARRAY).description("해당 국가의 추천 기념품 리스트(최대 10개)"), + fieldWithPath("data[].souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].souvenirs[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java b/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java index a6045114..56554ed1 100644 --- a/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java +++ b/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java @@ -3,10 +3,10 @@ import com.souzip.docs.RestDocsSupport; import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.service.SearchService; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.common.dto.pagination.PaginationRequest; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/domain/shared/CoordinateTest.java b/src/test/java/com/souzip/domain/shared/CoordinateTest.java index 837c5565..14938ea0 100644 --- a/src/test/java/com/souzip/domain/shared/CoordinateTest.java +++ b/src/test/java/com/souzip/domain/shared/CoordinateTest.java @@ -1,12 +1,10 @@ package com.souzip.domain.shared; -import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.Test; import java.math.BigDecimal; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.*; class CoordinateTest { diff --git a/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java b/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java index 5d1d5d46..399a477f 100644 --- a/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java +++ b/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java @@ -1,12 +1,19 @@ package com.souzip.domain.souvenir.controller; -import com.souzip.application.file.dto.FileResponse; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; import com.souzip.docs.RestDocsSupport; import com.souzip.domain.category.entity.Category; -import com.souzip.domain.souvenir.dto.*; +import com.souzip.application.file.dto.FileResponse; +import com.souzip.domain.souvenir.dto.PriceResponse; +import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; +import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; +import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; +import com.souzip.domain.souvenir.dto.SouvenirNearbyResponse; +import com.souzip.domain.souvenir.dto.SouvenirRequest; +import com.souzip.domain.souvenir.dto.SouvenirResponse; +import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; import com.souzip.domain.souvenir.entity.Purpose; import com.souzip.domain.souvenir.service.SouvenirService; +import com.souzip.global.security.annotation.CurrentUserId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,12 +42,15 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields; -import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; class SouvenirControllerTest extends RestDocsSupport { @@ -54,26 +64,26 @@ protected Object initController() { @BeforeEach void setUpWithResolver(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument( - MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory - ) { - return 1L; - } - }) - .apply(documentationConfiguration(provider)) - .build(); + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return 1L; + } + }) + .apply(documentationConfiguration(provider)) + .build(); } @DisplayName("근처 기념품 조회") @@ -84,64 +94,58 @@ void getNearbySouvenirs() throws Exception { int radiusMeter = 4000; List nearbySouvenirs = List.of( - SouvenirNearbyResponse.from( - 1L, "Souvenir A", Category.SOUVENIR_BASIC, Purpose.GIFT, - 10000, 120000, "$", - "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd335.png", - new BigDecimal("40.7128123"), new BigDecimal("-74.0060123"), - "Some address A", - 5L, - null - ), - SouvenirNearbyResponse.from( - 2L, "Souvenir B", Category.FOOD_SNACK, Purpose.GIFT, - 20000, 240000, "$", - "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd123.png", - new BigDecimal("40.7228123"), new BigDecimal("-74.0010123"), - "Some address B", - 3L, - null - ) + SouvenirNearbyResponse.from( + 1L, "Souvenir A", Category.SOUVENIR_BASIC, Purpose.GIFT, + 10000, 120000, "$", + "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd335.png", + new BigDecimal("40.7128123"), new BigDecimal("-74.0060123"), + "Some address A" + ), + SouvenirNearbyResponse.from( + 2L, "Souvenir B", Category.FOOD_SNACK, Purpose.GIFT, + 20000, 240000, "$", + "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd123.png", + new BigDecimal("40.7228123"), new BigDecimal("-74.0010123"), + "Some address B" + ) ); - given(souvenirService.getNearbySouvenirs(userLatitude, userLongitude, radiusMeter, null)) - .willReturn(SouvenirNearbyListResponse.from(nearbySouvenirs)); + given(souvenirService.getNearbySouvenirs(userLatitude, userLongitude, radiusMeter)) + .willReturn(SouvenirNearbyListResponse.from(nearbySouvenirs)); mockMvc.perform(get("/api/souvenirs/nearby") - .param("latitude", String.valueOf(userLatitude)) - .param("longitude", String.valueOf(userLongitude)) - .param("radiusMeter", String.valueOf(radiusMeter))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("Souvenir A")) - .andDo(document("souvenirs/get-nearby-souvenirs", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("latitude").description("위도 (decimal, 소수점 7자리까지)"), - parameterWithName("longitude").description("경도 (decimal, 소수점 7자리까지)"), - parameterWithName("radiusMeter").description("조회 반경 (미터 단위, 기본값 5000m)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.souvenirs[]").type(JsonFieldType.ARRAY).description("근처 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.souvenirs[].purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.souvenirs[].localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer)"), - fieldWithPath("data.souvenirs[].krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer)"), - fieldWithPath("data.souvenirs[].currencySymbol").type(JsonFieldType.STRING).description("통화 기호").optional(), - fieldWithPath("data.souvenirs[].thumbnail").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data.souvenirs[].latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.souvenirs[].longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.souvenirs[].address").type(JsonFieldType.STRING).description("기념품 주소"), - fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부").optional(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .param("latitude", String.valueOf(userLatitude)) + .param("longitude", String.valueOf(userLongitude)) + .param("radiusMeter", String.valueOf(radiusMeter))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("Souvenir A")) + .andDo(document("souvenirs/get-nearby-souvenirs", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("latitude").description("위도 (decimal, 소수점 7자리까지)"), + parameterWithName("longitude").description("경도 (decimal, 소수점 7자리까지)"), + parameterWithName("radiusMeter").description("조회 반경 (미터 단위, 기본값 5000m)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirs[]").type(JsonFieldType.ARRAY).description("근처 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.souvenirs[].purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.souvenirs[].localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer)"), + fieldWithPath("data.souvenirs[].krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer)"), + fieldWithPath("data.souvenirs[].currencySymbol").type(JsonFieldType.STRING).description("통화 기호").optional(), + fieldWithPath("data.souvenirs[].thumbnail").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.souvenirs[].longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.souvenirs[].address").type(JsonFieldType.STRING).description("기념품 주소"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 조회") @@ -151,202 +155,198 @@ void getSouvenir() throws Exception { Long souvenirId = 1L; List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirDetailResponse response = new SouvenirDetailResponse( - souvenirId, - "테스트 기념품", - 10000, // v1 호환용 - deprecated - "$", // v1 호환용 - deprecated - 95000, // v1 호환용 - deprecated - priceResponse, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US", - "닉네임", - "https://example.com/profile.jpg", - true, - true, - 15, - filesResponse + souvenirId, + "테스트 기념품", + 10000, // v1 호환용 - deprecated + "$", // v1 호환용 - deprecated + 95000, // v1 호환용 - deprecated + priceResponse, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US", + "닉네임", + "https://example.com/profile.jpg", + true, + filesResponse ); String jwt = "Bearer test.jwt.token"; given(souvenirService.getSouvenir(souvenirId, jwt)).willReturn(response); mockMvc.perform(get("/api/souvenirs/{id}", souvenirId) - .header("Authorization", jwt)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/get-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).optional().description("위치 상세 설명"), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.isOwned").type(JsonFieldType.BOOLEAN).description("조회자가 소유자인지 여부"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부").optional(), - fieldWithPath("data.wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).optional().description("응답 메시지") - ) - )); + .header("Authorization", jwt)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/get-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).optional().description("위치 상세 설명"), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.isOwned").type(JsonFieldType.BOOLEAN).description("조회자가 소유자인지 여부"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).optional().description("응답 메시지") + ) + )); } @DisplayName("기념품 생성 (v1)") @Test void createSouvenirWithFiles() throws Exception { SouvenirCreateRequest request = new SouvenirCreateRequest( - "테스트 기념품", - 10000, - "$", - 95000, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US" + "테스트 기념품", + 10000, + "$", + 95000, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(request) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(request) ); MockMultipartFile file1 = new MockMultipartFile("files", "file1.jpg", "image/jpeg", "dummy content".getBytes()); MockMultipartFile file2 = new MockMultipartFile("files", "file2.jpg", "image/jpeg", "dummy content".getBytes()); List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "테스트 기념품", - 10000, "$", 95000, - priceResponse, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "US", - "닉네임", "https://example.com/profile.jpg", - filesResponse + 1L, "테스트 기념품", + 10000, "$", 95000, + priceResponse, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "US", + "닉네임", "https://example.com/profile.jpg", + filesResponse ); given(souvenirService.createSouvenir(any(SouvenirCreateRequest.class), eq(1L), any(List.class))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/souvenirs") - .file(souvenirPart) - .file(file1) - .file(file2)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/create-souvenir", - getDocumentRequest(), - getDocumentResponse(), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)"), - partWithName("files").description("업로드 이미지 파일 리스트 (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), - fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), - fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .file(file1) + .file(file2)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/create-souvenir", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)"), + partWithName("files").description("업로드 이미지 파일 리스트 (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), + fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), + fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 생성 (v2)") @@ -354,114 +354,114 @@ void createSouvenirWithFiles() throws Exception { void createSouvenirV2WithFiles() throws Exception { // TODO: v1 호환 필드(localPrice, currencySymbol, krwPrice) 향후 제거 예정 SouvenirRequest request = new SouvenirRequest( - "테스트 기념품 v2", - 1000, - "JPY", - "v2 API 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 1층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "JP" + "테스트 기념품 v2", + 1000, + "JPY", + "v2 API 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 1층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "JP" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(request) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(request) ); MockMultipartFile file1 = new MockMultipartFile("files", "file1.jpg", "image/jpeg", "dummy content".getBytes()); MockMultipartFile file2 = new MockMultipartFile("files", "file2.jpg", "image/jpeg", "dummy content".getBytes()); List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(1000, "¥"), - new PriceResponse.PriceDetail(9337, "₩") + new PriceResponse.PriceDetail(1000, "¥"), + new PriceResponse.PriceDetail(9337, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "테스트 기념품 v2", - 1000, "¥", 9337, // v1 호환용 - deprecated - priceResponse, - "v2 API 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 1층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", - "닉네임", "https://example.com/profile.jpg", - filesResponse + 1L, "테스트 기념품 v2", + 1000, "¥", 9337, // v1 호환용 - deprecated + priceResponse, + "v2 API 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 1층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", + "닉네임", "https://example.com/profile.jpg", + filesResponse ); given(souvenirService.createSouvenirV2(any(SouvenirRequest.class), eq(1L), any(List.class))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/v2/souvenirs") - .file(souvenirPart) - .file(file1) - .file(file2)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/create-souvenir-v2", - getDocumentRequest(), - getDocumentResponse(), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)"), - partWithName("files").description("업로드 이미지 파일 리스트 (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("price").description("가격 (integer)").optional(), - fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .file(file1) + .file(file2)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/create-souvenir-v2", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)"), + partWithName("files").description("업로드 이미지 파일 리스트 (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("price").description("가격 (integer)").optional(), + fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 수정 (v1)") @@ -470,108 +470,105 @@ void updateSouvenir() throws Exception { Long souvenirId = 1L; SouvenirUpdateRequest requestDto = new SouvenirUpdateRequest( - "수정된 테스트 기념품", - 10000, - "$", - 95000, - "수정된 테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US" + "수정된 테스트 기념품", + 10000, + "$", + 95000, + "수정된 테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(requestDto) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "수정된 테스트 기념품", - 10000, "$", 95000, - priceResponse, - "수정된 테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "US", - "닉네임", "https://example.com/profile.jpg", - List.of() + 1L, "수정된 테스트 기념품", + 10000, "$", 95000, + priceResponse, + "수정된 테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "US", + "닉네임", "https://example.com/profile.jpg", + List.of() ); given(souvenirService.updateSouvenir(eq(souvenirId), any(SouvenirUpdateRequest.class), eq(1L))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/souvenirs/{id}", souvenirId) - .file(souvenirPart) - .with(req -> { - req.setMethod("PUT"); - return req; - })) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/update-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), - fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), - fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").ignored(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .with(req -> { req.setMethod("PUT"); return req; })) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/update-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), + fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), + fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").ignored(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 수정 (v2)") @@ -581,106 +578,103 @@ void updateSouvenirV2() throws Exception { Long souvenirId = 1L; SouvenirRequest requestDto = new SouvenirRequest( - "수정된 기념품 v2", - 1500, - "JPY", - "v2 수정 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 2층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "JP" + "수정된 기념품 v2", + 1500, + "JPY", + "v2 수정 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 2층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "JP" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(requestDto) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(1500, "¥"), - new PriceResponse.PriceDetail(14005, "₩") + new PriceResponse.PriceDetail(1500, "¥"), + new PriceResponse.PriceDetail(14005, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "수정된 기념품 v2", - 1500, "¥", 14005, // v1 호환용 - deprecated - priceResponse, - "v2 수정 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 2층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", - "닉네임", "https://example.com/profile.jpg", - List.of() + 1L, "수정된 기념품 v2", + 1500, "¥", 14005, // v1 호환용 - deprecated + priceResponse, + "v2 수정 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 2층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", + "닉네임", "https://example.com/profile.jpg", + List.of() ); given(souvenirService.updateSouvenirV2(eq(souvenirId), any(SouvenirRequest.class), eq(1L))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/v2/souvenirs/{id}", souvenirId) - .file(souvenirPart) - .with(req -> { - req.setMethod("PUT"); - return req; - })) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/update-souvenir-v2", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("price").description("가격 (integer)").optional(), - fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").ignored(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .with(req -> { req.setMethod("PUT"); return req; })) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/update-souvenir-v2", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("price").description("가격 (integer)").optional(), + fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").ignored(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 삭제") @@ -689,21 +683,21 @@ void deleteSouvenir() throws Exception { Long souvenirId = 1L; mockMvc.perform(delete("/api/souvenirs/{id}", souvenirId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").exists()) - .andDo(document("souvenirs/delete-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.NULL).optional() - .description("소프트 딜리트 처리, 응답에는 데이터 포함 X"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()) + .andDo(document("souvenirs/delete-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).optional() + .description("소프트 딜리트 처리, 응답에는 데이터 포함 X"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java b/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java index 61fb5ff8..02c8615a 100644 --- a/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java @@ -11,9 +11,9 @@ import com.souzip.domain.user.dto.UserAgreementInfo; import com.souzip.domain.user.dto.UserProfileResponse; import com.souzip.domain.user.service.UserService; -import com.souzip.shared.common.dto.pagination.PaginationResponse; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -55,9 +55,9 @@ protected Object initController() { // Mock 헬퍼 메서드 private MySouvenirListResponse createMockMySouvenirListResponse( - List content, - int currentPage, int totalPages, long totalItems, int pageSize, - boolean first, boolean last, boolean hasNext, boolean hasPrevious + List content, + int currentPage, int totalPages, long totalItems, int pageSize, + boolean first, boolean last, boolean hasNext, boolean hasPrevious ) { MySouvenirListResponse response = mock(MySouvenirListResponse.class); PaginationResponse.PageInfo pageInfo = mock(PaginationResponse.PageInfo.class); @@ -87,31 +87,31 @@ void checkNickname_available() throws Exception { // when & then mockMvc.perform(post("/api/users/check-nickname") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.available").value(true)) - .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")) - .andDo(document("user/check-nickname-available", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("nickname").type(JsonFieldType.STRING) - .description("확인할 닉네임 (2~11자, 한글/영문/숫자만 가능)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT) - .description("닉네임 중복 확인 결과"), - fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) - .description("사용 가능 여부 (true: 사용 가능, false: 사용 불가)"), - fieldWithPath("data.message").type(JsonFieldType.STRING) - .description("확인 결과 메시지"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.available").value(true)) + .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")) + .andDo(document("user/check-nickname-available", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").type(JsonFieldType.STRING) + .description("확인할 닉네임 (2~11자, 한글/영문/숫자만 가능)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("닉네임 중복 확인 결과"), + fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) + .description("사용 가능 여부 (true: 사용 가능, false: 사용 불가)"), + fieldWithPath("data.message").type(JsonFieldType.STRING) + .description("확인 결과 메시지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } @Test @@ -125,31 +125,31 @@ void checkNickname_unavailable() throws Exception { // when & then mockMvc.perform(post("/api/users/check-nickname") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.available").value(false)) - .andExpect(jsonPath("$.data.message").value("이미 사용 중인 닉네임입니다.")) - .andDo(document("user/check-nickname-unavailable", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("nickname").type(JsonFieldType.STRING) - .description("확인할 닉네임 (이미 사용 중)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT) - .description("닉네임 중복 확인 결과"), - fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) - .description("사용 가능 여부 (false: 중복됨)"), - fieldWithPath("data.message").type(JsonFieldType.STRING) - .description("확인 결과 메시지"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.available").value(false)) + .andExpect(jsonPath("$.data.message").value("이미 사용 중인 닉네임입니다.")) + .andDo(document("user/check-nickname-unavailable", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").type(JsonFieldType.STRING) + .description("확인할 닉네임 (이미 사용 중)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("닉네임 중복 확인 결과"), + fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) + .description("사용 가능 여부 (false: 중복됨)"), + fieldWithPath("data.message").type(JsonFieldType.STRING) + .description("확인 결과 메시지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } @Test @@ -157,38 +157,38 @@ void checkNickname_unavailable() throws Exception { void completeOnboarding_nicknameAlreadyExists() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "중복닉네임", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "중복닉네임", + "red", + List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.NICKNAME_DUPLICATED)); + .willThrow(new BusinessException(ErrorCode.NICKNAME_DUPLICATED)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.message").value("이미 사용 중인 닉네임입니다.")) - .andDo(document("user/onboarding-nickname-duplicated", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("중복된 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("이미 사용 중인 닉네임입니다.")) + .andDo(document("user/onboarding-nickname-duplicated", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("중복된 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -196,72 +196,72 @@ void completeOnboarding_nicknameAlreadyExists() throws Exception { void completeOnboarding_success() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", - List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") + true, true, true, true, false, + "수집", "red", + List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") ); List categoryDtos = List.of( - new CategoryDto("FOOD_SNACK", "먹거리·간식"), - new CategoryDto("BEAUTY_HEALTH", "뷰티·헬스"), - new CategoryDto("FASHION_ACCESSORY", "패션·악세서리") + new CategoryDto("FOOD_SNACK", "먹거리·간식"), + new CategoryDto("BEAUTY_HEALTH", "뷰티·헬스"), + new CategoryDto("FASHION_ACCESSORY", "패션·악세서리") ); UserAgreementInfo agreementInfo = new UserAgreementInfo(true, true, true, true, false); OnboardingResponse response = new OnboardingResponse( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "수집", - "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg", - categoryDtos, - agreementInfo + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "수집", + "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg", + categoryDtos, + agreementInfo ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willReturn(response); + .willReturn(response); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) - .andExpect(jsonPath("$.data.nickname").value("수집")) - .andExpect(jsonPath("$.data.profileImageUrl").exists()) - .andExpect(jsonPath("$.data.categories").isArray()) - .andExpect(jsonPath("$.data.agreements.ageVerified").value(true)) - .andDo(document("user/onboarding-success", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부 (필수)"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (필수)"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의 (필수)"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의 (필수)"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의 (선택)"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임 (최대 11자)"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상 (red, blue, yellow, purple)"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (최소 1개, Category ENUM name)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("온보딩 완료 응답 데이터"), - fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID)"), - fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), - fieldWithPath("data.categories").type(JsonFieldType.ARRAY).description("선택한 카테고리 목록"), - fieldWithPath("data.categories[].name").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 한글 라벨"), - fieldWithPath("data.agreements").type(JsonFieldType.OBJECT).description("약관 동의 정보"), - fieldWithPath("data.agreements.ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("data.agreements.serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("data.agreements.privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("data.agreements.locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("data.agreements.marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + .andExpect(jsonPath("$.data.nickname").value("수집")) + .andExpect(jsonPath("$.data.profileImageUrl").exists()) + .andExpect(jsonPath("$.data.categories").isArray()) + .andExpect(jsonPath("$.data.agreements.ageVerified").value(true)) + .andDo(document("user/onboarding-success", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부 (필수)"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (필수)"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의 (필수)"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의 (필수)"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의 (선택)"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임 (최대 11자)"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상 (red, blue, yellow, purple)"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (최소 1개, Category ENUM name)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("온보딩 완료 응답 데이터"), + fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID)"), + fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("data.categories").type(JsonFieldType.ARRAY).description("선택한 카테고리 목록"), + fieldWithPath("data.categories[].name").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 한글 라벨"), + fieldWithPath("data.agreements").type(JsonFieldType.OBJECT).description("약관 동의 정보"), + fieldWithPath("data.agreements.ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("data.agreements.serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("data.agreements.privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("data.agreements.locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("data.agreements.marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -269,36 +269,36 @@ void completeOnboarding_success() throws Exception { void completeOnboarding_requiredAgreementNotChecked() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, false, true, true, false, - "수집", "red", List.of("FOOD_SNACK") + true, false, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_INPUT, "필수 약관에 모두 동의해야 합니다.")); + .willThrow(new BusinessException(ErrorCode.INVALID_INPUT, "필수 약관에 모두 동의해야 합니다.")); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("필수 약관에 모두 동의해야 합니다.")) - .andDo(document("user/onboarding-required-agreement-not-checked", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (false)"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("필수 약관에 모두 동의해야 합니다.")) + .andDo(document("user/onboarding-required-agreement-not-checked", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (false)"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -306,36 +306,36 @@ void completeOnboarding_requiredAgreementNotChecked() throws Exception { void completeOnboarding_alreadyCompleted() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "blue", List.of("FOOD_SNACK", "BEAUTY_HEALTH") + true, true, true, true, false, + "수집", "blue", List.of("FOOD_SNACK", "BEAUTY_HEALTH") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.ONBOARDING_ALREADY_COMPLETED)); + .willThrow(new BusinessException(ErrorCode.ONBOARDING_ALREADY_COMPLETED)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("이미 온보딩을 완료한 사용자입니다.")) - .andDo(document("user/onboarding-already-completed", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("이미 온보딩을 완료한 사용자입니다.")) + .andDo(document("user/onboarding-already-completed", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -343,36 +343,36 @@ void completeOnboarding_alreadyCompleted() throws Exception { void completeOnboarding_invalidCategory() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "yellow", List.of("INVALID_CATEGORY", "BEAUTY_HEALTH") + true, true, true, true, false, + "수집", "yellow", List.of("INVALID_CATEGORY", "BEAUTY_HEALTH") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_CATEGORY)); + .willThrow(new BusinessException(ErrorCode.INVALID_CATEGORY)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("유효하지 않은 카테고리입니다.")) - .andDo(document("user/onboarding-invalid-category", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (유효하지 않은 카테고리 포함)") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 카테고리입니다.")) + .andDo(document("user/onboarding-invalid-category", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (유효하지 않은 카테고리 포함)") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -380,36 +380,36 @@ void completeOnboarding_invalidCategory() throws Exception { void completeOnboarding_invalidProfileColor() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "invalid_color", List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "invalid_color", List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_PROFILE_IMAGE_COLOR)); + .willThrow(new BusinessException(ErrorCode.INVALID_PROFILE_IMAGE_COLOR)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("유효하지 않은 프로필 이미지 색상입니다.")) - .andDo(document("user/onboarding-invalid-profile-color", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("유효하지 않은 프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 프로필 이미지 색상입니다.")) + .andDo(document("user/onboarding-invalid-profile-color", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("유효하지 않은 프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -420,17 +420,17 @@ void withdraw_success() throws Exception { // when & then mockMvc.perform(delete("/api/users/me") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("회원탈퇴가 완료되었습니다.")) - .andDo(document("user/withdraw", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("회원탈퇴가 완료되었습니다.")) + .andDo(document("user/withdraw", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -438,157 +438,151 @@ void withdraw_success() throws Exception { void getMyProfile_success() throws Exception { // given UserProfileResponse response = new UserProfileResponse( - "a1b2c3d4...", - "테스트닉네임", - "test@kakao.com", - "https://example.com/profile.jpg" + "a1b2c3d4...", + "테스트닉네임", + "test@kakao.com", + "https://example.com/profile.jpg" ); given(userService.getUserProfile(any())).willReturn(response); // when & then mockMvc.perform(get("/api/users/me") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value("a1b2c3d4...")) - .andDo(document("user/my-profile", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 프로필 정보"), - fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), - fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), - fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("a1b2c3d4...")) + .andDo(document("user/my-profile", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 프로필 정보"), + fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("내가 등록한 기념품 목록을 조회한다.") void getMySouvenirs_success() throws Exception { + // given List content = List.of( - new MySouvenirResponse( - 1L, - "https://example.com/image1.jpg", - "KR", - LocalDateTime.of(2024, 1, 15, 10, 30), - LocalDateTime.of(2024, 1, 20, 15, 0), - 5L, - true - ), - new MySouvenirResponse( - 2L, - "https://example.com/image2.jpg", - "FR", - LocalDateTime.of(2024, 1, 10, 9, 0), - LocalDateTime.of(2024, 1, 10, 9, 0), - 0L, - false - ) + new MySouvenirResponse( + 1L, + "https://example.com/image1.jpg", + "KR", + LocalDateTime.of(2024, 1, 15, 10, 30), + LocalDateTime.of(2024, 1, 20, 15, 0) + ), + new MySouvenirResponse( + 2L, + "https://example.com/image2.jpg", + "FR", + LocalDateTime.of(2024, 1, 10, 9, 0), + LocalDateTime.of(2024, 1, 10, 9, 0) + ) ); MySouvenirListResponse response = createMockMySouvenirListResponse( - content, 1, 1, 2L, 12, true, true, false, false + content, 1, 1, 2L, 12, true, true, false, false ); given(userService.getMySouvenirs(any(), eq(1), eq(12))).willReturn(response); + // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호 (기본값: 1)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), - fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL"), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("data.content[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 1)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("기념품 목록 조회 시 페이지 번호를 지정할 수 있다.") void getMySouvenirs_withPage() throws Exception { + // given List content = List.of( - new MySouvenirResponse( - 3L, - null, - "KR", - LocalDateTime.of(2024, 1, 5, 10, 0), - LocalDateTime.of(2024, 1, 5, 10, 0), - 0L, - false - ) + new MySouvenirResponse( + 3L, + null, + "KR", + LocalDateTime.of(2024, 1, 5, 10, 0), + LocalDateTime.of(2024, 1, 5, 10, 0) + ) ); MySouvenirListResponse response = createMockMySouvenirListResponse( - content, 2, 3, 25L, 12, false, false, true, true + content, 2, 3, 25L, 12, false, false, true, true ); given(userService.getMySouvenirs(any(), eq(2), eq(12))).willReturn(response); + // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "2") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs-page-2", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호"), - parameterWithName("size").description("페이지 크기") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), - fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("data.content[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), - fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "2") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs-page-2", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -596,39 +590,39 @@ void getMySouvenirs_withPage() throws Exception { void getMySouvenirs_empty() throws Exception { // given MySouvenirListResponse response = createMockMySouvenirListResponse( - List.of(), 1, 0, 0L, 12, true, true, false, false + List.of(), 1, 0, 0L, 12, true, true, false, false ); given(userService.getMySouvenirs(any(), eq(1), eq(12))).willReturn(response); // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs-empty", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호").optional(), - parameterWithName("size").description("페이지 크기").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("빈 기념품 목록"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs-empty", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("빈 기념품 목록"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/user/entity/UserTest.java b/src/test/java/com/souzip/domain/user/entity/UserTest.java index 49b74bf1..79d20520 100644 --- a/src/test/java/com/souzip/domain/user/entity/UserTest.java +++ b/src/test/java/com/souzip/domain/user/entity/UserTest.java @@ -1,11 +1,9 @@ package com.souzip.domain.user.entity; -import com.souzip.shared.domain.Provider; +import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.time.LocalDateTime; - import static org.assertj.core.api.Assertions.assertThat; class UserTest { @@ -13,20 +11,28 @@ class UserTest { @Test @DisplayName("anonymize 호출 시 개인정보가 익명화된다.") void anonymize_success() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", "test@kakao.com", null); + // when user.anonymize(); + // then + assertThat(user.getName()).isEqualTo("탈퇴한사용자"); assertThat(user.getNickname()).isEqualTo("탈퇴한사용자"); } @Test - @DisplayName("restore 호출 시 nickname은 빈값으로 초기화된다.") + @DisplayName("restore 호출 시 name이 복구되고 nickname은 빈값으로 초기화된다.") void restore_success() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "탈퇴한사용자", "탈퇴한사용자", "test@kakao.com", null); + // when user.restore("테스트유저"); + // then + assertThat(user.getName()).isEqualTo("테스트유저"); assertThat(user.getNickname()).isEmpty(); assertThat(user.getDeleted()).isFalse(); assertThat(user.getRestoredAt()).isNotNull(); @@ -38,13 +44,16 @@ void restore_success() { @Test @DisplayName("updateLastLoginAt 호출 시 lastLoginAt이 현재 시간으로 설정된다.") void updateLastLoginAt_success() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "테스트", "테스트", "test@kakao.com", null); LocalDateTime before = LocalDateTime.now(); + // when user.updateLastLoginAt(); + // then assertThat(user.getLastLoginAt()).isNotNull(); assertThat(user.getLastLoginAt()).isAfterOrEqualTo(before); assertThat(user.getLastLoginAt()).isBeforeOrEqualTo(LocalDateTime.now()); } -} \ No newline at end of file +} diff --git a/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java b/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java index 9f11b256..00e41ca6 100644 --- a/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.service; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; import com.souzip.adapter.config.ObjectStorageProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,11 +21,11 @@ class ProfileImageServiceTest { @BeforeEach void setUp() { properties = new ObjectStorageProperties( - "https://kr.object.ncloudstorage.com", - "kr-standard", - "souzip-dev-images", - "test-access-key", - "test-secret-key" + "https://kr.object.ncloudstorage.com", + "kr-standard", + "souzip-dev-images", + "test-access-key", + "test-secret-key" ); profileImageService = new ProfileImageService(properties); } @@ -49,8 +49,8 @@ void resolveProfileImageUrl_allValidColors(String color) { // then assertThat(url) - .startsWith("https://kr.object.ncloudstorage.com/souzip-dev-images/profile/") - .endsWith(color + ".png"); + .startsWith("https://kr.object.ncloudstorage.com/souzip-dev-images/profile/") + .endsWith(color + ".png"); } @ParameterizedTest @@ -82,9 +82,9 @@ void resolveProfileImageUrl_withNullOrEmpty_returnsDefault(String input) { void resolveProfileImageUrl_withInvalidColor_throwsException(String invalidColor) { // when & then assertThatThrownBy(() -> profileImageService.resolveProfileImageUrl(invalidColor)) - .isInstanceOf(BusinessException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PROFILE_IMAGE_COLOR) - .hasMessageContaining("유효하지 않은 프로필 이미지 색상입니다."); + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PROFILE_IMAGE_COLOR) + .hasMessageContaining("유효하지 않은 프로필 이미지 색상입니다."); } @Test @@ -95,7 +95,7 @@ void resolveProfileImageUrl_urlFormat() { // then assertThat(url) - .matches("https://.*\\.ncloudstorage\\.com/.*/profile/[a-z]+\\.png"); + .matches("https://.*\\.ncloudstorage\\.com/.*/profile/[a-z]+\\.png"); } @Test @@ -106,7 +106,7 @@ void getAvailableColors() { // then assertThat(colors) - .containsExactlyInAnyOrder("red", "blue", "yellow", "purple") - .hasSize(4); + .containsExactlyInAnyOrder("red", "blue", "yellow", "purple") + .hasSize(4); } } diff --git a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java index d0ce4e93..3b442968 100644 --- a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java @@ -1,15 +1,19 @@ package com.souzip.domain.user.service; -import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.domain.auth.entity.RefreshToken; +import com.souzip.domain.auth.repository.RefreshTokenRepository; import com.souzip.domain.user.dto.NicknameCheckResponse; import com.souzip.domain.user.dto.OnboardingRequest; import com.souzip.domain.user.dto.OnboardingResponse; +import com.souzip.domain.user.entity.Provider; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.entity.UserAgreement; import com.souzip.domain.user.repository.UserAgreementRepository; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.shared.domain.Provider; -import com.souzip.shared.exception.BusinessException; +import com.souzip.global.exception.BusinessException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,14 +21,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; -import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class UserServiceTest { @@ -47,45 +52,60 @@ class UserServiceTest { @Test @DisplayName("사용 가능한 닉네임을 확인한다.") void checkNickname_available() { - given(userRepository.existsByNickname("새로운닉네임")).willReturn(false); + // given + String nickname = "새로운닉네임"; + given(userRepository.existsByNickname(nickname)).willReturn(false); - NicknameCheckResponse response = userService.checkNickname("새로운닉네임"); + // when + NicknameCheckResponse response = userService.checkNickname(nickname); + // then assertThat(response.isAvailable()).isTrue(); assertThat(response.getMessage()).isEqualTo("사용 가능한 닉네임입니다."); - verify(userRepository).existsByNickname("새로운닉네임"); + verify(userRepository).existsByNickname(nickname); } @Test @DisplayName("이미 사용 중인 닉네임을 확인한다.") void checkNickname_unavailable() { - given(userRepository.existsByNickname("중복닉네임")).willReturn(true); + // given + String nickname = "중복닉네임"; + given(userRepository.existsByNickname(nickname)).willReturn(true); - NicknameCheckResponse response = userService.checkNickname("중복닉네임"); + // when + NicknameCheckResponse response = userService.checkNickname(nickname); + // then assertThat(response.isAvailable()).isFalse(); assertThat(response.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); + verify(userRepository).existsByNickname(nickname); } @Test @DisplayName("온보딩 시 닉네임이 중복되면 에러가 발생한다.") void completeOnboarding_nicknameAlreadyExists() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", + "test@kakao.com", null); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "중복닉네임", "red", List.of("FOOD_SNACK") + true, true, true, true, false, + "중복닉네임", + "red", + List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userRepository.existsByNickname("중복닉네임")).willReturn(true); + // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 사용 중인 닉네임입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 사용 중인 닉네임입니다."); + verify(userRepository).existsByNickname("중복닉네임"); verify(userAgreementRepository, never()).save(any()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); } @@ -93,18 +113,28 @@ void completeOnboarding_nicknameAlreadyExists() { @Test @DisplayName("온보딩을 완료하면 User와 UserAgreement가 저장된다.") void completeOnboarding_success() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", + "test@kakao.com", "https://kakao.com/profile.jpg"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", - List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") + true, + true, + true, + true, + false, + "수집", + "red", + List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") ); String expectedImageUrl = "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg"; - UserAgreement agreement = UserAgreement.of(spyUser, true, true, true, true, false); + UserAgreement agreement = UserAgreement.of( + spyUser, + true, true, true, true, false + ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); @@ -115,12 +145,15 @@ void completeOnboarding_success() { given(spyUser.getNickname()).willReturn("수집"); given(spyUser.getProfileImageUrl()).willReturn(expectedImageUrl); + // when OnboardingResponse response = userService.completeOnboarding(1L, request); + // then assertThat(response.userId()).isEqualTo("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); assertThat(response.nickname()).isEqualTo("수집"); assertThat(response.profileImageUrl()).isEqualTo(expectedImageUrl); assertThat(response.categories()).hasSize(3); + assertThat(response.agreements()).isNotNull(); assertThat(response.agreements().ageVerified()).isTrue(); assertThat(response.agreements().marketingConsent()).isFalse(); @@ -132,20 +165,29 @@ void completeOnboarding_success() { @Test @DisplayName("필수 약관에 동의하지 않으면 온보딩에 실패한다.") void completeOnboarding_requiredAgreementNotChecked() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", + "test@kakao.com", null); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, false, true, true, false, - "수집", "red", List.of("FOOD_SNACK") + true, + false, + true, + true, + false, + "수집", + "red", + List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); + // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("필수 약관에 모두 동의해야 합니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("필수 약관에 모두 동의해야 합니다."); verify(userAgreementRepository, never()).save(any()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); @@ -154,21 +196,26 @@ void completeOnboarding_requiredAgreementNotChecked() { @Test @DisplayName("이미 약관에 동의한 사용자는 중복 저장되지 않는다.") void completeOnboarding_agreementAlreadyExists() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", + "test@kakao.com", null); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", + "red", + List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userAgreementRepository.existsByUser(spyUser)).willReturn(true); + // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 약관에 동의한 사용자입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 약관에 동의한 사용자입니다."); verify(userAgreementRepository, never()).save(any()); } @@ -176,20 +223,25 @@ void completeOnboarding_agreementAlreadyExists() { @Test @DisplayName("이미 온보딩을 완료한 사용자는 다시 온보딩할 수 없다.") void completeOnboarding_alreadyCompleted() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "수집", "수집", + "test@kakao.com", "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "새닉네임", "blue", List.of("FOOD_SNACK") + true, true, true, true, false, + "새닉네임", + "blue", + List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(false); + // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 온보딩을 완료한 사용자입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 온보딩을 완료한 사용자입니다."); verify(profileImageService, never()).resolveProfileImageUrl(anyString()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); @@ -198,21 +250,26 @@ void completeOnboarding_alreadyCompleted() { @Test @DisplayName("유효하지 않은 카테고리로 온보딩 시 에러가 발생한다.") void completeOnboarding_invalidCategory() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", + "test@kakao.com", null); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", List.of("INVALID_CATEGORY", "FOOD_SNACK") + true, true, true, true, false, + "수집", + "red", + List.of("INVALID_CATEGORY", "FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userAgreementRepository.existsByUser(spyUser)).willReturn(false); + // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("유효하지 않은 카테고리입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("유효하지 않은 카테고리입니다."); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); } @@ -220,33 +277,50 @@ void completeOnboarding_invalidCategory() { @Test @DisplayName("존재하지 않는 사용자로 온보딩 시 에러가 발생한다.") void completeOnboarding_userNotFound() { + // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", + "red", + List.of("FOOD_SNACK") ); given(userRepository.findById(999L)).willReturn(Optional.empty()); + // when & then assertThatThrownBy(() -> userService.completeOnboarding(999L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); } @Test @DisplayName("회원탈퇴 시 User는 익명화되고 약관 동의도 삭제된다.") void withdraw_success() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + // given + User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", null, null); User spyUser = spy(user); - UserAgreement agreement = UserAgreement.of(spyUser, true, true, true, true, false); + RefreshToken refreshToken = RefreshToken.of( + spyUser, + "refresh_token", + LocalDateTime.now().plusDays(30) + ); + + UserAgreement agreement = UserAgreement.of( + spyUser, + true, true, true, true, false + ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); + given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(refreshToken)); given(userAgreementRepository.findByUser(spyUser)).willReturn(Optional.of(agreement)); + // when userService.withdraw(1L); + // then verify(spyUser).anonymize(); - verify(refreshTokenRepository).deleteByUserId(1L); + verify(refreshTokenRepository).delete(refreshToken); verify(userAgreementRepository).delete(agreement); verify(userRepository).delete(spyUser); } @@ -254,42 +328,33 @@ void withdraw_success() { @Test @DisplayName("존재하지 않는 사용자로 회원탈퇴 시 에러가 발생한다.") void withdraw_withNotExistUser_throwsException() { + // given given(userRepository.findById(999L)).willReturn(Optional.empty()); + // when & then assertThatThrownBy(() -> userService.withdraw(999L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); } @Test - @DisplayName("약관 동의가 없어도 회원탈퇴는 성공한다.") - void withdraw_withoutAgreement_success() { - User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); + @DisplayName("Refresh Token과 약관 동의가 없어도 회원탈퇴는 성공한다.") + void withdraw_withoutRefreshTokenAndAgreement_success() { + // given + User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", null, null); User spyUser = spy(user); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); + given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.empty()); given(userAgreementRepository.findByUser(spyUser)).willReturn(Optional.empty()); + // when userService.withdraw(1L); + // then verify(spyUser).anonymize(); - verify(refreshTokenRepository).deleteByUserId(1L); verify(userRepository).delete(spyUser); + verify(refreshTokenRepository, never()).delete(any()); verify(userAgreementRepository, never()).delete(any()); } - - @Test - @DisplayName("탈퇴된 유저 삭제 쿼리를 호출하고 삭제 결과를 반환한다") - void deleteWithdrawnUsers_callsRepositoryAndReturnsCount() { - // given - given(userRepository.deleteByDeletedTrue()) - .willReturn(3L); - - // when - long result = userService.deleteWithdrawnUsers(); - - // then - verify(userRepository).deleteByDeletedTrue(); - assertThat(result).isEqualTo(3L); - } -} \ No newline at end of file +} diff --git a/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java b/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java deleted file mode 100644 index afc90a45..00000000 --- a/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java +++ /dev/null @@ -1,315 +0,0 @@ -package com.souzip.domain.wishlist.controller; - -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.wishlist.dto.MyWishlistListResponse; -import com.souzip.domain.wishlist.dto.MyWishlistResponse; -import com.souzip.domain.wishlist.dto.WishlistResponse; -import com.souzip.domain.wishlist.service.WishlistService; -import com.souzip.shared.common.dto.pagination.PaginationResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.core.MethodParameter; -import org.springframework.data.web.PageableHandlerMethodArgumentResolver; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.restdocs.RestDocumentationContextProvider; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -import java.time.LocalDateTime; -import java.util.List; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.request.RequestDocumentation.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class WishlistControllerTest extends RestDocsSupport { - - private final WishlistService wishlistService = mock(WishlistService.class); - - @Override - protected Object initController() { - return new WishlistController(wishlistService); - } - - @BeforeEach - void setUpWithResolver(RestDocumentationContextProvider provider) { - this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers( - new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { - return 1L; - } - }, - new PageableHandlerMethodArgumentResolver() - ) - .apply(documentationConfiguration(provider)) - .build(); - } - - private MyWishlistListResponse createMockMyWishlistListResponse( - List content, - int currentPage, int totalPages, long totalItems, int pageSize, - boolean first, boolean last, boolean hasNext, boolean hasPrevious - ) { - MyWishlistListResponse response = mock(MyWishlistListResponse.class); - PaginationResponse.PageInfo pageInfo = mock(PaginationResponse.PageInfo.class); - - given(response.content()).willReturn(content); - given(response.pagination()).willReturn(pageInfo); - given(pageInfo.getCurrentPage()).willReturn(currentPage); - given(pageInfo.getTotalPages()).willReturn(totalPages); - given(pageInfo.getTotalItems()).willReturn(totalItems); - given(pageInfo.getPageSize()).willReturn(pageSize); - given(pageInfo.isFirst()).willReturn(first); - given(pageInfo.isLast()).willReturn(last); - given(pageInfo.isHasNext()).willReturn(hasNext); - given(pageInfo.isHasPrevious()).willReturn(hasPrevious); - - return response; - } - - @DisplayName("찜 등록") - @Test - void addWishlist() throws Exception { - Long souvenirId = 1L; - - given(wishlistService.addWishlist(1L, souvenirId)) - .willReturn(WishlistResponse.of(souvenirId, true)); - - mockMvc.perform(post("/api/wishlists/{souvenirId}", souvenirId) - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirId").value(souvenirId)) - .andExpect(jsonPath("$.data.wishlisted").value(true)) - .andDo(document("wishlists/add", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("souvenirId").description("찜할 기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.wishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (true: 찜 등록됨)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @DisplayName("찜 취소") - @Test - void removeWishlist() throws Exception { - Long souvenirId = 1L; - - given(wishlistService.removeWishlist(1L, souvenirId)) - .willReturn(WishlistResponse.of(souvenirId, false)); - - mockMvc.perform(delete("/api/wishlists/{souvenirId}", souvenirId) - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirId").value(souvenirId)) - .andExpect(jsonPath("$.data.wishlisted").value(false)) - .andDo(document("wishlists/remove", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("souvenirId").description("찜 취소할 기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.wishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (false: 찜 취소됨)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("내가 찜한 기념품 목록을 조회한다.") - void getMyWishlists_success() throws Exception { - List content = List.of( - new MyWishlistResponse( - 1L, - "글로시에 립밤", - "US", - "https://example.com/image1.jpg", - LocalDateTime.of(2024, 1, 15, 10, 30), - true - ), - new MyWishlistResponse( - 2L, - "말차초콜릿", - "JP", - "https://example.com/image2.jpg", - LocalDateTime.of(2024, 1, 10, 9, 0), - true - ) - ); - - MyWishlistListResponse response = createMockMyWishlistListResponse( - content, 1, 1, 2L, 12, true, true, false, false - ); - - given(wishlistService.getMyWishlist(any(), any())).willReturn(response); - - mockMvc.perform(get("/api/users/me/wishlists") - .header("Authorization", "Bearer valid_access_token") - .param("page", "0") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-wishlists", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호 (기본값: 0)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("찜한 기념품 목록"), - fieldWithPath("data.content[].souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL").optional(), - fieldWithPath("data.content[].wishedAt").type(JsonFieldType.STRING).description("찜한 시간"), - fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 찜 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("찜한 기념품 목록 조회 시 페이지 번호를 지정할 수 있다.") - void getMyWishlists_withPage() throws Exception { - List content = List.of( - new MyWishlistResponse( - 3L, - "전통 부채", - "KR", - null, - LocalDateTime.of(2024, 1, 5, 10, 0), - true - ) - ); - - MyWishlistListResponse response = createMockMyWishlistListResponse( - content, 2, 3, 25L, 12, false, false, true, true - ); - - given(wishlistService.getMyWishlist(any(), any())).willReturn(response); - - mockMvc.perform(get("/api/users/me/wishlists") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-wishlists-page-2", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호"), - parameterWithName("size").description("페이지 크기") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("찜한 기념품 목록"), - fieldWithPath("data.content[].souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), - fieldWithPath("data.content[].wishedAt").type(JsonFieldType.STRING).description("찜한 시간"), - fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 찜 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("찜한 기념품이 없으면 빈 목록을 반환한다.") - void getMyWishlists_empty() throws Exception { - MyWishlistListResponse response = createMockMyWishlistListResponse( - List.of(), 1, 0, 0L, 12, true, true, false, false - ); - - given(wishlistService.getMyWishlist(any(), any())).willReturn(response); - - mockMvc.perform(get("/api/users/me/wishlists") - .header("Authorization", "Bearer valid_access_token") - .param("page", "0") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-wishlists-empty", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호").optional(), - parameterWithName("size").description("페이지 크기").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("빈 찜 목록"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 찜 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } -} diff --git a/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java b/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java deleted file mode 100644 index 41090b8d..00000000 --- a/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.domain.wishlist.entity; - -import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.user.entity.User; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -class WishlistTest { - - @Test - @DisplayName("of 호출 시 user와 souvenir가 올바르게 설정된다.") - void of_success() { - // given - User user = mock(User.class); - Souvenir souvenir = mock(Souvenir.class); - - // when - Wishlist wishlist = Wishlist.of(user, souvenir); - - // then - assertThat(wishlist.getUser()).isEqualTo(user); - assertThat(wishlist.getSouvenir()).isEqualTo(souvenir); - } -} diff --git a/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java b/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java deleted file mode 100644 index c5544bf6..00000000 --- a/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.souzip.domain.wishlist.service; - -import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.domain.souvenir.repository.SouvenirRepository; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.domain.wishlist.dto.WishlistResponse; -import com.souzip.domain.wishlist.entity.Wishlist; -import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.shared.exception.BusinessException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class WishlistServiceTest { - - @Mock - private WishlistRepository wishlistRepository; - - @Mock - private UserRepository userRepository; - - @Mock - private SouvenirRepository souvenirRepository; - - @InjectMocks - private WishlistService wishlistService; - - @Test - @DisplayName("찜 등록에 성공한다.") - void addWishlist_success() { - // given - Long userId = 1L; - Long souvenirId = 1L; - - User user = mock(User.class); - Souvenir souvenir = mock(Souvenir.class); - - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.of(souvenir)); - given(wishlistRepository.save(any(Wishlist.class))).willReturn(any()); - - // when - WishlistResponse response = wishlistService.addWishlist(userId, souvenirId); - - // then - assertThat(response.souvenirId()).isEqualTo(souvenirId); - assertThat(response.wishlisted()).isTrue(); - verify(wishlistRepository).save(any(Wishlist.class)); - } - - @Test - @DisplayName("이미 찜한 기념품을 다시 찜하면 에러가 발생한다.") - void addWishlist_alreadyWishlisted() { - // given - Long userId = 1L; - Long souvenirId = 1L; - - User user = mock(User.class); - Souvenir souvenir = mock(Souvenir.class); - - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.of(souvenir)); - given(wishlistRepository.save(any(Wishlist.class))).willThrow(DataIntegrityViolationException.class); - - // when & then - assertThatThrownBy(() -> wishlistService.addWishlist(userId, souvenirId)) - .isInstanceOf(BusinessException.class); - - verify(wishlistRepository).save(any(Wishlist.class)); - } - - @Test - @DisplayName("존재하지 않는 기념품을 찜하면 에러가 발생한다.") - void addWishlist_souvenirNotFound() { - // given - Long userId = 1L; - Long souvenirId = 999L; - - User user = mock(User.class); - - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> wishlistService.addWishlist(userId, souvenirId)) - .isInstanceOf(BusinessException.class); - - verify(wishlistRepository, never()).save(any()); - } - - @Test - @DisplayName("찜 취소에 성공한다.") - void removeWishlist_success() { - // given - Long userId = 1L; - Long souvenirId = 1L; - String userUuid = "1ffc89fd-bb85-489e-9218-63c4f9680b27"; - - User user = mock(User.class); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(user.getUserId()).willReturn(userUuid); - given(wishlistRepository.existsByUserUserIdAndSouvenirId(userUuid, souvenirId)).willReturn(true); - - // when - WishlistResponse response = wishlistService.removeWishlist(userId, souvenirId); - - // then - assertThat(response.souvenirId()).isEqualTo(souvenirId); - assertThat(response.wishlisted()).isFalse(); - verify(wishlistRepository).deleteByUserIdAndSouvenirId(userId, souvenirId); - } - - @Test - @DisplayName("찜하지 않은 기념품을 취소하면 에러가 발생한다.") - void removeWishlist_notFound() { - // given - Long userId = 1L; - Long souvenirId = 1L; - String userUuid = "1ffc89fd-bb85-489e-9218-63c4f9680b27"; - - User user = mock(User.class); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(user.getUserId()).willReturn(userUuid); - given(wishlistRepository.existsByUserUserIdAndSouvenirId(userUuid, souvenirId)).willReturn(false); - - // when & then - assertThatThrownBy(() -> wishlistService.removeWishlist(userId, souvenirId)) - .isInstanceOf(BusinessException.class); - - verify(wishlistRepository, never()).deleteByUserIdAndSouvenirId(any(), any()); - } -} diff --git a/src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java b/src/test/java/com/souzip/global/aop/MdcTraceIdTest.java similarity index 98% rename from src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java rename to src/test/java/com/souzip/global/aop/MdcTraceIdTest.java index d082026b..5f0d6273 100644 --- a/src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java +++ b/src/test/java/com/souzip/global/aop/MdcTraceIdTest.java @@ -1,4 +1,4 @@ -package com.souzip.shared.aop; +package com.souzip.global.aop; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java b/src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java similarity index 97% rename from src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java rename to src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java index 750f6ef7..39255358 100644 --- a/src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java +++ b/src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java @@ -1,4 +1,4 @@ -package com.souzip.shared.common.dto.pagination; +package com.souzip.global.common.dto.pagination; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java b/src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java similarity index 82% rename from src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java rename to src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java index 0dd702eb..2271c4b5 100644 --- a/src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java +++ b/src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java @@ -1,7 +1,6 @@ -package com.souzip.shared.common.dto.pagination; +package com.souzip.global.common.dto.pagination; import java.util.List; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -16,21 +15,21 @@ class PaginationResponseTest { @DisplayName("페이지 정보 확인") @CsvSource({ - "0, 10, 23, 1, 3, true, false, true, false", - "1, 10, 30, 2, 3, false, false, true, true", - "2, 10, 23, 3, 3, false, true, false, true" + "0, 10, 23, 1, 3, true, false, true, false", + "1, 10, 30, 2, 3, false, false, true, true", + "2, 10, 23, 3, 3, false, true, false, true" }) @ParameterizedTest void pageInfo( - int pageNumber, - int pageSize, - long totalElements, - int expectedCurrentPage, - int expectedTotalPages, - boolean expectedFirst, - boolean expectedLast, - boolean expectedHasNext, - boolean expectedHasPrevious + int pageNumber, + int pageSize, + long totalElements, + int expectedCurrentPage, + int expectedTotalPages, + boolean expectedFirst, + boolean expectedLast, + boolean expectedHasNext, + boolean expectedHasPrevious ) { // given List content = List.of("item1", "item2", "item3"); diff --git a/src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java b/src/test/java/com/souzip/global/exception/BusinessExceptionTest.java similarity index 96% rename from src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java rename to src/test/java/com/souzip/global/exception/BusinessExceptionTest.java index f486b543..bb274d21 100644 --- a/src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java +++ b/src/test/java/com/souzip/global/exception/BusinessExceptionTest.java @@ -1,4 +1,4 @@ -package com.souzip.shared.exception; +package com.souzip.global.exception; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index ccd7b556..572af4bd 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -5,9 +5,6 @@ spring: username: sa password: - flyway: - enabled: false - jpa: hibernate: ddl-auto: update @@ -19,7 +16,4 @@ spring: logging: level: root: INFO - org.springframework.test.context: DEBUG - -firebase: - enabled: false \ No newline at end of file + org.springframework.test.context: DEBUG \ No newline at end of file From 58e9be05b3912dfc1803d5019e1a42872cc67e43 Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:32:05 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[SOU-567]=20=EB=94=94=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EB=9F=89=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 디스크 사용량 모니터링 스크립트 수정 * chore: 불필요한 주석 제거 * chore: 오타 수정 --- deploy/dev/scripts/rollback.sh | 14 ++++++++++---- deploy/prod/scripts/deploy.sh | 26 +++++++++++++++++++++++--- deploy/shared/discord-notify.sh | 2 +- deploy/shared/system-monitor.sh | 31 ++++++++++++++++++++++--------- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/deploy/dev/scripts/rollback.sh b/deploy/dev/scripts/rollback.sh index c3d32309..177aa933 100644 --- a/deploy/dev/scripts/rollback.sh +++ b/deploy/dev/scripts/rollback.sh @@ -36,7 +36,8 @@ echo -e "${GREEN}[SUCCESS] 롤백 이미지 발견: ${PREVIOUS_IMAGE}${NC}" echo -e "${YELLOW}[2/6] 현재 APP 컨테이너 중지${NC}" cd "$DEPLOY_DIR" || exit 1 -docker-compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f souzip-api 2>/dev/null || true + +docker compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f souzip-api 2>/dev/null || true echo -e "${GREEN}[SUCCESS] APP 컨테이너 중지 완료${NC}" echo -e "${YELLOW}[3/6] 이전 버전으로 태그 변경${NC}" @@ -44,7 +45,8 @@ docker tag ${REGISTRY}:previous ${REGISTRY}:latest echo -e "${GREEN}[SUCCESS] 태그 변경 완료${NC}" echo -e "${YELLOW}[4/6] 이전 버전 APP 컨테이너 시작${NC}" -docker-compose -f "$APP_COMPOSE" up -d + +docker compose -f "$APP_COMPOSE" up -d echo -e "${GREEN}[SUCCESS] 이전 버전 컨테이너 시작 완료${NC}" echo -e "${YELLOW}[5/6] 애플리케이션 시작 대기${NC}" @@ -80,7 +82,7 @@ if [ "$HEALTH_OK" = true ]; then if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then source "$WORK_DIR/deploy/shared/discord-notify.sh" - notify_rollback_success + notify_rollback_success "dev" fi exit 0 else @@ -90,4 +92,8 @@ else docker logs --tail 50 souzip-api || true if [ ! -z "${DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then - source "$WORK_DIR/d + source "$WORK_DIR/deploy/shared/discord-notify.sh" + notify_rollback_failed "dev" + fi + exit 1 +fi diff --git a/deploy/prod/scripts/deploy.sh b/deploy/prod/scripts/deploy.sh index 370af3d1..f318b519 100755 --- a/deploy/prod/scripts/deploy.sh +++ b/deploy/prod/scripts/deploy.sh @@ -25,6 +25,17 @@ GREEN_PORT=8082 MAX_RETRY=6 RETRY_INTERVAL=10 +ENV_FILE="$DEPLOY_DIR/.env" +if [ -f "$ENV_FILE" ]; then + while IFS='=' read -r key value; do + case "$key" in + DISCORD_WEBHOOK_URL|PROD_DISCORD_WEBHOOK_URL|PROD_API_DOCS_URL|COMMIT_MESSAGE|DEPLOYER) + export "$key=$value" + ;; + esac + done < "$ENV_FILE" +fi + cd "$WORK_DIR" || exit 1 echo -e "${YELLOW}[1/7] 최신 이미지 다운로드${NC}" @@ -64,7 +75,6 @@ if [ "$CURRENT_PORT" == "$BLUE_PORT" ]; then STOP_PROJECT=$BLUE_PROJECT STOP_COMPOSE=$BLUE_COMPOSE - else TARGET="blue" TARGET_PORT=$BLUE_PORT @@ -102,8 +112,13 @@ while [ $RETRY_COUNT -lt $MAX_RETRY ]; do done if [ "$HEALTH_OK" = false ]; then - echo -e "${RED}[ERROR] 헬스체크 실패${NC}" + echo -e "${RED}[ERROR] 헬스체크 실패 - 자동 롤백 완료 (nginx 전환 없음)${NC}" docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" down + + if [ ! -z "${PROD_DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then + source "$WORK_DIR/deploy/shared/discord-notify.sh" + notify_rollback_success "prod" + fi exit 1 fi @@ -128,4 +143,9 @@ echo -e "${YELLOW}[7/7] 이미지 정리${NC}" docker image prune -f || true -echo -e "${GREEN}[DEPLOY SUCCESS] 완료${NC}" \ No newline at end of file +echo -e "${GREEN}[DEPLOY SUCCESS] 완료${NC}" + +if [ ! -z "${PROD_DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then + source "$WORK_DIR/deploy/shared/discord-notify.sh" + notify_deploy_success "$NEW_IMAGE" "${PROD_API_DOCS_URL:-}" "${COMMIT_MESSAGE:-}" "${DEPLOYER:-}" "prod" +fi diff --git a/deploy/shared/discord-notify.sh b/deploy/shared/discord-notify.sh index 1abcede0..3ae2a0bb 100644 --- a/deploy/shared/discord-notify.sh +++ b/deploy/shared/discord-notify.sh @@ -260,4 +260,4 @@ notify_container_stopped() { }') curl -s -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL" > /dev/null -} \ No newline at end of file +} diff --git a/deploy/shared/system-monitor.sh b/deploy/shared/system-monitor.sh index 6d5c92b7..014d0a1f 100644 --- a/deploy/shared/system-monitor.sh +++ b/deploy/shared/system-monitor.sh @@ -39,20 +39,31 @@ else CONTAINER_WARNING_SENT=false fi +NGINX_UPSTREAM_FILE="/etc/nginx/conf.d/upstream-souzip.conf" + if [ "$ENV" = "dev" ]; then HEALTH_CHECK_URL="http://localhost:8080/actuator/health" CONTAINER_NAME="souzip-api" else - CONTAINER_NAME="souzip-api-${CONTAINER_COLOR:-blue}" - case "$CONTAINER_COLOR" in - blue) + if [ -f "$NGINX_UPSTREAM_FILE" ]; then + ACTIVE_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$NGINX_UPSTREAM_FILE" | cut -d: -f2 | head -n 1 || true) + else + ACTIVE_PORT="" + fi + + case "$ACTIVE_PORT" in + 8081) HEALTH_CHECK_URL="http://localhost:8081/actuator/health" + CONTAINER_NAME="souzip-api-blue" ;; - green) + 8082) HEALTH_CHECK_URL="http://localhost:8082/actuator/health" + CONTAINER_NAME="souzip-api-green" ;; *) + echo "[WARN] nginx upstream에서 active 포트를 읽지 못했습니다. blue(8081)로 fallback합니다." HEALTH_CHECK_URL="http://localhost:8081/actuator/health" + CONTAINER_NAME="souzip-api-blue" ;; esac fi @@ -65,7 +76,7 @@ if curl -f -s --max-time 5 $HEALTH_CHECK_URL > /dev/null 2>&1; then DOWN_DURATION=$(( UP_TIMESTAMP - DOWN_TIMESTAMP )) DOWNTIME_MIN=$(( DOWN_DURATION / 60 )) if [ ! -z "$DISCORD_WEBHOOK_URL" ]; then - notify_server_up "${DOWNTIME_MIN}분" "$ENV" + notify_server_up "$ENV" "${DOWNTIME_MIN}분" fi fi HEALTH_STATUS="up" @@ -90,7 +101,7 @@ DISK_PERCENT=$(df / | awk 'NR==2 {print int($5)}') if [ $DISK_PERCENT -ge $DISK_THRESHOLD ]; then if [ "$DISK_WARNING_SENT" = "false" ]; then if [ ! -z "$DISCORD_WEBHOOK_URL" ]; then - notify_disk_warning "$DISK_USAGE" "$DISK_PERCENT" "$ENV" + notify_disk_warning "$ENV" "$DISK_USAGE" "$DISK_PERCENT" fi DISK_WARNING_SENT=true fi @@ -103,7 +114,7 @@ MEMORY_PERCENT=$(free | awk 'NR==2 {print int($3/$2 * 100)}') if [ $MEMORY_PERCENT -ge $MEMORY_THRESHOLD ]; then if [ "$MEMORY_WARNING_SENT" = "false" ]; then if [ ! -z "$DISCORD_WEBHOOK_URL" ]; then - notify_memory_warning "$MEMORY_USAGE" "$MEMORY_PERCENT" "$ENV" + notify_memory_warning "$ENV" "$MEMORY_USAGE" "$MEMORY_PERCENT" fi MEMORY_WARNING_SENT=true fi @@ -123,11 +134,13 @@ else CONTAINER_WARNING_SENT=false fi -cat > "$STATUS_FILE" << EOF +TEMP_STATUS_FILE=$(mktemp "${STATUS_FILE}.tmp.XXXXXX") +cat > "$TEMP_STATUS_FILE" << EOF HEALTH_STATUS="$HEALTH_STATUS" HEALTH_FAILURE_COUNT=$HEALTH_FAILURE_COUNT HEALTH_DOWN_SINCE="$HEALTH_DOWN_SINCE" DISK_WARNING_SENT=$DISK_WARNING_SENT MEMORY_WARNING_SENT=$MEMORY_WARNING_SENT CONTAINER_WARNING_SENT=$CONTAINER_WARNING_SENT -EOF \ No newline at end of file +EOF +mv "$TEMP_STATUS_FILE" "$STATUS_FILE" From 3e347d50183452178a35a51d4a631c3eeb206d63 Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:02:36 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20wishlist=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=84=A4=EA=B3=84=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/wishlist/entity/Wishlist.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java diff --git a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java new file mode 100644 index 00000000..ea61c239 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java @@ -0,0 +1,35 @@ +package com.souzip.domain.wishlist.entity; + +import com.souzip.domain.shared.BaseEntity; +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "wishlist", indexes = { + @Index(name = "idx_wishlist_user_souvenir", columnList = "user_id, souvenir_id", unique = true), + @Index(name = "idx_wishlist_user_created", columnList = "user_id, created_at") +}) +@Entity +public class Wishlist extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "souvenir_id", nullable = false, + foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Souvenir souvenir; + + public static Wishlist of(User user, Souvenir souvenir) { + return Wishlist.builder() + .user(user) + .souvenir(souvenir) + .build(); + } +} From 8a7a6ebb5262995ff999b24e19a343026672fd04 Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:23:05 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[SOU-569]=20=EC=B0=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D/=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84=20(#233?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 찜 레포지토리 구현 * feat: 찜 비즈니스 로직 구현 * feat: 찜 응답 DTO 정의 * feat: 찜 API 구현 * feat: 찜 관련 에러코드 추가 --- .../controller/WishlistController.java | 34 +++++++++++ .../wishlist/dto/MyWishlistListResponse.java | 15 +++++ .../wishlist/dto/MyWishlistResponse.java | 25 ++++++++ .../domain/wishlist/dto/WishlistResponse.java | 10 ++++ .../repository/WishlistRepository.java | 18 ++++++ .../wishlist/service/WishlistService.java | 58 +++++++++++++++++++ .../souzip/global/exception/ErrorCode.java | 3 + 7 files changed, 163 insertions(+) create mode 100644 src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java create mode 100644 src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java create mode 100644 src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java create mode 100644 src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java create mode 100644 src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java create mode 100644 src/main/java/com/souzip/domain/wishlist/service/WishlistService.java diff --git a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java new file mode 100644 index 00000000..c8b4a576 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java @@ -0,0 +1,34 @@ +package com.souzip.domain.wishlist.controller; + +import com.souzip.domain.wishlist.dto.WishlistResponse; +import com.souzip.domain.wishlist.service.WishlistService; +import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.global.security.annotation.CurrentUserId; +import com.souzip.global.security.annotation.RequireAuth; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +public class WishlistController { + + private final WishlistService wishlistService; + + @PostMapping("/api/wishlists/{souvenirId}") + @RequireAuth + public SuccessResponse addWishlist( + @CurrentUserId Long currentUserId, + @PathVariable Long souvenirId + ) { + return SuccessResponse.of(wishlistService.addWishlist(currentUserId, souvenirId)); + } + + @DeleteMapping("/api/wishlists/{souvenirId}") + @RequireAuth + public SuccessResponse removeWishlist( + @CurrentUserId Long currentUserId, + @PathVariable Long souvenirId + ) { + return SuccessResponse.of(wishlistService.removeWishlist(currentUserId, souvenirId)); + } +} diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java new file mode 100644 index 00000000..b37266f0 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java @@ -0,0 +1,15 @@ +package com.souzip.domain.wishlist.dto; + +import com.souzip.global.common.dto.pagination.PaginationResponse.PageInfo; +import org.springframework.data.domain.Page; + +import java.util.List; + +public record MyWishlistListResponse( + List content, + PageInfo pagination +) { + public static MyWishlistListResponse from(Page page) { + return new MyWishlistListResponse(page.getContent(), PageInfo.of(page)); + } +} diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java new file mode 100644 index 00000000..37007f92 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java @@ -0,0 +1,25 @@ +package com.souzip.domain.wishlist.dto; + +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.wishlist.entity.Wishlist; + +import java.time.LocalDateTime; + +public record MyWishlistResponse( + Long souvenirId, + String name, + String countryCode, + String thumbnailUrl, + LocalDateTime wishedAt +) { + public static MyWishlistResponse of(Wishlist wishlist, String thumbnailUrl) { + Souvenir s = wishlist.getSouvenir(); + return new MyWishlistResponse( + s.getId(), + s.getName(), + s.getCountryCode(), + thumbnailUrl, + wishlist.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java new file mode 100644 index 00000000..770ac825 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/dto/WishlistResponse.java @@ -0,0 +1,10 @@ +package com.souzip.domain.wishlist.dto; + +public record WishlistResponse( + Long souvenirId, + boolean wishlisted +) { + public static WishlistResponse of(Long souvenirId, boolean wishlisted) { + return new WishlistResponse(souvenirId, wishlisted); + } +} diff --git a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java new file mode 100644 index 00000000..3b3e1ca5 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java @@ -0,0 +1,18 @@ +package com.souzip.domain.wishlist.repository; + +import com.souzip.domain.wishlist.entity.Wishlist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface WishlistRepository extends JpaRepository { + + boolean existsByUserIdAndSouvenirId(Long userId, Long souvenirId); + + @Modifying + @Query("DELETE FROM Wishlist w WHERE w.user.id = :userId AND w.souvenir.id = :souvenirId") + void deleteByUserIdAndSouvenirId(@Param("userId") Long userId, @Param("souvenirId") Long souvenirId); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java new file mode 100644 index 00000000..99971d76 --- /dev/null +++ b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java @@ -0,0 +1,58 @@ +package com.souzip.domain.wishlist.service; + +import com.souzip.application.file.FileQueryService; +import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.souvenir.repository.SouvenirRepository; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.domain.wishlist.dto.WishlistResponse; +import com.souzip.domain.wishlist.entity.Wishlist; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.global.exception.BusinessException; +import com.souzip.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WishlistService { + + private final WishlistRepository wishlistRepository; + private final UserRepository userRepository; + private final SouvenirRepository souvenirRepository; + + @Transactional + public WishlistResponse addWishlist(Long userId, Long souvenirId) { + User user = findUserById(userId); + Souvenir souvenir = findSouvenirById(souvenirId); + try { + wishlistRepository.save(Wishlist.of(user, souvenir)); + } catch (DataIntegrityViolationException e) { + throw new BusinessException(ErrorCode.ALREADY_WISHLISTED); + } + return WishlistResponse.of(souvenirId, true); + } + + @Transactional + public WishlistResponse removeWishlist(Long userId, Long souvenirId) { + if (!wishlistRepository.existsByUserIdAndSouvenirId(userId, souvenirId)) { + throw new BusinessException(ErrorCode.WISHLIST_NOT_FOUND); + } + wishlistRepository.deleteByUserIdAndSouvenirId(userId, souvenirId); + return WishlistResponse.of(souvenirId, false); + } + + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + private Souvenir findSouvenirById(Long souvenirId) { + return souvenirRepository.findByIdAndDeletedFalse(souvenirId) + .orElseThrow(() -> new BusinessException(ErrorCode.SOUVENIR_NOT_FOUND)); + } +} diff --git a/src/main/java/com/souzip/global/exception/ErrorCode.java b/src/main/java/com/souzip/global/exception/ErrorCode.java index 2f06c698..71ba4c97 100644 --- a/src/main/java/com/souzip/global/exception/ErrorCode.java +++ b/src/main/java/com/souzip/global/exception/ErrorCode.java @@ -43,6 +43,9 @@ public enum ErrorCode implements BaseErrorCode { SOUVENIR_NOT_FOUND(HttpStatus.NOT_FOUND, "기념품을 찾을 수 없습니다."), INVALID_SOUVENIR_LOCATION(HttpStatus.BAD_REQUEST, "기념품의 위치 정보가 유효하지 않습니다."), + WISHLIST_NOT_FOUND(HttpStatus.NOT_FOUND, "찜 목록에 없는 기념품입니다."), + ALREADY_WISHLISTED(HttpStatus.BAD_REQUEST, "이미 찜한 기념품입니다."), + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "파일 크기가 50MB를 초과합니다."), INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "허용되지 않은 파일 형식입니다."), From e404f0ce7c8883d6765e019f3acee105cff1d883 Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:38:17 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20Wishlist=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B0=8F=20ORM=20XML?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20=EC=B6=94=EA=B0=80=20(#234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/wishlist/entity/Wishlist.java | 11 +---------- src/main/resources/META-INF/orm.xml | 19 +++++++++++++++++++ .../migration/V15__create_wishlist_table.sql | 10 ++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/db/migration/V15__create_wishlist_table.sql diff --git a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java index ea61c239..ddad5617 100644 --- a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java +++ b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java @@ -3,27 +3,18 @@ import com.souzip.domain.shared.BaseEntity; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.user.entity.User; -import jakarta.persistence.*; +import jakarta.persistence.Entity; import lombok.*; @Getter @Builder(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@Table(name = "wishlist", indexes = { - @Index(name = "idx_wishlist_user_souvenir", columnList = "user_id, souvenir_id", unique = true), - @Index(name = "idx_wishlist_user_created", columnList = "user_id, created_at") -}) @Entity public class Wishlist extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "souvenir_id", nullable = false, - foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private Souvenir souvenir; public static Wishlist of(User user, Souvenir souvenir) { diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index ead65aed..5a2e49c7 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -91,6 +91,25 @@ + + + + user_id + souvenir_id + +
+ + + + + + + + + + +
+ diff --git a/src/main/resources/db/migration/V15__create_wishlist_table.sql b/src/main/resources/db/migration/V15__create_wishlist_table.sql new file mode 100644 index 00000000..ee999898 --- /dev/null +++ b/src/main/resources/db/migration/V15__create_wishlist_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE wishlist ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + souvenir_id BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX idx_wishlist_user_souvenir ON wishlist(user_id, souvenir_id); +CREATE INDEX idx_wishlist_user_created ON wishlist(user_id, created_at); From d8f54070c6369b5c1adc4da879510b081f2faa5c Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:54:51 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=B0=9C=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WishlistController.java | 11 +++++ .../repository/WishlistRepository.java | 10 +++- .../wishlist/service/WishlistService.java | 46 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java index c8b4a576..1526fae3 100644 --- a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java +++ b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java @@ -1,11 +1,13 @@ package com.souzip.domain.wishlist.controller; +import com.souzip.domain.wishlist.dto.MyWishlistListResponse; import com.souzip.domain.wishlist.dto.WishlistResponse; import com.souzip.domain.wishlist.service.WishlistService; import com.souzip.global.common.dto.SuccessResponse; import com.souzip.global.security.annotation.CurrentUserId; import com.souzip.global.security.annotation.RequireAuth; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @@ -31,4 +33,13 @@ public SuccessResponse removeWishlist( ) { return SuccessResponse.of(wishlistService.removeWishlist(currentUserId, souvenirId)); } + + @GetMapping("/api/users/me/wishlists") + @RequireAuth + public SuccessResponse getMyWishlist( + @CurrentUserId Long currentUserId, + Pageable pageable + ) { + return SuccessResponse.of(wishlistService.getMyWishlist(currentUserId, pageable)); + } } diff --git a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java index 3b3e1ca5..a1c93bcb 100644 --- a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java +++ b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java @@ -1,6 +1,8 @@ package com.souzip.domain.wishlist.repository; import com.souzip.domain.wishlist.entity.Wishlist; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -15,4 +17,10 @@ public interface WishlistRepository extends JpaRepository { @Modifying @Query("DELETE FROM Wishlist w WHERE w.user.id = :userId AND w.souvenir.id = :souvenirId") void deleteByUserIdAndSouvenirId(@Param("userId") Long userId, @Param("souvenirId") Long souvenirId); -} \ No newline at end of file + + @Query( + value = "SELECT w FROM Wishlist w JOIN FETCH w.souvenir WHERE w.user.id = :userId ORDER BY w.createdAt DESC", + countQuery = "SELECT COUNT(w) FROM Wishlist w WHERE w.user.id = :userId" + ) + Page findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); +} diff --git a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java index 99971d76..dd3b9a6e 100644 --- a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java +++ b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java @@ -1,11 +1,16 @@ package com.souzip.domain.wishlist.service; import com.souzip.application.file.FileQueryService; +import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.domain.file.EntityType; +import com.souzip.domain.file.File; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.souvenir.repository.SouvenirRepository; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.repository.UserRepository; +import com.souzip.domain.wishlist.dto.MyWishlistListResponse; +import com.souzip.domain.wishlist.dto.MyWishlistResponse; import com.souzip.domain.wishlist.dto.WishlistResponse; import com.souzip.domain.wishlist.entity.Wishlist; import com.souzip.domain.wishlist.repository.WishlistRepository; @@ -13,9 +18,16 @@ import com.souzip.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -24,6 +36,8 @@ public class WishlistService { private final WishlistRepository wishlistRepository; private final UserRepository userRepository; private final SouvenirRepository souvenirRepository; + private final FileQueryService fileQueryService; + private final FileStorage fileStorage; @Transactional public WishlistResponse addWishlist(Long userId, Long souvenirId) { @@ -46,6 +60,38 @@ public WishlistResponse removeWishlist(Long userId, Long souvenirId) { return WishlistResponse.of(souvenirId, false); } + public MyWishlistListResponse getMyWishlist(Long userId, Pageable pageable) { + Page wishlistPage = wishlistRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + List souvenirIds = wishlistPage.getContent().stream() + .map(w -> w.getSouvenir().getId()) + .toList(); + + Map thumbnailMap = getThumbnails(souvenirIds); + + Page responsePage = wishlistPage.map(wishlist -> { + String thumbnailUrl = Optional.ofNullable(thumbnailMap.get(wishlist.getSouvenir().getId())) + .map(FileResponse::url) + .orElse(null); + return MyWishlistResponse.of(wishlist, thumbnailUrl); + }); + + return MyWishlistListResponse.from(responsePage); + } + + private Map getThumbnails(List souvenirIds) { + Map fileMap = fileQueryService.findThumbnailsByEntityIds(EntityType.SOUVENIR, souvenirIds); + + return fileMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> FileResponse.of( + entry.getValue(), + fileStorage.generateUrl(entry.getValue().getStorageKey()) + ) + )); + } + private User findUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); From e1894bbcb7ac009964f57ebbf61c2363fd9917c8 Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Sun, 10 May 2026 22:36:21 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=EC=9A=B4=EC=98=81=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94=20(#258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [SOU-593] 기념품 조회 응답에 찜 수/여부 추가 (#250) * feat: 찜 수 집계 쿼리 추가 * feat: 찜 수 및 찜 ID 목록 조회 쿼리 추가 * feat: 기념품 상세 조회에 찜 수/여부 추가 * feat: 근처 기념품 목록 조회에 찜 수/여부 추가 * feat: 내 기념품 목록 조회에 찜 수/여부 추가 * feat: 일반 추천 기념품 조회에 찜 수/여부 추가 * feat: AI 추천 기념품 조회에 찜 수/여부 추가 * test: 찜 수/여부 추가에 따른 테스트 수정 * [SOU-571] 찜 기능 테스트 코드 및 API 문서 작성 (#251) * feat: 찜 기능 테스트 코드 및 API 문서 작성 * feat: 찜 목록 조회 응답에 isWishlisted 필드 추가 * [SOU-601] 탈퇴 유저 삭제 배치 스케줄러 구현 (#252) * feat: 탈퇴 유저 삭제 배치 스케줄러 추가 * feat: 탈퇴 유저 삭제 로직 반환값 추가 및 테스트 수정 * chore: 불필요한 import 구문 제거 * fix: userId UUID 타입 불일치로 인한 NumberFormatException 수정 (#253) * feat: 탈퇴 유저 배치 삭제로 정책 변경 및 테스트 수정 (#254) * feat: 정보 동의한 회원들에게만 알림을 발송하도록 수정 (#255) * refactor: auth 바운디드 컨텍스트 수정 * fix: 깃 충돌 수정 * refactor: 정책에 따라 테스트 코드 수정 * [SOU-622] Docker DB 전환 및 자동 백업 파이프라인 구축 --------- Co-authored-by: johoon030 <142702766+hoonjo123@users.noreply.github.com> Co-authored-by: Develop-KIM Co-authored-by: monkey <105265603+Develop-KIM@users.noreply.github.com> --- deploy/prod/docker-compose.db.yaml | 34 + deploy/prod/scripts/deploy.sh | 73 +- src/docs/asciidoc/app/index.adoc | 1 + src/docs/asciidoc/app/souvenir.adoc | 12 +- src/docs/asciidoc/app/user.adoc | 22 +- src/docs/asciidoc/app/wishlist.adoc | 24 + .../googlegeocoding/GoogleGeocoding.java | 2 +- .../googleplaces/GooglePlaces.java | 4 +- .../adapter/storage/file/NcpStorage.java | 4 +- .../adapter/webapi/admin/AdminNoticeApi.java | 5 +- .../adapter/webapi/location/GeocodingApi.java | 11 +- .../adapter/webapi/location/LocationApi.java | 13 +- .../webapi/location/dto/SearchResponse.java | 3 +- .../souzip/adapter/webapi/user/NoticeApi.java | 2 +- .../location/ReverseGeocodingService.java | 2 +- .../application/location/dto/SearchPlace.java | 2 +- .../location/provided/ReverseGeocoding.java | 2 +- .../location/required/AddressProvider.java | 2 +- .../adapter}/config/WebConfig.java | 7 +- .../RefreshTokenCleanupScheduler.java | 13 +- .../security/annotation/CurrentUserId.java | 2 +- .../security/annotation/RequireAuth.java | 2 +- .../security/config/SecurityConfig.java | 10 +- .../security/jwt/JwtAuthenticationFilter.java | 57 + .../adapter}/security/jwt/JwtProperties.java | 5 +- .../security/jwt/JwtTokenProvider.java | 86 ++ .../CurrentUserIdArgumentResolver.java | 23 +- .../adapter/social/AppleSocialClient.java | 46 + .../adapter/social/GoogleSocialClient.java | 55 + .../adapter/social/KakaoSocialClient.java | 62 + .../auth/adapter/social/SocialProperties.java | 15 + .../com/souzip/auth/adapter/web/AuthApi.java | 48 + .../auth/adapter/web/dto/LoginRequest.java | 6 + .../auth/adapter/web/dto/LoginResponse.java | 22 + .../auth/adapter/web/dto/RefreshRequest.java | 6 + .../auth/adapter/web/dto/RefreshResponse.java | 15 + .../souzip/auth/application/AuthService.java | 92 ++ .../auth/application/SocialClientFactory.java | 27 + .../auth/application/dto/LoginInfo.java | 10 + .../auth/application/dto/RefreshInfo.java | 7 + .../auth/application/dto/SocialUserInfo.java | 4 + .../souzip/auth/application/dto/UserInfo.java | 9 + .../application/exception/AuthErrorCode.java | 27 + .../application/exception/AuthException.java | 46 + .../auth/application/provided/Auth.java | 13 + .../required/RefreshTokenRepository.java | 24 + .../application/required/SocialClient.java | 10 + .../application/required/TokenProvider.java | 11 + .../auth/application/required/UserPort.java | 8 + .../com/souzip/auth/domain/RefreshToken.java | 34 + .../admin/application/AdminAuthService.java | 34 +- .../application/AdminCityQueryUseCase.java | 2 +- .../admin/application/port/CityQueryPort.java | 6 +- .../query/AdminCityQueryService.java | 4 +- .../admin/exception/AdminErrorCode.java | 2 +- .../admin/exception/AdminException.java | 2 +- .../AdminExpiredRefreshTokenException.java | 2 +- .../AdminInvalidRefreshTokenException.java | 2 +- .../exception/AdminLoginFailedException.java | 2 +- .../exception/AdminNotFoundException.java | 2 +- .../exception/InvalidUsernameException.java | 2 +- .../persistence/CityQueryAdapter.java | 34 +- .../jwt/AdminJwtAuthenticationFilter.java | 16 +- .../presentation/AdminAuthController.java | 9 +- .../AdminManagementController.java | 62 +- .../presentation/response/AdminResponse.java | 37 +- .../souzip/domain/audit/entity/AuditLog.java | 82 +- .../domain/audit/service/AuditService.java | 46 +- .../domain/auth/client/AppleApiClient.java | 59 - .../domain/auth/client/GoogleApiClient.java | 55 - .../domain/auth/client/KakaoApiClient.java | 55 - .../domain/auth/client/OAuthClient.java | 7 - .../auth/client/OAuthClientFactory.java | 27 - .../auth/controller/AuthController.java | 44 - .../souzip/domain/auth/dto/AppleUserInfo.java | 45 - .../domain/auth/dto/GoogleUserInfo.java | 34 - .../souzip/domain/auth/dto/KakaoUserInfo.java | 44 - .../souzip/domain/auth/dto/LoginRequest.java | 5 - .../souzip/domain/auth/dto/LoginResponse.java | 27 - .../souzip/domain/auth/dto/LoginUserInfo.java | 15 - .../souzip/domain/auth/dto/OAuthUserInfo.java | 10 - .../domain/auth/dto/RefreshRequest.java | 6 - .../domain/auth/dto/RefreshResponse.java | 10 - .../domain/auth/entity/RefreshToken.java | 49 - .../repository/RefreshTokenRepository.java | 13 - .../RefreshTokenRepositoryCustom.java | 8 - .../RefreshTokenRepositoryImpl.java | 25 - .../domain/auth/service/AuthService.java | 241 ---- .../controller/CategoryController.java | 2 +- .../command/CityCommandService.java | 6 +- .../com/souzip/domain/city/entity/City.java | 36 +- .../country/controller/CountryController.java | 2 +- .../souzip/domain/country/entity/Country.java | 56 +- .../country/service/CountryService.java | 4 +- .../controller/CurrencyController.java | 4 +- .../currency/service/CurrencyService.java | 4 +- .../service/ExchangeRateService.java | 100 +- .../java/com/souzip/domain/file/File.java | 2 +- .../com/souzip/domain/location/Location.java | 4 +- .../controller/AppleMigrationController.java | 28 - .../dto/AppleMigrationPrepareResponse.java | 23 - .../service/AppleMigrationPrepService.java | 177 --- .../apple/service/AppleTransferService.java | 198 ---- .../java/com/souzip/domain/notice/Notice.java | 5 +- .../AiRecommendationController.java | 6 +- .../ai/dto/AiRecommendationResponse.java | 8 +- .../ai/service/AiRecommendationService.java | 70 +- .../GeneralRecommendationController.java | 32 +- .../general/dto/GeneralRecommendationDto.java | 10 +- ...ralRecommendationRepositoryCustomImpl.java | 30 +- .../service/GeneralRecommendationService.java | 135 ++- .../search/controller/SearchController.java | 6 +- .../domain/search/dto/SearchListResponse.java | 2 +- .../domain/search/service/SearchService.java | 10 +- .../controller/SouvenirController.java | 51 +- .../souvenir/dto/MySouvenirListResponse.java | 6 +- .../souvenir/dto/MySouvenirResponse.java | 26 +- .../souvenir/dto/SouvenirDetailResponse.java | 135 ++- .../souvenir/dto/SouvenirNearbyResponse.java | 105 +- .../domain/souvenir/entity/Souvenir.java | 123 +- .../repository/SouvenirRepository.java | 7 +- .../souvenir/service/SouvenirService.java | 48 +- .../domain/user/adapter/UserPortImpl.java | 32 + .../user/controller/UserController.java | 34 +- .../souzip/domain/user/entity/Provider.java | 23 - .../com/souzip/domain/user/entity/User.java | 73 +- .../domain/user/entity/UserAgreement.java | 40 +- .../repository/UserAgreementRepository.java | 2 + .../user/repository/UserRepository.java | 14 +- .../user/scheduler/UserCleanupScheduler.java | 22 + .../user/service/ProfileImageService.java | 18 +- .../domain/user/service/UserService.java | 43 +- .../controller/WishlistController.java | 18 +- .../wishlist/dto/MyWishlistListResponse.java | 2 +- .../wishlist/dto/MyWishlistResponse.java | 6 +- .../domain/wishlist/entity/Wishlist.java | 2 +- .../repository/WishlistRepository.java | 24 +- .../wishlist/service/WishlistService.java | 7 +- .../souzip/global/oauth/OAuthProperties.java | 26 - .../security/jwt/JwtAuthenticationFilter.java | 105 -- .../global/security/jwt/JwtTokenProvider.java | 67 -- .../{global => shared}/aop/MdcTraceId.java | 6 +- .../aop/ServiceExceptionLoggingAspect.java | 13 +- .../{global => shared}/aop/TraceIdAspect.java | 2 +- .../audit/annotation/Audit.java | 3 +- .../audit/aspect/AuditAspect.java | 113 +- .../audit/dto/AuditContext.java | 4 +- .../clova/ClovaStudioClient.java | 43 +- .../clova/PromptLoader.java | 6 +- .../common/dto/ErrorResponse.java | 28 +- .../common/dto/SuccessResponse.java | 12 +- .../dto/pagination/PaginationRequest.java | 7 +- .../dto/pagination/PaginationResponse.java | 52 +- .../com/souzip/shared/config/AppConfig.java | 17 + .../config/AppleMigrationConfig.java | 16 +- .../config/ClovaStudioProperties.java | 2 +- .../config/CorsProperties.java | 2 +- .../config/JpaAuditingConfig.java | 2 +- .../config/PasswordEncoderConfig.java | 2 +- .../config/QuerydslConfig.java | 2 +- .../config/RestTemplateConfig.java | 2 +- .../config/SchedulerConfig.java | 2 +- .../shared => shared/domain}/BaseEntity.java | 12 +- .../shared => shared/domain}/Coordinate.java | 2 +- .../com/souzip/shared/domain/Provider.java | 24 + .../exception/BaseErrorCode.java | 4 +- .../exception/BusinessException.java | 2 +- .../exception/ErrorCode.java | 16 +- .../exception/GlobalExceptionHandler.java | 8 +- .../util/HttpRequestUtils.java | 20 +- src/main/resources/META-INF/orm.xml | 244 ++-- .../googlegeocoding/GoogleGeocodingTest.java | 2 +- .../adapter/storage/file/NcpStorageTest.java | 2 +- .../webapi/geocoding/GeocodingApiTest.java | 2 +- .../webapi/location/LocationApiTest.java | 2 +- .../location/LocationSearchServiceTest.java | 9 +- .../location/ReverseGeocodingServiceTest.java | 2 +- .../souzip/auth/controller/AuthApiTest.java | 372 ++++++ .../RefreshTokenCleanupSchedulerTest.java | 14 +- .../souzip/auth/service/AuthServiceTest.java | 168 +++ .../java/com/souzip/docs/RestDocsSupport.java | 2 +- .../application/AdminAuthServiceTest.java | 28 +- .../AdminCityQueryServiceTest.java | 4 +- .../AdminRefreshTokenRepositoryTest.java | 3 +- .../persistence/AdminRepositoryTest.java | 4 +- .../admin/presentation/AdminAuthApiTest.java | 244 ++++ .../presentation/AdminAuthControllerTest.java | 242 ---- .../AdminManagementControllerTest.java | 4 +- .../auth/controller/AuthControllerTest.java | 593 ---------- .../domain/auth/service/AuthServiceTest.java | 459 -------- .../command/CityCommandServiceTest.java | 4 +- .../country/service/CountryServiceTest.java | 32 +- .../currency/service/CurrencyServiceTest.java | 4 +- .../AiRecommendationControllerTest.java | 211 ++-- .../GeneralRecommendationControllerTest.java | 331 +++--- .../controller/SearchControllerTest.java | 8 +- .../souzip/domain/shared/CoordinateTest.java | 4 +- .../controller/SouvenirControllerTest.java | 1026 +++++++++-------- .../user/controller/UserControllerTest.java | 766 ++++++------ .../souzip/domain/user/entity/UserTest.java | 25 +- .../user/service/ProfileImageServiceTest.java | 30 +- .../domain/user/service/UserServiceTest.java | 209 ++-- .../controller/WishlistControllerTest.java | 315 +++++ .../domain/wishlist/entity/WishlistTest.java | 27 + .../wishlist/service/WishlistServiceTest.java | 148 +++ .../aop/MdcTraceIdTest.java | 2 +- .../dto/pagination/PaginationRequestTest.java | 2 +- .../pagination/PaginationResponseTest.java | 27 +- .../exception/BusinessExceptionTest.java | 2 +- 209 files changed, 4890 insertions(+), 5271 deletions(-) create mode 100644 deploy/prod/docker-compose.db.yaml create mode 100644 src/docs/asciidoc/app/wishlist.adoc rename src/main/java/com/souzip/{global => auth/adapter}/config/WebConfig.java (87%) rename src/main/java/com/souzip/{domain/auth => auth/adapter/integration}/scheduler/RefreshTokenCleanupScheduler.java (78%) rename src/main/java/com/souzip/{global => auth/adapter}/security/annotation/CurrentUserId.java (83%) rename src/main/java/com/souzip/{global => auth/adapter}/security/annotation/RequireAuth.java (87%) rename src/main/java/com/souzip/{global => auth/adapter}/security/config/SecurityConfig.java (94%) create mode 100644 src/main/java/com/souzip/auth/adapter/security/jwt/JwtAuthenticationFilter.java rename src/main/java/com/souzip/{global => auth/adapter}/security/jwt/JwtProperties.java (88%) create mode 100644 src/main/java/com/souzip/auth/adapter/security/jwt/JwtTokenProvider.java rename src/main/java/com/souzip/{global => auth/adapter}/security/resolver/CurrentUserIdArgumentResolver.java (71%) create mode 100644 src/main/java/com/souzip/auth/adapter/social/AppleSocialClient.java create mode 100644 src/main/java/com/souzip/auth/adapter/social/GoogleSocialClient.java create mode 100644 src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java create mode 100644 src/main/java/com/souzip/auth/adapter/social/SocialProperties.java create mode 100644 src/main/java/com/souzip/auth/adapter/web/AuthApi.java create mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java create mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java create mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java create mode 100644 src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java create mode 100644 src/main/java/com/souzip/auth/application/AuthService.java create mode 100644 src/main/java/com/souzip/auth/application/SocialClientFactory.java create mode 100644 src/main/java/com/souzip/auth/application/dto/LoginInfo.java create mode 100644 src/main/java/com/souzip/auth/application/dto/RefreshInfo.java create mode 100644 src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java create mode 100644 src/main/java/com/souzip/auth/application/dto/UserInfo.java create mode 100644 src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java create mode 100644 src/main/java/com/souzip/auth/application/exception/AuthException.java create mode 100644 src/main/java/com/souzip/auth/application/provided/Auth.java create mode 100644 src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java create mode 100644 src/main/java/com/souzip/auth/application/required/SocialClient.java create mode 100644 src/main/java/com/souzip/auth/application/required/TokenProvider.java create mode 100644 src/main/java/com/souzip/auth/application/required/UserPort.java create mode 100644 src/main/java/com/souzip/auth/domain/RefreshToken.java delete mode 100644 src/main/java/com/souzip/domain/auth/client/AppleApiClient.java delete mode 100644 src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java delete mode 100644 src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java delete mode 100644 src/main/java/com/souzip/domain/auth/client/OAuthClient.java delete mode 100644 src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java delete mode 100644 src/main/java/com/souzip/domain/auth/controller/AuthController.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/LoginRequest.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/LoginResponse.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java delete mode 100644 src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java delete mode 100644 src/main/java/com/souzip/domain/auth/entity/RefreshToken.java delete mode 100644 src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java delete mode 100644 src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java delete mode 100644 src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java delete mode 100644 src/main/java/com/souzip/domain/auth/service/AuthService.java delete mode 100644 src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java delete mode 100644 src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java delete mode 100644 src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java delete mode 100644 src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java create mode 100644 src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java delete mode 100644 src/main/java/com/souzip/domain/user/entity/Provider.java create mode 100644 src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java delete mode 100644 src/main/java/com/souzip/global/oauth/OAuthProperties.java delete mode 100644 src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java delete mode 100644 src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java rename src/main/java/com/souzip/{global => shared}/aop/MdcTraceId.java (91%) rename src/main/java/com/souzip/{global => shared}/aop/ServiceExceptionLoggingAspect.java (75%) rename src/main/java/com/souzip/{global => shared}/aop/TraceIdAspect.java (96%) rename src/main/java/com/souzip/{global => shared}/audit/annotation/Audit.java (89%) rename src/main/java/com/souzip/{global => shared}/audit/aspect/AuditAspect.java (59%) rename src/main/java/com/souzip/{global => shared}/audit/dto/AuditContext.java (88%) rename src/main/java/com/souzip/{global => shared}/clova/ClovaStudioClient.java (76%) rename src/main/java/com/souzip/{global => shared}/clova/PromptLoader.java (84%) rename src/main/java/com/souzip/{global => shared}/common/dto/ErrorResponse.java (75%) rename src/main/java/com/souzip/{global => shared}/common/dto/SuccessResponse.java (80%) rename src/main/java/com/souzip/{global => shared}/common/dto/pagination/PaginationRequest.java (85%) rename src/main/java/com/souzip/{global => shared}/common/dto/pagination/PaginationResponse.java (67%) create mode 100644 src/main/java/com/souzip/shared/config/AppConfig.java rename src/main/java/com/souzip/{global => shared}/config/AppleMigrationConfig.java (77%) rename src/main/java/com/souzip/{global => shared}/config/ClovaStudioProperties.java (89%) rename src/main/java/com/souzip/{global => shared}/config/CorsProperties.java (91%) rename src/main/java/com/souzip/{global => shared}/config/JpaAuditingConfig.java (85%) rename src/main/java/com/souzip/{global => shared}/config/PasswordEncoderConfig.java (92%) rename src/main/java/com/souzip/{global => shared}/config/QuerydslConfig.java (93%) rename src/main/java/com/souzip/{global => shared}/config/RestTemplateConfig.java (90%) rename src/main/java/com/souzip/{global => shared}/config/SchedulerConfig.java (85%) rename src/main/java/com/souzip/{domain/shared => shared/domain}/BaseEntity.java (68%) rename src/main/java/com/souzip/{domain/shared => shared/domain}/Coordinate.java (97%) create mode 100644 src/main/java/com/souzip/shared/domain/Provider.java rename src/main/java/com/souzip/{global => shared}/exception/BaseErrorCode.java (79%) rename src/main/java/com/souzip/{global => shared}/exception/BusinessException.java (91%) rename src/main/java/com/souzip/{global => shared}/exception/ErrorCode.java (80%) rename src/main/java/com/souzip/{global => shared}/exception/GlobalExceptionHandler.java (96%) rename src/main/java/com/souzip/{global => shared}/util/HttpRequestUtils.java (90%) create mode 100644 src/test/java/com/souzip/auth/controller/AuthApiTest.java rename src/test/java/com/souzip/{domain => }/auth/scheduler/RefreshTokenCleanupSchedulerTest.java (77%) create mode 100644 src/test/java/com/souzip/auth/service/AuthServiceTest.java create mode 100644 src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java delete mode 100644 src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java delete mode 100644 src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java delete mode 100644 src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java create mode 100644 src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java create mode 100644 src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java create mode 100644 src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java rename src/test/java/com/souzip/{global => shared}/aop/MdcTraceIdTest.java (98%) rename src/test/java/com/souzip/{global => shared}/common/dto/pagination/PaginationRequestTest.java (97%) rename src/test/java/com/souzip/{global => shared}/common/dto/pagination/PaginationResponseTest.java (82%) rename src/test/java/com/souzip/{global => shared}/exception/BusinessExceptionTest.java (96%) diff --git a/deploy/prod/docker-compose.db.yaml b/deploy/prod/docker-compose.db.yaml new file mode 100644 index 00000000..e44a9ba2 --- /dev/null +++ b/deploy/prod/docker-compose.db.yaml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + postgres: + image: postgis/postgis:16-3.4 + container_name: souzip-prod-db + restart: always + networks: + - souzip-network + ports: + - "127.0.0.1:5432:5432" + env_file: + - .env.prod + environment: + POSTGRES_DB: souzip_prod + POSTGRES_USER: ${PROD_POSTGRES_USER} + POSTGRES_PASSWORD: ${PROD_POSTGRES_PASSWORD} + TZ: Asia/Seoul + volumes: + - postgres-data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${PROD_POSTGRES_USER} -d souzip_prod"] + interval: 10s + timeout: 5s + retries: 10 + +networks: + souzip-network: + external: true + +volumes: + postgres-data: + driver: local \ No newline at end of file diff --git a/deploy/prod/scripts/deploy.sh b/deploy/prod/scripts/deploy.sh index f318b519..ebfe38ff 100755 --- a/deploy/prod/scripts/deploy.sh +++ b/deploy/prod/scripts/deploy.sh @@ -13,6 +13,9 @@ DEPLOY_DIR="$WORK_DIR/deploy/prod" BLUE_COMPOSE="docker-compose.blue.yaml" GREEN_COMPOSE="docker-compose.green.yaml" +MONITORING_COMPOSE="docker-compose.monitoring.yaml" +LOGGING_COMPOSE="docker-compose.logging.yaml" +DB_COMPOSE="docker-compose.db.yaml" BLUE_PROJECT="souzip-blue" GREEN_PROJECT="souzip-green" @@ -22,8 +25,8 @@ NGINX_UPSTREAM_FILE="/etc/nginx/conf.d/upstream-souzip.conf" BLUE_PORT=8081 GREEN_PORT=8082 -MAX_RETRY=6 -RETRY_INTERVAL=10 +MAX_RETRY=12 +RETRY_INTERVAL=15 ENV_FILE="$DEPLOY_DIR/.env" if [ -f "$ENV_FILE" ]; then @@ -38,7 +41,7 @@ fi cd "$WORK_DIR" || exit 1 -echo -e "${YELLOW}[1/7] 최신 이미지 다운로드${NC}" +echo -e "${YELLOW}[1/9] 최신 이미지 다운로드${NC}" docker pull ${REGISTRY}:latest @@ -58,7 +61,7 @@ cd "$DEPLOY_DIR" || exit 1 exit 1 } -echo -e "${YELLOW}[2/7] 현재 active 포트 확인${NC}" +echo -e "${YELLOW}[2/9] 현재 active 포트 확인${NC}" CURRENT_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$NGINX_UPSTREAM_FILE" | cut -d: -f2 | head -n 1 || true) @@ -87,12 +90,64 @@ fi echo -e "${GREEN}[INFO] 현재:$CURRENT_PORT → 배포:$TARGET($TARGET_PORT)${NC}" -echo -e "${YELLOW}[3/7] $TARGET 컨테이너 실행${NC}" +echo -e "${YELLOW}[3/9] DB 컨테이너 확인${NC}" +if ! docker ps --format '{{.Names}}' | grep -q '^souzip-prod-db$'; then + docker compose -f "$DB_COMPOSE" up -d + if [ $? -ne 0 ]; then + echo -e "${RED}[ERROR] DB 컨테이너 시작 실패${NC}" + exit 1 + fi + echo -e "${GREEN}[SUCCESS] DB 컨테이너 시작 완료${NC}" +else + echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}" +fi + +echo -e "${YELLOW}[4/9] 모니터링 스택 확인${NC}" +PROMETHEUS_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-prometheus-prod$' && echo "true" || echo "false") +GRAFANA_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-grafana-prod$' && echo "true" || echo "false") +NODE_EXPORTER_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-node-exporter$' && echo "true" || echo "false") +CADVISOR_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-cadvisor$' && echo "true" || echo "false") + +if [ "$PROMETHEUS_RUNNING" = "false" ] || [ "$GRAFANA_RUNNING" = "false" ] || [ "$NODE_EXPORTER_RUNNING" = "false" ] || [ "$CADVISOR_RUNNING" = "false" ]; then + if [ -f "$MONITORING_COMPOSE" ]; then + echo -e "${YELLOW}[INFO] 모니터링 스택 시작 중...${NC}" + if docker compose -f "$MONITORING_COMPOSE" up -d; then + echo -e "${GREEN}[SUCCESS] 모니터링 스택 시작 완료${NC}" + else + echo -e "${RED}[WARNING] 모니터링 스택 시작 실패 (계속 진행)${NC}" + fi + else + echo -e "${YELLOW}[INFO] 모니터링 설정 파일 없음 ($MONITORING_COMPOSE)${NC}" + fi +else + echo -e "${GREEN}[SUCCESS] 모니터링 스택 이미 실행 중${NC}" +fi + +echo -e "${YELLOW}[4.5/9] 로그 수집 스택 확인${NC}" +LOKI_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-loki-prod$' && echo "true" || echo "false") +PROMTAIL_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-promtail-prod$' && echo "true" || echo "false") + +if [ "$LOKI_RUNNING" = "false" ] || [ "$PROMTAIL_RUNNING" = "false" ]; then + if [ -f "$LOGGING_COMPOSE" ]; then + echo -e "${YELLOW}[INFO] 로그 수집 스택 시작 중...${NC}" + if docker compose -f "$LOGGING_COMPOSE" up -d; then + echo -e "${GREEN}[SUCCESS] 로그 수집 스택 시작 완료${NC}" + else + echo -e "${RED}[WARNING] 로그 수집 스택 시작 실패 (계속 진행)${NC}" + fi + else + echo -e "${YELLOW}[INFO] 로그 수집 설정 파일 없음 ($LOGGING_COMPOSE)${NC}" + fi +else + echo -e "${GREEN}[SUCCESS] 로그 수집 스택 이미 실행 중${NC}" +fi + +echo -e "${YELLOW}[5/9] $TARGET 컨테이너 실행${NC}" docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" pull docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" up -d -echo -e "${YELLOW}[4/7] 헬스체크 시작${NC}" +echo -e "${YELLOW}[6/9] 헬스체크 시작${NC}" RETRY_COUNT=0 HEALTH_OK=false @@ -122,7 +177,7 @@ if [ "$HEALTH_OK" = false ]; then exit 1 fi -echo -e "${YELLOW}[5/7] nginx upstream 전환${NC}" +echo -e "${YELLOW}[7/9] nginx upstream 전환${NC}" sudo tee "$NGINX_UPSTREAM_FILE" > /dev/null <(createAuthHeaders(accessToken)), + GoogleUserResponse.class + ).getBody(); + + return new SocialUserInfo(response.id(), response.email()); + } catch (Exception e) { + log.error("구글 사용자 정보 조회 중 오류 발생", e); + throw AuthException.googleApiError(); + } + } + + private HttpHeaders createAuthHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } + + private record GoogleUserResponse(String id, String email) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java b/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java new file mode 100644 index 00000000..ad4e3c79 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/social/KakaoSocialClient.java @@ -0,0 +1,62 @@ +package com.souzip.auth.adapter.social; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@RequiredArgsConstructor +@EnableConfigurationProperties(SocialProperties.class) +@Component +public class KakaoSocialClient implements SocialClient { + + private final RestTemplate restTemplate; + private final SocialProperties socialProperties; + + @Override + public Provider getProvider() { + return Provider.KAKAO; + } + + @Override + public SocialUserInfo getUserInfo(String accessToken) { + try { + KakaoUserResponse response = restTemplate.exchange( + socialProperties.kakao().userInfoUrl(), + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(accessToken)), + KakaoUserResponse.class + ).getBody(); + + return new SocialUserInfo( + String.valueOf(response.id()), + response.kakaoAccount().email() + ); + } catch (Exception e) { + log.error("카카오 사용자 정보 조회 중 오류 발생", e); + throw AuthException.kakaoApiError(); + } + } + + private HttpHeaders createAuthHeaders(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + return headers; + } + + private record KakaoUserResponse(Long id, @JsonProperty("kakao_account") KakaoAccount kakaoAccount) { + } + + private record KakaoAccount(String email) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java b/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java new file mode 100644 index 00000000..ecbeef50 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/social/SocialProperties.java @@ -0,0 +1,15 @@ +package com.souzip.auth.adapter.social; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth") +public record SocialProperties( + Kakao kakao, + Google google +) { + public record Kakao(String userInfoUrl) { + } + + public record Google(String userInfoUrl) { + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/AuthApi.java b/src/main/java/com/souzip/auth/adapter/web/AuthApi.java new file mode 100644 index 00000000..67b5dc71 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/AuthApi.java @@ -0,0 +1,48 @@ +package com.souzip.auth.adapter.web; + +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import com.souzip.auth.adapter.web.dto.LoginRequest; +import com.souzip.auth.adapter.web.dto.LoginResponse; +import com.souzip.auth.adapter.web.dto.RefreshRequest; +import com.souzip.auth.adapter.web.dto.RefreshResponse; +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.provided.Auth; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@RestController +public class AuthApi { + + private final Auth auth; + + @PostMapping("/login/{provider}") + public SuccessResponse login( + @PathVariable String provider, + @RequestBody LoginRequest request + ) { + LoginInfo info = auth.login(Provider.from(provider), request.accessToken()); + + return SuccessResponse.of(LoginResponse.from(info)); + } + + @PostMapping("/refresh") + public SuccessResponse refresh(@RequestBody RefreshRequest request) { + RefreshInfo info = auth.refresh(request.refreshToken()); + + return SuccessResponse.of(RefreshResponse.from(info)); + } + + @RequireAuth + @PostMapping("/logout") + public SuccessResponse logout(@CurrentUserId Long currentUserId) { + auth.logout(currentUserId); + + return SuccessResponse.of(null, "로그아웃 되었습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java b/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java new file mode 100644 index 00000000..c1fd3e55 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/LoginRequest.java @@ -0,0 +1,6 @@ +package com.souzip.auth.adapter.web.dto; + +public record LoginRequest( + String accessToken +) { +} diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java b/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java new file mode 100644 index 00000000..8e4bcd8a --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/LoginResponse.java @@ -0,0 +1,22 @@ +package com.souzip.auth.adapter.web.dto; + +import com.souzip.auth.application.dto.LoginInfo; + +public record LoginResponse( + String accessToken, + String refreshToken, + boolean needsOnboarding, + UserDto user +) { + public record UserDto(String userId, String nickname) { + } + + public static LoginResponse from(LoginInfo info) { + return new LoginResponse( + info.accessToken(), + info.refreshToken(), + info.needsOnboarding(), + new UserDto(info.userId(), info.nickname()) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java new file mode 100644 index 00000000..e0204e54 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshRequest.java @@ -0,0 +1,6 @@ +package com.souzip.auth.adapter.web.dto; + +public record RefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java new file mode 100644 index 00000000..2ed925a1 --- /dev/null +++ b/src/main/java/com/souzip/auth/adapter/web/dto/RefreshResponse.java @@ -0,0 +1,15 @@ +package com.souzip.auth.adapter.web.dto; + +import com.souzip.auth.application.dto.RefreshInfo; + +public record RefreshResponse( + String accessToken, + String refreshToken +) { + public static RefreshResponse from(RefreshInfo info) { + return new RefreshResponse( + info.accessToken(), + info.refreshToken() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/AuthService.java b/src/main/java/com/souzip/auth/application/AuthService.java new file mode 100644 index 00000000..73b9c2d9 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/AuthService.java @@ -0,0 +1,92 @@ +package com.souzip.auth.application; + +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.provided.Auth; +import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.auth.application.required.TokenProvider; +import com.souzip.auth.application.required.UserPort; +import com.souzip.auth.domain.RefreshToken; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AuthService implements Auth { + + private final SocialClientFactory socialClientFactory; + private final UserPort userPort; + private final RefreshTokenRepository refreshTokenRepository; + private final TokenProvider tokenProvider; + + @Transactional + @Override + public LoginInfo login(Provider provider, String accessToken) { + var socialUserInfo = socialClientFactory.getClient(provider) + .getUserInfo(accessToken); + + UserInfo userInfo = userPort.findOrCreateUser( + provider, + socialUserInfo.providerId(), + socialUserInfo.email() + ); + + refreshTokenRepository.deleteByUserId(userInfo.id()); + + String newRefreshToken = tokenProvider.generateRefreshToken(); + refreshTokenRepository.save( + RefreshToken.create(userInfo.id(), newRefreshToken, tokenProvider.getRefreshTokenExpiresAt()) + ); + + log.info("로그인 성공 - userId: {}, provider: {}", userInfo.id(), provider); + + return new LoginInfo( + tokenProvider.generateAccessToken(userInfo.id()), + newRefreshToken, + userInfo.needsOnboarding(), + userInfo.userId(), + userInfo.nickname() + ); + } + + @Transactional + @Override + public RefreshInfo refresh(String refreshToken) { + RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(AuthException::invalidRefreshToken); + + if (storedToken.isExpired()) { + refreshTokenRepository.delete(storedToken); + + throw AuthException.expiredRefreshToken(); + } + + String newRefreshToken = tokenProvider.generateRefreshToken(); + + storedToken.rotate(newRefreshToken, tokenProvider.getRefreshTokenExpiresAt()); + + refreshTokenRepository.save(storedToken); + + log.debug("토큰 재발급 - userId: {}", storedToken.getUserId()); + + return new RefreshInfo( + tokenProvider.generateAccessToken(storedToken.getUserId()), + newRefreshToken + ); + } + + @Transactional + @Override + public void logout(Long userId) { + refreshTokenRepository.deleteByUserId(userId); + + log.info("로그아웃 - userId: {}", userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/SocialClientFactory.java b/src/main/java/com/souzip/auth/application/SocialClientFactory.java new file mode 100644 index 00000000..146b801b --- /dev/null +++ b/src/main/java/com/souzip/auth/application/SocialClientFactory.java @@ -0,0 +1,27 @@ +package com.souzip.auth.application; + +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.shared.domain.Provider; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@Component +public class SocialClientFactory { + + private final Map clients; + + public SocialClientFactory(List socialClients) { + this.clients = socialClients.stream() + .collect(Collectors.toMap(SocialClient::getProvider, c -> c)); + } + + public SocialClient getClient(Provider provider) { + return Optional.ofNullable(clients.get(provider)) + .orElseThrow(AuthException::unsupportedProvider); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/LoginInfo.java b/src/main/java/com/souzip/auth/application/dto/LoginInfo.java new file mode 100644 index 00000000..cfb95297 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/LoginInfo.java @@ -0,0 +1,10 @@ +package com.souzip.auth.application.dto; + +public record LoginInfo( + String accessToken, + String refreshToken, + boolean needsOnboarding, + String userId, + String nickname +) { +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java b/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java new file mode 100644 index 00000000..549433bb --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/RefreshInfo.java @@ -0,0 +1,7 @@ +package com.souzip.auth.application.dto; + +public record RefreshInfo( + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java b/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java new file mode 100644 index 00000000..9ab39b14 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/SocialUserInfo.java @@ -0,0 +1,4 @@ +package com.souzip.auth.application.dto; + +public record SocialUserInfo(String providerId, String email) { +} diff --git a/src/main/java/com/souzip/auth/application/dto/UserInfo.java b/src/main/java/com/souzip/auth/application/dto/UserInfo.java new file mode 100644 index 00000000..5fc2421d --- /dev/null +++ b/src/main/java/com/souzip/auth/application/dto/UserInfo.java @@ -0,0 +1,9 @@ +package com.souzip.auth.application.dto; + +public record UserInfo( + Long id, + String userId, + String nickname, + boolean needsOnboarding +) { +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java b/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java new file mode 100644 index 00000000..e32f6cd1 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/exception/AuthErrorCode.java @@ -0,0 +1,27 @@ +package com.souzip.auth.application.exception; + +import com.souzip.shared.exception.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), + + INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "토큰 형식이 올바르지 않습니다."), + INVALID_JWT_STRUCTURE(HttpStatus.BAD_REQUEST, "JWT 구조가 올바르지 않습니다."), + TOKEN_DECODE_FAILED(HttpStatus.BAD_REQUEST, "토큰 디코딩에 실패했습니다."), + TOKEN_PARSE_FAILED(HttpStatus.BAD_REQUEST, "토큰 파싱에 실패했습니다."), + UNSUPPORTED_PROVIDER(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인입니다."), + + KAKAO_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API 호출에 실패했습니다."), + GOOGLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 API 호출에 실패했습니다."), + APPLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 API 호출에 실패했습니다."); + + private final HttpStatus status; + + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/exception/AuthException.java b/src/main/java/com/souzip/auth/application/exception/AuthException.java new file mode 100644 index 00000000..8cb237a7 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/exception/AuthException.java @@ -0,0 +1,46 @@ +package com.souzip.auth.application.exception; + +import com.souzip.shared.exception.BusinessException; + +public class AuthException extends BusinessException { + + public AuthException(AuthErrorCode errorCode) { + super(errorCode); + } + + public static AuthException invalidRefreshToken() { + return new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + public static AuthException expiredRefreshToken() { + return new AuthException(AuthErrorCode.EXPIRED_REFRESH_TOKEN); + } + + public static AuthException unsupportedProvider() { + return new AuthException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + + public static AuthException invalidJwtStructure() { + return new AuthException(AuthErrorCode.INVALID_JWT_STRUCTURE); + } + + public static AuthException tokenDecodeFailed() { + return new AuthException(AuthErrorCode.TOKEN_DECODE_FAILED); + } + + public static AuthException tokenParseFailed() { + return new AuthException(AuthErrorCode.TOKEN_PARSE_FAILED); + } + + public static AuthException kakaoApiError() { + return new AuthException(AuthErrorCode.KAKAO_API_ERROR); + } + + public static AuthException googleApiError() { + return new AuthException(AuthErrorCode.GOOGLE_API_ERROR); + } + + public static AuthException appleApiError() { + return new AuthException(AuthErrorCode.APPLE_API_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/provided/Auth.java b/src/main/java/com/souzip/auth/application/provided/Auth.java new file mode 100644 index 00000000..3659c1b5 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/provided/Auth.java @@ -0,0 +1,13 @@ +package com.souzip.auth.application.provided; + +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.shared.domain.Provider; + +public interface Auth { + LoginInfo login(Provider provider, String accessToken); + + RefreshInfo refresh(String refreshToken); + + void logout(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java b/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java new file mode 100644 index 00000000..c2bcf69f --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/RefreshTokenRepository.java @@ -0,0 +1,24 @@ +package com.souzip.auth.application.required; + +import com.souzip.auth.domain.RefreshToken; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface RefreshTokenRepository extends Repository { + RefreshToken save(RefreshToken token); + + Optional findByToken(String token); + + void deleteByUserId(Long userId); + + void delete(RefreshToken token); + + @Modifying + @Query("DELETE FROM RefreshToken t WHERE t.expiresAt < :now") + int deleteAllExpiredBefore(@Param("now") LocalDateTime now); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/SocialClient.java b/src/main/java/com/souzip/auth/application/required/SocialClient.java new file mode 100644 index 00000000..1fb80087 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/SocialClient.java @@ -0,0 +1,10 @@ +package com.souzip.auth.application.required; + +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.shared.domain.Provider; + +public interface SocialClient { + SocialUserInfo getUserInfo(String accessToken); + + Provider getProvider(); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/TokenProvider.java b/src/main/java/com/souzip/auth/application/required/TokenProvider.java new file mode 100644 index 00000000..6757ee11 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/TokenProvider.java @@ -0,0 +1,11 @@ +package com.souzip.auth.application.required; + +import java.time.LocalDateTime; + +public interface TokenProvider { + String generateAccessToken(Long userId); + + String generateRefreshToken(); + + LocalDateTime getRefreshTokenExpiresAt(); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/application/required/UserPort.java b/src/main/java/com/souzip/auth/application/required/UserPort.java new file mode 100644 index 00000000..144e14d6 --- /dev/null +++ b/src/main/java/com/souzip/auth/application/required/UserPort.java @@ -0,0 +1,8 @@ +package com.souzip.auth.application.required; + +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.shared.domain.Provider; + +public interface UserPort { + UserInfo findOrCreateUser(Provider provider, String providerId, String email); +} \ No newline at end of file diff --git a/src/main/java/com/souzip/auth/domain/RefreshToken.java b/src/main/java/com/souzip/auth/domain/RefreshToken.java new file mode 100644 index 00000000..3cb51e67 --- /dev/null +++ b/src/main/java/com/souzip/auth/domain/RefreshToken.java @@ -0,0 +1,34 @@ +package com.souzip.auth.domain; + +import com.souzip.shared.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class RefreshToken extends BaseEntity { + + private Long userId; + + private String token; + + private LocalDateTime expiresAt; + + public static RefreshToken create(Long userId, String token, LocalDateTime expiresAt) { + return new RefreshToken(userId, token, expiresAt); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public void rotate(String newToken, LocalDateTime newExpiresAt) { + this.token = newToken; + this.expiresAt = newExpiresAt; + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java index 73c9282c..7447ceb9 100644 --- a/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java +++ b/src/main/java/com/souzip/domain/admin/application/AdminAuthService.java @@ -1,5 +1,6 @@ package com.souzip.domain.admin.application; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.application.command.AdminLoginCommand; import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; @@ -11,7 +12,6 @@ import com.souzip.domain.admin.model.Username; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; import com.souzip.domain.admin.repository.AdminRepository; -import com.souzip.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -37,7 +37,7 @@ public class AdminAuthService { @Transactional public AdminLoginResult login(AdminLoginCommand command) { Admin admin = adminRepository.findByUsername(new Username(command.username())) - .orElseThrow(AdminNotFoundException::new); + .orElseThrow(AdminNotFoundException::new); validatePassword(admin, command.password()); @@ -58,7 +58,7 @@ public RefreshResult refresh(String refreshTokenValue) { validateRefreshToken(refreshToken); Admin admin = adminRepository.findById(refreshToken.getAdminId()) - .orElseThrow(AdminNotFoundException::new); + .orElseThrow(AdminNotFoundException::new); String newAccessToken = jwtTokenProvider.generateToken(admin.getId().toString()); @@ -72,7 +72,7 @@ public RefreshResult refresh(String refreshTokenValue) { @Transactional public void logout(UUID adminId) { refreshTokenRepository.findByAdminId(adminId) - .ifPresent(refreshTokenRepository::delete); + .ifPresent(refreshTokenRepository::delete); } private void validatePassword(Admin admin, String password) { @@ -85,10 +85,10 @@ private void saveRefreshToken(Admin admin, String tokenValue) { LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); refreshTokenRepository.findByAdminId(admin.getId()) - .ifPresentOrElse( - token -> updateExistingToken(token, tokenValue, expiresAt), - () -> createNewToken(admin.getId(), tokenValue, expiresAt) - ); + .ifPresentOrElse( + token -> updateExistingToken(token, tokenValue, expiresAt), + () -> createNewToken(admin.getId(), tokenValue, expiresAt) + ); } private void updateExistingToken(AdminRefreshToken token, String tokenValue, LocalDateTime expiresAt) { @@ -103,7 +103,7 @@ private void createNewToken(UUID adminId, String tokenValue, LocalDateTime expir private AdminRefreshToken findRefreshToken(String tokenValue) { return refreshTokenRepository.findByToken(tokenValue) - .orElseThrow(AdminInvalidRefreshTokenException::new); + .orElseThrow(AdminInvalidRefreshTokenException::new); } private void validateRefreshToken(AdminRefreshToken refreshToken) { @@ -133,13 +133,15 @@ private RefreshResult renewRefreshToken(AdminRefreshToken refreshToken, Admin ad } public record AdminLoginResult( - Admin admin, - String accessToken, - String refreshToken - ) {} + Admin admin, + String accessToken, + String refreshToken + ) { + } public record RefreshResult( - String accessToken, - String refreshToken - ) {} + String accessToken, + String refreshToken + ) { + } } diff --git a/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java index 0000b793..df2894aa 100644 --- a/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java +++ b/src/main/java/com/souzip/domain/admin/application/AdminCityQueryUseCase.java @@ -2,7 +2,7 @@ import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; import com.souzip.domain.admin.application.query.CitySearchQuery; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; public interface AdminCityQueryUseCase { diff --git a/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java index 49ac7784..b5a385b2 100644 --- a/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java +++ b/src/main/java/com/souzip/domain/admin/application/port/CityQueryPort.java @@ -1,6 +1,7 @@ package com.souzip.domain.admin.application.port; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.time.LocalDateTime; public interface CityQueryPort { @@ -13,5 +14,6 @@ record CityQueryResult( String nameEn, Integer priority, LocalDateTime updatedAt - ) {} + ) { + } } diff --git a/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java index e59ca11e..baf66efa 100644 --- a/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java +++ b/src/main/java/com/souzip/domain/admin/application/query/AdminCityQueryService.java @@ -3,7 +3,7 @@ import com.souzip.domain.admin.application.AdminCityQueryUseCase; import com.souzip.domain.admin.application.port.CityQueryPort; import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +18,7 @@ public class AdminCityQueryService implements AdminCityQueryUseCase { @Override public PaginationResponse getCities(CitySearchQuery query) { return cityQueryPort.getCities( - query.countryId(), query.keyword(), query.pageNo(), query.pageSize() + query.countryId(), query.keyword(), query.pageNo(), query.pageSize() ); } } diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java index d71940ce..808d0030 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminErrorCode.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BaseErrorCode; +import com.souzip.shared.exception.BaseErrorCode; import org.springframework.http.HttpStatus; public enum AdminErrorCode implements BaseErrorCode { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminException.java b/src/main/java/com/souzip/domain/admin/exception/AdminException.java index 1645bfad..632db616 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminException extends BusinessException { public AdminException(AdminErrorCode errorCode) { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java b/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java index 50414923..f65ed114 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminExpiredRefreshTokenException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminExpiredRefreshTokenException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java b/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java index caca6044..2c6de256 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminInvalidRefreshTokenException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminInvalidRefreshTokenException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java b/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java index 1b753143..ef35020d 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminLoginFailedException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminLoginFailedException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java b/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java index 6a6a54cd..da896ed5 100644 --- a/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java +++ b/src/main/java/com/souzip/domain/admin/exception/AdminNotFoundException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class AdminNotFoundException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java b/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java index b24b2167..57218b9a 100644 --- a/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java +++ b/src/main/java/com/souzip/domain/admin/exception/InvalidUsernameException.java @@ -1,6 +1,6 @@ package com.souzip.domain.admin.exception; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; public class InvalidUsernameException extends BusinessException { diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java index 4bd49b64..94006b43 100644 --- a/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/persistence/CityQueryAdapter.java @@ -2,8 +2,10 @@ import com.souzip.domain.admin.application.port.CityQueryPort; import com.souzip.domain.city.application.port.CityAdminPort; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.util.List; + import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -17,26 +19,26 @@ public class CityQueryAdapter implements CityQueryPort { @Override public PaginationResponse getCities( - Long countryId, - String keyword, - int pageNo, - int pageSize + Long countryId, + String keyword, + int pageNo, + int pageSize ) { Page page = cityAdminPort.getCities( - countryId, - keyword, - PageRequest.of(pageNo - 1, pageSize) + countryId, + keyword, + PageRequest.of(pageNo - 1, pageSize) ); List content = page.getContent().stream() - .map(c -> new CityQueryResult( - c.id(), - c.nameKr(), - c.nameEn(), - c.priority(), - c.updatedAt() - )) - .toList(); + .map(c -> new CityQueryResult( + c.id(), + c.nameKr(), + c.nameEn(), + c.priority(), + c.updatedAt() + )) + .toList(); return PaginationResponse.of(page, content); } diff --git a/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java index f99ddbd2..bf59e5c8 100644 --- a/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java +++ b/src/main/java/com/souzip/domain/admin/infrastructure/security/jwt/AdminJwtAuthenticationFilter.java @@ -1,8 +1,8 @@ package com.souzip.domain.admin.infrastructure.security.jwt; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.model.Admin; import com.souzip.domain.admin.repository.AdminRepository; -import com.souzip.global.security.jwt.JwtTokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -10,8 +10,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; // ← 추가 -import org.springframework.security.core.authority.SimpleGrantedAuthority; // ← 추가 +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -19,7 +19,7 @@ import java.io.IOException; import java.util.Collections; -import java.util.List; // ← 추가 +import java.util.List; import java.util.UUID; @Slf4j @@ -101,8 +101,8 @@ private boolean isTokenInvalid(String token) { } private Admin getAdminFromToken(String token) { - String adminId = jwtTokenProvider.getUserIdFromToken(token); - return adminRepository.findById(UUID.fromString(adminId)).orElse(null); + Long adminId = jwtTokenProvider.getUserIdFromToken(token); + return adminRepository.findById(UUID.fromString(String.valueOf(adminId))).orElse(null); } private boolean isAdminAbsent(Admin admin) { @@ -111,11 +111,11 @@ private boolean isAdminAbsent(Admin admin) { private void setAuthentication(Admin admin) { List authorities = Collections.singletonList( - new SimpleGrantedAuthority("ROLE_" + admin.getRole().name()) + new SimpleGrantedAuthority("ROLE_" + admin.getRole().name()) ); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(admin, null, authorities); + new UsernamePasswordAuthenticationToken(admin, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java index 8e3ca7d0..e79925b8 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminAuthController.java @@ -1,23 +1,24 @@ package com.souzip.domain.admin.presentation; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.admin.application.AdminAuthService; import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; +import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; import com.souzip.domain.admin.presentation.request.AdminLoginRequest; import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; import com.souzip.domain.admin.presentation.response.AdminLoginResponse; import com.souzip.domain.admin.presentation.response.AdminRefreshResponse; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; -import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RequiredArgsConstructor @RequestMapping("/api/admin/auth") @RestController diff --git a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java index 3a5b1359..7bdd64cf 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java +++ b/src/main/java/com/souzip/domain/admin/presentation/AdminManagementController.java @@ -21,12 +21,14 @@ import com.souzip.domain.admin.presentation.request.UpdateCityRequest; import com.souzip.domain.admin.presentation.response.AdminResponse; import com.souzip.domain.admin.presentation.response.InviteAdminResponse; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; import jakarta.validation.Valid; + import java.util.List; import java.util.UUID; + import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -51,12 +53,12 @@ public class AdminManagementController { @SuperAdminOnly @PostMapping("/invite") public SuccessResponse inviteAdmin( - @Valid @RequestBody InviteAdminRequest request + @Valid @RequestBody InviteAdminRequest request ) { Admin admin = adminManagementUseCase.inviteAdmin(new InviteAdminCommand( - request.username(), - request.password(), - request.role() + request.username(), + request.password(), + request.role() )); return SuccessResponse.of(InviteAdminResponse.from(admin), "관리자 초대가 완료되었습니다."); } @@ -64,21 +66,21 @@ public SuccessResponse inviteAdmin( @SuperAdminOnly @GetMapping("/list") public SuccessResponse> getAdmins( - @ModelAttribute PaginationRequest paginationRequest + @ModelAttribute PaginationRequest paginationRequest ) { return SuccessResponse.of(AdminResponse.ofPageResult( - adminManagementUseCase.getAdmins( - paginationRequest.getPageNo(), - paginationRequest.getPageSize() - ) + adminManagementUseCase.getAdmins( + paginationRequest.getPageNo(), + paginationRequest.getPageSize() + ) )); } @SuperAdminOnly @DeleteMapping("/{adminId}") public SuccessResponse deleteAdmin( - @PathVariable UUID adminId, - @CurrentAdminId UUID requesterId + @PathVariable UUID adminId, + @CurrentAdminId UUID requesterId ) { adminManagementUseCase.deleteAdmin(adminId, requesterId); return SuccessResponse.of(null, "관리자가 삭제되었습니다."); @@ -95,15 +97,15 @@ public SuccessResponse> getCountries( @ViewerAccess @GetMapping("/cities") public SuccessResponse> getCities( - @RequestParam Long countryId, - @RequestParam(required = false) String keyword, - @ModelAttribute PaginationRequest paginationRequest + @RequestParam Long countryId, + @RequestParam(required = false) String keyword, + @ModelAttribute PaginationRequest paginationRequest ) { CitySearchQuery query = CitySearchQuery.of( - countryId, - keyword, - paginationRequest.getPageNo(), - paginationRequest.getPageSize() + countryId, + keyword, + paginationRequest.getPageNo(), + paginationRequest.getPageSize() ); return SuccessResponse.of(adminCityQueryUseCase.getCities(query)); } @@ -111,14 +113,14 @@ public SuccessResponse> getCities( @AdminAccess @PostMapping("/cities") public SuccessResponse createCity( - @Valid @RequestBody CreateCityRequest request + @Valid @RequestBody CreateCityRequest request ) { adminManagementUseCase.createCity(new AdminCreateCityCommand( - request.nameEn(), - request.nameKr(), - request.latitude(), - request.longitude(), - request.countryId() + request.nameEn(), + request.nameKr(), + request.latitude(), + request.longitude(), + request.countryId() )); return SuccessResponse.of(null, "도시가 추가되었습니다."); } @@ -126,7 +128,7 @@ public SuccessResponse createCity( @AdminAccess @DeleteMapping("/cities/{cityId}") public SuccessResponse deleteCity( - @PathVariable Long cityId + @PathVariable Long cityId ) { adminManagementUseCase.deleteCity(new AdminDeleteCityCommand(cityId)); return SuccessResponse.of(null, "도시가 삭제되었습니다."); @@ -135,8 +137,8 @@ public SuccessResponse deleteCity( @AdminAccess @PatchMapping("/cities/{cityId}/priority") public SuccessResponse updateCityPriority( - @PathVariable Long cityId, - @RequestParam(required = false) Integer priority + @PathVariable Long cityId, + @RequestParam(required = false) Integer priority ) { adminManagementUseCase.updateCityPriority(new AdminUpdateCityPriorityCommand(cityId, priority)); return SuccessResponse.of(null, "우선순위가 업데이트되었습니다."); diff --git a/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java index 37db88ea..33cd56e3 100644 --- a/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java +++ b/src/main/java/com/souzip/domain/admin/presentation/response/AdminResponse.java @@ -3,39 +3,40 @@ import com.souzip.domain.admin.application.AdminManagementService.AdminPageResult; import com.souzip.domain.admin.model.Admin; import com.souzip.domain.admin.model.AdminRole; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.time.LocalDateTime; import java.util.List; import java.util.UUID; public record AdminResponse( - UUID id, - String username, - AdminRole role, - LocalDateTime lastLoginAt, - LocalDateTime createdAt + UUID id, + String username, + AdminRole role, + LocalDateTime lastLoginAt, + LocalDateTime createdAt ) { public static AdminResponse from(Admin admin) { return new AdminResponse( - admin.getId(), - admin.getUsername().value(), - admin.getRole(), - admin.getLastLoginAt(), - admin.getCreatedAt() + admin.getId(), + admin.getUsername().value(), + admin.getRole(), + admin.getLastLoginAt(), + admin.getCreatedAt() ); } public static PaginationResponse ofPageResult(AdminPageResult result) { List content = result.admins().stream() - .map(AdminResponse::from) - .toList(); + .map(AdminResponse::from) + .toList(); return PaginationResponse.of( - content, - result.pageNo(), - result.pageSize(), - result.total(), - result.totalPages() + content, + result.pageNo(), + result.pageSize(), + result.total(), + result.totalPages() ); } } diff --git a/src/main/java/com/souzip/domain/audit/entity/AuditLog.java b/src/main/java/com/souzip/domain/audit/entity/AuditLog.java index 76f4bf3d..84aa6bab 100644 --- a/src/main/java/com/souzip/domain/audit/entity/AuditLog.java +++ b/src/main/java/com/souzip/domain/audit/entity/AuditLog.java @@ -1,7 +1,7 @@ package com.souzip.domain.audit.entity; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.domain.Provider; import jakarta.persistence.*; import lombok.*; @@ -11,13 +11,13 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity @Table( - indexes = { - @Index(name = "idx_audit_user_id", columnList = "user_id"), - @Index(name = "idx_audit_action", columnList = "action, created_at"), - @Index(name = "idx_audit_category", columnList = "category, created_at"), - @Index(name = "idx_audit_target", columnList = "target_type, target_id"), - @Index(name = "idx_audit_created_at", columnList = "created_at DESC") - } + indexes = { + @Index(name = "idx_audit_user_id", columnList = "user_id"), + @Index(name = "idx_audit_action", columnList = "action, created_at"), + @Index(name = "idx_audit_category", columnList = "category, created_at"), + @Index(name = "idx_audit_target", columnList = "target_type, target_id"), + @Index(name = "idx_audit_created_at", columnList = "created_at DESC") + } ) public class AuditLog extends BaseEntity { @@ -58,45 +58,45 @@ public class AuditLog extends BaseEntity { private String metadata; public static AuditLog success( - String userId, - AuditAction action, - String ipAddress, - String userAgent, - String appVersion, - String metadata + String userId, + AuditAction action, + String ipAddress, + String userAgent, + String appVersion, + String metadata ) { return AuditLog.builder() - .userId(userId) - .action(action) - .category(action.getCategory()) - .success(true) - .ipAddress(ipAddress) - .deviceType(DeviceType.fromUserAgent(userAgent)) - .appVersion(appVersion) - .userAgent(userAgent) - .metadata(metadata) - .build(); + .userId(userId) + .action(action) + .category(action.getCategory()) + .success(true) + .ipAddress(ipAddress) + .deviceType(DeviceType.fromUserAgent(userAgent)) + .appVersion(appVersion) + .userAgent(userAgent) + .metadata(metadata) + .build(); } public static AuditLog failure( - AuditAction action, - String failureReason, - String ipAddress, - String userAgent, - String appVersion, - String metadata + AuditAction action, + String failureReason, + String ipAddress, + String userAgent, + String appVersion, + String metadata ) { return AuditLog.builder() - .action(action) - .category(action.getCategory()) - .success(false) - .failureReason(failureReason) - .ipAddress(ipAddress) - .deviceType(DeviceType.fromUserAgent(userAgent)) - .appVersion(appVersion) - .userAgent(userAgent) - .metadata(metadata) - .build(); + .action(action) + .category(action.getCategory()) + .success(false) + .failureReason(failureReason) + .ipAddress(ipAddress) + .deviceType(DeviceType.fromUserAgent(userAgent)) + .appVersion(appVersion) + .userAgent(userAgent) + .metadata(metadata) + .build(); } public void setOAuthProvider(Provider provider) { diff --git a/src/main/java/com/souzip/domain/audit/service/AuditService.java b/src/main/java/com/souzip/domain/audit/service/AuditService.java index f0ad70b7..5d4a835a 100644 --- a/src/main/java/com/souzip/domain/audit/service/AuditService.java +++ b/src/main/java/com/souzip/domain/audit/service/AuditService.java @@ -3,7 +3,7 @@ import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.audit.entity.AuditLog; import com.souzip.domain.audit.repository.AuditLogRepository; -import com.souzip.global.audit.dto.AuditContext; +import com.souzip.shared.audit.dto.AuditContext; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; @@ -46,7 +46,7 @@ public void logFailure(AuditAction action, String failureReason, String ipAddress, String userAgent, String appVersion) { try { AuditLog auditLog = createFailureLog(action, failureReason, - ipAddress, userAgent, appVersion); + ipAddress, userAgent, appVersion); saveLog(auditLog); incrementFailureMetric(action); logFailureWarning(action, failureReason); @@ -58,12 +58,12 @@ public void logFailure(AuditAction action, String failureReason, private AuditLog createSuccessLog(AuditContext context) { AuditLog auditLog = AuditLog.success( - context.getUserId(), - context.getAction(), - context.getIpAddress(), - context.getUserAgent(), - context.getAppVersion(), - context.getMetadata() + context.getUserId(), + context.getAction(), + context.getIpAddress(), + context.getUserAgent(), + context.getAppVersion(), + context.getMetadata() ); enrichLogWithOptionalFields(auditLog, context); @@ -84,12 +84,12 @@ private void enrichLogWithOptionalFields(AuditLog auditLog, AuditContext context private AuditLog createFailureLog(AuditAction action, String failureReason, String ipAddress, String userAgent, String appVersion) { return AuditLog.failure( - action, - failureReason, - ipAddress, - userAgent, - appVersion, - null + action, + failureReason, + ipAddress, + userAgent, + appVersion, + null ); } @@ -111,24 +111,24 @@ private void incrementMetric(AuditAction action, boolean success) { private Counter buildCounter(AuditAction action, boolean success) { return meterRegistry.counter( - METRIC_NAME, - TAG_ACTION, action.name(), - TAG_CATEGORY, action.getCategory().name(), - TAG_SUCCESS, String.valueOf(success) + METRIC_NAME, + TAG_ACTION, action.name(), + TAG_CATEGORY, action.getCategory().name(), + TAG_SUCCESS, String.valueOf(success) ); } private void logSuccessInfo(AuditContext context) { log.info("[Audit success] - userId: {}, action: {}, category: {}", - context.getUserId(), - context.getAction(), - context.getAction().getCategory()); + context.getUserId(), + context.getAction(), + context.getAction().getCategory()); } private void logFailureWarning(AuditAction action, String failureReason) { log.warn("[Audit failure] - action: {}, reason: {}", - action, - failureReason); + action, + failureReason); } private void logSaveError(Exception e) { diff --git a/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java b/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java deleted file mode 100644 index d831bd87..00000000 --- a/src/main/java/com/souzip/domain/auth/client/AppleApiClient.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.souzip.domain.auth.dto.AppleUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Base64; - -@Slf4j -@RequiredArgsConstructor -@Component -public class AppleApiClient implements OAuthClient { - - private final ObjectMapper objectMapper; - - @Override - public OAuthUserInfo getUserInfo(String identityToken) { - try { - return parseIdentityToken(identityToken); - } catch (Exception e) { - throw new BusinessException(ErrorCode.APPLE_API_ERROR); - } - } - - private AppleUserInfo parseIdentityToken(String identityToken) { - String[] parts = splitToken(identityToken); - String payload = decodePayload(parts[1]); - return parseJson(payload); - } - - private String[] splitToken(String identityToken) { - String[] parts = identityToken.split("\\."); - if (parts.length < 2) { - throw new BusinessException(ErrorCode.INVALID_JWT_STRUCTURE); - } - return parts; - } - - private String decodePayload(String encodedPayload) { - try { - return new String(Base64.getUrlDecoder().decode(encodedPayload)); - } catch (IllegalArgumentException e) { - throw new BusinessException(ErrorCode.TOKEN_DECODE_FAILED); - } - } - - private AppleUserInfo parseJson(String payload) { - try { - return objectMapper.readValue(payload, AppleUserInfo.class); - } catch (Exception e) { - throw new BusinessException(ErrorCode.TOKEN_PARSE_FAILED); - } - } -} diff --git a/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java b/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java deleted file mode 100644 index 7b803b43..00000000 --- a/src/main/java/com/souzip/domain/auth/client/GoogleApiClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.auth.dto.GoogleUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.oauth.OAuthProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Slf4j -@RequiredArgsConstructor -@Component -public class GoogleApiClient implements OAuthClient { - - private final RestTemplate restTemplate; - private final OAuthProperties oAuthProperties; - - @Override - public OAuthUserInfo getUserInfo(String accessToken) { - try { - return callGoogleUserInfoApi(accessToken); - } catch (Exception e) { - log.error("구글 사용자 정보 조회 중 오류 발생", e); - throw new BusinessException(ErrorCode.GOOGLE_API_ERROR); - } - } - - private GoogleUserInfo callGoogleUserInfoApi(String accessToken) { - String url = oAuthProperties.getGoogle().getUserInfoUrl(); - HttpHeaders headers = createAuthHeaders(accessToken); - HttpEntity request = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - request, - GoogleUserInfo.class - ); - - return response.getBody(); - } - - private HttpHeaders createAuthHeaders(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - return headers; - } -} diff --git a/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java b/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java deleted file mode 100644 index e9a57c2f..00000000 --- a/src/main/java/com/souzip/domain/auth/client/KakaoApiClient.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.auth.dto.KakaoUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.oauth.OAuthProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Slf4j -@RequiredArgsConstructor -@Component -public class KakaoApiClient implements OAuthClient { - - private final RestTemplate restTemplate; - private final OAuthProperties oAuthProperties; - - @Override - public OAuthUserInfo getUserInfo(String accessToken) { - try { - return callKakaoUserInfoApi(accessToken); - } catch (Exception e) { - log.error("카카오 사용자 정보 조회 중 오류 발생", e); - throw new BusinessException(ErrorCode.KAKAO_API_ERROR); - } - } - - private KakaoUserInfo callKakaoUserInfoApi(String accessToken) { - String url = oAuthProperties.getKakao().getUserInfoUrl(); - HttpHeaders headers = createAuthHeaders(accessToken); - HttpEntity request = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - request, - KakaoUserInfo.class - ); - - return response.getBody(); - } - - private HttpHeaders createAuthHeaders(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - return headers; - } -} diff --git a/src/main/java/com/souzip/domain/auth/client/OAuthClient.java b/src/main/java/com/souzip/domain/auth/client/OAuthClient.java deleted file mode 100644 index 0322a6e6..00000000 --- a/src/main/java/com/souzip/domain/auth/client/OAuthClient.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.auth.dto.OAuthUserInfo; - -public interface OAuthClient { - OAuthUserInfo getUserInfo(String accessToken); -} diff --git a/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java b/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java deleted file mode 100644 index 32acde69..00000000 --- a/src/main/java/com/souzip/domain/auth/client/OAuthClientFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.domain.auth.client; - -import com.souzip.domain.user.entity.Provider; -import org.springframework.stereotype.Component; - -import java.util.EnumMap; -import java.util.Map; - -@Component -public class OAuthClientFactory { - - private final Map clients; - - public OAuthClientFactory( - KakaoApiClient kakaoApiClient, - GoogleApiClient googleApiClient, - AppleApiClient appleApiClient - ) { - this.clients = new EnumMap<>(Provider.class); - clients.put(Provider.KAKAO, kakaoApiClient); - clients.put(Provider.GOOGLE, googleApiClient); - clients.put(Provider.APPLE, appleApiClient); - } - public OAuthClient getClient(Provider provider) { - return clients.get(provider); - } -} diff --git a/src/main/java/com/souzip/domain/auth/controller/AuthController.java b/src/main/java/com/souzip/domain/auth/controller/AuthController.java deleted file mode 100644 index 3010fa9f..00000000 --- a/src/main/java/com/souzip/domain/auth/controller/AuthController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.souzip.domain.auth.controller; - -import com.souzip.domain.auth.dto.LoginRequest; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.RefreshRequest; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.service.AuthService; -import com.souzip.domain.user.entity.Provider; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RequestMapping("/api/auth") -@RestController -public class AuthController { - - private final AuthService authService; - - @PostMapping("/login/{provider}") - public SuccessResponse login(@PathVariable String provider, @RequestBody LoginRequest request) { - LoginResponse response = authService.login(Provider.from(provider), request.accessToken()); - return SuccessResponse.of(response); - } - - @PostMapping("/refresh") - public SuccessResponse refresh(@RequestBody RefreshRequest request) { - RefreshResponse response = authService.refresh(request.refreshToken()); - return SuccessResponse.of(response); - } - - @PostMapping("/logout") - @RequireAuth - public SuccessResponse logout(@CurrentUserId Long currentUserId) { - authService.logout(currentUserId); - return SuccessResponse.of(null, "로그아웃 되었습니다."); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java deleted file mode 100644 index 4315a7e3..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/AppleUserInfo.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class AppleUserInfo implements OAuthUserInfo { - - private String sub; - private String email; - - @JsonProperty("email_verified") - private Boolean emailVerified; - - @JsonProperty("transfer_sub") - private String transferSub; - - @Override - public String getProviderId() { - return sub; - } - - @Override - public String getName() { - if (isValidEmail(email)) { - return email.split("@")[0]; - } - return "apple.user"; - } - - @Override - public String getEmail() { - return email; - } - - public String getTransferSub() { - return transferSub; - } - - private boolean isValidEmail(String email) { - return email != null && email.contains("@"); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java deleted file mode 100644 index 979277ca..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/GoogleUserInfo.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class GoogleUserInfo implements OAuthUserInfo { - - @JsonProperty("id") - private String id; - - @JsonProperty("email") - private String email; - - @JsonProperty("name") - private String name; - - @Override - public String getProviderId() { - return id; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getEmail() { - return email; - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java deleted file mode 100644 index ecde2a47..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/KakaoUserInfo.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class KakaoUserInfo implements OAuthUserInfo { - - private Long id; - - @JsonProperty("kakao_account") - private KakaoAccount kakaoAccount; - - @Getter - @NoArgsConstructor - public static class KakaoAccount { - - private Profile profile; - private String email; - - @Getter - @NoArgsConstructor - public static class Profile { - private String nickname; - } - } - - @Override - public String getProviderId() { - return String.valueOf(id); - } - - @Override - public String getName() { - return kakaoAccount.profile.nickname; - } - - @Override - public String getEmail() { - return kakaoAccount.email; - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java b/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java deleted file mode 100644 index 2dff0d95..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/LoginRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.souzip.domain.auth.dto; - -public record LoginRequest( - String accessToken -) {} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java b/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java deleted file mode 100644 index 294821b0..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/LoginResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.souzip.domain.auth.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class LoginResponse { - private String accessToken; - private String refreshToken; - private LoginUserInfo user; - private boolean needsOnboarding; - - public static LoginResponse of( - String accessToken, - String refreshToken, - LoginUserInfo user, - boolean needsOnboarding - ) { - return LoginResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .user(user) - .needsOnboarding(needsOnboarding) - .build(); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java deleted file mode 100644 index 8067d298..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/LoginUserInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.domain.auth.dto; - -import com.souzip.domain.user.entity.User; - -public record LoginUserInfo( - String userId, - String nickname -) { - public static LoginUserInfo from(User user) { - return new LoginUserInfo( - user.getUserId(), - user.getNickname() - ); - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java deleted file mode 100644 index ee0818d2..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/OAuthUserInfo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.domain.auth.dto; - -public interface OAuthUserInfo { - String getProviderId(); - String getName(); - String getEmail(); - default String getProfileImageUrl() { - return null; - } -} diff --git a/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java b/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java deleted file mode 100644 index b410f1cb..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/RefreshRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.souzip.domain.auth.dto; - -public record RefreshRequest( - String refreshToken -) { -} diff --git a/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java b/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java deleted file mode 100644 index 8588c3e7..00000000 --- a/src/main/java/com/souzip/domain/auth/dto/RefreshResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.souzip.domain.auth.dto; - -public record RefreshResponse( - String accessToken, - String refreshToken -) { - public static RefreshResponse of(String accessToken, String refreshToken) { - return new RefreshResponse(accessToken, refreshToken); - } -} diff --git a/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java b/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java deleted file mode 100644 index 7808a7b8..00000000 --- a/src/main/java/com/souzip/domain/auth/entity/RefreshToken.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.souzip.domain.auth.entity; - -import com.souzip.domain.user.entity.User; -import com.souzip.domain.shared.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Getter -@Builder(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Table(name = "refresh_token", - indexes = { - @Index(name = "idx_refresh_token_user_id", columnList = "user_id"), - @Index(name = "idx_refresh_token_token", columnList = "token") - } -) -@Entity -public class RefreshToken extends BaseEntity { - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) - private User user; - - @Column(nullable = false, unique = true) - private String token; - - @Column(nullable = false) - private LocalDateTime expiresAt; - - public static RefreshToken of(User user, String token, LocalDateTime expiresAt) { - return RefreshToken.builder() - .user(user) - .token(token) - .expiresAt(expiresAt) - .build(); - } - - public boolean isExpired() { - return LocalDateTime.now().isAfter(expiresAt); - } - - public void updateToken(String newToken, LocalDateTime newExpiresAt) { - this.token = newToken; - this.expiresAt = newExpiresAt; - } -} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java deleted file mode 100644 index afd50707..00000000 --- a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.souzip.domain.auth.repository; - -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.user.entity.User; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RefreshTokenRepository extends JpaRepository, RefreshTokenRepositoryCustom { - - Optional findByUser(User user); - - Optional findByToken(String token); -} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java deleted file mode 100644 index d3d0eb70..00000000 --- a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryCustom.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.domain.auth.repository; - -import java.time.LocalDateTime; - -public interface RefreshTokenRepositoryCustom { - - int deleteAllByExpiresAtBefore(LocalDateTime now); -} diff --git a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java b/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java deleted file mode 100644 index 27153247..00000000 --- a/src/main/java/com/souzip/domain/auth/repository/RefreshTokenRepositoryImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.souzip.domain.auth.repository; - -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.time.LocalDateTime; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -import static com.souzip.domain.auth.entity.QRefreshToken.refreshToken; - -@RequiredArgsConstructor -@Repository -public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom { - - private final JPAQueryFactory queryFactory; - - @Override - public int deleteAllByExpiresAtBefore(LocalDateTime now) { - long deletedCount = queryFactory - .delete(refreshToken) - .where(refreshToken.expiresAt.lt(now)) - .execute(); - - return (int) deletedCount; - } -} diff --git a/src/main/java/com/souzip/domain/auth/service/AuthService.java b/src/main/java/com/souzip/domain/auth/service/AuthService.java deleted file mode 100644 index 639b09ce..00000000 --- a/src/main/java/com/souzip/domain/auth/service/AuthService.java +++ /dev/null @@ -1,241 +0,0 @@ -package com.souzip.domain.auth.service; - -import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.domain.auth.dto.AppleUserInfo; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.domain.auth.client.OAuthClient; -import com.souzip.domain.auth.client.OAuthClientFactory; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.LoginUserInfo; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.auth.repository.RefreshTokenRepository; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.security.jwt.JwtTokenProvider; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -@Slf4j -@RequiredArgsConstructor -@Service -public class AuthService { - - private static final int REFRESH_TOKEN_VALIDITY_DAYS = 30; - private static final int REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS = 10; - - private final OAuthClientFactory oauthClientFactory; - private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; - private final JwtTokenProvider jwtTokenProvider; - - @Audit(action = AuditAction.LOGIN) - @Transactional - public LoginResponse login(Provider provider, String oauthAccessToken) { - OAuthClient oauthClient = oauthClientFactory.getClient(provider); - OAuthUserInfo oauthUserInfo = oauthClient.getUserInfo(oauthAccessToken); - - User user = findOrCreateUser(provider, oauthUserInfo); - - user.updateLastLoginAt(); - - String accessToken = jwtTokenProvider.generateToken(user.getUserId()); - String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId()); - - saveRefreshToken(user, refreshToken); - - LoginUserInfo userInfo = LoginUserInfo.from(user); - boolean needsOnboarding = user.needsOnboarding(); - - return LoginResponse.of(accessToken, refreshToken, userInfo, needsOnboarding); - } - - @Transactional - public RefreshResponse refresh(String refreshTokenValue) { - RefreshToken refreshToken = findRefreshToken(refreshTokenValue); - validateRefreshToken(refreshToken); - - User user = refreshToken.getUser(); - String newAccessToken = jwtTokenProvider.generateToken(user.getUserId()); - - if (isRefreshTokenExpiringSoon(refreshToken)) { - return renewRefreshToken(refreshToken, user, newAccessToken); - } - - return RefreshResponse.of(newAccessToken, refreshToken.getToken()); - } - - @Audit(action = AuditAction.LOGOUT) - @Transactional - public void logout(Long currentUserId) { - User user = userRepository.findById(currentUserId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - refreshTokenRepository.findByUser(user) - .ifPresent(refreshTokenRepository::delete); - } - - private User findOrCreateUser(Provider provider, OAuthUserInfo oauthUserInfo) { - if (isAppleProvider(provider)) { - return findOrCreateAppleUser(oauthUserInfo); - } - - return findOrCreateGeneralUser(provider, oauthUserInfo); - } - - private User findOrCreateGeneralUser(Provider provider, OAuthUserInfo oauthUserInfo) { - Optional existingUser = userRepository - .findByProviderAndProviderId(provider, oauthUserInfo.getProviderId()); - - return existingUser.map(user -> restoreIfDeleted(user, oauthUserInfo)) - .orElseGet(() -> createUser(provider, oauthUserInfo)); - } - - private User findOrCreateAppleUser(OAuthUserInfo oauthUserInfo) { - String newProviderId = oauthUserInfo.getProviderId(); - String transferSub = extractTransferSub(oauthUserInfo); - - Optional existingUser = findByProviderId(newProviderId); - if (existingUser.isPresent()) { - return handleExistingUser(existingUser.get(), oauthUserInfo, newProviderId); - } - - if (hasTransferSub(transferSub)) { - Optional migratedUser = attemptMigration(transferSub, newProviderId, oauthUserInfo); - - return migratedUser.orElseGet(() -> createNewAppleUser(newProviderId, oauthUserInfo)); - } - - return createNewAppleUser(newProviderId, oauthUserInfo); - } - - private Optional findByProviderId(String providerId) { - return userRepository.findByProviderAndProviderId(Provider.APPLE, providerId); - } - - private String extractTransferSub(OAuthUserInfo oauthUserInfo) { - if (oauthUserInfo instanceof AppleUserInfo appleUserInfo) { - return appleUserInfo.getTransferSub(); - } - return null; - } - - private boolean hasTransferSub(String transferSub) { - return transferSub != null; - } - - private User handleExistingUser(User user, OAuthUserInfo oauthUserInfo, String providerId) { - log.info("Apple 사용자 로그인 - providerId로 찾음: {}", providerId); - return restoreIfDeleted(user, oauthUserInfo); - } - - private Optional attemptMigration(String transferSub, String newProviderId, OAuthUserInfo oauthUserInfo) { - Optional existingUser = userRepository.findByTransferIdentifier(transferSub); - - return existingUser.map(user -> migrateUser(user, transferSub, newProviderId, oauthUserInfo)); - } - - private User migrateUser(User user, String transferSub, String newProviderId, OAuthUserInfo oauthUserInfo) { - log.info("Apple 앱 이전 감지 - 기존 providerId: {}, transfer_sub: {}, 새 providerId: {}", - user.getProviderId(), transferSub, newProviderId); - - user.updateProviderId(newProviderId); - userRepository.save(user); - - User restoredUser = restoreIfDeleted(user, oauthUserInfo); - - log.info("Apple 사용자 마이그레이션 완료: userId={}", user.getId()); - return restoredUser; - } - - private User createNewAppleUser(String providerId, OAuthUserInfo oauthUserInfo) { - log.info("새로운 Apple 사용자 생성: providerId={}", providerId); - return createUser(Provider.APPLE, oauthUserInfo); - } - - private User restoreIfDeleted(User user, OAuthUserInfo oauthUserInfo) { - if (user.isDeleted()) { - String name = oauthUserInfo.getName(); - user.restore(name); - } - return user; - } - - private User createUser(Provider provider, OAuthUserInfo oauthUserInfo) { - User user = User.of(provider, oauthUserInfo); - return userRepository.save(user); - } - - private boolean isAppleProvider(Provider provider) { - return provider == Provider.APPLE; - } - - private RefreshResponse renewRefreshToken(RefreshToken refreshToken, User user, String newAccessToken) { - String newRefreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId()); - updateRefreshToken(refreshToken, newRefreshToken); - - log.info("Refresh Token 갱신: userId={}, remainingDays={}", - user.getUserId(), getRemainingDays(refreshToken)); - - return RefreshResponse.of(newAccessToken, newRefreshToken); - } - - private void saveRefreshToken(User user, String tokenValue) { - LocalDateTime expiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); - - refreshTokenRepository.findByUser(user) - .ifPresentOrElse( - token -> updateExistingToken(token, tokenValue, expiresAt), - () -> createNewToken(user, tokenValue, expiresAt) - ); - } - - private void updateExistingToken(RefreshToken token, String tokenValue, LocalDateTime expiresAt) { - token.updateToken(tokenValue, expiresAt); - } - - private void createNewToken(User user, String tokenValue, LocalDateTime expiresAt) { - RefreshToken newToken = RefreshToken.of(user, tokenValue, expiresAt); - refreshTokenRepository.save(newToken); - } - - private RefreshToken findRefreshToken(String tokenValue) { - return refreshTokenRepository.findByToken(tokenValue) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN)); - } - - private void validateRefreshToken(RefreshToken refreshToken) { - if (refreshToken.isExpired()) { - deleteExpiredToken(refreshToken); - throw new BusinessException(ErrorCode.EXPIRED_REFRESH_TOKEN); - } - } - - private void deleteExpiredToken(RefreshToken refreshToken) { - refreshTokenRepository.delete(refreshToken); - log.info("만료된 Refresh Token 삭제: token={}", refreshToken.getId()); - } - - private boolean isRefreshTokenExpiringSoon(RefreshToken refreshToken) { - LocalDateTime threshold = LocalDateTime.now().plusDays(REFRESH_TOKEN_RENEWAL_THRESHOLD_DAYS); - return refreshToken.getExpiresAt().isBefore(threshold); - } - - private void updateRefreshToken(RefreshToken refreshToken, String newTokenValue) { - LocalDateTime newExpiresAt = LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS); - refreshToken.updateToken(newTokenValue, newExpiresAt); - } - - private long getRemainingDays(RefreshToken refreshToken) { - return java.time.Duration.between(LocalDateTime.now(), refreshToken.getExpiresAt()).toDays(); - } -} diff --git a/src/main/java/com/souzip/domain/category/controller/CategoryController.java b/src/main/java/com/souzip/domain/category/controller/CategoryController.java index 7218f073..65408844 100644 --- a/src/main/java/com/souzip/domain/category/controller/CategoryController.java +++ b/src/main/java/com/souzip/domain/category/controller/CategoryController.java @@ -2,7 +2,7 @@ import com.souzip.domain.category.dto.CategoriesResponse; import com.souzip.domain.category.service.CategoryService; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java b/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java index 08eb790e..45b184b7 100644 --- a/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java +++ b/src/main/java/com/souzip/domain/city/application/command/CityCommandService.java @@ -9,9 +9,11 @@ import com.souzip.domain.city.service.CityPriorityDomainService; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.math.BigDecimal; + import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/souzip/domain/city/entity/City.java b/src/main/java/com/souzip/domain/city/entity/City.java index 6d116753..83c936c0 100644 --- a/src/main/java/com/souzip/domain/city/entity/City.java +++ b/src/main/java/com/souzip/domain/city/entity/City.java @@ -1,9 +1,9 @@ package com.souzip.domain.city.entity; import com.souzip.domain.country.entity.Country; -import com.souzip.domain.shared.BaseEntity; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; @@ -15,9 +15,9 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity @Table(name = "city", indexes = { - @Index(name = "idx_city_country", columnList = "country_id"), - @Index(name = "idx_city_name", columnList = "name_en, name_kr"), - @Index(name = "idx_city_priority", columnList = "country_id, priority") + @Index(name = "idx_city_country", columnList = "country_id"), + @Index(name = "idx_city_name", columnList = "name_en, name_kr"), + @Index(name = "idx_city_priority", columnList = "country_id, priority") }) public class City extends BaseEntity { @@ -41,20 +41,20 @@ public class City extends BaseEntity { private Country country; public static City create( - String nameEn, - String nameKr, - BigDecimal latitude, - BigDecimal longitude, - Country country + String nameEn, + String nameKr, + BigDecimal latitude, + BigDecimal longitude, + Country country ) { return City.builder() - .nameEn(nameEn) - .nameKr(nameKr) - .latitude(latitude) - .longitude(longitude) - .country(country) - .priority(null) - .build(); + .nameEn(nameEn) + .nameKr(nameKr) + .latitude(latitude) + .longitude(longitude) + .country(country) + .priority(null) + .build(); } public void updatePriority(Integer priority) { diff --git a/src/main/java/com/souzip/domain/country/controller/CountryController.java b/src/main/java/com/souzip/domain/country/controller/CountryController.java index b93a154c..6d804ddc 100644 --- a/src/main/java/com/souzip/domain/country/controller/CountryController.java +++ b/src/main/java/com/souzip/domain/country/controller/CountryController.java @@ -2,7 +2,7 @@ import com.souzip.domain.country.dto.CountryListResponse; import com.souzip.domain.country.service.CountryService; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/souzip/domain/country/entity/Country.java b/src/main/java/com/souzip/domain/country/entity/Country.java index 4412709e..d6fb33a0 100644 --- a/src/main/java/com/souzip/domain/country/entity/Country.java +++ b/src/main/java/com/souzip/domain/country/entity/Country.java @@ -1,21 +1,11 @@ package com.souzip.domain.country.entity; import com.souzip.domain.currency.entity.Currency; -import com.souzip.domain.shared.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.ConstraintMode; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.ForeignKey; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import com.souzip.shared.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + import java.math.BigDecimal; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -54,26 +44,26 @@ public class Country extends BaseEntity { private Currency currency; public static Country of( - String nameEn, - String nameKr, - String code, - String capital, - Region region, - String imageUrl, - BigDecimal latitude, - BigDecimal longitude, - Currency currency + String nameEn, + String nameKr, + String code, + String capital, + Region region, + String imageUrl, + BigDecimal latitude, + BigDecimal longitude, + Currency currency ) { return Country.builder() - .nameEn(nameEn) - .nameKr(nameKr) - .code(code) - .capital(capital) - .region(region) - .imageUrl(imageUrl) - .latitude(latitude) - .longitude(longitude) - .currency(currency) - .build(); + .nameEn(nameEn) + .nameKr(nameKr) + .code(code) + .capital(capital) + .region(region) + .imageUrl(imageUrl) + .latitude(latitude) + .longitude(longitude) + .currency(currency) + .build(); } } diff --git a/src/main/java/com/souzip/domain/country/service/CountryService.java b/src/main/java/com/souzip/domain/country/service/CountryService.java index d0916061..2d9866c4 100644 --- a/src/main/java/com/souzip/domain/country/service/CountryService.java +++ b/src/main/java/com/souzip/domain/country/service/CountryService.java @@ -9,8 +9,8 @@ import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.entity.Currency; import com.souzip.domain.currency.repository.CurrencyRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java b/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java index f025016d..51cafe0e 100644 --- a/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java +++ b/src/main/java/com/souzip/domain/currency/controller/CurrencyController.java @@ -1,9 +1,9 @@ package com.souzip.domain.currency.controller; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.currency.dto.CurrencyResponse; import com.souzip.domain.currency.service.CurrencyService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/src/main/java/com/souzip/domain/currency/service/CurrencyService.java b/src/main/java/com/souzip/domain/currency/service/CurrencyService.java index 060668ed..202740c3 100644 --- a/src/main/java/com/souzip/domain/currency/service/CurrencyService.java +++ b/src/main/java/com/souzip/domain/currency/service/CurrencyService.java @@ -3,8 +3,8 @@ import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.dto.CurrencyResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java b/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java index 892a91fc..7434a624 100644 --- a/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java +++ b/src/main/java/com/souzip/domain/exchangerate/service/ExchangeRateService.java @@ -14,8 +14,8 @@ import com.souzip.domain.souvenir.dto.PriceData; import com.souzip.domain.souvenir.dto.PriceResponse; import com.souzip.domain.souvenir.vo.PriceInfo; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -42,9 +42,9 @@ public class ExchangeRateService { private final CurrencyRepository currencyRepository; public ExchangeCalculatedPrice calculatePrice( - String countryCode, - Integer localPrice, - Integer krwPrice + String countryCode, + Integer localPrice, + Integer krwPrice ) { CountryResponseDto country = countryService.getCountryByCode(countryCode); ExchangeRateResponseDto rate = getRate(country.currency().code()); @@ -61,9 +61,9 @@ public ExchangeCalculatedPrice calculatePrice( } public PriceData calculatePriceData( - Integer price, - String currency, - String countryCode + Integer price, + String currency, + String countryCode ) { if (hasInvalidPriceInput(price, currency)) { return createEmptyPriceData(); @@ -75,16 +75,16 @@ public PriceData calculatePriceData( String localCurrency = getLocalCurrency(countryCode); PriceInfo convertedPrice = calculateConvertedPrice( - originalPrice, - localCurrency, - exchangeAmount + originalPrice, + localCurrency, + exchangeAmount ); return new PriceData( - originalPrice, - exchangeAmount, - currencySymbol, - convertedPrice + originalPrice, + exchangeAmount, + currencySymbol, + convertedPrice ); } @@ -97,10 +97,10 @@ public PriceResponse createPriceResponse(PriceInfo originalPrice, PriceInfo conv String convertedSymbol = getCurrencySymbol(convertedPrice.getCurrency()); return PriceResponse.of( - originalPrice.getAmount(), - originalSymbol, - convertedPrice.getAmount(), - convertedSymbol + originalPrice.getAmount(), + originalSymbol, + convertedPrice.getAmount(), + convertedSymbol ); } @@ -114,37 +114,37 @@ private boolean hasKrwPrice(Integer krwPrice) { private ExchangeCalculatedPrice createEmptyPrice(CountryResponseDto country) { return new ExchangeCalculatedPrice( - ZERO_PRICE, - ZERO_PRICE, - country.currency().symbol() + ZERO_PRICE, + ZERO_PRICE, + country.currency().symbol() ); } private ExchangeCalculatedPrice fromLocalPrice( - Integer localPrice, - ExchangeRateResponseDto rate, - CountryResponseDto country + Integer localPrice, + ExchangeRateResponseDto rate, + CountryResponseDto country ) { int krw = multiply(localPrice, rate.rate()); return new ExchangeCalculatedPrice( - localPrice, - krw, - country.currency().symbol() + localPrice, + krw, + country.currency().symbol() ); } private ExchangeCalculatedPrice fromKrwPrice( - Integer krwPrice, - ExchangeRateResponseDto rate, - CountryResponseDto country + Integer krwPrice, + ExchangeRateResponseDto rate, + CountryResponseDto country ) { int local = divide(krwPrice, rate.rate()); return new ExchangeCalculatedPrice( - local, - krwPrice, - country.currency().symbol() + local, + krwPrice, + country.currency().symbol() ); } @@ -181,9 +181,9 @@ private PriceData createEmptyPriceData() { } private PriceInfo calculateConvertedPrice( - PriceInfo originalPrice, - String localCurrency, - Integer exchangeAmount + PriceInfo originalPrice, + String localCurrency, + Integer exchangeAmount ) { if (isKrwInput(originalPrice)) { return convertKrwToLocal(originalPrice.getAmount(), localCurrency); @@ -207,8 +207,8 @@ private boolean hasNoOriginalPrice(PriceInfo originalPrice) { private String getLocalCurrency(String countryCode) { return countryRepository.findByCodeWithCurrency(countryCode) - .map(country -> country.getCurrency().getCode()) - .orElseThrow(() -> new BusinessException(ErrorCode.COUNTRY_NOT_FOUND)); + .map(country -> country.getCurrency().getCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.COUNTRY_NOT_FOUND)); } private String getCurrencySymbol(String currencyCode) { @@ -217,8 +217,8 @@ private String getCurrencySymbol(String currencyCode) { } return currencyRepository.findByCode(currencyCode) - .map(Currency::getSymbol) - .orElseThrow(() -> new BusinessException(ErrorCode.CURRENCY_NOT_FOUND)); + .map(Currency::getSymbol) + .orElseThrow(() -> new BusinessException(ErrorCode.CURRENCY_NOT_FOUND)); } private boolean isNullCurrency(String currencyCode) { @@ -245,21 +245,21 @@ private Integer calculateForeignAmount(Integer krwAmount, String toCurrency) { private int multiply(Integer value, BigDecimal rate) { return BigDecimal.valueOf(value) - .multiply(rate) - .setScale(0, RoundingMode.HALF_UP) - .intValue(); + .multiply(rate) + .setScale(0, RoundingMode.HALF_UP) + .intValue(); } private int divide(Integer value, BigDecimal rate) { return BigDecimal.valueOf(value) - .divide(rate, 0, RoundingMode.HALF_UP) - .intValue(); + .divide(rate, 0, RoundingMode.HALF_UP) + .intValue(); } public ExchangeRateResponseDto getRate(String currencyCode) { ExchangeRate rate = exchangeRateRepository - .findByCurrencyCodeAndBaseCode(currencyCode, DEFAULT_BASE_CURRENCY) - .orElseThrow(() -> new BusinessException(ErrorCode.EXCHANGE_RATE_NOT_FOUND)); + .findByCurrencyCodeAndBaseCode(currencyCode, DEFAULT_BASE_CURRENCY) + .orElseThrow(() -> new BusinessException(ErrorCode.EXCHANGE_RATE_NOT_FOUND)); return ExchangeRateResponseDto.from(rate); } @@ -307,8 +307,8 @@ private void saveOrUpdate(ExchangeRate newRate) { private Optional findExistingRate(ExchangeRate newRate) { return exchangeRateRepository.findByCurrencyCodeAndBaseCode( - newRate.getCurrencyCode(), - newRate.getBaseCode() + newRate.getCurrencyCode(), + newRate.getBaseCode() ); } diff --git a/src/main/java/com/souzip/domain/file/File.java b/src/main/java/com/souzip/domain/file/File.java index f515ad1d..ff312cb7 100644 --- a/src/main/java/com/souzip/domain/file/File.java +++ b/src/main/java/com/souzip/domain/file/File.java @@ -1,6 +1,6 @@ package com.souzip.domain.file; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/location/Location.java b/src/main/java/com/souzip/domain/location/Location.java index 5ac59312..44ba5686 100644 --- a/src/main/java/com/souzip/domain/location/Location.java +++ b/src/main/java/com/souzip/domain/location/Location.java @@ -1,7 +1,7 @@ package com.souzip.domain.location; -import com.souzip.domain.shared.BaseEntity; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.domain.Coordinate; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java b/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java deleted file mode 100644 index b0e70fac..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/controller/AppleMigrationController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.souzip.domain.migration.apple.controller; - -import com.souzip.domain.migration.apple.dto.AppleMigrationPrepareResponse; -import com.souzip.domain.migration.apple.service.AppleMigrationPrepService; -import com.souzip.domain.migration.apple.service.AppleMigrationPrepService.MigrationResult; -import com.souzip.global.common.dto.SuccessResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.*; - -@Slf4j -@RestController -@RequestMapping("/api/migration/apple") -@RequiredArgsConstructor -public class AppleMigrationController { - - private final AppleMigrationPrepService migrationPrepService; - - @PostMapping("/prepare") - public SuccessResponse prepare() { - log.info("Apple 마이그레이션 준비 API 호출"); - - MigrationResult result = migrationPrepService.prepareAllAppleUsers(); - AppleMigrationPrepareResponse data = AppleMigrationPrepareResponse.from(result); - - return SuccessResponse.of(data, "마이그레이션 준비 완료"); - } -} diff --git a/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java b/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java deleted file mode 100644 index 7a42ab3c..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/dto/AppleMigrationPrepareResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.souzip.domain.migration.apple.dto; - -import com.souzip.domain.migration.apple.service.AppleMigrationPrepService.MigrationResult; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class AppleMigrationPrepareResponse { - private final int total; - private final int successCount; - private final int failCount; - private final int skipCount; - - public static AppleMigrationPrepareResponse from(MigrationResult result) { - return AppleMigrationPrepareResponse.builder() - .total(result.total()) - .successCount(result.success()) - .failCount(result.fail()) - .skipCount(result.skip()) - .build(); - } -} diff --git a/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java b/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java deleted file mode 100644 index b0663713..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/service/AppleMigrationPrepService.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.souzip.domain.migration.apple.service; - -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AppleMigrationPrepService { - - private final UserRepository userRepository; - private final AppleTransferService appleTransferService; - - private static final int RATE_LIMIT_DELAY_MILLIS = 100; - - @Transactional - public MigrationResult prepareAllAppleUsers() { - log.info("=== Apple 사용자 마이그레이션 준비 시작 ==="); - - List appleUsers = findAllAppleUsers(); - - if (hasNoAppleUsers(appleUsers)) { - throw new BusinessException(ErrorCode.NO_APPLE_USERS_FOUND); - } - - MigrationCounters counters = processAllUsers(appleUsers); - - return logAndReturnResult(appleUsers.size(), counters); - } - - private List findAllAppleUsers() { - List appleUsers = userRepository.findByProvider(Provider.APPLE); - log.info("총 {} 명의 Apple 사용자 발견", appleUsers.size()); - return appleUsers; - } - - private boolean hasNoAppleUsers(List appleUsers) { - return appleUsers.isEmpty(); - } - - private MigrationCounters processAllUsers(List appleUsers) { - MigrationCounters counters = new MigrationCounters(); - appleUsers.forEach(user -> processUser(user, counters)); - return counters; - } - - private void processUser(User user, MigrationCounters counters) { - try { - if (alreadyHasTransferIdentifier(user)) { - handleAlreadyMigrated(user, counters); - return; - } - - attemptMigration(user, counters); - waitForRateLimit(); - } catch (BusinessException e) { - throw e; - } catch (Exception e) { - handleMigrationFailure(user, counters, e); - } - } - - private boolean alreadyHasTransferIdentifier(User user) { - return user.getTransferIdentifier() != null; - } - - private void handleAlreadyMigrated(User user, MigrationCounters counters) { - log.info("이미 저장됨 - userId: {}, transferId: {}", - user.getId(), user.getTransferIdentifier()); - counters.incrementSkip(); - } - - private void attemptMigration(User user, MigrationCounters counters) { - String transferIdentifier = appleTransferService.getTransferIdentifier(user.getProviderId()); - - if (hasTransferIdentifier(transferIdentifier)) { - saveMigration(user, transferIdentifier, counters); - } else { - handleNoTransferIdentifier(user, counters); - } - } - - private boolean hasTransferIdentifier(String transferIdentifier) { - return transferIdentifier != null; - } - - private void saveMigration(User user, String transferIdentifier, MigrationCounters counters) { - user.updateTransferIdentifier(transferIdentifier); - userRepository.save(user); - counters.incrementSuccess(); - - log.info("저장 성공 - userId: {}, providerId: {}, transferId: {}", - user.getId(), user.getProviderId(), transferIdentifier); - } - - private void handleNoTransferIdentifier(User user, MigrationCounters counters) { - counters.incrementFail(); - log.warn("transfer_identifier 없음 - userId: {}, providerId: {}", - user.getId(), user.getProviderId()); - } - - private void waitForRateLimit() { - try { - Thread.sleep(RATE_LIMIT_DELAY_MILLIS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Rate limit 대기 중 인터럽트 발생", e); - } - } - - private void handleMigrationFailure(User user, MigrationCounters counters, Exception e) { - counters.incrementFail(); - log.error("실패 - userId: {}, providerId: {}", user.getId(), user.getProviderId(), e); - } - - private MigrationResult logAndReturnResult(int total, MigrationCounters counters) { - MigrationResult result = new MigrationResult( - total, - counters.getSuccess(), - counters.getFail(), - counters.getSkip() - ); - - log.info("=== 마이그레이션 준비 완료 ==="); - log.info("총: {}, 성공: {}, 실패: {}, 스킵: {}", - result.total(), result.success(), result.fail(), result.skip()); - - return result; - } - - private static class MigrationCounters { - private final AtomicInteger successCount = new AtomicInteger(0); - private final AtomicInteger failCount = new AtomicInteger(0); - private final AtomicInteger skipCount = new AtomicInteger(0); - - public void incrementSuccess() { - successCount.incrementAndGet(); - } - - public void incrementFail() { - failCount.incrementAndGet(); - } - - public void incrementSkip() { - skipCount.incrementAndGet(); - } - - public int getSuccess() { - return successCount.get(); - } - - public int getFail() { - return failCount.get(); - } - - public int getSkip() { - return skipCount.get(); - } - } - - public record MigrationResult( - int total, - int success, - int fail, - int skip - ) {} -} diff --git a/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java b/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java deleted file mode 100644 index 3ca8f75f..00000000 --- a/src/main/java/com/souzip/domain/migration/apple/service/AppleTransferService.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.souzip.domain.migration.apple.service; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.souzip.global.config.AppleMigrationConfig; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import io.jsonwebtoken.Jwts; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Base64; -import java.util.Date; - -@Slf4j -@Service -@RequiredArgsConstructor -public class AppleTransferService { - - private final AppleMigrationConfig appleConfig; - private final RestTemplate restTemplate = new RestTemplate(); - - private static final String APPLE_AUTH_URL = "https://appleid.apple.com/auth/token"; - private static final String APPLE_MIGRATION_URL = "https://appleid.apple.com/auth/usermigrationinfo"; - private static final long JWT_EXPIRATION_MILLIS = 3600000L; - - public String getTransferIdentifier(String userId) { - try { - TransferResponse response = callAppleMigrationApi(userId); - return extractTransferSub(response, userId); - } catch (Exception e) { - log.error("Apple transfer identifier 조회 실패 - userId: {}", userId, e); - throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); - } - } - - private TransferResponse callAppleMigrationApi(String userId) { - String clientSecret = generateClientSecret(); - String accessToken = getAccessToken(clientSecret); - - HttpHeaders headers = createHeaders(); - headers.setBearerAuth(accessToken); - - MultiValueMap params = createParams(userId, clientSecret); - HttpEntity> request = new HttpEntity<>(params, headers); - - ResponseEntity response = restTemplate.postForEntity( - APPLE_MIGRATION_URL, - request, - TransferResponse.class - ); - - return response.getBody(); - } - - private String getAccessToken(String clientSecret) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("grant_type", "client_credentials"); - params.add("scope", "user.migration"); - params.add("client_id", appleConfig.getClientId()); - params.add("client_secret", clientSecret); - - ResponseEntity response = restTemplate.postForEntity( - APPLE_AUTH_URL, - new HttpEntity<>(params, headers), - AccessTokenResponse.class - ); - - log.info("Apple access token 발급 성공"); - return response.getBody().getAccessToken(); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - return headers; - } - - private MultiValueMap createParams(String userId, String clientSecret) { - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("sub", userId); - params.add("target", appleConfig.getTeamId()); - params.add("client_id", appleConfig.getClientId()); - params.add("client_secret", clientSecret); - return params; - } - - private String extractTransferSub(TransferResponse response, String userId) { - if (hasValidTransferSub(response)) { - return logAndReturnTransferSub(response, userId); - } - - logNoTransferSub(userId); - return null; - } - - private boolean hasValidTransferSub(TransferResponse response) { - return response != null && response.getTransferSub() != null; - } - - private String logAndReturnTransferSub(TransferResponse response, String userId) { - String transferSub = response.getTransferSub(); - log.info("transfer_identifier 조회 성공 - userId: {}, transferId: {}", userId, transferSub); - return transferSub; - } - - private void logNoTransferSub(String userId) { - log.warn("transfer_identifier 없음 - userId: {}", userId); - } - - private String generateClientSecret() { - try { - PrivateKey privateKey = getPrivateKey(); - String jwt = buildJwt(privateKey); - logJwtGeneration(jwt); - return jwt; - } catch (Exception e) { - log.error("client_secret 생성 실패", e); - throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); - } - } - - private String buildJwt(PrivateKey privateKey) { - long nowMillis = System.currentTimeMillis(); - Date now = new Date(nowMillis); - Date expiryDate = new Date(nowMillis + JWT_EXPIRATION_MILLIS); - - return Jwts.builder() - .header() - .add("kid", appleConfig.getOldKeyId()) - .add("alg", "ES256") - .and() - .issuer(appleConfig.getOldTeamId()) - .issuedAt(now) - .expiration(expiryDate) - .audience() - .add("https://appleid.apple.com") - .and() - .subject(appleConfig.getClientId()) - .signWith(privateKey, Jwts.SIG.ES256) - .compact(); - } - - private void logJwtGeneration(String jwt) { - log.info("Generated JWT: {}", jwt); - log.info("Old Team ID: {}", appleConfig.getOldTeamId()); - log.info("Old Key ID: {}", appleConfig.getOldKeyId()); - log.info("Client ID: {}", appleConfig.getClientId()); - } - - private PrivateKey getPrivateKey() { - try { - String cleanedKey = cleanPrivateKey(appleConfig.getOldPrivateKey()); // 이전 팀 Private Key - byte[] keyBytes = Base64.getDecoder().decode(cleanedKey); - return generatePrivateKey(keyBytes); - } catch (Exception e) { - log.error("Private Key 파싱 실패", e); - throw new BusinessException(ErrorCode.APPLE_MIGRATION_FAILED); - } - } - - private String cleanPrivateKey(String privateKey) { - return privateKey - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\\n", "") - .replaceAll("\\s+", ""); - } - - private PrivateKey generatePrivateKey(byte[] keyBytes) throws Exception { - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("EC"); - return keyFactory.generatePrivate(keySpec); - } - - @Getter - public static class AccessTokenResponse { - @JsonProperty("access_token") - private String accessToken; - } - - @Getter - public static class TransferResponse { - @JsonProperty("transfer_sub") - private String transferSub; - } -} diff --git a/src/main/java/com/souzip/domain/notice/Notice.java b/src/main/java/com/souzip/domain/notice/Notice.java index e0d2836e..76656d0f 100644 --- a/src/main/java/com/souzip/domain/notice/Notice.java +++ b/src/main/java/com/souzip/domain/notice/Notice.java @@ -1,11 +1,12 @@ package com.souzip.domain.notice; -import com.souzip.domain.shared.BaseEntity; -import java.util.UUID; +import com.souzip.shared.domain.BaseEntity; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.UUID; + import static java.util.Objects.requireNonNull; @Getter diff --git a/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java b/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java index 1efa3284..daf1d462 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java +++ b/src/main/java/com/souzip/domain/recommend/ai/controller/AiRecommendationController.java @@ -1,10 +1,10 @@ package com.souzip.domain.recommend.ai.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.service.AiRecommendationService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java b/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java index 6b553c6b..e200c858 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java +++ b/src/main/java/com/souzip/domain/recommend/ai/dto/AiRecommendationResponse.java @@ -10,10 +10,12 @@ public record RecommendedSouvenir( String name, String category, String countryCode, - String thumbnailUrl + String thumbnailUrl, + long wishlistCount, + boolean isWishlisted ) { - public static RecommendedSouvenir from(Long id, String name, String category, String countryCode, String thumbnailUrl) { - return new RecommendedSouvenir(id, name, category, countryCode, thumbnailUrl); + public static RecommendedSouvenir from(Long id, String name, String category, String countryCode, String thumbnailUrl, long wishlistCount, boolean isWishlisted) { + return new RecommendedSouvenir(id, name, category, countryCode, thumbnailUrl, wishlistCount, isWishlisted); } } } diff --git a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java index 85a1df0d..c933ae64 100644 --- a/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/ai/service/AiRecommendationService.java @@ -11,8 +11,13 @@ import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.repository.AiRecommendationRepositoryCustom; import com.souzip.domain.souvenir.entity.Souvenir; -import com.souzip.global.clova.ClovaStudioClient; -import com.souzip.global.clova.PromptLoader; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.clova.ClovaStudioClient; +import com.souzip.shared.clova.PromptLoader; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; @@ -33,6 +38,8 @@ public class AiRecommendationService { private final ObjectMapper objectMapper; private final FileQueryService fileQueryService; private final FileStorage fileStorage; + private final WishlistRepository wishlistRepository; + private final UserRepository userRepository; public AiRecommendationResponse getCategoryRecommendationsForUser(Long userId) { log.info("getCategoryRecommendationsForUser 시작, userId={}", userId); @@ -64,8 +71,10 @@ public AiRecommendationResponse getCategoryRecommendationsForUser(Long userId) { Map> recommendedNamesByCategory = parseClovaResponse(clovaResponse); log.info("추천 이름 파싱: {}", recommendedNamesByCategory); + String userUuid = findUserUuid(userId); + return new AiRecommendationResponse( - mapToRecommendedSouvenirs(recommendedNamesByCategory) + mapToRecommendedSouvenirs(recommendedNamesByCategory, userUuid) ); } @@ -107,20 +116,29 @@ public AiRecommendationResponse getRecentSouvenirRecommendations(Long userId) { Map> recommendedNamesByCategory = parseClovaResponse(clovaResponse); log.info("추천 이름 파싱: {}", recommendedNamesByCategory); - List finalSouvenirs = - recommendedNamesByCategory.values().stream() - .flatMap(List::stream) - .map(aiRecommendationRepository::findByName) - .filter(Optional::isPresent) - .map(Optional::get) - .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( - s.getId(), - s.getName(), - s.getCategory().name(), - s.getCountryCode(), - getThumbnailUrl(s.getId()) - )) - .collect(Collectors.toList()); + List souvenirs = recommendedNamesByCategory.values().stream() + .flatMap(List::stream) + .map(aiRecommendationRepository::findByName) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + + String userUuid = findUserUuid(userId); + List souvenirIds = souvenirs.stream().map(Souvenir::getId).toList(); + Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(userUuid); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); + + List finalSouvenirs = souvenirs.stream() + .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( + s.getId(), + s.getName(), + s.getCategory().name(), + s.getCountryCode(), + getThumbnailUrl(s.getId()), + wishlistCountMap.getOrDefault(s.getId(), 0L), + wishlistedIds.contains(s.getId()) + )) + .collect(Collectors.toList()); log.info("최종 추천 souvenirs 개수: {}", finalSouvenirs.size()); return new AiRecommendationResponse(finalSouvenirs); @@ -169,7 +187,8 @@ private Map> parseClovaResponse(String clovaResponse) { try { return objectMapper.readValue( clovaResponse, - new TypeReference>>>() {} + new TypeReference>>>() { + } ).entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, @@ -183,7 +202,8 @@ private Map> parseClovaResponse(String clovaResponse) { } private List mapToRecommendedSouvenirs( - Map> recommendedNamesByCategory + Map> recommendedNamesByCategory, + String userUuid ) { List souvenirs = recommendedNamesByCategory.values().stream() .flatMap(List::stream) @@ -197,6 +217,8 @@ private List mapToRecommendedSouve .toList(); Map thumbnailMap = getThumbnails(souvenirIds); + Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(userUuid); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); return souvenirs.stream() .map(s -> AiRecommendationResponse.RecommendedSouvenir.from( @@ -206,11 +228,19 @@ private List mapToRecommendedSouve s.getCountryCode(), Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null) + .orElse(null), + wishlistCountMap.getOrDefault(s.getId(), 0L), + wishlistedIds.contains(s.getId()) )) .collect(Collectors.toList()); } + private String findUserUuid(Long userId) { + return userRepository.findById(userId) + .map(User::getUserId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + private String getThumbnailUrl(Long souvenirId) { File file = fileQueryService.findFirst(EntityType.SOUVENIR, souvenirId); return fileStorage.generateUrl(file.getStorageKey()); diff --git a/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java b/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java index ba261e52..ac52dadc 100644 --- a/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java +++ b/src/main/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationController.java @@ -4,12 +4,9 @@ import com.souzip.domain.recommend.general.dto.GeneralRecommendationDto; import com.souzip.domain.recommend.general.dto.GeneralRecommendationStatsDto; import com.souzip.domain.recommend.general.service.GeneralRecommendationService; -import com.souzip.global.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -21,13 +18,19 @@ public class GeneralRecommendationController { private final GeneralRecommendationService generalRecommendationService; @GetMapping("/discovery/general/country/{countryCode}") - public SuccessResponse> getCountryTop10(@PathVariable String countryCode) { - return SuccessResponse.of(generalRecommendationService.getTop10ByCountry(countryCode)); + public SuccessResponse> getCountryTop10( + @PathVariable String countryCode, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader + ) { + return SuccessResponse.of(generalRecommendationService.getTop10ByCountry(countryCode, authorizationHeader)); } @GetMapping("/discovery/general/category/{categoryName}") - public SuccessResponse> getCategoryTop10(@PathVariable String categoryName) { - return SuccessResponse.of(generalRecommendationService.getTop10ByCategory(categoryName)); + public SuccessResponse> getCategoryTop10( + @PathVariable String categoryName, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader + ) { + return SuccessResponse.of(generalRecommendationService.getTop10ByCategory(categoryName, authorizationHeader)); } @GetMapping("/discovery/general/stats") @@ -36,15 +39,14 @@ public SuccessResponse> getTopCountriesAllTi } @GetMapping("/countries/souvenirs") - public SuccessResponse> getTopCountriesWithTop10Souvenirs() { - return SuccessResponse.of(generalRecommendationService.getTopCountriesWithTop10Souvenirs()); + public SuccessResponse> getTopCountriesWithTop10Souvenirs( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader + ) { + return SuccessResponse.of(generalRecommendationService.getTopCountriesWithTop10Souvenirs(authorizationHeader)); } @GetMapping("/discovery/general/countries/top10") public SuccessResponse> getTopCountriesAllTimeTop10() { - return SuccessResponse.of( - generalRecommendationService.getTop10CountriesBySouvenirCount() - ); + return SuccessResponse.of(generalRecommendationService.getTop10CountriesBySouvenirCount()); } - } diff --git a/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java b/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java index 1ad7b732..e6eb9490 100644 --- a/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java +++ b/src/main/java/com/souzip/domain/recommend/general/dto/GeneralRecommendationDto.java @@ -8,15 +8,19 @@ public record GeneralRecommendationDto( String name, Category category, String countryCode, - String thumbnailUrl + String thumbnailUrl, + long wishlistCount, + Boolean isWishlisted ) { - public static GeneralRecommendationDto of(Souvenir souvenir, String thumbnailUrl) { + public static GeneralRecommendationDto of(Souvenir souvenir, String thumbnailUrl, long wishlistCount, Boolean isWishlisted) { return new GeneralRecommendationDto( souvenir.getId(), souvenir.getName(), souvenir.getCategory(), souvenir.getCountryCode(), - thumbnailUrl + thumbnailUrl, + wishlistCount, + isWishlisted ); } } diff --git a/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java b/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java index ec8a91eb..cb3ef67b 100644 --- a/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java +++ b/src/main/java/com/souzip/domain/recommend/general/repository/GeneralRecommendationRepositoryCustomImpl.java @@ -7,8 +7,8 @@ import com.souzip.domain.souvenir.entity.QSouvenir; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.user.entity.QUser; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -25,15 +25,15 @@ public List findTop10ByCategoryRecent(String categoryName) { QUser u = QUser.user; Category category = Category.from(categoryName) - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CATEGORY)); + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CATEGORY)); return queryFactory.selectFrom(s) - .leftJoin(s.user, u).fetchJoin() - .where(s.category.eq(category) - .and(s.deleted.eq(false))) - .orderBy(s.createdAt.desc()) - .limit(10) - .fetch(); + .leftJoin(s.user, u).fetchJoin() + .where(s.category.eq(category) + .and(s.deleted.eq(false))) + .orderBy(s.createdAt.desc()) + .limit(10) + .fetch(); } @Override @@ -42,12 +42,12 @@ public List findTop10ByCountry(String countryCode) { QUser u = QUser.user; return queryFactory.selectFrom(s) - .leftJoin(s.user, u).fetchJoin() - .where(s.countryCode.eq(countryCode) - .and(s.deleted.eq(false))) - .orderBy(s.createdAt.desc()) - .limit(10) - .fetch(); + .leftJoin(s.user, u).fetchJoin() + .where(s.countryCode.eq(countryCode) + .and(s.deleted.eq(false))) + .orderBy(s.createdAt.desc()) + .limit(10) + .fetch(); } @Override diff --git a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java index e74ca2e8..4267815e 100644 --- a/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java +++ b/src/main/java/com/souzip/domain/recommend/general/service/GeneralRecommendationService.java @@ -3,6 +3,7 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; import com.souzip.domain.recommend.general.dto.CountryRecommendationDto; @@ -10,50 +11,40 @@ import com.souzip.domain.recommend.general.dto.GeneralRecommendationStatsDto; import com.souzip.domain.recommend.general.repository.GeneralRecommendationRepositoryCustom; import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.wishlist.repository.WishlistRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class GeneralRecommendationService { + private static final String BEARER_PREFIX = "Bearer "; + private static final int BEARER_PREFIX_LENGTH = 7; private final GeneralRecommendationRepositoryCustom generalRecommendationRepository; private final FileQueryService fileQueryService; private final FileStorage fileStorage; + private final WishlistRepository wishlistRepository; + private final JwtTokenProvider jwtTokenProvider; - public List getTop10ByCategory(String categoryName) { - List souvenirs = generalRecommendationRepository - .findTop10ByCategoryRecent(categoryName); - - List souvenirIds = souvenirs.stream() - .map(Souvenir::getId) - .toList(); - - Map thumbnailMap = getThumbnails(souvenirIds); - - return souvenirs.stream() - .map(s -> new GeneralRecommendationDto( - s.getId(), - s.getName(), - s.getCategory(), - s.getCountryCode(), - Optional.ofNullable(thumbnailMap.get(s.getId())) - .map(FileResponse::url) - .orElse(null) - )) - .toList(); + public List getTop10ByCategory(String categoryName, @Nullable String authorizationHeader) { + List souvenirs = generalRecommendationRepository.findTop10ByCategoryRecent(categoryName); + return toDto(souvenirs, authorizationHeader); } public List getTop10CountriesBySouvenirCount() { return generalRecommendationRepository.findTop10CountriesBySouvenirCount(); } - public List getTopCountriesWithTop10Souvenirs() { + public List getTopCountriesWithTop10Souvenirs(@Nullable String authorizationHeader) { List topCountries = generalRecommendationRepository.findTop10CountriesBySouvenirCount(); @@ -74,16 +65,14 @@ public List getTopCountriesWithTop10Souvenirs() { .toList(); Map thumbnailMap = allSouvenirIds.isEmpty() - ? Map.of() - : getThumbnails(allSouvenirIds); + ? Map.of() + : getThumbnails(allSouvenirIds); - Map statsMap = topCountries.stream() - .collect(Collectors.toMap( - GeneralRecommendationStatsDto::countryCode, - s -> s, - (a, b) -> a, - LinkedHashMap::new - )); + String userId = extractUserId(authorizationHeader); + Set wishlistedIds = userId != null + ? wishlistRepository.findSouvenirIdsByUserId(userId) + : Collections.emptySet(); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(allSouvenirIds); List result = new ArrayList<>(); for (GeneralRecommendationStatsDto stats : topCountries) { @@ -95,7 +84,9 @@ public List getTopCountriesWithTop10Souvenirs() { s, Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null) + .orElse(null), + wishlistCountMap.getOrDefault(s.getId(), 0L), + userId != null ? wishlistedIds.contains(s.getId()) : null )) .toList(); @@ -110,31 +101,81 @@ public List getTopCountriesWithTop10Souvenirs() { return result; } - public List getTop10ByCountry(String countryCode) { - List souvenirs = generalRecommendationRepository - .findTop10ByCountry(countryCode); + public List getTop10ByCountry(String countryCode, @Nullable String authorizationHeader) { + List souvenirs = generalRecommendationRepository.findTop10ByCountry(countryCode); + return toDto(souvenirs, authorizationHeader); + } - List souvenirIds = souvenirs.stream() - .map(Souvenir::getId) - .toList(); + public List getTop3CountriesBySouvenirCount() { + return generalRecommendationRepository.findTop3CountriesBySouvenirCount(); + } - Map thumbnailMap = getThumbnails(souvenirIds); + private List toDto(List souvenirs, @Nullable String authorizationHeader) { + List souvenirIds = souvenirs.stream().map(Souvenir::getId).toList(); + Map thumbnailMap = souvenirIds.isEmpty() ? Map.of() : getThumbnails(souvenirIds); + + String userId = extractUserId(authorizationHeader); + Set wishlistedIds = userId != null + ? wishlistRepository.findSouvenirIdsByUserId(userId) + : Collections.emptySet(); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); return souvenirs.stream() - .map(s -> new GeneralRecommendationDto( - s.getId(), - s.getName(), - s.getCategory(), - s.getCountryCode(), + .map(s -> GeneralRecommendationDto.of( + s, Optional.ofNullable(thumbnailMap.get(s.getId())) .map(FileResponse::url) - .orElse(null) + .orElse(null), + wishlistCountMap.getOrDefault(s.getId(), 0L), + userId != null ? wishlistedIds.contains(s.getId()) : null )) .toList(); } - public List getTop3CountriesBySouvenirCount() { - return generalRecommendationRepository.findTop3CountriesBySouvenirCount(); + @Nullable + private String extractUserId(@Nullable String authorizationHeader) { + if (hasNoAuthorizationHeader(authorizationHeader)) { + return null; + } + + if (isNotBearerToken(authorizationHeader)) { + return null; + } + + String token = extractToken(authorizationHeader); + + if (isEmptyToken(token)) { + return null; + } + + return parseUserIdFromToken(token); + } + + private boolean hasNoAuthorizationHeader(String authorizationHeader) { + return authorizationHeader == null; + } + + private boolean isNotBearerToken(String authorizationHeader) { + return !authorizationHeader.startsWith(BEARER_PREFIX); + } + + private String extractToken(String authorizationHeader) { + return authorizationHeader.substring(BEARER_PREFIX_LENGTH).trim(); + } + + private boolean isEmptyToken(String token) { + return token.isEmpty(); + } + + @Nullable + private String parseUserIdFromToken(String token) { + try { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + return userId != null ? String.valueOf(userId) : null; + } catch (Exception e) { + log.debug("Failed to parse token", e); + return null; + } } private Map getThumbnails(List souvenirIds) { diff --git a/src/main/java/com/souzip/domain/search/controller/SearchController.java b/src/main/java/com/souzip/domain/search/controller/SearchController.java index a3728e39..bbe63a27 100644 --- a/src/main/java/com/souzip/domain/search/controller/SearchController.java +++ b/src/main/java/com/souzip/domain/search/controller/SearchController.java @@ -2,9 +2,9 @@ import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.service.SearchService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.SuccessResponse; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java b/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java index 856ee8ad..ced1bc4a 100644 --- a/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java +++ b/src/main/java/com/souzip/domain/search/dto/SearchListResponse.java @@ -1,6 +1,6 @@ package com.souzip.domain.search.dto; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; public class SearchListResponse extends PaginationResponse { diff --git a/src/main/java/com/souzip/domain/search/service/SearchService.java b/src/main/java/com/souzip/domain/search/service/SearchService.java index 0ad3a601..a30b0f0e 100644 --- a/src/main/java/com/souzip/domain/search/service/SearchService.java +++ b/src/main/java/com/souzip/domain/search/service/SearchService.java @@ -3,11 +3,13 @@ import com.souzip.domain.city.entity.City; import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.repository.LocationSearchRepository; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.util.List; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java b/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java index ade8ffc1..a213ca24 100644 --- a/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java +++ b/src/main/java/com/souzip/domain/souvenir/controller/SouvenirController.java @@ -1,15 +1,10 @@ package com.souzip.domain.souvenir.controller; -import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; -import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; -import com.souzip.domain.souvenir.dto.SouvenirRequest; -import com.souzip.domain.souvenir.dto.SouvenirResponse; -import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; +import com.souzip.domain.souvenir.dto.*; import com.souzip.domain.souvenir.service.SouvenirService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -25,20 +20,22 @@ public class SouvenirController { @GetMapping("/api/souvenirs/nearby") public SuccessResponse getNearbySouvenirs( - @RequestParam double latitude, - @RequestParam double longitude, - @RequestParam(required = false, defaultValue = "5000") double radiusMeter + @RequestParam double latitude, + @RequestParam double longitude, + @RequestParam(required = false, defaultValue = "5000") double radiusMeter, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader ) { - return SuccessResponse.of(souvenirService.getNearbySouvenirs(latitude, longitude, radiusMeter)); + return SuccessResponse.of(souvenirService.getNearbySouvenirs(latitude, longitude, radiusMeter, authorizationHeader)); } @GetMapping("/api/souvenirs/{id}") public SuccessResponse getSouvenir( - @PathVariable Long id, - @RequestHeader(value = "Authorization", required = false) String authorizationHeader + @PathVariable Long id, + @RequestHeader(value = "Authorization", required = false) String authorizationHeader ) { SouvenirDetailResponse response = souvenirService.getSouvenir(id, authorizationHeader); return SuccessResponse.of(response); + } @RequireAuth @@ -51,9 +48,9 @@ public SuccessResponse deleteSouvenir(@PathVariable Long id, @CurrentUserI @RequireAuth @PostMapping("/api/souvenirs") public SuccessResponse createSouvenir( - @Valid @RequestPart("souvenir") SouvenirCreateRequest request, - @RequestPart(value = "files") List files, - @CurrentUserId Long userId + @Valid @RequestPart("souvenir") SouvenirCreateRequest request, + @RequestPart(value = "files") List files, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.createSouvenir(request, userId, files); return SuccessResponse.of(response); @@ -62,9 +59,9 @@ public SuccessResponse createSouvenir( @RequireAuth @PutMapping("/api/souvenirs/{id}") public SuccessResponse updateSouvenir( - @Valid @PathVariable Long id, - @RequestPart("souvenir") SouvenirUpdateRequest request, - @CurrentUserId Long userId + @Valid @PathVariable Long id, + @RequestPart("souvenir") SouvenirUpdateRequest request, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.updateSouvenir(id, request, userId); return SuccessResponse.of(response); @@ -73,9 +70,9 @@ public SuccessResponse updateSouvenir( @RequireAuth @PostMapping("/api/v2/souvenirs") public SuccessResponse createSouvenirV2( - @Valid @RequestPart("souvenir") SouvenirRequest request, - @RequestPart(value = "files") List files, - @CurrentUserId Long userId + @Valid @RequestPart("souvenir") SouvenirRequest request, + @RequestPart(value = "files") List files, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.createSouvenirV2(request, userId, files); return SuccessResponse.of(response); @@ -84,9 +81,9 @@ public SuccessResponse createSouvenirV2( @RequireAuth @PutMapping("/api/v2/souvenirs/{id}") public SuccessResponse updateSouvenirV2( - @PathVariable Long id, - @Valid @RequestPart("souvenir") SouvenirRequest request, - @CurrentUserId Long userId + @PathVariable Long id, + @Valid @RequestPart("souvenir") SouvenirRequest request, + @CurrentUserId Long userId ) { SouvenirResponse response = souvenirService.updateSouvenirV2(id, request, userId); return SuccessResponse.of(response); diff --git a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java index ba115e49..d0d372d1 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirListResponse.java @@ -1,13 +1,13 @@ package com.souzip.domain.souvenir.dto; -import com.souzip.global.common.dto.pagination.PaginationResponse.PageInfo; +import com.souzip.shared.common.dto.pagination.PaginationResponse.PageInfo; import org.springframework.data.domain.Page; import java.util.List; public record MySouvenirListResponse( - List content, - PageInfo pagination + List content, + PageInfo pagination ) { public static MySouvenirListResponse from(Page page) { PageInfo pagination = PageInfo.of(page); diff --git a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java index f737331e..abd40340 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/MySouvenirResponse.java @@ -5,19 +5,23 @@ import java.time.LocalDateTime; public record MySouvenirResponse( - Long id, - String thumbnailUrl, - String countryCode, - LocalDateTime createdAt, - LocalDateTime updatedAt + Long id, + String thumbnailUrl, + String countryCode, + LocalDateTime createdAt, + LocalDateTime updatedAt, + long wishlistCount, + boolean isWishlisted ) { - public static MySouvenirResponse of(Souvenir souvenir, String thumbnailUrl) { + public static MySouvenirResponse of(Souvenir souvenir, String thumbnailUrl, boolean isWishlisted, long wishlistCount) { return new MySouvenirResponse( - souvenir.getId(), - thumbnailUrl, - souvenir.getCountryCode(), - souvenir.getCreatedAt(), - souvenir.getUpdatedAt() + souvenir.getId(), + thumbnailUrl, + souvenir.getCountryCode(), + souvenir.getCreatedAt(), + souvenir.getUpdatedAt(), + wishlistCount, + isWishlisted ); } } diff --git a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java index 46185b1d..d209e21b 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirDetailResponse.java @@ -9,80 +9,87 @@ import java.util.List; public record SouvenirDetailResponse( - Long id, - String name, - - Integer localPrice, - String currencySymbol, - Integer krwPrice, - - PriceResponse price, - - String description, - String address, - String locationDetail, - BigDecimal latitude, - BigDecimal longitude, - Category category, - Purpose purpose, - String countryCode, - String userNickname, - String userProfileImageUrl, - Boolean isOwned, - List files + Long id, + String name, + Integer localPrice, + String currencySymbol, + Integer krwPrice, + PriceResponse price, + String description, + String address, + String locationDetail, + BigDecimal latitude, + BigDecimal longitude, + Category category, + Purpose purpose, + String countryCode, + String userNickname, + String userProfileImageUrl, + Boolean isOwned, + Boolean isWishlisted, + long wishlistCount, + List files ) { public static SouvenirDetailResponse of( - Souvenir souvenir, - List files, - Boolean isOwned + Souvenir souvenir, + List files, + Boolean isOwned, + Boolean isWishlisted, + long wishlistCount ) { return new SouvenirDetailResponse( - souvenir.getId(), - souvenir.getName(), - souvenir.getLocalPrice(), - souvenir.getCurrencySymbol(), - souvenir.getKrwPrice(), - null, - souvenir.getDescription(), - souvenir.getAddress(), - souvenir.getLocationDetail(), - souvenir.getLatitude(), - souvenir.getLongitude(), - souvenir.getCategory(), - souvenir.getPurpose(), - souvenir.getCountryCode(), - souvenir.getUser().getNickname(), - souvenir.getUser().getProfileImageUrl(), - isOwned, - files + souvenir.getId(), + souvenir.getName(), + souvenir.getLocalPrice(), + souvenir.getCurrencySymbol(), + souvenir.getKrwPrice(), + null, + souvenir.getDescription(), + souvenir.getAddress(), + souvenir.getLocationDetail(), + souvenir.getLatitude(), + souvenir.getLongitude(), + souvenir.getCategory(), + souvenir.getPurpose(), + souvenir.getCountryCode(), + souvenir.getUser().getNickname(), + souvenir.getUser().getProfileImageUrl(), + isOwned, + isWishlisted, + wishlistCount, + files ); } public static SouvenirDetailResponse of( - Souvenir souvenir, - List files, - Boolean isOwned, - PriceResponse priceResponse + Souvenir souvenir, + List files, + Boolean isOwned, + Boolean isWishlisted, + long wishlistCount, + PriceResponse priceResponse ) { return new SouvenirDetailResponse( - souvenir.getId(), - souvenir.getName(), - souvenir.getLocalPrice(), - souvenir.getCurrencySymbol(), - souvenir.getKrwPrice(), - priceResponse, - souvenir.getDescription(), - souvenir.getAddress(), - souvenir.getLocationDetail(), - souvenir.getLatitude(), - souvenir.getLongitude(), - souvenir.getCategory(), - souvenir.getPurpose(), - souvenir.getCountryCode(), - souvenir.getUser().getNickname(), - souvenir.getUser().getProfileImageUrl(), - isOwned, - files + souvenir.getId(), + souvenir.getName(), + souvenir.getLocalPrice(), + souvenir.getCurrencySymbol(), + souvenir.getKrwPrice(), + priceResponse, + souvenir.getDescription(), + souvenir.getAddress(), + souvenir.getLocationDetail(), + souvenir.getLatitude(), + souvenir.getLongitude(), + souvenir.getCategory(), + souvenir.getPurpose(), + souvenir.getCountryCode(), + souvenir.getUser().getNickname(), + souvenir.getUser().getProfileImageUrl(), + isOwned, + isWishlisted, + wishlistCount, + files ); } } diff --git a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java index 190c0674..0a58ee20 100644 --- a/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java +++ b/src/main/java/com/souzip/domain/souvenir/dto/SouvenirNearbyResponse.java @@ -4,20 +4,23 @@ import com.souzip.domain.souvenir.entity.Purpose; import java.math.BigDecimal; +import java.util.Set; import java.util.function.Function; public record SouvenirNearbyResponse( - Long id, - String name, - Category category, - Purpose purpose, - int localPrice, - int krwPrice, - String currencySymbol, - String thumbnail, - BigDecimal latitude, - BigDecimal longitude, - String address + Long id, + String name, + Category category, + Purpose purpose, + int localPrice, + int krwPrice, + String currencySymbol, + String thumbnail, + BigDecimal latitude, + BigDecimal longitude, + String address, + long wishlistCount, + Boolean isWishlisted ) { private static final int INDEX_ID = 0; private static final int INDEX_NAME = 1; @@ -30,38 +33,44 @@ public record SouvenirNearbyResponse( private static final int INDEX_LATITUDE = 8; private static final int INDEX_LONGITUDE = 9; private static final int INDEX_ADDRESS = 10; + private static final int INDEX_WISHLIST_COUNT = 11; public static SouvenirNearbyResponse from( - Long id, - String name, - Category category, - Purpose purpose, - int localPrice, - int krwPrice, - String currencySymbol, - String thumbnail, - BigDecimal latitude, - BigDecimal longitude, - String address + Long id, + String name, + Category category, + Purpose purpose, + int localPrice, + int krwPrice, + String currencySymbol, + String thumbnail, + BigDecimal latitude, + BigDecimal longitude, + String address, + long wishlistCount, + Boolean isWishlisted ) { return new SouvenirNearbyResponse( - id, - name, - category, - purpose, - localPrice, - krwPrice, - currencySymbol, - thumbnail, - latitude, - longitude, - address + id, + name, + category, + purpose, + localPrice, + krwPrice, + currencySymbol, + thumbnail, + latitude, + longitude, + address, + wishlistCount, + isWishlisted ); } public static SouvenirNearbyResponse fromObjectArray( - Object[] row, - Function urlGenerator + Object[] row, + Function urlGenerator, + Set wishlistedIds ) { Long id = extractId(row); String name = extractName(row); @@ -76,19 +85,23 @@ public static SouvenirNearbyResponse fromObjectArray( String address = extractAddress(row); String imageUrl = generateImageUrl(thumbnail, urlGenerator); + long wishlistCount = ((Number) row[INDEX_WISHLIST_COUNT]).longValue(); + Boolean isWishlisted = wishlistedIds.isEmpty() ? null : wishlistedIds.contains(id); return new SouvenirNearbyResponse( - id, - name, - category, - purpose, - localPrice, - krwPrice, - currencySymbol, - imageUrl, - latitude, - longitude, - address + id, + name, + category, + purpose, + localPrice, + krwPrice, + currencySymbol, + imageUrl, + latitude, + longitude, + address, + wishlistCount, + isWishlisted ); } diff --git a/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java b/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java index a6cfe50f..60771fd9 100644 --- a/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java +++ b/src/main/java/com/souzip/domain/souvenir/entity/Souvenir.java @@ -6,20 +6,11 @@ import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; import com.souzip.domain.souvenir.vo.PriceInfo; import com.souzip.domain.user.entity.User; -import com.souzip.domain.shared.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import com.souzip.shared.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + import java.math.BigDecimal; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; @Getter @Builder(access = AccessLevel.PRIVATE) @@ -89,65 +80,65 @@ public class Souvenir extends BaseEntity { @Deprecated public static Souvenir of( - SouvenirCreateRequest request, - User user, - Integer calculatedLocalPrice, - Integer calculatedKrwPrice + SouvenirCreateRequest request, + User user, + Integer calculatedLocalPrice, + Integer calculatedKrwPrice ) { return Souvenir.builder() - .name(request.name()) - .localPrice(calculatedLocalPrice) - .currencySymbol(request.currencySymbol()) - .krwPrice(calculatedKrwPrice) - .description(request.description()) - .address(request.address()) - .locationDetail(request.locationDetail()) - .latitude(request.latitude()) - .longitude(request.longitude()) - .category(request.category()) - .purpose(request.purpose()) - .countryCode(request.countryCode()) - .user(user) - .deleted(false) - .build(); + .name(request.name()) + .localPrice(calculatedLocalPrice) + .currencySymbol(request.currencySymbol()) + .krwPrice(calculatedKrwPrice) + .description(request.description()) + .address(request.address()) + .locationDetail(request.locationDetail()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .category(request.category()) + .purpose(request.purpose()) + .countryCode(request.countryCode()) + .user(user) + .deleted(false) + .build(); } public static Souvenir ofV2( - SouvenirRequest request, - User user, - PriceInfo originalPrice, - Integer exchangeAmount, - String currencySymbol, - PriceInfo convertedPrice + SouvenirRequest request, + User user, + PriceInfo originalPrice, + Integer exchangeAmount, + String currencySymbol, + PriceInfo convertedPrice ) { return Souvenir.builder() - .name(request.name()) - .originalAmount(extractAmount(originalPrice)) - .originalCurrency(extractCurrency(originalPrice)) - .exchangeAmount(exchangeAmount) - .convertedAmount(extractAmount(convertedPrice)) - .convertedCurrency(extractCurrency(convertedPrice)) - .localPrice(extractAmount(originalPrice)) - .currencySymbol(currencySymbol) - .krwPrice(exchangeAmount) - .description(request.description()) - .address(request.address()) - .locationDetail(request.locationDetail()) - .latitude(request.latitude()) - .longitude(request.longitude()) - .category(request.category()) - .purpose(request.purpose()) - .countryCode(request.countryCode()) - .user(user) - .deleted(false) - .build(); + .name(request.name()) + .originalAmount(extractAmount(originalPrice)) + .originalCurrency(extractCurrency(originalPrice)) + .exchangeAmount(exchangeAmount) + .convertedAmount(extractAmount(convertedPrice)) + .convertedCurrency(extractCurrency(convertedPrice)) + .localPrice(extractAmount(originalPrice)) + .currencySymbol(currencySymbol) + .krwPrice(exchangeAmount) + .description(request.description()) + .address(request.address()) + .locationDetail(request.locationDetail()) + .latitude(request.latitude()) + .longitude(request.longitude()) + .category(request.category()) + .purpose(request.purpose()) + .countryCode(request.countryCode()) + .user(user) + .deleted(false) + .build(); } @Deprecated public void update( - SouvenirUpdateRequest request, - Integer calculatedLocalPrice, - Integer calculatedKrwPrice + SouvenirUpdateRequest request, + Integer calculatedLocalPrice, + Integer calculatedKrwPrice ) { this.name = request.name(); this.localPrice = calculatedLocalPrice; @@ -164,11 +155,11 @@ public void update( } public void updateV2( - SouvenirRequest request, - PriceInfo originalPrice, - Integer exchangeAmount, - String currencySymbol, - PriceInfo convertedPrice + SouvenirRequest request, + PriceInfo originalPrice, + Integer exchangeAmount, + String currencySymbol, + PriceInfo convertedPrice ) { this.name = request.name(); this.originalAmount = extractAmount(originalPrice); diff --git a/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java b/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java index e86519c5..00a68033 100644 --- a/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java +++ b/src/main/java/com/souzip/domain/souvenir/repository/SouvenirRepository.java @@ -27,18 +27,23 @@ public interface SouvenirRepository extends JpaRepository, Souve f.storage_key, s.latitude, s.longitude, - s.address + s.address, + COUNT(w.id) AS wishlist_count FROM souvenir s LEFT JOIN file f ON f.entity_type = 'Souvenir' AND f.entity_id = s.id AND f.display_order = 1 + LEFT JOIN wishlist w ON w.souvenir_id = s.id WHERE s.deleted = false AND ST_DWithin( ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)::geography, :radiusMeter ) + GROUP BY s.id, s.name, s.category, s.purpose, s.local_price, + s.krw_price, s.currency_symbol, f.storage_key, + s.latitude, s.longitude, s.address ORDER BY ST_Distance( ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(s.longitude, s.latitude), 4326)::geography diff --git a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java index 337fe4c7..111a11a8 100644 --- a/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java +++ b/src/main/java/com/souzip/domain/souvenir/service/SouvenirService.java @@ -5,28 +5,21 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.exchangerate.dto.ExchangeCalculatedPrice; import com.souzip.domain.exchangerate.service.ExchangeRateService; import com.souzip.domain.file.EntityType; import com.souzip.domain.file.File; -import com.souzip.domain.souvenir.dto.PriceData; -import com.souzip.domain.souvenir.dto.PriceResponse; -import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; -import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyResponse; -import com.souzip.domain.souvenir.dto.SouvenirRequest; -import com.souzip.domain.souvenir.dto.SouvenirResponse; -import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; +import com.souzip.domain.souvenir.dto.*; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.souvenir.repository.SouvenirRepository; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.security.jwt.JwtTokenProvider; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.audit.annotation.Audit; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,6 +29,7 @@ import java.util.Collections; import java.util.List; +import java.util.Set; @Slf4j @Transactional(readOnly = true) @@ -48,6 +42,7 @@ public class SouvenirService { private final SouvenirRepository souvenirRepository; private final UserRepository userRepository; + private final WishlistRepository wishlistRepository; private final FileModifyService fileModifyService; private final FileQueryService fileQueryService; private final FileStorage fileStorage; @@ -58,18 +53,22 @@ public class SouvenirService { public SouvenirNearbyListResponse getNearbySouvenirs( double latitude, double longitude, - double radiusMeter + double radiusMeter, + @Nullable String authorizationHeader ) { - List results = souvenirRepository.findNearbySouvenirs( - latitude, - longitude, - radiusMeter - ); + String userId = extractUserId(authorizationHeader); + + List results = souvenirRepository.findNearbySouvenirs(latitude, longitude, radiusMeter); + + Set wishlistedIds = userId != null + ? wishlistRepository.findSouvenirIdsByUserId(userId) + : Collections.emptySet(); List list = results.stream() .map(row -> SouvenirNearbyResponse.fromObjectArray( row, - ncpStorage::generateUrl + ncpStorage::generateUrl, + wishlistedIds )) .toList(); @@ -81,13 +80,13 @@ public SouvenirDetailResponse getSouvenir( @Nullable String authorizationHeader ) { String userId = extractUserId(authorizationHeader); - Souvenir souvenir = findSouvenirById(souvenirId); List files = getFiles(souvenirId); boolean isOwned = souvenir.isOwnedBy(userId); + boolean isWishlisted = userId != null && wishlistRepository.existsByUserUserIdAndSouvenirId(userId, souvenirId); + long wishlistCount = wishlistRepository.countBySouvenirId(souvenirId); PriceResponse priceResponse = createPriceResponse(souvenir); - - return SouvenirDetailResponse.of(souvenir, files, isOwned, priceResponse); + return SouvenirDetailResponse.of(souvenir, files, isOwned, isWishlisted, wishlistCount, priceResponse); } @Audit(action = AuditAction.SOUVENIR_DELETED) @@ -258,7 +257,8 @@ private boolean isEmptyToken(String token) { @Nullable private String parseUserIdFromToken(String token) { try { - return jwtTokenProvider.getUserIdFromToken(token); + Long userId = jwtTokenProvider.getUserIdFromToken(token); + return userId != null ? String.valueOf(userId) : null; } catch (Exception e) { log.debug("Failed to parse token", e); return null; diff --git a/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java b/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java new file mode 100644 index 00000000..346e548c --- /dev/null +++ b/src/main/java/com/souzip/domain/user/adapter/UserPortImpl.java @@ -0,0 +1,32 @@ +package com.souzip.domain.user.adapter; + +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.auth.application.required.UserPort; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.shared.domain.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserPortImpl implements UserPort { + + private final UserRepository userRepository; + + @Override + public UserInfo findOrCreateUser(Provider provider, String providerId, String email) { + User user = userRepository + .findByProviderAndProviderId(provider, providerId) + .orElseGet(() -> userRepository.save( + User.of(provider, providerId, email) + )); + + return new UserInfo( + user.getId(), + user.getUserId(), + user.getNickname(), + user.needsOnboarding() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/user/controller/UserController.java b/src/main/java/com/souzip/domain/user/controller/UserController.java index e981e75c..d3b26061 100644 --- a/src/main/java/com/souzip/domain/user/controller/UserController.java +++ b/src/main/java/com/souzip/domain/user/controller/UserController.java @@ -1,24 +1,14 @@ package com.souzip.domain.user.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.souvenir.dto.MySouvenirListResponse; -import com.souzip.domain.user.dto.NicknameCheckRequest; -import com.souzip.domain.user.dto.NicknameCheckResponse; -import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.domain.user.dto.OnboardingResponse; -import com.souzip.domain.user.dto.UserProfileResponse; +import com.souzip.domain.user.dto.*; import com.souzip.domain.user.service.UserService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RequestMapping("/api/users") @@ -30,7 +20,7 @@ public class UserController { @PostMapping("/check-nickname") @RequireAuth public SuccessResponse checkNickname( - @Valid @RequestBody NicknameCheckRequest request + @Valid @RequestBody NicknameCheckRequest request ) { NicknameCheckResponse response = userService.checkNickname(request.nickname()); return SuccessResponse.of(response); @@ -39,8 +29,8 @@ public SuccessResponse checkNickname( @PostMapping("/onboarding") @RequireAuth public SuccessResponse completeOnboarding( - @CurrentUserId Long currentUserId, - @Valid @RequestBody OnboardingRequest request + @CurrentUserId Long currentUserId, + @Valid @RequestBody OnboardingRequest request ) { OnboardingResponse response = userService.completeOnboarding(currentUserId, request); return SuccessResponse.of(response); @@ -49,7 +39,7 @@ public SuccessResponse completeOnboarding( @GetMapping("/me") @RequireAuth public SuccessResponse getMyProfile( - @CurrentUserId Long currentUserId + @CurrentUserId Long currentUserId ) { UserProfileResponse profile = userService.getUserProfile(currentUserId); return SuccessResponse.of(profile); @@ -58,9 +48,9 @@ public SuccessResponse getMyProfile( @GetMapping("/me/souvenirs") @RequireAuth public SuccessResponse getMySouvenirs( - @CurrentUserId Long currentUserId, - @RequestParam int page, - @RequestParam int size + @CurrentUserId Long currentUserId, + @RequestParam int page, + @RequestParam int size ) { MySouvenirListResponse souvenirs = userService.getMySouvenirs(currentUserId, page, size); return SuccessResponse.of(souvenirs); diff --git a/src/main/java/com/souzip/domain/user/entity/Provider.java b/src/main/java/com/souzip/domain/user/entity/Provider.java deleted file mode 100644 index d4399633..00000000 --- a/src/main/java/com/souzip/domain/user/entity/Provider.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.souzip.domain.user.entity; - -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import java.util.Arrays; - -public enum Provider { - KAKAO, - APPLE, - GOOGLE; - - public static Provider from(String value) { - if (value == null || value.isBlank()) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "Provider는 필수입니다."); - } - - return Arrays.stream(values()) - .filter(provider -> provider.name().equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new BusinessException( - ErrorCode.INVALID_INPUT, "지원하지 않는 로그인 방식입니다: " + value)); - } -} diff --git a/src/main/java/com/souzip/domain/user/entity/User.java b/src/main/java/com/souzip/domain/user/entity/User.java index b69f64df..49bbf9b3 100644 --- a/src/main/java/com/souzip/domain/user/entity/User.java +++ b/src/main/java/com/souzip/domain/user/entity/User.java @@ -1,15 +1,15 @@ package com.souzip.domain.user.entity; -import com.souzip.domain.auth.dto.OAuthUserInfo; import com.souzip.domain.category.entity.Category; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; +import com.souzip.shared.domain.Provider; import jakarta.persistence.*; -import java.util.HashSet; -import java.util.Set; import lombok.*; import org.hibernate.annotations.SQLDelete; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; @Getter @@ -17,15 +17,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Table(name = "\"user\"", - uniqueConstraints = { - @UniqueConstraint(columnNames = {"provider", "provider_id"}) - }, - indexes = { - @Index(name = "idx_user_id", columnList = "user_id"), - @Index(name = "idx_provider", columnList = "provider, provider_id"), - @Index(name = "idx_deleted", columnList = "deleted"), - @Index(name = "idx_email", columnList = "email") - } + uniqueConstraints = { + @UniqueConstraint(columnNames = {"provider", "provider_id"}) + }, + indexes = { + @Index(name = "idx_user_id", columnList = "user_id"), + @Index(name = "idx_provider", columnList = "provider, provider_id"), + @Index(name = "idx_deleted", columnList = "deleted"), + @Index(name = "idx_email", columnList = "email") + } ) @SQLDelete(sql = "UPDATE \"user\" SET deleted = true, deleted_at = CURRENT_TIMESTAMP WHERE id = ?") @Entity @@ -61,8 +61,8 @@ public class User extends BaseEntity { @ElementCollection(fetch = FetchType.LAZY) @CollectionTable( - name = "user_category", - joinColumns = @JoinColumn(name = "user_id") + name = "user_category", + joinColumns = @JoinColumn(name = "user_id") ) @Column(name = "category") @Enumerated(EnumType.STRING) @@ -81,37 +81,18 @@ protected void onCreate() { ensureUserId(); } - public static User of( - Provider provider, - String providerId, - String name, - String nickname, - String email, - String profileImageUrl - ) { + public static User of(Provider provider, String providerId, String email) { return User.builder() - .userId(UUID.randomUUID().toString()) - .provider(provider) - .providerId(providerId) - .name(name) - .nickname(nickname) - .email(email) - .profileImageUrl(profileImageUrl) - .onboardingCompleted(false) - .categories(new HashSet<>()) - .deleted(false) - .build(); - } - - public static User of(Provider provider, OAuthUserInfo oauthUserInfo) { - return User.of( - provider, - oauthUserInfo.getProviderId(), - oauthUserInfo.getName(), - "", - oauthUserInfo.getEmail(), - oauthUserInfo.getProfileImageUrl() - ); + .userId(UUID.randomUUID().toString()) + .provider(provider) + .providerId(providerId) + .name("") + .nickname("") + .email(email) + .onboardingCompleted(false) + .categories(new HashSet<>()) + .deleted(false) + .build(); } private void ensureUserId() { @@ -161,4 +142,4 @@ public void updateProviderId(String providerId) { public void updateLastLoginAt() { this.lastLoginAt = LocalDateTime.now(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/domain/user/entity/UserAgreement.java b/src/main/java/com/souzip/domain/user/entity/UserAgreement.java index a38f787a..2467b398 100644 --- a/src/main/java/com/souzip/domain/user/entity/UserAgreement.java +++ b/src/main/java/com/souzip/domain/user/entity/UserAgreement.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.entity; import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.domain.shared.BaseEntity; +import com.souzip.shared.domain.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -32,31 +32,31 @@ public class UserAgreement extends BaseEntity { private Boolean marketingConsent; public static UserAgreement of( - User user, - Boolean ageVerified, - Boolean serviceTerms, - Boolean privacyRequired, - Boolean locationService, - Boolean marketingConsent + User user, + Boolean ageVerified, + Boolean serviceTerms, + Boolean privacyRequired, + Boolean locationService, + Boolean marketingConsent ) { return UserAgreement.builder() - .user(user) - .ageVerified(ageVerified) - .serviceTerms(serviceTerms) - .privacyRequired(privacyRequired) - .locationService(locationService) - .marketingConsent(marketingConsent) - .build(); + .user(user) + .ageVerified(ageVerified) + .serviceTerms(serviceTerms) + .privacyRequired(privacyRequired) + .locationService(locationService) + .marketingConsent(marketingConsent) + .build(); } public static UserAgreement of(User user, OnboardingRequest request) { return of( - user, - request.ageVerified(), - request.serviceTerms(), - request.privacyRequired(), - request.locationService(), - request.marketingConsent() + user, + request.ageVerified(), + request.serviceTerms(), + request.privacyRequired(), + request.locationService(), + request.marketingConsent() ); } } diff --git a/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java b/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java index 62a32cce..bba83483 100644 --- a/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java +++ b/src/main/java/com/souzip/domain/user/repository/UserAgreementRepository.java @@ -11,4 +11,6 @@ public interface UserAgreementRepository extends JpaRepository findByUser(User user); boolean existsByUser(User user); + + long countByMarketingConsentTrue(); } diff --git a/src/main/java/com/souzip/domain/user/repository/UserRepository.java b/src/main/java/com/souzip/domain/user/repository/UserRepository.java index dae37a66..ab86490f 100644 --- a/src/main/java/com/souzip/domain/user/repository/UserRepository.java +++ b/src/main/java/com/souzip/domain/user/repository/UserRepository.java @@ -1,10 +1,12 @@ package com.souzip.domain.user.repository; -import com.souzip.domain.user.entity.Provider; import com.souzip.domain.user.entity.User; +import com.souzip.shared.domain.Provider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { @@ -17,4 +19,12 @@ public interface UserRepository extends JpaRepository { Optional findByTransferIdentifier(String transferIdentifier); List findByProvider(Provider provider); + + @Query(""" + SELECT DISTINCT u.email FROM User u + WHERE u.deleted = false AND u.email IS NOT NULL + """) + List findDistinctEmailsByActiveUsers(); + + long deleteByDeletedTrue(); } diff --git a/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java b/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java new file mode 100644 index 00000000..a608c812 --- /dev/null +++ b/src/main/java/com/souzip/domain/user/scheduler/UserCleanupScheduler.java @@ -0,0 +1,22 @@ +package com.souzip.domain.user.scheduler; + +import com.souzip.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserCleanupScheduler { + + private final UserService userService; + + @Scheduled(cron = "0 0 3 * * *") + public void runUserCleanup() { + log.info("탈퇴 유저 삭제 배치 스케줄러 실행 시작"); + long deletedCount = userService.deleteWithdrawnUsers(); + log.info("탈퇴 유저 {}명 삭제 완료", deletedCount); + } +} diff --git a/src/main/java/com/souzip/domain/user/service/ProfileImageService.java b/src/main/java/com/souzip/domain/user/service/ProfileImageService.java index b8dd95f0..f0336d60 100644 --- a/src/main/java/com/souzip/domain/user/service/ProfileImageService.java +++ b/src/main/java/com/souzip/domain/user/service/ProfileImageService.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.service; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import com.souzip.adapter.config.ObjectStorageProperties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -20,7 +20,7 @@ public class ProfileImageService { private static final String URL_FORMAT = "%s/%s/%s/%s%s"; private static final Set AVAILABLE_COLORS = Set.of( - "red", "blue", "yellow", "purple" + "red", "blue", "yellow", "purple" ); public String resolveProfileImageUrl(String input) { @@ -71,12 +71,12 @@ private boolean isValidColor(String color) { private String buildColorUrl(String color) { return String.format( - URL_FORMAT, - storageProperties.getEndpoint(), - storageProperties.getBucket(), - PROFILE_FOLDER, - color, - IMAGE_EXTENSION + URL_FORMAT, + storageProperties.getEndpoint(), + storageProperties.getBucket(), + PROFILE_FOLDER, + color, + IMAGE_EXTENSION ); } } diff --git a/src/main/java/com/souzip/domain/user/service/UserService.java b/src/main/java/com/souzip/domain/user/service/UserService.java index 04087f71..92cb7665 100644 --- a/src/main/java/com/souzip/domain/user/service/UserService.java +++ b/src/main/java/com/souzip/domain/user/service/UserService.java @@ -3,8 +3,8 @@ import com.souzip.application.file.FileQueryService; import com.souzip.application.file.dto.FileResponse; import com.souzip.application.file.required.FileStorage; +import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.domain.auth.repository.RefreshTokenRepository; import com.souzip.domain.category.dto.CategoryDto; import com.souzip.domain.category.entity.Category; import com.souzip.domain.file.EntityType; @@ -21,14 +21,10 @@ import com.souzip.domain.user.entity.UserAgreement; import com.souzip.domain.user.repository.UserAgreementRepository; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.audit.annotation.Audit; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -38,6 +34,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + @Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) @@ -49,6 +52,7 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; private final ProfileImageService profileImageService; private final SouvenirRepository souvenirRepository; + private final WishlistRepository wishlistRepository; private final FileQueryService fileQueryService; private final FileStorage fileStorage; @@ -85,12 +89,20 @@ public OnboardingResponse completeOnboarding(Long userId, OnboardingRequest requ @Transactional public void withdraw(Long userId) { User user = findUserById(userId); - deleteRefreshTokenIfExists(user); + + deleteRefreshTokenIfExists(userId); deleteUserAgreementIfExists(user); + user.anonymize(); + userRepository.delete(user); } + @Transactional + public long deleteWithdrawnUsers() { + return userRepository.deleteByDeletedTrue(); + } + public UserProfileResponse getUserProfile(Long userId) { User user = findUserById(userId); return UserProfileResponse.from(user); @@ -107,12 +119,16 @@ public MySouvenirListResponse getMySouvenirs(Long userId, int page, int size) { .toList(); Map thumbnailMap = getThumbnails(souvenirIds); + Set wishlistedIds = wishlistRepository.findSouvenirIdsByUserId(user.getUserId()); + Map wishlistCountMap = wishlistRepository.countBySouvenirIds(souvenirIds); Page responsePage = souvenirPage.map(souvenir -> { String thumbnailUrl = Optional.ofNullable(thumbnailMap.get(souvenir.getId())) .map(FileResponse::url) .orElse(null); - return MySouvenirResponse.of(souvenir, thumbnailUrl); + boolean isWishlisted = wishlistedIds.contains(souvenir.getId()); + long wishlistCount = wishlistCountMap.getOrDefault(souvenir.getId(), 0L); + return MySouvenirResponse.of(souvenir, thumbnailUrl, isWishlisted, wishlistCount); }); return MySouvenirListResponse.from(responsePage); @@ -174,9 +190,8 @@ private boolean isRequiredAgreementNotAccepted(OnboardingRequest request) { || !request.locationService(); } - private void deleteRefreshTokenIfExists(User user) { - refreshTokenRepository.findByUser(user) - .ifPresent(refreshTokenRepository::delete); + private void deleteRefreshTokenIfExists(Long userId) { + refreshTokenRepository.deleteByUserId(userId); } private void deleteUserAgreementIfExists(User user) { diff --git a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java index 1526fae3..f4247470 100644 --- a/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java +++ b/src/main/java/com/souzip/domain/wishlist/controller/WishlistController.java @@ -1,11 +1,11 @@ package com.souzip.domain.wishlist.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.auth.adapter.security.annotation.RequireAuth; import com.souzip.domain.wishlist.dto.MyWishlistListResponse; import com.souzip.domain.wishlist.dto.WishlistResponse; import com.souzip.domain.wishlist.service.WishlistService; -import com.souzip.global.common.dto.SuccessResponse; -import com.souzip.global.security.annotation.CurrentUserId; -import com.souzip.global.security.annotation.RequireAuth; +import com.souzip.shared.common.dto.SuccessResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; @@ -19,8 +19,8 @@ public class WishlistController { @PostMapping("/api/wishlists/{souvenirId}") @RequireAuth public SuccessResponse addWishlist( - @CurrentUserId Long currentUserId, - @PathVariable Long souvenirId + @CurrentUserId Long currentUserId, + @PathVariable Long souvenirId ) { return SuccessResponse.of(wishlistService.addWishlist(currentUserId, souvenirId)); } @@ -28,8 +28,8 @@ public SuccessResponse addWishlist( @DeleteMapping("/api/wishlists/{souvenirId}") @RequireAuth public SuccessResponse removeWishlist( - @CurrentUserId Long currentUserId, - @PathVariable Long souvenirId + @CurrentUserId Long currentUserId, + @PathVariable Long souvenirId ) { return SuccessResponse.of(wishlistService.removeWishlist(currentUserId, souvenirId)); } @@ -37,8 +37,8 @@ public SuccessResponse removeWishlist( @GetMapping("/api/users/me/wishlists") @RequireAuth public SuccessResponse getMyWishlist( - @CurrentUserId Long currentUserId, - Pageable pageable + @CurrentUserId Long currentUserId, + Pageable pageable ) { return SuccessResponse.of(wishlistService.getMyWishlist(currentUserId, pageable)); } diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java index b37266f0..f4edc416 100644 --- a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java +++ b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistListResponse.java @@ -1,6 +1,6 @@ package com.souzip.domain.wishlist.dto; -import com.souzip.global.common.dto.pagination.PaginationResponse.PageInfo; +import com.souzip.shared.common.dto.pagination.PaginationResponse.PageInfo; import org.springframework.data.domain.Page; import java.util.List; diff --git a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java index 37007f92..2582f008 100644 --- a/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java +++ b/src/main/java/com/souzip/domain/wishlist/dto/MyWishlistResponse.java @@ -10,7 +10,8 @@ public record MyWishlistResponse( String name, String countryCode, String thumbnailUrl, - LocalDateTime wishedAt + LocalDateTime wishedAt, + boolean isWishlisted ) { public static MyWishlistResponse of(Wishlist wishlist, String thumbnailUrl) { Souvenir s = wishlist.getSouvenir(); @@ -19,7 +20,8 @@ public static MyWishlistResponse of(Wishlist wishlist, String thumbnailUrl) { s.getName(), s.getCountryCode(), thumbnailUrl, - wishlist.getCreatedAt() + wishlist.getCreatedAt(), + true ); } } diff --git a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java index ddad5617..dc24fb1a 100644 --- a/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java +++ b/src/main/java/com/souzip/domain/wishlist/entity/Wishlist.java @@ -1,8 +1,8 @@ package com.souzip.domain.wishlist.entity; -import com.souzip.domain.shared.BaseEntity; import com.souzip.domain.souvenir.entity.Souvenir; import com.souzip.domain.user.entity.User; +import com.souzip.shared.domain.BaseEntity; import jakarta.persistence.Entity; import lombok.*; diff --git a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java index a1c93bcb..c4de3895 100644 --- a/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java +++ b/src/main/java/com/souzip/domain/wishlist/repository/WishlistRepository.java @@ -9,10 +9,15 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @Repository public interface WishlistRepository extends JpaRepository { - boolean existsByUserIdAndSouvenirId(Long userId, Long souvenirId); + boolean existsByUserUserIdAndSouvenirId(String userId, Long souvenirId); @Modifying @Query("DELETE FROM Wishlist w WHERE w.user.id = :userId AND w.souvenir.id = :souvenirId") @@ -23,4 +28,21 @@ public interface WishlistRepository extends JpaRepository { countQuery = "SELECT COUNT(w) FROM Wishlist w WHERE w.user.id = :userId" ) Page findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + @Query("SELECT w.souvenir.id FROM Wishlist w WHERE w.user.userId = :userId") + Set findSouvenirIdsByUserId(@Param("userId") String userId); + + @Query("SELECT COUNT(w) FROM Wishlist w WHERE w.souvenir.id = :souvenirId") + long countBySouvenirId(@Param("souvenirId") Long souvenirId); + + @Query("SELECT w.souvenir.id, COUNT(w) FROM Wishlist w WHERE w.souvenir.id IN :souvenirIds GROUP BY w.souvenir.id") + List countBySouvenirIdsRaw(@Param("souvenirIds") List souvenirIds); + + default Map countBySouvenirIds(List souvenirIds) { + return countBySouvenirIdsRaw(souvenirIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } } diff --git a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java index dd3b9a6e..73c1ed6f 100644 --- a/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java +++ b/src/main/java/com/souzip/domain/wishlist/service/WishlistService.java @@ -14,8 +14,8 @@ import com.souzip.domain.wishlist.dto.WishlistResponse; import com.souzip.domain.wishlist.entity.Wishlist; import com.souzip.domain.wishlist.repository.WishlistRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; @@ -53,7 +53,8 @@ public WishlistResponse addWishlist(Long userId, Long souvenirId) { @Transactional public WishlistResponse removeWishlist(Long userId, Long souvenirId) { - if (!wishlistRepository.existsByUserIdAndSouvenirId(userId, souvenirId)) { + User user = findUserById(userId); + if (!wishlistRepository.existsByUserUserIdAndSouvenirId(user.getUserId(), souvenirId)) { throw new BusinessException(ErrorCode.WISHLIST_NOT_FOUND); } wishlistRepository.deleteByUserIdAndSouvenirId(userId, souvenirId); diff --git a/src/main/java/com/souzip/global/oauth/OAuthProperties.java b/src/main/java/com/souzip/global/oauth/OAuthProperties.java deleted file mode 100644 index a4807eee..00000000 --- a/src/main/java/com/souzip/global/oauth/OAuthProperties.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.souzip.global.oauth; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@RequiredArgsConstructor -@ConfigurationProperties(prefix = "oauth") -public class OAuthProperties { - - private final Kakao kakao; - private final Google google; - - @Getter - @RequiredArgsConstructor - public static class Kakao { - private final String userInfoUrl; - } - - @Getter - @RequiredArgsConstructor - public static class Google { - private final String userInfoUrl; - } -} diff --git a/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java deleted file mode 100644 index 5eda49e3..00000000 --- a/src/main/java/com/souzip/global/security/jwt/JwtAuthenticationFilter.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.souzip.global.security.jwt; - -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.Collections; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; - - private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - try { - authenticateRequest(request); - } catch (Exception e) { - log.error("사용자 인증 정보를 SecurityContext에 설정할 수 없습니다.", e); - } - - filterChain.doFilter(request, response); - } - - private void authenticateRequest(HttpServletRequest request) { - String token = extractTokenFromRequest(request); - - if (isTokenAbsent(token)) { - return; - } - - User user = getUserFromToken(token); - - if (isUserAbsent(user)) { - return; - } - - setAuthentication(user); - } - - private String extractTokenFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - - if (hasBearerToken(bearerToken)) { - return extractToken(bearerToken); - } - - return null; - } - - private boolean hasBearerToken(String bearerToken) { - return StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX); - } - - private String extractToken(String bearerToken) { - return bearerToken.substring(BEARER_PREFIX.length()); - } - - private boolean isTokenAbsent(String token) { - return isTokenNull(token) || isTokenInvalid(token); - } - - private boolean isTokenNull(String token) { - return token == null; - } - - private boolean isTokenInvalid(String token) { - return !jwtTokenProvider.validateToken(token); - } - - private User getUserFromToken(String token) { - String userId = jwtTokenProvider.getUserIdFromToken(token); - return userRepository.findByUserId(userId).orElse(null); - } - - private boolean isUserAbsent(User user) { - return user == null; - } - - private void setAuthentication(User user) { - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList()); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java deleted file mode 100644 index b09f64c8..00000000 --- a/src/main/java/com/souzip/global/security/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.souzip.global.security.jwt; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Slf4j -@RequiredArgsConstructor -@Component -public class JwtTokenProvider { - - private final JwtProperties jwtProperties; - - public String generateToken(String userId) { - return createToken(userId, jwtProperties.getExpiration()); - } - - public String generateRefreshToken(String userId) { - return createToken(userId, jwtProperties.getRefreshExpiration()); - } - - private String createToken(String userId, Long expirationMs) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expirationMs); - - return Jwts.builder() - .subject(userId) - .issuedAt(now) - .expiration(expiryDate) - .signWith(getSigningKey()) - .compact(); - } - - public String getUserIdFromToken(String token) { - return parseClaims(token).getSubject(); - } - - public boolean validateToken(String token) { - try { - parseClaims(token); - return true; - } catch (Exception e) { - log.debug("Invalid JWT token: {}", e.getMessage()); - return false; - } - } - - private Claims parseClaims(String token) { - return Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - private SecretKey getSigningKey() { - byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8); - return Keys.hmacShaKeyFor(keyBytes); - } -} diff --git a/src/main/java/com/souzip/global/aop/MdcTraceId.java b/src/main/java/com/souzip/shared/aop/MdcTraceId.java similarity index 91% rename from src/main/java/com/souzip/global/aop/MdcTraceId.java rename to src/main/java/com/souzip/shared/aop/MdcTraceId.java index 344a4a3f..a6231c29 100644 --- a/src/main/java/com/souzip/global/aop/MdcTraceId.java +++ b/src/main/java/com/souzip/shared/aop/MdcTraceId.java @@ -1,13 +1,15 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; import java.util.UUID; + import org.slf4j.MDC; public class MdcTraceId { private static final String TRACE_ID = "traceId"; - private MdcTraceId() {} + private MdcTraceId() { + } public static String generate() { return UUID.randomUUID().toString().substring(0, 8); diff --git a/src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java b/src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java similarity index 75% rename from src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java rename to src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java index 2111e769..a6459721 100644 --- a/src/main/java/com/souzip/global/aop/ServiceExceptionLoggingAspect.java +++ b/src/main/java/com/souzip/shared/aop/ServiceExceptionLoggingAspect.java @@ -1,6 +1,6 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterThrowing; @@ -16,16 +16,17 @@ public class ServiceExceptionLoggingAspect { @Pointcut("execution(* com.souzip.api..service..*(..))") - public void serviceLayer() {} + public void serviceLayer() { + } @AfterThrowing(pointcut = "serviceLayer()", throwing = "ex") public void logServiceException(JoinPoint joinPoint, Exception ex) { if (ex instanceof BusinessException) return; log.error("[SERVICE-ERROR] method={} message={}", - joinPoint.getSignature().toShortString(), - ex.getMessage(), - ex + joinPoint.getSignature().toShortString(), + ex.getMessage(), + ex ); } } diff --git a/src/main/java/com/souzip/global/aop/TraceIdAspect.java b/src/main/java/com/souzip/shared/aop/TraceIdAspect.java similarity index 96% rename from src/main/java/com/souzip/global/aop/TraceIdAspect.java rename to src/main/java/com/souzip/shared/aop/TraceIdAspect.java index 18670887..913289fe 100644 --- a/src/main/java/com/souzip/global/aop/TraceIdAspect.java +++ b/src/main/java/com/souzip/shared/aop/TraceIdAspect.java @@ -1,4 +1,4 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; diff --git a/src/main/java/com/souzip/global/audit/annotation/Audit.java b/src/main/java/com/souzip/shared/audit/annotation/Audit.java similarity index 89% rename from src/main/java/com/souzip/global/audit/annotation/Audit.java rename to src/main/java/com/souzip/shared/audit/annotation/Audit.java index e4c9fb49..96435ed9 100644 --- a/src/main/java/com/souzip/global/audit/annotation/Audit.java +++ b/src/main/java/com/souzip/shared/audit/annotation/Audit.java @@ -1,6 +1,7 @@ -package com.souzip.global.audit.annotation; +package com.souzip.shared.audit.annotation; import com.souzip.domain.audit.entity.AuditAction; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/src/main/java/com/souzip/global/audit/aspect/AuditAspect.java b/src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java similarity index 59% rename from src/main/java/com/souzip/global/audit/aspect/AuditAspect.java rename to src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java index 8cb6d87e..2cfd071e 100644 --- a/src/main/java/com/souzip/global/audit/aspect/AuditAspect.java +++ b/src/main/java/com/souzip/shared/audit/aspect/AuditAspect.java @@ -1,17 +1,15 @@ -package com.souzip.global.audit.aspect; +package com.souzip.shared.audit.aspect; +import com.souzip.auth.application.dto.LoginInfo; import com.souzip.domain.audit.entity.AuditAction; import com.souzip.domain.audit.service.AuditService; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.LoginUserInfo; import com.souzip.domain.user.dto.OnboardingRequest; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.global.audit.annotation.Audit; -import com.souzip.global.audit.dto.AuditContext; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import com.souzip.global.util.HttpRequestUtils; +import com.souzip.shared.audit.annotation.Audit; +import com.souzip.shared.audit.dto.AuditContext; +import com.souzip.shared.domain.Provider; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; +import com.souzip.shared.util.HttpRequestUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,7 +32,7 @@ public class AuditAspect { private static final String ONBOARDING_METADATA_FORMAT = - "{\"serviceTerms\": %s, \"privacyRequired\": %s, \"marketingConsent\": %s, \"locationService\": %s}"; + "{\"serviceTerms\": %s, \"privacyRequired\": %s, \"marketingConsent\": %s, \"locationService\": %s}"; private final AuditService auditService; @@ -65,11 +63,11 @@ private void logSuccess(HttpServletRequest request, private void logFailure(HttpServletRequest request, AuditAction action, Exception e) { auditService.logFailure( - action, - e.getClass().getSimpleName(), - HttpRequestUtils.extractClientIp(request), - HttpRequestUtils.extractUserAgent(request), - HttpRequestUtils.extractAppVersion(request) + action, + e.getClass().getSimpleName(), + HttpRequestUtils.extractClientIp(request), + HttpRequestUtils.extractUserAgent(request), + HttpRequestUtils.extractAppVersion(request) ); } @@ -80,14 +78,14 @@ private AuditContext buildSuccessContext(HttpServletRequest request, Audit audit ) { return AuditContext.builder() - .userId(extractUserId(result, joinPoint, audit)) - .action(action) - .ipAddress(HttpRequestUtils.extractClientIp(request)) - .userAgent(HttpRequestUtils.extractUserAgent(request)) - .appVersion(HttpRequestUtils.extractAppVersion(request)) - .oauthProvider(extractOAuthProvider(joinPoint)) - .metadata(buildMetadata(joinPoint)) - .build(); + .userId(extractUserId(result, joinPoint, audit)) + .action(action) + .ipAddress(HttpRequestUtils.extractClientIp(request)) + .userAgent(HttpRequestUtils.extractUserAgent(request)) + .appVersion(HttpRequestUtils.extractAppVersion(request)) + .oauthProvider(extractOAuthProvider(joinPoint)) + .metadata(buildMetadata(joinPoint)) + .build(); } private String extractUserId(Object result, ProceedingJoinPoint joinPoint, Audit audit) { @@ -95,8 +93,8 @@ private String extractUserId(Object result, ProceedingJoinPoint joinPoint, Audit return extractUserIdFromArgs(joinPoint); } - if (isLoginResponse(result)) { - return extractUserIdFromLoginResponse((LoginResponse) result); + if (result instanceof LoginInfo loginInfo) { + return loginInfo.userId(); } return extractUserIdFromSecurity(); @@ -108,65 +106,50 @@ private boolean hasUserIdParam(Audit audit) { private String extractUserIdFromArgs(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(this::isUserIdArg) - .map(String.class::cast) - .findFirst() - .orElse(null); - } - - private boolean isLoginResponse(Object result) { - return result instanceof LoginResponse; - } - - private String extractUserIdFromLoginResponse(LoginResponse loginResponse) { - return Optional.ofNullable(loginResponse.getUser()) - .map(LoginUserInfo::userId) - .orElse(null); + .filter(this::isUserIdArg) + .map(String.class::cast) + .findFirst() + .orElse(null); } private String extractUserIdFromSecurity() { return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) - .filter(this::isAuthenticatedUser) - .map(Authentication::getPrincipal) - .map(User.class::cast) - .map(User::getUserId) - .orElse(null); - } - - private boolean isAuthenticatedUser(Authentication authentication) { - return authentication.getPrincipal() instanceof User; + .map(Authentication::getPrincipal) + .filter(Long.class::isInstance) + .map(principal -> String.valueOf((Long) principal)) + .orElse(null); } private Provider extractOAuthProvider(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(Provider.class::isInstance) - .map(Provider.class::cast) - .findFirst() - .orElse(null); + .filter(Provider.class::isInstance) + .map(Provider.class::cast) + .findFirst() + .orElse(null); } private String buildMetadata(ProceedingJoinPoint joinPoint) { return Arrays.stream(joinPoint.getArgs()) - .filter(OnboardingRequest.class::isInstance) - .map(OnboardingRequest.class::cast) - .findFirst() - .map(this::formatOnboardingMetadata) - .orElse(null); + .filter(OnboardingRequest.class::isInstance) + .map(OnboardingRequest.class::cast) + .findFirst() + .map(this::formatOnboardingMetadata) + .orElse(null); } private String formatOnboardingMetadata(OnboardingRequest req) { return String.format( - ONBOARDING_METADATA_FORMAT, - req.serviceTerms(), - req.privacyRequired(), - req.marketingConsent(), - req.locationService() + ONBOARDING_METADATA_FORMAT, + req.serviceTerms(), + req.privacyRequired(), + req.marketingConsent(), + req.locationService() ); } private HttpServletRequest getCurrentRequest() { ServletRequestAttributes attributes = - (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); validateRequestAttributes(attributes); return attributes.getRequest(); } @@ -184,4 +167,4 @@ private boolean isUserIdArg(Object arg) { private boolean isMissingAttributes(ServletRequestAttributes attributes) { return attributes == null; } -} +} \ No newline at end of file diff --git a/src/main/java/com/souzip/global/audit/dto/AuditContext.java b/src/main/java/com/souzip/shared/audit/dto/AuditContext.java similarity index 88% rename from src/main/java/com/souzip/global/audit/dto/AuditContext.java rename to src/main/java/com/souzip/shared/audit/dto/AuditContext.java index 0f63ec8a..09e93183 100644 --- a/src/main/java/com/souzip/global/audit/dto/AuditContext.java +++ b/src/main/java/com/souzip/shared/audit/dto/AuditContext.java @@ -1,7 +1,7 @@ -package com.souzip.global.audit.dto; +package com.souzip.shared.audit.dto; import com.souzip.domain.audit.entity.AuditAction; -import com.souzip.domain.user.entity.Provider; +import com.souzip.shared.domain.Provider; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/souzip/global/clova/ClovaStudioClient.java b/src/main/java/com/souzip/shared/clova/ClovaStudioClient.java similarity index 76% rename from src/main/java/com/souzip/global/clova/ClovaStudioClient.java rename to src/main/java/com/souzip/shared/clova/ClovaStudioClient.java index 2a516550..d326d8f0 100644 --- a/src/main/java/com/souzip/global/clova/ClovaStudioClient.java +++ b/src/main/java/com/souzip/shared/clova/ClovaStudioClient.java @@ -1,8 +1,8 @@ -package com.souzip.global.clova; +package com.souzip.shared.clova; -import com.souzip.global.config.ClovaStudioProperties; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.config.ClovaStudioProperties; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; @@ -41,10 +41,11 @@ private String chat(String userMessage) { try { HttpEntity> request = createRequest(userMessage); ResponseEntity> response = restTemplate.exchange( - properties.getApiUrl(), - HttpMethod.POST, - request, - new ParameterizedTypeReference>() {} + properties.getApiUrl(), + HttpMethod.POST, + request, + new ParameterizedTypeReference>() { + } ); return parseResponse(response.getBody()); @@ -71,27 +72,27 @@ private HttpHeaders createHeaders() { private Map createRequestBody(String userMessage) { return Map.of( - "messages", createMessages(userMessage), - "topP", TOP_P, - "topK", TOP_K, - "maxTokens", MAX_TOKENS, - "temperature", TEMPERATURE, - "repeatPenalty", REPEAT_PENALTY, - "includeAiFilters", true + "messages", createMessages(userMessage), + "topP", TOP_P, + "topK", TOP_K, + "maxTokens", MAX_TOKENS, + "temperature", TEMPERATURE, + "repeatPenalty", REPEAT_PENALTY, + "includeAiFilters", true ); } private List> createMessages(String userMessage) { return List.of( - createMessage("system", CURATOR_SYSTEM_PROMPT), - createMessage("user", userMessage) + createMessage("system", CURATOR_SYSTEM_PROMPT), + createMessage("user", userMessage) ); } private Map createMessage(String role, String text) { return Map.of( - "role", role, - "content", List.of(Map.of("type", "text", "text", text)) + "role", role, + "content", List.of(Map.of("type", "text", "text", text)) ); } @@ -113,7 +114,7 @@ private String parseResponse(Map responseBody) { private String parseListContent(List> contentList) { return contentList.stream() - .map(item -> (String) item.get("text")) - .reduce("", String::concat); + .map(item -> (String) item.get("text")) + .reduce("", String::concat); } } diff --git a/src/main/java/com/souzip/global/clova/PromptLoader.java b/src/main/java/com/souzip/shared/clova/PromptLoader.java similarity index 84% rename from src/main/java/com/souzip/global/clova/PromptLoader.java rename to src/main/java/com/souzip/shared/clova/PromptLoader.java index 51b2c135..e9b265a1 100644 --- a/src/main/java/com/souzip/global/clova/PromptLoader.java +++ b/src/main/java/com/souzip/shared/clova/PromptLoader.java @@ -1,7 +1,7 @@ -package com.souzip.global.clova; +package com.souzip.shared.clova; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/souzip/global/common/dto/ErrorResponse.java b/src/main/java/com/souzip/shared/common/dto/ErrorResponse.java similarity index 75% rename from src/main/java/com/souzip/global/common/dto/ErrorResponse.java rename to src/main/java/com/souzip/shared/common/dto/ErrorResponse.java index 07cb6312..bd47f359 100644 --- a/src/main/java/com/souzip/global/common/dto/ErrorResponse.java +++ b/src/main/java/com/souzip/shared/common/dto/ErrorResponse.java @@ -1,8 +1,10 @@ -package com.souzip.global.common.dto; +package com.souzip.shared.common.dto; import com.fasterxml.jackson.annotation.JsonInclude; -import com.souzip.global.aop.MdcTraceId; +import com.souzip.shared.aop.MdcTraceId; + import java.util.List; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -24,17 +26,17 @@ private ErrorResponse(String traceId, String message, List errors) { public static ErrorResponse of(String message) { return ErrorResponse.builder() - .traceId(MdcTraceId.get()) - .message(message) - .build(); + .traceId(MdcTraceId.get()) + .message(message) + .build(); } public static ErrorResponse of(String message, List errors) { return ErrorResponse.builder() - .traceId(MdcTraceId.get()) - .message(message) - .errors(errors) - .build(); + .traceId(MdcTraceId.get()) + .message(message) + .errors(errors) + .build(); } @Getter @@ -53,10 +55,10 @@ private FieldError(String field, String value, String reason) { public static FieldError of(String field, String value, String reason) { return FieldError.builder() - .field(field) - .value(value) - .reason(reason) - .build(); + .field(field) + .value(value) + .reason(reason) + .build(); } } } diff --git a/src/main/java/com/souzip/global/common/dto/SuccessResponse.java b/src/main/java/com/souzip/shared/common/dto/SuccessResponse.java similarity index 80% rename from src/main/java/com/souzip/global/common/dto/SuccessResponse.java rename to src/main/java/com/souzip/shared/common/dto/SuccessResponse.java index aacf8e03..94fea218 100644 --- a/src/main/java/com/souzip/global/common/dto/SuccessResponse.java +++ b/src/main/java/com/souzip/shared/common/dto/SuccessResponse.java @@ -1,4 +1,4 @@ -package com.souzip.global.common.dto; +package com.souzip.shared.common.dto; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AccessLevel; @@ -20,9 +20,9 @@ private SuccessResponse(T data, String message) { public static SuccessResponse of(T data, String message) { return SuccessResponse.builder() - .data(data) - .message(message) - .build(); + .data(data) + .message(message) + .build(); } public static SuccessResponse of(T data) { @@ -31,7 +31,7 @@ public static SuccessResponse of(T data) { public static SuccessResponse of(String message) { return SuccessResponse.builder() - .message(message) - .build(); + .message(message) + .build(); } } diff --git a/src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java similarity index 85% rename from src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java rename to src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java index ff9c9a08..55f5ff21 100644 --- a/src/main/java/com/souzip/global/common/dto/pagination/PaginationRequest.java +++ b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationRequest.java @@ -1,6 +1,7 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import java.util.Optional; + import lombok.Getter; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -19,10 +20,10 @@ public class PaginationRequest { public PaginationRequest(Integer pageNo, Integer pageSize) { this.pageNo = validatePageNo( - Optional.ofNullable(pageNo).orElse(DEFAULT_PAGE_NO) + Optional.ofNullable(pageNo).orElse(DEFAULT_PAGE_NO) ); this.pageSize = validatePageSize( - Optional.ofNullable(pageSize).orElse(DEFAULT_PAGE_SIZE) + Optional.ofNullable(pageSize).orElse(DEFAULT_PAGE_SIZE) ); } diff --git a/src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java similarity index 67% rename from src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java rename to src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java index 7b3050e4..f1e8774c 100644 --- a/src/main/java/com/souzip/global/common/dto/pagination/PaginationResponse.java +++ b/src/main/java/com/souzip/shared/common/dto/pagination/PaginationResponse.java @@ -1,7 +1,9 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import com.fasterxml.jackson.annotation.JsonInclude; + import java.util.List; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -22,16 +24,16 @@ protected PaginationResponse(List content, PageInfo pagination) { public static PaginationResponse of(Page page, List content) { return PaginationResponse.builder() - .content(content) - .pagination(PageInfo.of(page)) - .build(); + .content(content) + .pagination(PageInfo.of(page)) + .build(); } public static PaginationResponse of(List content, int pageNo, int pageSize, long total, int totalPages) { return PaginationResponse.builder() - .content(content) - .pagination(PageInfo.of(pageNo, pageSize, total, totalPages)) - .build(); + .content(content) + .pagination(PageInfo.of(pageNo, pageSize, total, totalPages)) + .build(); } @Getter @@ -64,28 +66,28 @@ private PageInfo(int currentPage, int totalPages, public static PageInfo of(Page page) { return PageInfo.builder() - .currentPage(toDisplayPageNumber(page.getNumber())) - .totalPages(page.getTotalPages()) - .totalItems(page.getTotalElements()) - .pageSize(page.getSize()) - .first(page.isFirst()) - .last(page.isLast()) - .hasNext(page.hasNext()) - .hasPrevious(page.hasPrevious()) - .build(); + .currentPage(toDisplayPageNumber(page.getNumber())) + .totalPages(page.getTotalPages()) + .totalItems(page.getTotalElements()) + .pageSize(page.getSize()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); } public static PageInfo of(int pageNo, int pageSize, long total, int totalPages) { return PageInfo.builder() - .currentPage(pageNo) - .totalPages(totalPages) - .totalItems(total) - .pageSize(pageSize) - .first(pageNo == 1) - .last(pageNo >= totalPages) - .hasNext(pageNo < totalPages) - .hasPrevious(pageNo > 1) - .build(); + .currentPage(pageNo) + .totalPages(totalPages) + .totalItems(total) + .pageSize(pageSize) + .first(pageNo == 1) + .last(pageNo >= totalPages) + .hasNext(pageNo < totalPages) + .hasPrevious(pageNo > 1) + .build(); } private static int toDisplayPageNumber(int zeroBasedPageNumber) { diff --git a/src/main/java/com/souzip/shared/config/AppConfig.java b/src/main/java/com/souzip/shared/config/AppConfig.java new file mode 100644 index 00000000..e583c381 --- /dev/null +++ b/src/main/java/com/souzip/shared/config/AppConfig.java @@ -0,0 +1,17 @@ +package com.souzip.shared.config; + +import com.souzip.adapter.config.ObjectStorageProperties; +import com.souzip.auth.adapter.security.jwt.JwtProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({ + JwtProperties.class, + ObjectStorageProperties.class, + ClovaStudioProperties.class, + AppleMigrationConfig.class, + CorsProperties.class +}) +public class AppConfig { +} diff --git a/src/main/java/com/souzip/global/config/AppleMigrationConfig.java b/src/main/java/com/souzip/shared/config/AppleMigrationConfig.java similarity index 77% rename from src/main/java/com/souzip/global/config/AppleMigrationConfig.java rename to src/main/java/com/souzip/shared/config/AppleMigrationConfig.java index c094b100..b92c2020 100644 --- a/src/main/java/com/souzip/global/config/AppleMigrationConfig.java +++ b/src/main/java/com/souzip/shared/config/AppleMigrationConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -18,13 +18,13 @@ public class AppleMigrationConfig { @ConstructorBinding public AppleMigrationConfig( - String clientId, - String teamId, - String keyId, - String privateKey, - String oldTeamId, - String oldKeyId, - String oldPrivateKey + String clientId, + String teamId, + String keyId, + String privateKey, + String oldTeamId, + String oldKeyId, + String oldPrivateKey ) { this.clientId = clientId; this.teamId = teamId; diff --git a/src/main/java/com/souzip/global/config/ClovaStudioProperties.java b/src/main/java/com/souzip/shared/config/ClovaStudioProperties.java similarity index 89% rename from src/main/java/com/souzip/global/config/ClovaStudioProperties.java rename to src/main/java/com/souzip/shared/config/ClovaStudioProperties.java index 8134e860..6611a072 100644 --- a/src/main/java/com/souzip/global/config/ClovaStudioProperties.java +++ b/src/main/java/com/souzip/shared/config/ClovaStudioProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/souzip/global/config/CorsProperties.java b/src/main/java/com/souzip/shared/config/CorsProperties.java similarity index 91% rename from src/main/java/com/souzip/global/config/CorsProperties.java rename to src/main/java/com/souzip/shared/config/CorsProperties.java index e4b5cba0..ddaf55c9 100644 --- a/src/main/java/com/souzip/global/config/CorsProperties.java +++ b/src/main/java/com/souzip/shared/config/CorsProperties.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/souzip/global/config/JpaAuditingConfig.java b/src/main/java/com/souzip/shared/config/JpaAuditingConfig.java similarity index 85% rename from src/main/java/com/souzip/global/config/JpaAuditingConfig.java rename to src/main/java/com/souzip/shared/config/JpaAuditingConfig.java index 147284b8..2662ebc7 100644 --- a/src/main/java/com/souzip/global/config/JpaAuditingConfig.java +++ b/src/main/java/com/souzip/shared/config/JpaAuditingConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/src/main/java/com/souzip/global/config/PasswordEncoderConfig.java b/src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java similarity index 92% rename from src/main/java/com/souzip/global/config/PasswordEncoderConfig.java rename to src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java index 25774aef..47035212 100644 --- a/src/main/java/com/souzip/global/config/PasswordEncoderConfig.java +++ b/src/main/java/com/souzip/shared/config/PasswordEncoderConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/global/config/QuerydslConfig.java b/src/main/java/com/souzip/shared/config/QuerydslConfig.java similarity index 93% rename from src/main/java/com/souzip/global/config/QuerydslConfig.java rename to src/main/java/com/souzip/shared/config/QuerydslConfig.java index 6eae15b0..0a894a51 100644 --- a/src/main/java/com/souzip/global/config/QuerydslConfig.java +++ b/src/main/java/com/souzip/shared/config/QuerydslConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; diff --git a/src/main/java/com/souzip/global/config/RestTemplateConfig.java b/src/main/java/com/souzip/shared/config/RestTemplateConfig.java similarity index 90% rename from src/main/java/com/souzip/global/config/RestTemplateConfig.java rename to src/main/java/com/souzip/shared/config/RestTemplateConfig.java index 6c17ff42..c8522ac9 100644 --- a/src/main/java/com/souzip/global/config/RestTemplateConfig.java +++ b/src/main/java/com/souzip/shared/config/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/souzip/global/config/SchedulerConfig.java b/src/main/java/com/souzip/shared/config/SchedulerConfig.java similarity index 85% rename from src/main/java/com/souzip/global/config/SchedulerConfig.java rename to src/main/java/com/souzip/shared/config/SchedulerConfig.java index ae48e2ab..6f8bd45a 100644 --- a/src/main/java/com/souzip/global/config/SchedulerConfig.java +++ b/src/main/java/com/souzip/shared/config/SchedulerConfig.java @@ -1,4 +1,4 @@ -package com.souzip.global.config; +package com.souzip.shared.config; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/src/main/java/com/souzip/domain/shared/BaseEntity.java b/src/main/java/com/souzip/shared/domain/BaseEntity.java similarity index 68% rename from src/main/java/com/souzip/domain/shared/BaseEntity.java rename to src/main/java/com/souzip/shared/domain/BaseEntity.java index 3ee96c6f..7a64119d 100644 --- a/src/main/java/com/souzip/domain/shared/BaseEntity.java +++ b/src/main/java/com/souzip/shared/domain/BaseEntity.java @@ -1,17 +1,13 @@ -package com.souzip.domain.shared; +package com.souzip.shared.domain; -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; -import java.time.LocalDateTime; +import jakarta.persistence.*; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDateTime; + @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/souzip/domain/shared/Coordinate.java b/src/main/java/com/souzip/shared/domain/Coordinate.java similarity index 97% rename from src/main/java/com/souzip/domain/shared/Coordinate.java rename to src/main/java/com/souzip/shared/domain/Coordinate.java index a9be6135..75ebc318 100644 --- a/src/main/java/com/souzip/domain/shared/Coordinate.java +++ b/src/main/java/com/souzip/shared/domain/Coordinate.java @@ -1,4 +1,4 @@ -package com.souzip.domain.shared; +package com.souzip.shared.domain; import lombok.AccessLevel; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/souzip/shared/domain/Provider.java b/src/main/java/com/souzip/shared/domain/Provider.java new file mode 100644 index 00000000..16d7778b --- /dev/null +++ b/src/main/java/com/souzip/shared/domain/Provider.java @@ -0,0 +1,24 @@ +package com.souzip.shared.domain; + +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + +import java.util.Arrays; + +public enum Provider { + KAKAO, + APPLE, + GOOGLE; + + public static Provider from(String value) { + if (value == null || value.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "Provider는 필수입니다."); + } + + return Arrays.stream(values()) + .filter(provider -> provider.name().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new BusinessException( + ErrorCode.INVALID_INPUT, "지원하지 않는 로그인 방식입니다: " + value)); + } +} diff --git a/src/main/java/com/souzip/global/exception/BaseErrorCode.java b/src/main/java/com/souzip/shared/exception/BaseErrorCode.java similarity index 79% rename from src/main/java/com/souzip/global/exception/BaseErrorCode.java rename to src/main/java/com/souzip/shared/exception/BaseErrorCode.java index b834c33f..35d168e6 100644 --- a/src/main/java/com/souzip/global/exception/BaseErrorCode.java +++ b/src/main/java/com/souzip/shared/exception/BaseErrorCode.java @@ -1,9 +1,11 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import org.springframework.http.HttpStatus; public interface BaseErrorCode { HttpStatus getStatus(); + String getMessage(); + String name(); } diff --git a/src/main/java/com/souzip/global/exception/BusinessException.java b/src/main/java/com/souzip/shared/exception/BusinessException.java similarity index 91% rename from src/main/java/com/souzip/global/exception/BusinessException.java rename to src/main/java/com/souzip/shared/exception/BusinessException.java index 3ba7e2dc..587875cb 100644 --- a/src/main/java/com/souzip/global/exception/BusinessException.java +++ b/src/main/java/com/souzip/shared/exception/BusinessException.java @@ -1,4 +1,4 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import lombok.Getter; diff --git a/src/main/java/com/souzip/global/exception/ErrorCode.java b/src/main/java/com/souzip/shared/exception/ErrorCode.java similarity index 80% rename from src/main/java/com/souzip/global/exception/ErrorCode.java rename to src/main/java/com/souzip/shared/exception/ErrorCode.java index 71ba4c97..00cbc984 100644 --- a/src/main/java/com/souzip/global/exception/ErrorCode.java +++ b/src/main/java/com/souzip/shared/exception/ErrorCode.java @@ -1,4 +1,4 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -20,24 +20,10 @@ public enum ErrorCode implements BaseErrorCode { INVALID_PROFILE_IMAGE_COLOR(HttpStatus.BAD_REQUEST, "유효하지 않은 프로필 이미지 색상입니다."), NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), - EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 Refresh Token입니다."), - INVALID_TOKEN_FORMAT(HttpStatus.BAD_REQUEST, "토큰 형식이 올바르지 않습니다."), - INVALID_JWT_STRUCTURE(HttpStatus.BAD_REQUEST, "JWT 구조가 올바르지 않습니다."), - TOKEN_DECODE_FAILED(HttpStatus.BAD_REQUEST, "토큰 디코딩에 실패했습니다."), - TOKEN_PARSE_FAILED(HttpStatus.BAD_REQUEST, "토큰 파싱에 실패했습니다."), - - KAKAO_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 API 호출에 실패했습니다."), - GOOGLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 API 호출에 실패했습니다."), - APPLE_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 API 호출에 실패했습니다."), - COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 국가 정보를 찾을 수 없습니다."), COUNTRY_REGION_INVALID(HttpStatus.BAD_REQUEST, "국가의 지역 코드가 유효하지 않습니다."), - CITY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 도시 정보를 찾을 수 없습니다."), - CURRENCY_NOT_FOUND(HttpStatus.BAD_REQUEST, "통화를 찾을 수 없습니다."), - EXCHANGE_RATE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 환율 정보를 찾을 수 없습니다."), SOUVENIR_NOT_FOUND(HttpStatus.NOT_FOUND, "기념품을 찾을 수 없습니다."), diff --git a/src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java b/src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java similarity index 96% rename from src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java rename to src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java index 43e9e57a..6423ef1c 100644 --- a/src/main/java/com/souzip/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/souzip/shared/exception/GlobalExceptionHandler.java @@ -1,10 +1,12 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; + +import com.souzip.shared.aop.MdcTraceId; +import com.souzip.shared.common.dto.ErrorResponse; -import com.souzip.global.aop.MdcTraceId; -import com.souzip.global.common.dto.ErrorResponse; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; + import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; diff --git a/src/main/java/com/souzip/global/util/HttpRequestUtils.java b/src/main/java/com/souzip/shared/util/HttpRequestUtils.java similarity index 90% rename from src/main/java/com/souzip/global/util/HttpRequestUtils.java rename to src/main/java/com/souzip/shared/util/HttpRequestUtils.java index 040ed251..f590ae7f 100644 --- a/src/main/java/com/souzip/global/util/HttpRequestUtils.java +++ b/src/main/java/com/souzip/shared/util/HttpRequestUtils.java @@ -1,4 +1,4 @@ -package com.souzip.global.util; +package com.souzip.shared.util; import jakarta.servlet.http.HttpServletRequest; import lombok.AccessLevel; @@ -21,7 +21,7 @@ public class HttpRequestUtils { private static final String UNKNOWN_IP = "unknown"; private static final Pattern APP_VERSION_PATTERN = - Pattern.compile("Souzip-(iOS|Android)/(\\d+\\.\\d+\\.\\d+)"); + Pattern.compile("Souzip-(iOS|Android)/(\\d+\\.\\d+\\.\\d+)"); public static String extractClientIp(HttpServletRequest request) { String ip = getFirstValidIp(request); @@ -44,14 +44,14 @@ public static String extractAppVersion(HttpServletRequest request) { private static String getFirstValidIp(HttpServletRequest request) { return Arrays.stream(new String[]{ - request.getHeader(HEADER_X_FORWARDED_FOR), - request.getHeader(HEADER_X_REAL_IP), - request.getHeader(HEADER_PROXY_CLIENT_IP), - request.getRemoteAddr() - }) - .filter(HttpRequestUtils::isValidIp) - .findFirst() - .orElse(UNKNOWN); + request.getHeader(HEADER_X_FORWARDED_FOR), + request.getHeader(HEADER_X_REAL_IP), + request.getHeader(HEADER_PROXY_CLIENT_IP), + request.getRemoteAddr() + }) + .filter(HttpRequestUtils::isValidIp) + .findFirst() + .orElse(UNKNOWN); } private static String parseVersionFromUserAgent(HttpServletRequest request) { diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index 5a2e49c7..dd65e9f9 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -1,124 +1,142 @@ - FIELD + FIELD - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - name - address - -
- - - - - - - - - -
+ + + + +
+ + + + + + + + + + + +
- - - - entity_type - entity_id - display_order - -
- - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + name + address + +
+ + + + + + + + + +
- - - - - - - - - - - - - - - STRING - - - + +
+ + entity_type + entity_id + display_order + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
- - - - user_id - souvenir_id - -
- - - - - - - - - - -
+ + + + + + + + + + + + + + + STRING + + + - - - - - - - - - - + +
+ + user_id + souvenir_id + +
+ + + + + + + + + + +
+ + + + + + + + + + +
diff --git a/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java b/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java index 82f1efe1..360434ad 100644 --- a/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java +++ b/src/test/java/com/souzip/adapter/integration/googlegeocoding/GoogleGeocodingTest.java @@ -2,7 +2,7 @@ import com.souzip.adapter.integration.googlegeocoding.dto.GoogleGeocodingResponse; import com.souzip.application.location.dto.AddressResult; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java index 85a0c4b8..0209ec4b 100644 --- a/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java +++ b/src/test/java/com/souzip/adapter/storage/file/NcpStorageTest.java @@ -4,7 +4,7 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.souzip.adapter.config.ObjectStorageProperties; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java b/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java index f9dd32d0..214f4282 100644 --- a/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/geocoding/GeocodingApiTest.java @@ -4,7 +4,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.provided.ReverseGeocoding; import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java b/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java index a6b536e2..cc635b27 100644 --- a/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java +++ b/src/test/java/com/souzip/adapter/webapi/location/LocationApiTest.java @@ -9,7 +9,7 @@ import com.souzip.docs.RestDocsSupport; import com.souzip.domain.city.entity.City; import com.souzip.domain.country.entity.Country; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java b/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java index 9bcbe87f..8a487cba 100644 --- a/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java +++ b/src/test/java/com/souzip/application/location/LocationSearchServiceTest.java @@ -1,17 +1,15 @@ package com.souzip.application.location; import com.souzip.application.location.dto.CitySearchResult; -import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.dto.PlaceSearchResult; +import com.souzip.application.location.dto.SearchPlace; import com.souzip.application.location.dto.SearchResult; import com.souzip.application.location.required.CitySearchRepository; import com.souzip.application.location.required.PlaceSearchProvider; import com.souzip.domain.city.entity.City; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.entity.Region; -import com.souzip.domain.shared.Coordinate; -import java.math.BigDecimal; -import java.util.List; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +21,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; +import java.math.BigDecimal; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java b/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java index 9a8923df..613cb329 100644 --- a/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java +++ b/src/test/java/com/souzip/application/location/ReverseGeocodingServiceTest.java @@ -2,7 +2,7 @@ import com.souzip.application.location.dto.AddressResult; import com.souzip.application.location.required.AddressProvider; -import com.souzip.domain.shared.Coordinate; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/auth/controller/AuthApiTest.java b/src/test/java/com/souzip/auth/controller/AuthApiTest.java new file mode 100644 index 00000000..b8abd2dc --- /dev/null +++ b/src/test/java/com/souzip/auth/controller/AuthApiTest.java @@ -0,0 +1,372 @@ +package com.souzip.auth.controller; + +import com.souzip.auth.adapter.web.AuthApi; +import com.souzip.auth.adapter.web.dto.LoginRequest; +import com.souzip.auth.adapter.web.dto.RefreshRequest; +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.provided.Auth; +import com.souzip.docs.CommonDocumentation; +import com.souzip.docs.RestDocsSupport; +import com.souzip.shared.domain.Provider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AuthApiTest extends RestDocsSupport { + + private static final String PROVIDER_DESC = "OAuth Provider (kakao, google, apple)"; + + private final Auth auth = mock(Auth.class); + + @Override + protected Object initController() { + return new AuthApi(auth); + } + + private LoginInfo createLoginInfo(boolean needsOnboarding) { + return new LoginInfo( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", + needsOnboarding, + "20251204", + "수집" + ); + } + + private static org.springframework.restdocs.payload.FieldDescriptor[] loginResponseFields() { + return new org.springframework.restdocs.payload.FieldDescriptor[]{ + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), + fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + }; + } + + @Test + @DisplayName("카카오 로그인 - 신규 사용자") + void loginWithKakao_newUser() throws Exception { + LoginRequest request = new LoginRequest("kakao_access_token"); + given(auth.login(eq(Provider.KAKAO), anyString())).willReturn(createLoginInfo(true)); + + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").exists()) + .andExpect(jsonPath("$.data.refreshToken").exists()) + .andExpect(jsonPath("$.data.user.userId").value("20251204")) + .andExpect(jsonPath("$.data.user.nickname").value("수집")) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-kakao-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("카카오 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("카카오 로그인 - 기존 사용자") + void loginWithKakao_existingUser() throws Exception { + LoginRequest request = new LoginRequest("kakao_access_token"); + given(auth.login(eq(Provider.KAKAO), anyString())).willReturn(createLoginInfo(false)); + + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-kakao-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("카카오 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("카카오 로그인 - 카카오 API 에러") + void loginWithKakao_kakaoApiError() throws Exception { + LoginRequest request = new LoginRequest("invalid_kakao_token"); + given(auth.login(eq(Provider.KAKAO), anyString())).willThrow(AuthException.kakaoApiError()); + + mockMvc.perform(post("/api/auth/login/{provider}", "kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("카카오 API 호출에 실패했습니다.")) + .andDo(document("auth/login-kakao-api-error", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("유효하지 않은 카카오 Access Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("Google 로그인 - 신규 사용자") + void loginWithGoogle_newUser() throws Exception { + LoginRequest request = new LoginRequest("google_access_token"); + given(auth.login(eq(Provider.GOOGLE), anyString())).willReturn(createLoginInfo(true)); + + mockMvc.perform(post("/api/auth/login/{provider}", "google") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-google-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("구글 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("Google 로그인 - 기존 사용자") + void loginWithGoogle_existingUser() throws Exception { + LoginRequest request = new LoginRequest("google_access_token"); + given(auth.login(eq(Provider.GOOGLE), anyString())).willReturn(createLoginInfo(false)); + + mockMvc.perform(post("/api/auth/login/{provider}", "google") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-google-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("구글 Access Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("Apple 로그인 - 신규 사용자") + void loginWithApple_newUser() throws Exception { + LoginRequest request = new LoginRequest("apple_id_token"); + given(auth.login(eq(Provider.APPLE), anyString())).willReturn(createLoginInfo(true)); + + mockMvc.perform(post("/api/auth/login/{provider}", "apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(true)) + .andDo(document("auth/login-apple-new-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("애플 ID Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("Apple 로그인 - 기존 사용자") + void loginWithApple_existingUser() throws Exception { + LoginRequest request = new LoginRequest("apple_id_token"); + given(auth.login(eq(Provider.APPLE), anyString())).willReturn(createLoginInfo(false)); + + mockMvc.perform(post("/api/auth/login/{provider}", "apple") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.needsOnboarding").value(false)) + .andDo(document("auth/login-apple-existing-user", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("애플 ID Token")), + apiResponseFields(loginResponseFields()) + )); + } + + @Test + @DisplayName("지원하지 않는 Provider - 400 에러") + void loginWithUnsupportedProvider_returns400() throws Exception { + LoginRequest request = new LoginRequest("naver_access_token"); + given(auth.login(any(), anyString())).willThrow(AuthException.unsupportedProvider()); + + mockMvc.perform(post("/api/auth/login/{provider}", "naver") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andDo(document("auth/login-unsupported-provider", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("OAuth Access Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("잘못된 Provider 형식 - 400 에러") + void loginWithInvalidProvider_returns400() throws Exception { + LoginRequest request = new LoginRequest("invalid_token"); + given(auth.login(any(), anyString())).willThrow(AuthException.unsupportedProvider()); + + mockMvc.perform(post("/api/auth/login/{provider}", "invalid123") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andDo(document("auth/login-invalid-provider", + getDocumentRequest(), + getDocumentResponse(), + pathParameters(parameterWithName("provider").description(PROVIDER_DESC)), + requestFields(fieldWithPath("accessToken").type(JsonFieldType.STRING).description("OAuth Access Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("Refresh Token 재발급") + void refresh_withValidToken() throws Exception { + RefreshRequest request = new RefreshRequest("valid_refresh_token"); + given(auth.refresh(anyString())).willReturn( + new RefreshInfo("new_access_token", "valid_refresh_token") + ); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new_access_token")) + .andExpect(jsonPath("$.data.refreshToken").value("valid_refresh_token")) + .andDo(document("auth/refresh-valid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token")), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새 Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("Refresh Token 만료 임박 - 둘 다 재발급") + void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { + RefreshRequest request = new RefreshRequest("expiring_soon_token"); + given(auth.refresh(anyString())).willReturn( + new RefreshInfo("new_access_token", "new_refresh_token") + ); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.refreshToken").value("new_refresh_token")) + .andDo(document("auth/refresh-expiring-soon", + getDocumentRequest(), + getDocumentResponse(), + requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token (만료 10일 이하)")), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새 Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새 Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("만료된 Refresh Token - 401 에러") + void refresh_withExpiredToken_returns401() throws Exception { + RefreshRequest request = new RefreshRequest("expired_refresh_token"); + given(auth.refresh(anyString())).willThrow(AuthException.expiredRefreshToken()); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 Refresh Token입니다.")) + .andDo(document("auth/refresh-expired-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("만료된 Refresh Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token - 401 에러") + void refresh_withInvalidToken_returns401() throws Exception { + RefreshRequest request = new RefreshRequest("invalid_refresh_token"); + given(auth.refresh(anyString())).willThrow(AuthException.invalidRefreshToken()); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("유효하지 않은 Refresh Token입니다.")) + .andDo(document("auth/refresh-invalid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields(fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("유효하지 않은 Refresh Token")), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("로그아웃 성공") + void logout_success() throws Exception { + doNothing().when(auth).logout(anyLong()); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) + .andDo(document("auth/logout", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } +} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java b/src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java similarity index 77% rename from src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java rename to src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java index e57e548f..e9d0fa4c 100644 --- a/src/test/java/com/souzip/domain/auth/scheduler/RefreshTokenCleanupSchedulerTest.java +++ b/src/test/java/com/souzip/auth/scheduler/RefreshTokenCleanupSchedulerTest.java @@ -1,6 +1,7 @@ -package com.souzip.domain.auth.scheduler; +package com.souzip.auth.scheduler; -import com.souzip.domain.auth.repository.RefreshTokenRepository; +import com.souzip.auth.adapter.integration.scheduler.RefreshTokenCleanupScheduler; +import com.souzip.auth.application.required.RefreshTokenRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,8 +12,8 @@ import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class RefreshTokenCleanupSchedulerTest { @@ -26,14 +27,11 @@ class RefreshTokenCleanupSchedulerTest { @Test @DisplayName("만료된 Refresh Token을 삭제한다.") void cleanupExpiredTokens() { - // given - LocalDateTime now = LocalDateTime.now(); - // when scheduler.cleanUpExpiredRefreshTokens(); // then verify(refreshTokenRepository, times(1)) - .deleteAllByExpiresAtBefore(any(LocalDateTime.class)); + .deleteAllExpiredBefore(any(LocalDateTime.class)); } -} +} \ No newline at end of file diff --git a/src/test/java/com/souzip/auth/service/AuthServiceTest.java b/src/test/java/com/souzip/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..1be66e0f --- /dev/null +++ b/src/test/java/com/souzip/auth/service/AuthServiceTest.java @@ -0,0 +1,168 @@ +package com.souzip.auth.service; + +import com.souzip.auth.application.AuthService; +import com.souzip.auth.application.SocialClientFactory; +import com.souzip.auth.application.dto.LoginInfo; +import com.souzip.auth.application.dto.RefreshInfo; +import com.souzip.auth.application.dto.SocialUserInfo; +import com.souzip.auth.application.dto.UserInfo; +import com.souzip.auth.application.exception.AuthException; +import com.souzip.auth.application.required.RefreshTokenRepository; +import com.souzip.auth.application.required.SocialClient; +import com.souzip.auth.application.required.TokenProvider; +import com.souzip.auth.application.required.UserPort; +import com.souzip.auth.domain.RefreshToken; +import com.souzip.shared.domain.Provider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private SocialClientFactory socialClientFactory; + + @Mock + private UserPort userPort; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private TokenProvider tokenProvider; + + @Mock + private SocialClient socialClient; + + @InjectMocks + private AuthService authService; + + private static final Long USER_ID = 1L; + private static final String USER_STRING_ID = "550e8400"; + private static final String NICKNAME = "수집"; + private static final String ACCESS_TOKEN = "access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + private SocialUserInfo createSocialUserInfo() { + return new SocialUserInfo("kakao123", "test@gmail.com"); + } + + private UserInfo createUserInfo(boolean needsOnboarding) { + return new UserInfo(USER_ID, USER_STRING_ID, NICKNAME, needsOnboarding); + } + + @Test + @DisplayName("신규 사용자 로그인 시 토큰을 발급한다.") + void login_newUser_issuesTokens() { + // given + given(socialClientFactory.getClient(Provider.KAKAO)).willReturn(socialClient); + given(socialClient.getUserInfo("kakao_token")).willReturn(createSocialUserInfo()); + given(userPort.findOrCreateUser(eq(Provider.KAKAO), anyString(), anyString())) + .willReturn(createUserInfo(true)); + given(tokenProvider.generateAccessToken(USER_ID)).willReturn(ACCESS_TOKEN); + given(tokenProvider.generateRefreshToken()).willReturn(REFRESH_TOKEN); + given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); + + // when + LoginInfo result = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(result.accessToken()).isEqualTo(ACCESS_TOKEN); + assertThat(result.refreshToken()).isEqualTo(REFRESH_TOKEN); + assertThat(result.needsOnboarding()).isTrue(); + assertThat(result.userId()).isEqualTo(USER_STRING_ID); + assertThat(result.nickname()).isEqualTo(NICKNAME); + + verify(refreshTokenRepository).deleteByUserId(USER_ID); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("기존 사용자 로그인 시 needsOnboarding은 false다.") + void login_existingUser_needsOnboardingFalse() { + // given + given(socialClientFactory.getClient(Provider.KAKAO)).willReturn(socialClient); + given(socialClient.getUserInfo("kakao_token")).willReturn(createSocialUserInfo()); + given(userPort.findOrCreateUser(eq(Provider.KAKAO), anyString(), anyString())) + .willReturn(createUserInfo(false)); + given(tokenProvider.generateAccessToken(USER_ID)).willReturn(ACCESS_TOKEN); + given(tokenProvider.generateRefreshToken()).willReturn(REFRESH_TOKEN); + given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); + + // when + LoginInfo result = authService.login(Provider.KAKAO, "kakao_token"); + + // then + assertThat(result.needsOnboarding()).isFalse(); + } + + @Test + @DisplayName("유효한 Refresh Token으로 재발급 시 새 Access Token을 발급한다.") + void refresh_withValidToken_returnsNewAccessToken() { + // given + RefreshToken storedToken = RefreshToken.create(USER_ID, REFRESH_TOKEN, LocalDateTime.now().plusDays(20)); + + given(refreshTokenRepository.findByToken(REFRESH_TOKEN)).willReturn(Optional.of(storedToken)); + given(tokenProvider.generateAccessToken(USER_ID)).willReturn("new_access_token"); + given(tokenProvider.generateRefreshToken()).willReturn("new_refresh_token"); + given(tokenProvider.getRefreshTokenExpiresAt()).willReturn(LocalDateTime.now().plusDays(30)); + + // when + RefreshInfo result = authService.refresh(REFRESH_TOKEN); + + // then + assertThat(result.accessToken()).isEqualTo("new_access_token"); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("만료된 Refresh Token으로 재발급 시 토큰을 삭제하고 예외가 발생한다.") + void refresh_withExpiredToken_throwsException() { + // given + RefreshToken expiredToken = RefreshToken.create(USER_ID, REFRESH_TOKEN, LocalDateTime.now().minusDays(1)); + + given(refreshTokenRepository.findByToken(REFRESH_TOKEN)).willReturn(Optional.of(expiredToken)); + + // when & then + assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN)) + .isInstanceOf(AuthException.class) + .hasMessageContaining("만료된 Refresh Token입니다."); + + verify(refreshTokenRepository).delete(expiredToken); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token으로 재발급 시 예외가 발생한다.") + void refresh_withInvalidToken_throwsException() { + // given + given(refreshTokenRepository.findByToken("invalid_token")).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.refresh("invalid_token")) + .isInstanceOf(AuthException.class) + .hasMessageContaining("유효하지 않은 Refresh Token입니다."); + } + + @Test + @DisplayName("로그아웃 시 Refresh Token을 삭제한다.") + void logout_success() { + // when + authService.logout(USER_ID); + + // then + verify(refreshTokenRepository).deleteByUserId(USER_ID); + } +} \ No newline at end of file diff --git a/src/test/java/com/souzip/docs/RestDocsSupport.java b/src/test/java/com/souzip/docs/RestDocsSupport.java index 52646a1b..ecd31b9e 100644 --- a/src/test/java/com/souzip/docs/RestDocsSupport.java +++ b/src/test/java/com/souzip/docs/RestDocsSupport.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; -import com.souzip.global.exception.GlobalExceptionHandler; +import com.souzip.shared.exception.GlobalExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.core.MethodParameter; diff --git a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java index 251c0d06..d9d12ca7 100644 --- a/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java +++ b/src/test/java/com/souzip/domain/admin/application/AdminAuthServiceTest.java @@ -1,26 +1,22 @@ package com.souzip.domain.admin.application; +import com.souzip.auth.adapter.security.jwt.JwtTokenProvider; import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; import com.souzip.domain.admin.application.command.AdminLoginCommand; -import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; import com.souzip.domain.admin.exception.AdminLoginFailedException; import com.souzip.domain.admin.exception.AdminNotFoundException; -import com.souzip.domain.admin.model.Admin; -import com.souzip.domain.admin.model.AdminPasswordEncoder; -import com.souzip.domain.admin.model.AdminRefreshToken; -import com.souzip.domain.admin.model.AdminRole; -import com.souzip.domain.admin.model.Username; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.*; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; import com.souzip.domain.admin.repository.AdminRepository; -import com.souzip.global.security.jwt.JwtTokenProvider; -import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; @@ -54,7 +50,7 @@ void login_success() { // given AdminLoginCommand command = new AdminLoginCommand("admin123", "password123"); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); given(passwordEncoder.matches(anyString(), anyString())).willReturn(true); @@ -83,7 +79,7 @@ void login_fail_not_found() { // when & then assertThatThrownBy(() -> adminAuthService.login(command)) - .isInstanceOf(AdminNotFoundException.class); + .isInstanceOf(AdminNotFoundException.class); } @DisplayName("비밀번호 불일치 시 예외가 발생한다.") @@ -92,14 +88,14 @@ void login_fail_password_mismatch() { // given AdminLoginCommand command = new AdminLoginCommand("admin123", "wrongpassword"); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); given(adminRepository.findByUsername(any(Username.class))).willReturn(Optional.of(admin)); given(passwordEncoder.matches(anyString(), anyString())).willReturn(false); // when & then assertThatThrownBy(() -> adminAuthService.login(command)) - .isInstanceOf(AdminLoginFailedException.class); + .isInstanceOf(AdminLoginFailedException.class); } @DisplayName("리프레시 토큰 갱신에 성공한다.") @@ -108,7 +104,7 @@ void refresh_success() { // given UUID adminId = UUID.randomUUID(); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "refresh-token", expiresAt); @@ -132,7 +128,7 @@ void refresh_fail_invalid_token() { // when & then assertThatThrownBy(() -> adminAuthService.refresh("invalid-token")) - .isInstanceOf(AdminInvalidRefreshTokenException.class); + .isInstanceOf(AdminInvalidRefreshTokenException.class); } @DisplayName("만료된 리프레시 토큰으로 갱신 시 예외가 발생한다.") @@ -147,7 +143,7 @@ void refresh_fail_expired_token() { // when & then assertThatThrownBy(() -> adminAuthService.refresh("refresh-token")) - .isInstanceOf(AdminExpiredRefreshTokenException.class); + .isInstanceOf(AdminExpiredRefreshTokenException.class); verify(refreshTokenRepository, times(1)).delete(refreshToken); } @@ -158,7 +154,7 @@ void refresh_renew_expiring_token() { // given UUID adminId = UUID.randomUUID(); Admin admin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); + new TestAdminPasswordEncoder()); LocalDateTime expiresAt = LocalDateTime.now().plusDays(5); // 10일 미만 AdminRefreshToken refreshToken = AdminRefreshToken.create(adminId, "old-refresh-token", expiresAt); diff --git a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java index f1953947..1315ee8f 100644 --- a/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java +++ b/src/test/java/com/souzip/domain/admin/application/AdminCityQueryServiceTest.java @@ -4,9 +4,11 @@ import com.souzip.domain.admin.application.port.CityQueryPort.CityQueryResult; import com.souzip.domain.admin.application.query.AdminCityQueryService; import com.souzip.domain.admin.application.query.CitySearchQuery; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.time.LocalDateTime; import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java index 23208d4e..aaec602e 100644 --- a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRefreshTokenRepositoryTest.java @@ -2,7 +2,7 @@ import com.souzip.domain.admin.model.AdminRefreshToken; import com.souzip.domain.admin.repository.AdminRefreshTokenRepository; -import com.souzip.global.config.QuerydslConfig; +import com.souzip.shared.config.QuerydslConfig; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +12,7 @@ import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; + import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java index b89dacef..d1ef86ee 100644 --- a/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java +++ b/src/test/java/com/souzip/domain/admin/infrastructure/persistence/AdminRepositoryTest.java @@ -6,9 +6,11 @@ import com.souzip.domain.admin.model.AdminRole; import com.souzip.domain.admin.model.Username; import com.souzip.domain.admin.repository.AdminRepository; -import com.souzip.global.config.QuerydslConfig; +import com.souzip.shared.config.QuerydslConfig; + import java.util.List; import java.util.Optional; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java new file mode 100644 index 00000000..47beb932 --- /dev/null +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthApiTest.java @@ -0,0 +1,244 @@ +package com.souzip.domain.admin.presentation; + +import com.souzip.docs.CommonDocumentation; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.admin.application.AdminAuthService; +import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; +import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; +import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; +import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; +import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; +import com.souzip.domain.admin.model.Admin; +import com.souzip.domain.admin.model.AdminRole; +import com.souzip.domain.admin.presentation.request.AdminLoginRequest; +import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; +import java.util.UUID; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminAuthApiTest extends RestDocsSupport { + + private final AdminAuthService adminAuthService = mock(AdminAuthService.class); + + @Override + protected Object initController() { + return new AdminAuthController(adminAuthService); + } + + @Test + @DisplayName("어드민 로그인 성공") + void login_success() throws Exception { + // given + AdminLoginRequest request = new AdminLoginRequest("admin123", "password123"); + + Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + AdminLoginResult result = new AdminLoginResult(mockAdmin, "access-token", "refresh-token"); + + given(adminAuthService.login(any())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")) + .andExpect(jsonPath("$.data.username").value("admin123")) + .andExpect(jsonPath("$.data.role").value("SUPER_ADMIN")) + .andDo(document("admin/login", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("username").type(JsonFieldType.STRING).description("어드민 아이디 (4~10자)"), + fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), + fieldWithPath("data.id").type(JsonFieldType.STRING).description("어드민 ID (UUID)"), + fieldWithPath("data.username").type(JsonFieldType.STRING).description("어드민 아이디"), + fieldWithPath("data.role").type(JsonFieldType.STRING).description("권한 (SUPER_ADMIN)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("어드민 토큰 갱신 - Access Token만") + void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("valid-refresh-token"); + RefreshResult result = new RefreshResult("new-access-token", "valid-refresh-token"); + + given(adminAuthService.refresh(anyString())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("valid-refresh-token")) + .andDo(document("admin/refresh-valid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (유효기간 10일 초과 시 그대로 유지)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("어드민 토큰 갱신 - 만료 임박 시 둘 다 재발급") + void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("expiring-soon-token"); + RefreshResult result = new RefreshResult("new-access-token", "new-refresh-token"); + + given(adminAuthService.refresh(anyString())).willReturn(result); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) + .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")) + .andDo(document("admin/refresh-expiring-soon", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("Refresh Token (만료 10일 이하)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), + fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), + fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Refresh Token"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("만료된 Refresh Token - 401 에러") + void refresh_withExpiredToken_returns401() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("expired-refresh-token"); + given(adminAuthService.refresh(anyString())) + .willThrow(new AdminExpiredRefreshTokenException()); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("만료된 리프레시 토큰입니다.")) + .andDo(document("admin/refresh-expired-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("만료된 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("유효하지 않은 Refresh Token - 401 에러") + void refresh_withInvalidToken_returns401() throws Exception { + // given + AdminRefreshRequest request = new AdminRefreshRequest("invalid-refresh-token"); + given(adminAuthService.refresh(anyString())) + .willThrow(new AdminInvalidRefreshTokenException()); + + // when & then + mockMvc.perform(post("/api/admin/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("유효하지 않은 리프레시 토큰입니다.")) + .andDo(document("admin/refresh-invalid-token", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("refreshToken").type(JsonFieldType.STRING) + .description("유효하지 않은 Refresh Token") + ), + responseFields(CommonDocumentation.errorResponseFields()) + )); + } + + @Test + @DisplayName("어드민 로그아웃 성공") + void logout_success() throws Exception { + // given + Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, + new TestAdminPasswordEncoder()); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(mockAdmin, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + doNothing().when(adminAuthService).logout(any(UUID.class)); + + // when & then + mockMvc.perform(post("/api/admin/auth/logout") + .header("Authorization", "Bearer valid-access-token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃되었습니다.")) + .andDo(document("admin/logout", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer {accessToken}") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).description("응답 데이터 (없음)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") + ) + )); + + SecurityContextHolder.clearContext(); + } +} diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java deleted file mode 100644 index fdd1809b..00000000 --- a/src/test/java/com/souzip/domain/admin/presentation/AdminAuthControllerTest.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.souzip.domain.admin.presentation; - -import com.souzip.docs.CommonDocumentation; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.admin.application.AdminAuthService; -import com.souzip.domain.admin.application.AdminAuthService.AdminLoginResult; -import com.souzip.domain.admin.application.AdminAuthService.RefreshResult; -import com.souzip.domain.admin.exception.AdminExpiredRefreshTokenException; -import com.souzip.domain.admin.exception.AdminInvalidRefreshTokenException; -import com.souzip.domain.admin.fixture.TestAdminPasswordEncoder; -import com.souzip.domain.admin.model.Admin; -import com.souzip.domain.admin.model.AdminRole; -import com.souzip.domain.admin.presentation.request.AdminLoginRequest; -import com.souzip.domain.admin.presentation.request.AdminRefreshRequest; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.Collections; -import java.util.UUID; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.payload.PayloadDocumentation.*; -import static org.springframework.restdocs.headers.HeaderDocumentation.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AdminAuthControllerTest extends RestDocsSupport { - - private final AdminAuthService adminAuthService = mock(AdminAuthService.class); - - @Override - protected Object initController() { - return new AdminAuthController(adminAuthService); - } - - @Test - @DisplayName("어드민 로그인 성공") - void login_success() throws Exception { - // given - AdminLoginRequest request = new AdminLoginRequest("admin123", "password123"); - - Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); - - AdminLoginResult result = new AdminLoginResult(mockAdmin, "access-token", "refresh-token"); - - given(adminAuthService.login(any())).willReturn(result); - - // when & then - mockMvc.perform(post("/api/admin/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("refresh-token")) - .andExpect(jsonPath("$.data.username").value("admin123")) - .andExpect(jsonPath("$.data.role").value("SUPER_ADMIN")) - .andDo(document("admin/login", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("username").type(JsonFieldType.STRING).description("어드민 아이디 (4~10자)"), - fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.id").type(JsonFieldType.STRING).description("어드민 ID (UUID)"), - fieldWithPath("data.username").type(JsonFieldType.STRING).description("어드민 아이디"), - fieldWithPath("data.role").type(JsonFieldType.STRING).description("권한 (SUPER_ADMIN)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("어드민 토큰 갱신 - Access Token만") - void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("valid-refresh-token"); - RefreshResult result = new RefreshResult("new-access-token", "valid-refresh-token"); - - given(adminAuthService.refresh(anyString())).willReturn(result); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("valid-refresh-token")) - .andDo(document("admin/refresh-valid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("Refresh Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token (유효기간 10일 초과 시 그대로 유지)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("어드민 토큰 갱신 - 만료 임박 시 둘 다 재발급") - void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("expiring-soon-token"); - RefreshResult result = new RefreshResult("new-access-token", "new-refresh-token"); - - given(adminAuthService.refresh(anyString())).willReturn(result); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("new-access-token")) - .andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token")) - .andDo(document("admin/refresh-expiring-soon", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token (만료 10일 이하)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Refresh Token"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("만료된 Refresh Token - 401 에러") - void refresh_withExpiredToken_returns401() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("expired-refresh-token"); - given(adminAuthService.refresh(anyString())) - .willThrow(new AdminExpiredRefreshTokenException()); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("만료된 리프레시 토큰입니다.")) - .andDo(document("admin/refresh-expired-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("만료된 Refresh Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token - 401 에러") - void refresh_withInvalidToken_returns401() throws Exception { - // given - AdminRefreshRequest request = new AdminRefreshRequest("invalid-refresh-token"); - given(adminAuthService.refresh(anyString())) - .willThrow(new AdminInvalidRefreshTokenException()); - - // when & then - mockMvc.perform(post("/api/admin/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("유효하지 않은 리프레시 토큰입니다.")) - .andDo(document("admin/refresh-invalid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("유효하지 않은 Refresh Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("어드민 로그아웃 성공") - void logout_success() throws Exception { - // given - Admin mockAdmin = Admin.create("admin123", "password123", AdminRole.SUPER_ADMIN, - new TestAdminPasswordEncoder()); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(mockAdmin, null, Collections.emptyList()); - SecurityContextHolder.getContext().setAuthentication(authentication); - - doNothing().when(adminAuthService).logout(any(UUID.class)); - - // when & then - mockMvc.perform(post("/api/admin/auth/logout") - .header("Authorization", "Bearer valid-access-token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃되었습니다.")) - .andDo(document("admin/logout", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName("Authorization").description("Bearer {accessToken}") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.NULL).description("응답 데이터 (없음)").optional(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지") - ) - )); - - SecurityContextHolder.clearContext(); - } -} diff --git a/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java index 7fd3aba3..2934390c 100644 --- a/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java +++ b/src/test/java/com/souzip/domain/admin/presentation/AdminManagementControllerTest.java @@ -19,11 +19,13 @@ import com.souzip.domain.admin.presentation.request.CreateCityRequest; import com.souzip.domain.admin.presentation.request.InviteAdminRequest; import com.souzip.domain.admin.presentation.request.UpdateCityRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; +import com.souzip.shared.common.dto.pagination.PaginationResponse; + import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.UUID; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; diff --git a/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java deleted file mode 100644 index 6834d1b2..00000000 --- a/src/test/java/com/souzip/domain/auth/controller/AuthControllerTest.java +++ /dev/null @@ -1,593 +0,0 @@ -package com.souzip.domain.auth.controller; - -import com.souzip.docs.CommonDocumentation; -import com.souzip.docs.RestDocsSupport; -import com.souzip.domain.auth.dto.LoginRequest; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.LoginUserInfo; -import com.souzip.domain.auth.dto.RefreshRequest; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.service.AuthService; -import com.souzip.domain.user.entity.Provider; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.MediaType; -import org.springframework.restdocs.payload.JsonFieldType; - -import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; -import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; -import static com.souzip.docs.CommonDocumentation.apiResponseFields; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -class AuthControllerTest extends RestDocsSupport { - - private static final String PROVIDER_DESC = "OAuth Provider (kakao, google, apple)"; - - private final AuthService authService = mock(AuthService.class); - - @Override - protected Object initController() { - return new AuthController(authService); - } - - @Test - @DisplayName("카카오 로그인 - 신규 사용자") - void loginWithKakao_newUser() throws Exception { - // given - LoginRequest request = new LoginRequest("kakao_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").exists()) - .andExpect(jsonPath("$.data.refreshToken").exists()) - .andExpect(jsonPath("$.data.user.userId").value("20251204")) - .andExpect(jsonPath("$.data.user.nickname").value("수집")) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-kakao-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("카카오 Access Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID 앞 8자리)"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN) - .description("온보딩 필요 여부 (true = 온보딩 필요, false = 완료)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("카카오 로그인 - 기존 사용자") - void loginWithKakao_existingUser() throws Exception { - // given - LoginRequest request = new LoginRequest("kakao_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - false - ); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-kakao-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("카카오 Access Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN) - .description("온보딩 필요 여부 (false = 온보딩 완료, true = 필요)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("카카오 로그인 - 카카오 API 에러") - void loginWithKakao_kakaoApiError() throws Exception { - // given - LoginRequest request = new LoginRequest("invalid_kakao_token"); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willThrow(new BusinessException(ErrorCode.KAKAO_API_ERROR)); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("카카오 API 호출에 실패했습니다.")) - .andDo(document("auth/login-kakao-api-error", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("유효하지 않은 카카오 Access Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("Google 로그인 - 신규 사용자") - void loginWithGoogle_newUser() throws Exception { - // given - LoginRequest request = new LoginRequest("google_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("abcd1234", "구글유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.GOOGLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "google") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-google-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("구글 Access Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Google 로그인 - 기존 사용자") - void loginWithGoogle_existingUser() throws Exception { - // given - LoginRequest request = new LoginRequest("google_access_token"); - - LoginUserInfo userInfo = new LoginUserInfo("abcd1234", "구글유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - false - ); - - given(authService.login(eq(Provider.GOOGLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "google") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-google-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("구글 Access Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Apple 로그인 - 신규 사용자") - void loginWithApple_newUser() throws Exception { - // given - LoginRequest request = new LoginRequest("apple_id_token"); - - LoginUserInfo userInfo = new LoginUserInfo("xyz98765", "애플유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.APPLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "apple") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(true)) - .andDo(document("auth/login-apple-new-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("애플 ID Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Apple 로그인 - 기존 사용자") - void loginWithApple_existingUser() throws Exception { - // given - LoginRequest request = new LoginRequest("apple_id_token"); - - LoginUserInfo userInfo = new LoginUserInfo("xyz98765", "애플유저"); - LoginResponse response = LoginResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh_token", - userInfo, - false - ); - - given(authService.login(eq(Provider.APPLE), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "apple") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.needsOnboarding").value(false)) - .andDo(document("auth/login-apple-existing-user", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("애플 ID Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("로그인 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("JWT Refresh Token"), - fieldWithPath("data.user").type(JsonFieldType.OBJECT).description("사용자 정보"), - fieldWithPath("data.user.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.user.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.needsOnboarding").type(JsonFieldType.BOOLEAN).description("온보딩 필요 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("지원하지 않는 Provider - 400 에러") - void loginWithUnsupportedProvider_returns400() throws Exception { - // given - LoginRequest request = new LoginRequest("naver_access_token"); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "naver") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("지원하지 않는 로그인 방식입니다: naver")) - .andDo(document("auth/login-unsupported-provider", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("OAuth Access Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("잘못된 Provider 형식 - 400 에러") - void loginWithInvalidProvider_returns400() throws Exception { - // given - LoginRequest request = new LoginRequest("invalid_token"); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "invalid123") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andDo(document("auth/login-invalid-provider", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("provider").description(PROVIDER_DESC) - ), - requestFields( - fieldWithPath("accessToken").type(JsonFieldType.STRING) - .description("OAuth Access Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("대소문자 구분 없이 Provider 처리") - void loginWithCaseInsensitiveProvider_success() throws Exception { - // given - LoginRequest request = new LoginRequest("kakao_token"); - - LoginUserInfo userInfo = new LoginUserInfo("20251204", "수집"); - LoginResponse response = LoginResponse.of( - "access_token", - "refresh_token", - userInfo, - true - ); - - given(authService.login(eq(Provider.KAKAO), anyString())) - .willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/login/{provider}", "KAKAO") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").exists()); - // NOTE: 이 테스트는 “동작 검증”용으로 남기고 문서화는 생략(실무에서도 보통 이렇게 함) - } - - @Test - @DisplayName("Refresh Token 재발급 - Access Token만") - void refresh_withValidToken_returnsNewAccessTokenOnly() throws Exception { - // given - RefreshRequest request = new RefreshRequest("valid_refresh_token"); - RefreshResponse response = RefreshResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token", - "valid_refresh_token" - ); - - given(authService.refresh(anyString())).willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token")) - .andExpect(jsonPath("$.data.refreshToken").value("valid_refresh_token")) - .andDo(document("auth/refresh-valid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token (유효기간 10일 초과 시 그대로 유지)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("Refresh Token 만료 임박 - 둘 다 재발급") - void refresh_withExpiringSoon_returnsBothNewTokens() throws Exception { - // given - RefreshRequest request = new RefreshRequest("expiring_soon_token"); - RefreshResponse response = RefreshResponse.of( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access_token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_refresh_token" - ); - - given(authService.refresh(anyString())).willReturn(response); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.refreshToken").value("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_refresh_token")) - .andDo(document("auth/refresh-expiring-soon", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("Refresh Token (만료 10일 이하)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("토큰 재발급 응답 데이터"), - fieldWithPath("data.accessToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Access Token"), - fieldWithPath("data.refreshToken").type(JsonFieldType.STRING).description("새로 발급된 JWT Refresh Token"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); - } - - @Test - @DisplayName("만료된 Refresh Token - 401 에러") - void refresh_withExpiredToken_returns401() throws Exception { - // given - RefreshRequest request = new RefreshRequest("expired_refresh_token"); - given(authService.refresh(anyString())) - .willThrow(new BusinessException(ErrorCode.EXPIRED_REFRESH_TOKEN)); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("만료된 Refresh Token입니다.")) - .andDo(document("auth/refresh-expired-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("만료된 Refresh Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token - 401 에러") - void refresh_withInvalidToken_returns401() throws Exception { - // given - RefreshRequest request = new RefreshRequest("invalid_refresh_token"); - given(authService.refresh(anyString())) - .willThrow(new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN)); - - // when & then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.message").value("유효하지 않은 Refresh Token입니다.")) - .andDo(document("auth/refresh-invalid-token", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("refreshToken").type(JsonFieldType.STRING) - .description("유효하지 않은 Refresh Token") - ), - responseFields(CommonDocumentation.errorResponseFields()) - )); - } - - @Test - @DisplayName("로그아웃 성공") - void logout_success() throws Exception { - // given - doNothing().when(authService).logout(anyLong()); - - // when & then - mockMvc.perform(post("/api/auth/logout") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃 되었습니다.")) - .andDo(document("auth/logout", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); - } -} diff --git a/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java b/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java deleted file mode 100644 index 2934b0ff..00000000 --- a/src/test/java/com/souzip/domain/auth/service/AuthServiceTest.java +++ /dev/null @@ -1,459 +0,0 @@ -package com.souzip.domain.auth.service; - -import com.souzip.domain.auth.client.OAuthClient; -import com.souzip.domain.auth.client.OAuthClientFactory; -import com.souzip.domain.auth.dto.LoginResponse; -import com.souzip.domain.auth.dto.OAuthUserInfo; -import com.souzip.domain.auth.dto.RefreshResponse; -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.auth.repository.RefreshTokenRepository; -import com.souzip.domain.user.entity.Provider; -import com.souzip.domain.user.entity.User; -import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.security.jwt.JwtTokenProvider; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; - -@ExtendWith(MockitoExtension.class) -class AuthServiceTest { - - @Mock - private OAuthClientFactory oauthClientFactory; - - @Mock - private UserRepository userRepository; - - @Mock - private RefreshTokenRepository refreshTokenRepository; - - @Mock - private JwtTokenProvider jwtTokenProvider; - - @Mock - private OAuthClient oauthClient; - - @InjectMocks - private AuthService authService; - - private OAuthUserInfo createOAuthUserInfo() { - return new OAuthUserInfo() { - @Override - public String getProviderId() { - return "kakao123"; - } - - @Override - public String getName() { - return "수집"; - } - - @Override - public String getEmail() { - return "test@gmail.com"; - } - }; - } - - @Test - @DisplayName("신규 사용자 로그인 시 User를 생성하고 토큰을 발급한다.") - void login_newUser_createsUserAndIssuesTokens() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User newUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(newUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.empty()); - given(userRepository.save(any(User.class))).willReturn(spyUser); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(response.getAccessToken()).isEqualTo("access_token"); - assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); - assertThat(response.getUser().nickname()).isEmpty(); - assertThat(response.isNeedsOnboarding()).isTrue(); - - verify(spyUser).updateLastLoginAt(); - - verify(userRepository).save(any(User.class)); - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("온보딩 완료한 기존 사용자 로그인 시 needsOnboarding은 false다.") - void login_existingUserWithOnboardingCompleted_needsOnboardingFalse() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User existingUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(existingUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(false); - - RefreshToken existingToken = RefreshToken.of( - spyUser, - "old_refresh_token", - LocalDateTime.now().plusDays(20) - ); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(response.getAccessToken()).isEqualTo("access_token"); - assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); - assertThat(response.isNeedsOnboarding()).isFalse(); // ← 온보딩 완료! - - verify(userRepository, never()).save(any(User.class)); - verify(spyUser, never()).restore(anyString()); - } - - @Test - @DisplayName("온보딩 안 한 기존 사용자 로그인 시 needsOnboarding은 true다.") - void login_existingUserWithoutOnboarding_needsOnboardingTrue() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User existingUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(existingUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - RefreshToken existingToken = RefreshToken.of( - spyUser, - "old_refresh_token", - LocalDateTime.now().plusDays(20) - ); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - assertThat(response.getAccessToken()).isEqualTo("access_token"); - assertThat(response.getRefreshToken()).isEqualTo("refresh_token"); - assertThat(response.isNeedsOnboarding()).isTrue(); - - verify(userRepository, never()).save(any(User.class)); - verify(spyUser, never()).restore(anyString()); - } - - @Test - @DisplayName("탈퇴 회원 재로그인 시 User를 복구하고 needsOnboarding은 true다.") - void login_withdrawnUser_restoresUserAndNeedsOnboardingTrue() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User withdrawnUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(withdrawnUser); - - given(spyUser.isDeleted()).willReturn(true); - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - doCallRealMethod().when(spyUser).restore(anyString()); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken("550e8400")).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); - - // when - LoginResponse response = authService.login(Provider.KAKAO, "kakao_token"); - - // then - verify(spyUser).restore("수집"); - assertThat(response.isNeedsOnboarding()).isTrue(); - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("Refresh Token이 없으면 새로 생성한다.") - void login_withoutRefreshToken_createsNewToken() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User newUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(newUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(true); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.empty()); - given(userRepository.save(any(User.class))).willReturn(spyUser); - given(jwtTokenProvider.generateToken(anyString())).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("refresh_token"); - given(refreshTokenRepository.findByUser(any())).willReturn(Optional.empty()); - - // when - authService.login(Provider.KAKAO, "kakao_token"); - - // then - verify(refreshTokenRepository).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("Refresh Token이 있으면 업데이트한다.") - void login_withExistingRefreshToken_updatesToken() { - // given - OAuthUserInfo oauthUserInfo = createOAuthUserInfo(); - User existingUser = User.of(Provider.KAKAO, oauthUserInfo); - User spyUser = spy(existingUser); - - given(spyUser.getUserId()).willReturn("550e8400"); - given(spyUser.needsOnboarding()).willReturn(false); - - RefreshToken existingToken = spy(RefreshToken.of( - spyUser, - "old_refresh_token", - LocalDateTime.now().plusDays(20) - )); - - given(oauthClientFactory.getClient(Provider.KAKAO)).willReturn(oauthClient); - given(oauthClient.getUserInfo("kakao_token")).willReturn(oauthUserInfo); - given(userRepository.findByProviderAndProviderId(Provider.KAKAO, "kakao123")) - .willReturn(Optional.of(spyUser)); - given(jwtTokenProvider.generateToken(anyString())).willReturn("access_token"); - given(jwtTokenProvider.generateRefreshToken(anyString())).willReturn("new_refresh_token"); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(existingToken)); - - // when - authService.login(Provider.KAKAO, "kakao_token"); - - // then - verify(existingToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); - verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); - } - - @Test - @DisplayName("Refresh Token이 유효하고 만료 임박하지 않으면 Access Token만 재발급한다.") - void refresh_withValidToken_returnsNewAccessTokenOnly() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - given(spyUser.getUserId()).willReturn("550e8400"); - - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "valid_refresh_token", - LocalDateTime.now().plusDays(20) - ); - RefreshToken spyToken = spy(refreshToken); - given(spyToken.isExpired()).willReturn(false); - given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(20)); - - given(refreshTokenRepository.findByToken("valid_refresh_token")) - .willReturn(Optional.of(spyToken)); - given(jwtTokenProvider.generateToken("550e8400")) - .willReturn("new_access_token"); - - // when - RefreshResponse response = authService.refresh("valid_refresh_token"); - - // then - assertThat(response.accessToken()).isEqualTo("new_access_token"); - assertThat(response.refreshToken()).isEqualTo("valid_refresh_token"); - verify(jwtTokenProvider, never()).generateRefreshToken(anyString()); - } - - @Test - @DisplayName("Refresh Token이 10일 이하로 남으면 둘 다 재발급한다.") - void refresh_withExpiringSoon_returnsBothNewTokens() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - given(spyUser.getUserId()).willReturn("550e8400"); - - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "expiring_token", - LocalDateTime.now().plusDays(5) - ); - RefreshToken spyToken = spy(refreshToken); - given(spyToken.isExpired()).willReturn(false); - given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(5)); - - given(refreshTokenRepository.findByToken("expiring_token")) - .willReturn(Optional.of(spyToken)); - given(jwtTokenProvider.generateToken("550e8400")) - .willReturn("new_access_token"); - given(jwtTokenProvider.generateRefreshToken("550e8400")) - .willReturn("new_refresh_token"); - - // when - RefreshResponse response = authService.refresh("expiring_token"); - - // then - assertThat(response.accessToken()).isEqualTo("new_access_token"); - assertThat(response.refreshToken()).isEqualTo("new_refresh_token"); - verify(spyToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); - } - - @Test - @DisplayName("만료된 Refresh Token으로 재발급 시 토큰을 삭제하고 에러가 발생한다.") - void refresh_withExpiredToken_deletesTokenAndThrowsException() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - - RefreshToken expiredToken = RefreshToken.of( - user, - "expired_token", - LocalDateTime.now().minusDays(1) - ); - RefreshToken spyToken = spy(expiredToken); - given(spyToken.isExpired()).willReturn(true); - - given(refreshTokenRepository.findByToken("expired_token")) - .willReturn(Optional.of(spyToken)); - - // when & then - assertThatThrownBy(() -> authService.refresh("expired_token")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("만료된 Refresh Token입니다."); - - verify(refreshTokenRepository).delete(spyToken); - } - - @Test - @DisplayName("유효하지 않은 Refresh Token으로 재발급 시 에러가 발생한다.") - void refresh_withInvalidToken_throwsException() { - // given - given(refreshTokenRepository.findByToken("invalid_token")) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> authService.refresh("invalid_token")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("유효하지 않은 Refresh Token입니다."); - } - - @ParameterizedTest - @DisplayName("Refresh Token 만료 임계값 경계 조건 테스트") - @CsvSource({ - "11, false, 11일 남음 - 갱신 안 함", - "10, true, 정확히 10일 남음 - 갱신", - "9, true, 9일 남음 - 갱신" - }) - void refresh_boundaryCondition_test(int daysRemaining, boolean shouldRenew, String description) { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - given(spyUser.getUserId()).willReturn("550e8400"); - - String tokenValue = "token_" + daysRemaining + "_days"; - RefreshToken refreshToken = RefreshToken.of( - spyUser, - tokenValue, - LocalDateTime.now().plusDays(daysRemaining) - ); - RefreshToken spyToken = spy(refreshToken); - given(spyToken.isExpired()).willReturn(false); - given(spyToken.getExpiresAt()).willReturn(LocalDateTime.now().plusDays(daysRemaining)); - - given(refreshTokenRepository.findByToken(tokenValue)) - .willReturn(Optional.of(spyToken)); - given(jwtTokenProvider.generateToken("550e8400")) - .willReturn("new_access_token"); - - if (shouldRenew) { - given(jwtTokenProvider.generateRefreshToken("550e8400")) - .willReturn("new_refresh_token"); - } - - // when - RefreshResponse response = authService.refresh(tokenValue); - - // then - assertThat(response.accessToken()).isEqualTo("new_access_token"); - - if (shouldRenew) { - assertThat(response.refreshToken()).isEqualTo("new_refresh_token"); - verify(spyToken).updateToken(eq("new_refresh_token"), any(LocalDateTime.class)); - } else { - assertThat(response.refreshToken()).isEqualTo(tokenValue); - verify(jwtTokenProvider, never()).generateRefreshToken(anyString()); - verify(spyToken, never()).updateToken(anyString(), any(LocalDateTime.class)); - } - } - - @Test - @DisplayName("로그아웃 시 Refresh Token을 삭제한다.") - void logout_success() { - // given - User user = User.of(Provider.KAKAO, createOAuthUserInfo()); - User spyUser = spy(user); - - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "refresh_token", - LocalDateTime.now().plusDays(30) - ); - - given(userRepository.findById(1L)) - .willReturn(Optional.of(spyUser)); - given(refreshTokenRepository.findByUser(spyUser)) - .willReturn(Optional.of(refreshToken)); - - // when - authService.logout(1L); - - // then - verify(refreshTokenRepository).delete(refreshToken); - } - - @Test - @DisplayName("존재하지 않는 사용자로 로그아웃 시 에러가 발생한다.") - void logout_withNotExistUser_throwsException() { - // given - given(userRepository.findById(999L)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> authService.logout(999L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); - } -} diff --git a/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java b/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java index 9d5dd681..a5a967cb 100644 --- a/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java +++ b/src/test/java/com/souzip/domain/city/application/command/CityCommandServiceTest.java @@ -8,9 +8,11 @@ import com.souzip.domain.city.service.CityPriorityDomainService; import com.souzip.domain.country.entity.Country; import com.souzip.domain.country.repository.CountryRepository; -import com.souzip.global.exception.BusinessException; +import com.souzip.shared.exception.BusinessException; + import java.math.BigDecimal; import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java b/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java index d9112cf6..5412cd5b 100644 --- a/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java +++ b/src/test/java/com/souzip/domain/country/service/CountryServiceTest.java @@ -8,11 +8,13 @@ import com.souzip.domain.country.repository.CountryRepository; import com.souzip.domain.currency.entity.Currency; import com.souzip.domain.currency.repository.CurrencyRepository; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; + import java.math.BigDecimal; import java.util.List; import java.util.Optional; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,15 +43,15 @@ class CountryServiceTest { private Country createCountry(String nameEn, String nameKr, String code, Currency currency) { return Country.of( - nameEn, - nameKr, - code, - "Seoul", - Region.ASIA, - "https://flagcdn.com/w320/kr.png", - new BigDecimal("37.0"), - new BigDecimal("127.0"), - currency + nameEn, + nameKr, + code, + "Seoul", + Region.ASIA, + "https://flagcdn.com/w320/kr.png", + new BigDecimal("37.0"), + new BigDecimal("127.0"), + currency ); } @@ -74,8 +76,8 @@ void getAllCountries() { // then assertThat(response.countries()).hasSize(2); assertThat(response.countries()) - .extracting(CountryResponseDto::code) - .containsExactly("KR", "JP"); + .extracting(CountryResponseDto::code) + .containsExactly("KR", "JP"); } @Test @@ -104,7 +106,7 @@ void getCountryByCode_throwsException_whenCountryNotFound() { // when & then assertThatThrownBy(() -> countryService.getCountryByCode("INVALID")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining(ErrorCode.COUNTRY_NOT_FOUND.getMessage()); + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.COUNTRY_NOT_FOUND.getMessage()); } } diff --git a/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java b/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java index b79e5541..a1ffa896 100644 --- a/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java +++ b/src/test/java/com/souzip/domain/currency/service/CurrencyServiceTest.java @@ -4,8 +4,8 @@ import com.souzip.domain.currency.dto.CurrencyResponse; import com.souzip.domain.country.entity.Country; import com.souzip.domain.currency.entity.Currency; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; diff --git a/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java b/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java index 6e2f5963..11cfe65e 100644 --- a/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java +++ b/src/test/java/com/souzip/domain/recommend/ai/controller/AiRecommendationControllerTest.java @@ -1,9 +1,9 @@ package com.souzip.domain.recommend.ai.controller; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; import com.souzip.docs.RestDocsSupport; import com.souzip.domain.recommend.ai.dto.AiRecommendationResponse; import com.souzip.domain.recommend.ai.service.AiRecommendationService; -import com.souzip.global.security.annotation.CurrentUserId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,12 +29,13 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class AiRecommendationControllerTest extends RestDocsSupport { private final AiRecommendationService aiRecommendationService = - org.mockito.Mockito.mock(AiRecommendationService.class); + org.mockito.Mockito.mock(AiRecommendationService.class); @Override protected Object initController() { @@ -44,138 +45,136 @@ protected Object initController() { @BeforeEach void setUpWithResolver(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { - return 1L; - } - }) - .apply(org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration(provider)) - .build(); + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return 1L; + } + }) + .apply(org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration(provider)) + .build(); } @Test @DisplayName("사용자 선호 카테고리 기반 AI 추천 기념품 조회") void getAiCategoryRecommendations() throws Exception { - // given List souvenirs = List.of( - AiRecommendationResponse.RecommendedSouvenir.from(1L, "초코파이", "FOOD_SNACK", "KR", "https://example.com/thumb1.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(2L, "삼다수", "FOOD_SNACK", "KR", "https://example.com/thumb2.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(3L, "설화수 스킨", "BEAUTY_HEALTH", "KR", "https://example.com/thumb3.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(4L, "헤라 립스틱", "BEAUTY_HEALTH", "KR", "https://example.com/thumb4.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(5L, "한복 인형", "CULTURE_TRADITION", "KR", "https://example.com/thumb5.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(6L, "전통 부채", "CULTURE_TRADITION", "KR", "https://example.com/thumb6.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(7L, "여행용 파우치", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb7.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(8L, "휴대용 보조배터리", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb8.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(9L, "패션 마스크", "FASHION_ACCESSORY", "KR", "https://example.com/thumb9.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(10L, "손목 시계", "FASHION_ACCESSORY", "KR", "https://example.com/thumb10.jpg") + AiRecommendationResponse.RecommendedSouvenir.from(1L, "초코파이", "FOOD_SNACK", "KR", "https://example.com/thumb1.jpg", 3L, true), + AiRecommendationResponse.RecommendedSouvenir.from(2L, "삼다수", "FOOD_SNACK", "KR", "https://example.com/thumb2.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(3L, "설화수 스킨", "BEAUTY_HEALTH", "KR", "https://example.com/thumb3.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(4L, "헤라 립스틱", "BEAUTY_HEALTH", "KR", "https://example.com/thumb4.jpg", 2L, true), + AiRecommendationResponse.RecommendedSouvenir.from(5L, "한복 인형", "CULTURE_TRADITION", "KR", "https://example.com/thumb5.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(6L, "전통 부채", "CULTURE_TRADITION", "KR", "https://example.com/thumb6.jpg", 4L, false), + AiRecommendationResponse.RecommendedSouvenir.from(7L, "여행용 파우치", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb7.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(8L, "휴대용 보조배터리", "TRAVEL_PRACTICAL", "KR", "https://example.com/thumb8.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(9L, "패션 마스크", "FASHION_ACCESSORY", "KR", "https://example.com/thumb9.jpg", 2L, true), + AiRecommendationResponse.RecommendedSouvenir.from(10L, "손목 시계", "FASHION_ACCESSORY", "KR", "https://example.com/thumb10.jpg", 0L, false) ); given(aiRecommendationService.getCategoryRecommendationsForUser(eq(1L))) - .willReturn(new AiRecommendationResponse(souvenirs)); + .willReturn(new AiRecommendationResponse(souvenirs)); - // when & then mockMvc.perform(get("/api/discovery/ai/preference-category") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("초코파이")) - .andDo(document("recommend/ai/category", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("초코파이")) + .andDo(document("recommend/ai/category", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("사용자 최근 업로드 기념품 기반 AI 추천 기념품 조회") void getAiRecentUploadRecommendations() throws Exception { - // given List souvenirs = List.of( - AiRecommendationResponse.RecommendedSouvenir.from(1L, "모나카", "FOOD_SNACK", "JP", "https://example.com/recent/thumb1.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(2L, "센베이", "FOOD_SNACK", "JP", "https://example.com/recent/thumb2.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(3L, "다이후쿠", "FOOD_SNACK", "JP", "https://example.com/recent/thumb3.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(4L, "가나코", "FOOD_SNACK", "JP", "https://example.com/recent/thumb4.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(5L, "유바", "FOOD_SNACK", "JP", "https://example.com/recent/thumb5.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(6L, "아마자케", "FOOD_SNACK", "JP", "https://example.com/recent/thumb6.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(7L, "호지차", "FOOD_SNACK", "JP", "https://example.com/recent/thumb7.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(8L, "말차초콜릿", "FOOD_SNACK", "JP", "https://example.com/recent/thumb8.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(9L, "시세이도 스킨케어", "BEAUTY_HEALTH", "JP", "https://example.com/recent/thumb9.jpg"), - AiRecommendationResponse.RecommendedSouvenir.from(10L, "유젠 손수건", "CULTURE_TRADITION", "JP", "https://example.com/recent/thumb10.jpg") + AiRecommendationResponse.RecommendedSouvenir.from(1L, "모나카", "FOOD_SNACK", "JP", "https://example.com/recent/thumb1.jpg", 2L, true), + AiRecommendationResponse.RecommendedSouvenir.from(2L, "센베이", "FOOD_SNACK", "JP", "https://example.com/recent/thumb2.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(3L, "다이후쿠", "FOOD_SNACK", "JP", "https://example.com/recent/thumb3.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(4L, "가나코", "FOOD_SNACK", "JP", "https://example.com/recent/thumb4.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(5L, "유바", "FOOD_SNACK", "JP", "https://example.com/recent/thumb5.jpg", 3L, true), + AiRecommendationResponse.RecommendedSouvenir.from(6L, "아마자케", "FOOD_SNACK", "JP", "https://example.com/recent/thumb6.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(7L, "호지차", "FOOD_SNACK", "JP", "https://example.com/recent/thumb7.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(8L, "말차초콜릿", "FOOD_SNACK", "JP", "https://example.com/recent/thumb8.jpg", 1L, false), + AiRecommendationResponse.RecommendedSouvenir.from(9L, "시세이도 스킨케어", "BEAUTY_HEALTH", "JP", "https://example.com/recent/thumb9.jpg", 0L, false), + AiRecommendationResponse.RecommendedSouvenir.from(10L, "유젠 손수건", "CULTURE_TRADITION", "JP", "https://example.com/recent/thumb10.jpg", 0L, false) ); given(aiRecommendationService.getRecentSouvenirRecommendations(eq(1L))) - .willReturn(new AiRecommendationResponse(souvenirs)); + .willReturn(new AiRecommendationResponse(souvenirs)); - // when & then mockMvc.perform(get("/api/discovery/ai/preference-upload") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("모나카")) - .andExpect(jsonPath("$.data.souvenirs[0].category").value("FOOD_SNACK")) - .andExpect(jsonPath("$.data.souvenirs[0].countryCode").value("JP")) - .andExpect(jsonPath("$.data.souvenirs[0].thumbnailUrl").value("https://example.com/recent/thumb1.jpg")) - .andDo(document("recommend/ai/recent-upload", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("모나카")) + .andExpect(jsonPath("$.data.souvenirs[0].category").value("FOOD_SNACK")) + .andExpect(jsonPath("$.data.souvenirs[0].countryCode").value("JP")) + .andExpect(jsonPath("$.data.souvenirs[0].thumbnailUrl").value("https://example.com/recent/thumb1.jpg")) + .andDo(document("recommend/ai/recent-upload", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY).description("추천 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data.souvenirs[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("사용자 최근 업로드 기념품이 없으면 AI 추천은 빈 배열을 반환한다") void getAiRecentUploadRecommendations_empty() throws Exception { - // given given(aiRecommendationService.getRecentSouvenirRecommendations(1L)) - .willReturn(new AiRecommendationResponse(Collections.emptyList())); + .willReturn(new AiRecommendationResponse(Collections.emptyList())); - // when & then mockMvc.perform(get("/api/discovery/ai/preference-upload") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs").isArray()) - .andExpect(jsonPath("$.data.souvenirs").isEmpty()) - .andDo(document("recommend/ai/recent-upload-empty", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), - fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY) - .description("최근 업로드 이력이 없으면 빈 배열이 반환됩니다"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs").isArray()) + .andExpect(jsonPath("$.data.souvenirs").isEmpty()) + .andDo(document("recommend/ai/recent-upload-empty", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("AI 추천 응답 데이터"), + fieldWithPath("data.souvenirs").type(JsonFieldType.ARRAY) + .description("최근 업로드 이력이 없으면 빈 배열이 반환됩니다"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java b/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java index f10780ed..55f3b901 100644 --- a/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java +++ b/src/test/java/com/souzip/domain/recommend/general/controller/GeneralRecommendationControllerTest.java @@ -19,6 +19,8 @@ import static com.souzip.docs.CommonDocumentation.apiResponseFields; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -40,211 +42,224 @@ protected Object initController() { @DisplayName("카테고리별 추천 기념품 Top10 조회") @Test void getCategoryTop10() throws Exception { - // given List responseList = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - (long) i, - "기념품 " + i, - Category.SOUVENIR_BASIC, - "JP", - "https://example.com/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); - - given(generalRecommendationService.getTop10ByCategory("FOOD_SNACK")) - .willReturn(responseList); - - // when & then + .mapToObj(i -> new GeneralRecommendationDto( + (long) i, + "기념품 " + i, + Category.SOUVENIR_BASIC, + "JP", + "https://example.com/thumb" + i + ".jpg", + 3L, + null + )) + .collect(Collectors.toList()); + + given(generalRecommendationService.getTop10ByCategory("FOOD_SNACK", null)) + .willReturn(responseList); + mockMvc.perform(get("/api/discovery/general/category/{categoryName}", "FOOD_SNACK")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andDo(document("recommend/category-top10", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("categoryName").description("조회할 카테고리 이름 (예: FOOD_SNACK)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andDo(document("recommend/category-top10", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("categoryName").description("조회할 카테고리 이름 (예: FOOD_SNACK)") + ), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("나라별 추천 기념품 Top10 조회") @Test void getCountryTop10() throws Exception { - // given List responseList = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - (long) i, - "기념품 " + i, - Category.SOUVENIR_BASIC, - "KR", - "https://example.com/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); - - given(generalRecommendationService.getTop10ByCountry("KR")) - .willReturn(responseList); - - // when & then + .mapToObj(i -> new GeneralRecommendationDto( + (long) i, + "기념품 " + i, + Category.SOUVENIR_BASIC, + "KR", + "https://example.com/thumb" + i + ".jpg", + 2L, + null + )) + .collect(Collectors.toList()); + + given(generalRecommendationService.getTop10ByCountry("KR", null)) + .willReturn(responseList); + mockMvc.perform(get("/api/discovery/general/country/{countryCode}", "KR")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].id").value(1)) - .andExpect(jsonPath("$.data[0].name").value("기념품 1")) - .andDo(document("recommend/country-top10", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("countryCode").description("조회할 국가 코드 (예: KR, JP)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].name").value("기념품 1")) + .andDo(document("recommend/country-top10", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("countryCode").description("조회할 국가 코드 (예: KR, JP)") + ), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 리스트"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("기념품 최다등록 국가 Top10 조회(통계만)") @Test void getTopCountriesTop10() throws Exception { - // given List statsList = List.of( - new GeneralRecommendationStatsDto("JP", "일본", 20L), - new GeneralRecommendationStatsDto("US", "미국", 15L), - new GeneralRecommendationStatsDto("TW", "대만", 10L), - new GeneralRecommendationStatsDto("HK", "홍콩", 8L), - new GeneralRecommendationStatsDto("VN", "베트남", 4L), - new GeneralRecommendationStatsDto("KR", "한국", 4L), - new GeneralRecommendationStatsDto("DE", "독일", 4L), - new GeneralRecommendationStatsDto("FR", "프랑스", 4L), - new GeneralRecommendationStatsDto("CN", "중국", 3L), - new GeneralRecommendationStatsDto("PH", "필리핀", 3L) + new GeneralRecommendationStatsDto("JP", "일본", 20L), + new GeneralRecommendationStatsDto("US", "미국", 15L), + new GeneralRecommendationStatsDto("TW", "대만", 10L), + new GeneralRecommendationStatsDto("HK", "홍콩", 8L), + new GeneralRecommendationStatsDto("VN", "베트남", 4L), + new GeneralRecommendationStatsDto("KR", "한국", 4L), + new GeneralRecommendationStatsDto("DE", "독일", 4L), + new GeneralRecommendationStatsDto("FR", "프랑스", 4L), + new GeneralRecommendationStatsDto("CN", "중국", 3L), + new GeneralRecommendationStatsDto("PH", "필리핀", 3L) ); given(generalRecommendationService.getTop10CountriesBySouvenirCount()) - .willReturn(statsList); + .willReturn(statsList); - // when & then mockMvc.perform(get("/api/discovery/general/countries/top10")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(10)) - .andDo(document("recommend/stats-top10", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top10)"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(10)) + .andDo(document("recommend/stats-top10", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top10)"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Deprecated @DisplayName("기념품 최다등록 국가 Top3 조회(통계만)") @Test void getTopCountriesTop3() throws Exception { - // given List statsList = List.of( - new GeneralRecommendationStatsDto("JP", "일본", 20L), - new GeneralRecommendationStatsDto("US", "미국", 15L), - new GeneralRecommendationStatsDto("TW", "대만", 10L) + new GeneralRecommendationStatsDto("JP", "일본", 20L), + new GeneralRecommendationStatsDto("US", "미국", 15L), + new GeneralRecommendationStatsDto("TW", "대만", 10L) ); given(generalRecommendationService.getTop3CountriesBySouvenirCount()) - .willReturn(statsList); + .willReturn(statsList); - // when & then mockMvc.perform(get("/api/discovery/general/stats")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(3)) - .andDo(document("recommend/stats-top3", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top3)"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(3)) + .andDo(document("recommend/stats-top3", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("나라별 기념품 등록 통계(누적 Top3)"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 최다등록 Top10 국가 + 국가별 기념품 Top10 조회") @Test void getTopCountriesWithTop10Souvenirs() throws Exception { - // given List jpSouvenirs = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - 200L + i, - "일본 기념품 " + i, - Category.SOUVENIR_BASIC, - "JP", - "https://example.com/jp/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); + .mapToObj(i -> new GeneralRecommendationDto( + 200L + i, + "일본 기념품 " + i, + Category.SOUVENIR_BASIC, + "JP", + "https://example.com/jp/thumb" + i + ".jpg", + 5L, + null + )) + .collect(Collectors.toList()); List usSouvenirs = IntStream.rangeClosed(1, 10) - .mapToObj(i -> new GeneralRecommendationDto( - 300L + i, - "미국 기념품 " + i, - Category.SOUVENIR_BASIC, - "US", - "https://example.com/us/thumb" + i + ".jpg" - )) - .collect(Collectors.toList()); + .mapToObj(i -> new GeneralRecommendationDto( + 300L + i, + "미국 기념품 " + i, + Category.SOUVENIR_BASIC, + "US", + "https://example.com/us/thumb" + i + ".jpg", + 2L, + null + )) + .collect(Collectors.toList()); List response = List.of( - new CountryRecommendationDto("JP", "일본", 20L, jpSouvenirs), - new CountryRecommendationDto("US", "미국", 15L, usSouvenirs) + new CountryRecommendationDto("JP", "일본", 20L, jpSouvenirs), + new CountryRecommendationDto("US", "미국", 15L, usSouvenirs) ); - given(generalRecommendationService.getTopCountriesWithTop10Souvenirs()) - .willReturn(response); + given(generalRecommendationService.getTopCountriesWithTop10Souvenirs(null)) + .willReturn(response); - // when & then mockMvc.perform(get("/api/countries/souvenirs")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").isArray()) - .andDo(document("recommend/countries-top10-souvenirs-top10", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 최다등록 국가 Top10 및 국가별 기념품 Top10"), - fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), - fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), - fieldWithPath("data[].souvenirs").type(JsonFieldType.ARRAY).description("해당 국가의 추천 기념품 리스트(최대 10개)"), - fieldWithPath("data[].souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data[].souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), - fieldWithPath("data[].souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), - fieldWithPath("data[].souvenirs[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data[].souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andDo(document("recommend/countries-top10-souvenirs-top10", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName("Authorization").description("Bearer 액세스 토큰 (선택)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("기념품 최다등록 국가 Top10 및 국가별 기념품 Top10"), + fieldWithPath("data[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data[].countryNameKr").type(JsonFieldType.STRING).description("나라명 (한글)"), + fieldWithPath("data[].souvenirCount").type(JsonFieldType.NUMBER).description("등록된 기념품 개수 (integer)"), + fieldWithPath("data[].souvenirs").type(JsonFieldType.ARRAY).description("해당 국가의 추천 기념품 리스트(최대 10개)"), + fieldWithPath("data[].souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data[].souvenirs[].name").type(JsonFieldType.STRING).description("기념품 이름"), + fieldWithPath("data[].souvenirs[].category").type(JsonFieldType.STRING).description("기념품 카테고리"), + fieldWithPath("data[].souvenirs[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data[].souvenirs[].thumbnailUrl").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data[].souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (비로그인 시 null)").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java b/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java index 56554ed1..a6045114 100644 --- a/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java +++ b/src/test/java/com/souzip/domain/search/controller/SearchControllerTest.java @@ -3,10 +3,10 @@ import com.souzip.docs.RestDocsSupport; import com.souzip.domain.search.dto.SearchResponse; import com.souzip.domain.search.service.SearchService; -import com.souzip.global.common.dto.pagination.PaginationRequest; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.common.dto.pagination.PaginationRequest; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.restdocs.payload.JsonFieldType; diff --git a/src/test/java/com/souzip/domain/shared/CoordinateTest.java b/src/test/java/com/souzip/domain/shared/CoordinateTest.java index 14938ea0..837c5565 100644 --- a/src/test/java/com/souzip/domain/shared/CoordinateTest.java +++ b/src/test/java/com/souzip/domain/shared/CoordinateTest.java @@ -1,10 +1,12 @@ package com.souzip.domain.shared; +import com.souzip.shared.domain.Coordinate; import org.junit.jupiter.api.Test; import java.math.BigDecimal; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class CoordinateTest { diff --git a/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java b/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java index 399a477f..5d1d5d46 100644 --- a/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java +++ b/src/test/java/com/souzip/domain/souvenir/controller/SouvenirControllerTest.java @@ -1,19 +1,12 @@ package com.souzip.domain.souvenir.controller; +import com.souzip.application.file.dto.FileResponse; +import com.souzip.auth.adapter.security.annotation.CurrentUserId; import com.souzip.docs.RestDocsSupport; import com.souzip.domain.category.entity.Category; -import com.souzip.application.file.dto.FileResponse; -import com.souzip.domain.souvenir.dto.PriceResponse; -import com.souzip.domain.souvenir.dto.SouvenirCreateRequest; -import com.souzip.domain.souvenir.dto.SouvenirDetailResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyListResponse; -import com.souzip.domain.souvenir.dto.SouvenirNearbyResponse; -import com.souzip.domain.souvenir.dto.SouvenirRequest; -import com.souzip.domain.souvenir.dto.SouvenirResponse; -import com.souzip.domain.souvenir.dto.SouvenirUpdateRequest; +import com.souzip.domain.souvenir.dto.*; import com.souzip.domain.souvenir.entity.Purpose; import com.souzip.domain.souvenir.service.SouvenirService; -import com.souzip.global.security.annotation.CurrentUserId; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -42,15 +35,12 @@ import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.partWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.restdocs.request.RequestDocumentation.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class SouvenirControllerTest extends RestDocsSupport { @@ -64,26 +54,26 @@ protected Object initController() { @BeforeEach void setUpWithResolver(RestDocumentationContextProvider provider) { this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUserId.class) - && parameter.getParameterType().equals(Long.class); - } - - @Override - public Object resolveArgument( - MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory - ) { - return 1L; - } - }) - .apply(documentationConfiguration(provider)) - .build(); + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return 1L; + } + }) + .apply(documentationConfiguration(provider)) + .build(); } @DisplayName("근처 기념품 조회") @@ -94,58 +84,64 @@ void getNearbySouvenirs() throws Exception { int radiusMeter = 4000; List nearbySouvenirs = List.of( - SouvenirNearbyResponse.from( - 1L, "Souvenir A", Category.SOUVENIR_BASIC, Purpose.GIFT, - 10000, 120000, "$", - "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd335.png", - new BigDecimal("40.7128123"), new BigDecimal("-74.0060123"), - "Some address A" - ), - SouvenirNearbyResponse.from( - 2L, "Souvenir B", Category.FOOD_SNACK, Purpose.GIFT, - 20000, 240000, "$", - "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd123.png", - new BigDecimal("40.7228123"), new BigDecimal("-74.0010123"), - "Some address B" - ) + SouvenirNearbyResponse.from( + 1L, "Souvenir A", Category.SOUVENIR_BASIC, Purpose.GIFT, + 10000, 120000, "$", + "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd335.png", + new BigDecimal("40.7128123"), new BigDecimal("-74.0060123"), + "Some address A", + 5L, + null + ), + SouvenirNearbyResponse.from( + 2L, "Souvenir B", Category.FOOD_SNACK, Purpose.GIFT, + 20000, 240000, "$", + "https://test-dev-images.kr.object.ncloudstorage.com/1234ab123456/1234a123-e1f2-345b-aa12-d123456dd123.png", + new BigDecimal("40.7228123"), new BigDecimal("-74.0010123"), + "Some address B", + 3L, + null + ) ); - given(souvenirService.getNearbySouvenirs(userLatitude, userLongitude, radiusMeter)) - .willReturn(SouvenirNearbyListResponse.from(nearbySouvenirs)); + given(souvenirService.getNearbySouvenirs(userLatitude, userLongitude, radiusMeter, null)) + .willReturn(SouvenirNearbyListResponse.from(nearbySouvenirs)); mockMvc.perform(get("/api/souvenirs/nearby") - .param("latitude", String.valueOf(userLatitude)) - .param("longitude", String.valueOf(userLongitude)) - .param("radiusMeter", String.valueOf(radiusMeter))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) - .andExpect(jsonPath("$.data.souvenirs[0].name").value("Souvenir A")) - .andDo(document("souvenirs/get-nearby-souvenirs", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("latitude").description("위도 (decimal, 소수점 7자리까지)"), - parameterWithName("longitude").description("경도 (decimal, 소수점 7자리까지)"), - parameterWithName("radiusMeter").description("조회 반경 (미터 단위, 기본값 5000m)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.souvenirs[]").type(JsonFieldType.ARRAY).description("근처 기념품 리스트"), - fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.souvenirs[].purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.souvenirs[].localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer)"), - fieldWithPath("data.souvenirs[].krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer)"), - fieldWithPath("data.souvenirs[].currencySymbol").type(JsonFieldType.STRING).description("통화 기호").optional(), - fieldWithPath("data.souvenirs[].thumbnail").type(JsonFieldType.STRING).description("대표 이미지 URL"), - fieldWithPath("data.souvenirs[].latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.souvenirs[].longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.souvenirs[].address").type(JsonFieldType.STRING).description("기념품 주소"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .param("latitude", String.valueOf(userLatitude)) + .param("longitude", String.valueOf(userLongitude)) + .param("radiusMeter", String.valueOf(radiusMeter))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirs[0].id").value(1L)) + .andExpect(jsonPath("$.data.souvenirs[0].name").value("Souvenir A")) + .andDo(document("souvenirs/get-nearby-souvenirs", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("latitude").description("위도 (decimal, 소수점 7자리까지)"), + parameterWithName("longitude").description("경도 (decimal, 소수점 7자리까지)"), + parameterWithName("radiusMeter").description("조회 반경 (미터 단위, 기본값 5000m)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirs[]").type(JsonFieldType.ARRAY).description("근처 기념품 리스트"), + fieldWithPath("data.souvenirs[].id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.souvenirs[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.souvenirs[].category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.souvenirs[].purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.souvenirs[].localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer)"), + fieldWithPath("data.souvenirs[].krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer)"), + fieldWithPath("data.souvenirs[].currencySymbol").type(JsonFieldType.STRING).description("통화 기호").optional(), + fieldWithPath("data.souvenirs[].thumbnail").type(JsonFieldType.STRING).description("대표 이미지 URL"), + fieldWithPath("data.souvenirs[].latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.souvenirs[].longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.souvenirs[].address").type(JsonFieldType.STRING).description("기념품 주소"), + fieldWithPath("data.souvenirs[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.souvenirs[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부").optional(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 조회") @@ -155,198 +151,202 @@ void getSouvenir() throws Exception { Long souvenirId = 1L; List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirDetailResponse response = new SouvenirDetailResponse( - souvenirId, - "테스트 기념품", - 10000, // v1 호환용 - deprecated - "$", // v1 호환용 - deprecated - 95000, // v1 호환용 - deprecated - priceResponse, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US", - "닉네임", - "https://example.com/profile.jpg", - true, - filesResponse + souvenirId, + "테스트 기념품", + 10000, // v1 호환용 - deprecated + "$", // v1 호환용 - deprecated + 95000, // v1 호환용 - deprecated + priceResponse, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US", + "닉네임", + "https://example.com/profile.jpg", + true, + true, + 15, + filesResponse ); String jwt = "Bearer test.jwt.token"; given(souvenirService.getSouvenir(souvenirId, jwt)).willReturn(response); mockMvc.perform(get("/api/souvenirs/{id}", souvenirId) - .header("Authorization", jwt)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/get-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).optional().description("위치 상세 설명"), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.isOwned").type(JsonFieldType.BOOLEAN).description("조회자가 소유자인지 여부"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).optional().description("응답 메시지") - ) - )); + .header("Authorization", jwt)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/get-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).optional().description("위치 상세 설명"), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.isOwned").type(JsonFieldType.BOOLEAN).description("조회자가 소유자인지 여부"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부").optional(), + fieldWithPath("data.wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).optional().description("응답 메시지") + ) + )); } @DisplayName("기념품 생성 (v1)") @Test void createSouvenirWithFiles() throws Exception { SouvenirCreateRequest request = new SouvenirCreateRequest( - "테스트 기념품", - 10000, - "$", - 95000, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US" + "테스트 기념품", + 10000, + "$", + 95000, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(request) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(request) ); MockMultipartFile file1 = new MockMultipartFile("files", "file1.jpg", "image/jpeg", "dummy content".getBytes()); MockMultipartFile file2 = new MockMultipartFile("files", "file2.jpg", "image/jpeg", "dummy content".getBytes()); List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "테스트 기념품", - 10000, "$", 95000, - priceResponse, - "테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "US", - "닉네임", "https://example.com/profile.jpg", - filesResponse + 1L, "테스트 기념품", + 10000, "$", 95000, + priceResponse, + "테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "US", + "닉네임", "https://example.com/profile.jpg", + filesResponse ); given(souvenirService.createSouvenir(any(SouvenirCreateRequest.class), eq(1L), any(List.class))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/souvenirs") - .file(souvenirPart) - .file(file1) - .file(file2)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/create-souvenir", - getDocumentRequest(), - getDocumentResponse(), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)"), - partWithName("files").description("업로드 이미지 파일 리스트 (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), - fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), - fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .file(file1) + .file(file2)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/create-souvenir", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)"), + partWithName("files").description("업로드 이미지 파일 리스트 (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), + fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), + fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 생성 (v2)") @@ -354,114 +354,114 @@ void createSouvenirWithFiles() throws Exception { void createSouvenirV2WithFiles() throws Exception { // TODO: v1 호환 필드(localPrice, currencySymbol, krwPrice) 향후 제거 예정 SouvenirRequest request = new SouvenirRequest( - "테스트 기념품 v2", - 1000, - "JPY", - "v2 API 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 1층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "JP" + "테스트 기념품 v2", + 1000, + "JPY", + "v2 API 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 1층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "JP" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(request) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(request) ); MockMultipartFile file1 = new MockMultipartFile("files", "file1.jpg", "image/jpeg", "dummy content".getBytes()); MockMultipartFile file2 = new MockMultipartFile("files", "file2.jpg", "image/jpeg", "dummy content".getBytes()); List filesResponse = List.of( - new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), - new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) + new FileResponse(1L, "https://example.com/file1.jpg", "file1.jpg", 0), + new FileResponse(2L, "https://example.com/file2.jpg", "file2.jpg", 1) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(1000, "¥"), - new PriceResponse.PriceDetail(9337, "₩") + new PriceResponse.PriceDetail(1000, "¥"), + new PriceResponse.PriceDetail(9337, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "테스트 기념품 v2", - 1000, "¥", 9337, // v1 호환용 - deprecated - priceResponse, - "v2 API 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 1층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", - "닉네임", "https://example.com/profile.jpg", - filesResponse + 1L, "테스트 기념품 v2", + 1000, "¥", 9337, // v1 호환용 - deprecated + priceResponse, + "v2 API 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 1층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", + "닉네임", "https://example.com/profile.jpg", + filesResponse ); given(souvenirService.createSouvenirV2(any(SouvenirRequest.class), eq(1L), any(List.class))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/v2/souvenirs") - .file(souvenirPart) - .file(file1) - .file(file2)) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/create-souvenir-v2", - getDocumentRequest(), - getDocumentResponse(), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)"), - partWithName("files").description("업로드 이미지 파일 리스트 (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("price").description("가격 (integer)").optional(), - fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), - fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), - fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), - fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), - fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .file(file1) + .file(file2)) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/create-souvenir-v2", + getDocumentRequest(), + getDocumentResponse(), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)"), + partWithName("files").description("업로드 이미지 파일 리스트 (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("price").description("가격 (integer)").optional(), + fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").type(JsonFieldType.ARRAY).description("업로드된 파일 리스트"), + fieldWithPath("data.files[].id").type(JsonFieldType.NUMBER).description("파일 ID (integer)"), + fieldWithPath("data.files[].url").type(JsonFieldType.STRING).description("파일 URL"), + fieldWithPath("data.files[].originalName").type(JsonFieldType.STRING).description("원본 파일명"), + fieldWithPath("data.files[].displayOrder").type(JsonFieldType.NUMBER).description("파일 순서 (integer)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 수정 (v1)") @@ -470,105 +470,108 @@ void updateSouvenir() throws Exception { Long souvenirId = 1L; SouvenirUpdateRequest requestDto = new SouvenirUpdateRequest( - "수정된 테스트 기념품", - 10000, - "$", - 95000, - "수정된 테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "US" + "수정된 테스트 기념품", + 10000, + "$", + 95000, + "수정된 테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "US" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(requestDto) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(10000, "$"), - new PriceResponse.PriceDetail(95000, "₩") + new PriceResponse.PriceDetail(10000, "$"), + new PriceResponse.PriceDetail(95000, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "수정된 테스트 기념품", - 10000, "$", 95000, - priceResponse, - "수정된 테스트 설명", - "617 N MAIN FALLBROOK CA 92028-1934 USA", - "2층 빨간색 간판이 있는 장소", - new BigDecimal("35.689487"), - new BigDecimal("139.691706"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "US", - "닉네임", "https://example.com/profile.jpg", - List.of() + 1L, "수정된 테스트 기념품", + 10000, "$", 95000, + priceResponse, + "수정된 테스트 설명", + "617 N MAIN FALLBROOK CA 92028-1934 USA", + "2층 빨간색 간판이 있는 장소", + new BigDecimal("35.689487"), + new BigDecimal("139.691706"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "US", + "닉네임", "https://example.com/profile.jpg", + List.of() ); given(souvenirService.updateSouvenir(eq(souvenirId), any(SouvenirUpdateRequest.class), eq(1L))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/souvenirs/{id}", souvenirId) - .file(souvenirPart) - .with(req -> { req.setMethod("PUT"); return req; })) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/update-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), - fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), - fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").ignored(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .with(req -> { + req.setMethod("PUT"); + return req; + })) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/update-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("localPrice").description("현지 가격 (integer)").optional(), + fieldWithPath("currencySymbol").description("현지 통화 기호").optional(), + fieldWithPath("krwPrice").description("원화 가격 (integer)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("현지 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("현지 통화 기호 (v1 호환용)").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("원화 가격 (integer, v1 호환용)").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보").optional(), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격").optional(), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액").optional(), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼").optional(), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격").optional(), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액").optional(), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼").optional(), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").ignored(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 수정 (v2)") @@ -578,103 +581,106 @@ void updateSouvenirV2() throws Exception { Long souvenirId = 1L; SouvenirRequest requestDto = new SouvenirRequest( - "수정된 기념품 v2", - 1500, - "JPY", - "v2 수정 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 2층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, - Purpose.GIFT, - "JP" + "수정된 기념품 v2", + 1500, + "JPY", + "v2 수정 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 2층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, + Purpose.GIFT, + "JP" ); MockMultipartFile souvenirPart = new MockMultipartFile( - "souvenir", - "souvenir.json", - "application/json", - objectMapper.writeValueAsBytes(requestDto) + "souvenir", + "souvenir.json", + "application/json", + objectMapper.writeValueAsBytes(requestDto) ); PriceResponse priceResponse = new PriceResponse( - new PriceResponse.PriceDetail(1500, "¥"), - new PriceResponse.PriceDetail(14005, "₩") + new PriceResponse.PriceDetail(1500, "¥"), + new PriceResponse.PriceDetail(14005, "₩") ); SouvenirResponse response = new SouvenirResponse( - 1L, "수정된 기념품 v2", - 1500, "¥", 14005, // v1 호환용 - deprecated - priceResponse, - "v2 수정 테스트", - "일본 도쿄 미나토구", - "도쿄 타워 2층", - new BigDecimal("35.6586"), - new BigDecimal("139.7454"), - Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", - "닉네임", "https://example.com/profile.jpg", - List.of() + 1L, "수정된 기념품 v2", + 1500, "¥", 14005, // v1 호환용 - deprecated + priceResponse, + "v2 수정 테스트", + "일본 도쿄 미나토구", + "도쿄 타워 2층", + new BigDecimal("35.6586"), + new BigDecimal("139.7454"), + Category.SOUVENIR_BASIC, Purpose.GIFT, "JP", + "닉네임", "https://example.com/profile.jpg", + List.of() ); given(souvenirService.updateSouvenirV2(eq(souvenirId), any(SouvenirRequest.class), eq(1L))) - .willReturn(response); + .willReturn(response); mockMvc.perform(multipart("/api/v2/souvenirs/{id}", souvenirId) - .file(souvenirPart) - .with(req -> { req.setMethod("PUT"); return req; })) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("souvenirs/update-souvenir-v2", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - requestParts( - partWithName("souvenir").description("기념품 정보(JSON) (필수)") - ), - requestPartFields("souvenir", - fieldWithPath("name").description("기념품 이름"), - fieldWithPath("price").description("가격 (integer)").optional(), - fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), - fieldWithPath("description").description("기념품 설명"), - fieldWithPath("address").description("주소"), - fieldWithPath("locationDetail").description("상세 위치").optional(), - fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("category").description("카테고리 ENUM name"), - fieldWithPath("purpose").description("구매 목적 ENUM name"), - fieldWithPath("countryCode").description("국가 코드") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), - fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), - fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), - fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), - fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), - fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), - fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), - fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), - fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), - fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), - fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), - fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), - fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), - fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), - fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), - fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), - fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), - fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), - fieldWithPath("data.files").ignored(), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .file(souvenirPart) + .with(req -> { + req.setMethod("PUT"); + return req; + })) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("souvenirs/update-souvenir-v2", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + requestParts( + partWithName("souvenir").description("기념품 정보(JSON) (필수)") + ), + requestPartFields("souvenir", + fieldWithPath("name").description("기념품 이름"), + fieldWithPath("price").description("가격 (integer)").optional(), + fieldWithPath("currency").description("ISO 4217 통화 코드 (예: JPY, USD, KRW)").optional(), + fieldWithPath("description").description("기념품 설명"), + fieldWithPath("address").description("주소"), + fieldWithPath("locationDetail").description("상세 위치").optional(), + fieldWithPath("latitude").description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("longitude").description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("category").description("카테고리 ENUM name"), + fieldWithPath("purpose").description("구매 목적 ENUM name"), + fieldWithPath("countryCode").description("국가 코드") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("기념품 ID (integer)"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.localPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 현지 가격 (integer) - price.original.amount 사용 권장").optional(), + fieldWithPath("data.currencySymbol").type(JsonFieldType.STRING).description("**[Deprecated]** 현지 통화 기호 - price.original.symbol 사용 권장").optional(), + fieldWithPath("data.krwPrice").type(JsonFieldType.NUMBER).description("**[Deprecated]** 원화 가격 (integer) - price.converted.amount 사용 권장").optional(), + fieldWithPath("data.price").type(JsonFieldType.OBJECT).description("v2 가격 정보"), + fieldWithPath("data.price.original").type(JsonFieldType.OBJECT).description("사용자가 입력한 원본 가격"), + fieldWithPath("data.price.original.amount").type(JsonFieldType.NUMBER).description("원본 금액"), + fieldWithPath("data.price.original.symbol").type(JsonFieldType.STRING).description("원본 통화 심볼"), + fieldWithPath("data.price.converted").type(JsonFieldType.OBJECT).description("등록 시점 환율로 변환된 가격"), + fieldWithPath("data.price.converted.amount").type(JsonFieldType.NUMBER).description("변환 금액"), + fieldWithPath("data.price.converted.symbol").type(JsonFieldType.STRING).description("변환 통화 심볼"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("기념품 설명"), + fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소"), + fieldWithPath("data.locationDetail").type(JsonFieldType.STRING).description("위치 상세 설명").optional(), + fieldWithPath("data.latitude").type(JsonFieldType.NUMBER).description("위도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.longitude").type(JsonFieldType.NUMBER).description("경도 (decimal, 소수점 7자리까지)"), + fieldWithPath("data.category").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.purpose").type(JsonFieldType.STRING).description("목적 ENUM name"), + fieldWithPath("data.countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.userNickname").type(JsonFieldType.STRING).description("기념품 소유자 닉네임"), + fieldWithPath("data.userProfileImageUrl").type(JsonFieldType.STRING).description("기념품 소유자 프로필 이미지 URL"), + fieldWithPath("data.files").ignored(), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @DisplayName("기념품 삭제") @@ -683,21 +689,21 @@ void deleteSouvenir() throws Exception { Long souvenirId = 1L; mockMvc.perform(delete("/api/souvenirs/{id}", souvenirId)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").exists()) - .andDo(document("souvenirs/delete-souvenir", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("id").description("기념품 ID") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.NULL).optional() - .description("소프트 딜리트 처리, 응답에는 데이터 포함 X"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").exists()) + .andDo(document("souvenirs/delete-souvenir", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("id").description("기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.NULL).optional() + .description("소프트 딜리트 처리, 응답에는 데이터 포함 X"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java b/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java index 02c8615a..61fb5ff8 100644 --- a/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/souzip/domain/user/controller/UserControllerTest.java @@ -11,9 +11,9 @@ import com.souzip.domain.user.dto.UserAgreementInfo; import com.souzip.domain.user.dto.UserProfileResponse; import com.souzip.domain.user.service.UserService; -import com.souzip.global.common.dto.pagination.PaginationResponse; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -55,9 +55,9 @@ protected Object initController() { // Mock 헬퍼 메서드 private MySouvenirListResponse createMockMySouvenirListResponse( - List content, - int currentPage, int totalPages, long totalItems, int pageSize, - boolean first, boolean last, boolean hasNext, boolean hasPrevious + List content, + int currentPage, int totalPages, long totalItems, int pageSize, + boolean first, boolean last, boolean hasNext, boolean hasPrevious ) { MySouvenirListResponse response = mock(MySouvenirListResponse.class); PaginationResponse.PageInfo pageInfo = mock(PaginationResponse.PageInfo.class); @@ -87,31 +87,31 @@ void checkNickname_available() throws Exception { // when & then mockMvc.perform(post("/api/users/check-nickname") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.available").value(true)) - .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")) - .andDo(document("user/check-nickname-available", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("nickname").type(JsonFieldType.STRING) - .description("확인할 닉네임 (2~11자, 한글/영문/숫자만 가능)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT) - .description("닉네임 중복 확인 결과"), - fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) - .description("사용 가능 여부 (true: 사용 가능, false: 사용 불가)"), - fieldWithPath("data.message").type(JsonFieldType.STRING) - .description("확인 결과 메시지"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.available").value(true)) + .andExpect(jsonPath("$.data.message").value("사용 가능한 닉네임입니다.")) + .andDo(document("user/check-nickname-available", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").type(JsonFieldType.STRING) + .description("확인할 닉네임 (2~11자, 한글/영문/숫자만 가능)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("닉네임 중복 확인 결과"), + fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) + .description("사용 가능 여부 (true: 사용 가능, false: 사용 불가)"), + fieldWithPath("data.message").type(JsonFieldType.STRING) + .description("확인 결과 메시지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } @Test @@ -125,31 +125,31 @@ void checkNickname_unavailable() throws Exception { // when & then mockMvc.perform(post("/api/users/check-nickname") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.available").value(false)) - .andExpect(jsonPath("$.data.message").value("이미 사용 중인 닉네임입니다.")) - .andDo(document("user/check-nickname-unavailable", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("nickname").type(JsonFieldType.STRING) - .description("확인할 닉네임 (이미 사용 중)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT) - .description("닉네임 중복 확인 결과"), - fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) - .description("사용 가능 여부 (false: 중복됨)"), - fieldWithPath("data.message").type(JsonFieldType.STRING) - .description("확인 결과 메시지"), - fieldWithPath("message").type(JsonFieldType.STRING) - .description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.available").value(false)) + .andExpect(jsonPath("$.data.message").value("이미 사용 중인 닉네임입니다.")) + .andDo(document("user/check-nickname-unavailable", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("nickname").type(JsonFieldType.STRING) + .description("확인할 닉네임 (이미 사용 중)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT) + .description("닉네임 중복 확인 결과"), + fieldWithPath("data.available").type(JsonFieldType.BOOLEAN) + .description("사용 가능 여부 (false: 중복됨)"), + fieldWithPath("data.message").type(JsonFieldType.STRING) + .description("확인 결과 메시지"), + fieldWithPath("message").type(JsonFieldType.STRING) + .description("응답 메시지").optional() + ) + )); } @Test @@ -157,38 +157,38 @@ void checkNickname_unavailable() throws Exception { void completeOnboarding_nicknameAlreadyExists() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "중복닉네임", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "중복닉네임", + "red", + List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.NICKNAME_DUPLICATED)); + .willThrow(new BusinessException(ErrorCode.NICKNAME_DUPLICATED)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.message").value("이미 사용 중인 닉네임입니다.")) - .andDo(document("user/onboarding-nickname-duplicated", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("중복된 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value("이미 사용 중인 닉네임입니다.")) + .andDo(document("user/onboarding-nickname-duplicated", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("중복된 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -196,72 +196,72 @@ void completeOnboarding_nicknameAlreadyExists() throws Exception { void completeOnboarding_success() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "red", - List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") + true, true, true, true, false, + "수집", "red", + List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") ); List categoryDtos = List.of( - new CategoryDto("FOOD_SNACK", "먹거리·간식"), - new CategoryDto("BEAUTY_HEALTH", "뷰티·헬스"), - new CategoryDto("FASHION_ACCESSORY", "패션·악세서리") + new CategoryDto("FOOD_SNACK", "먹거리·간식"), + new CategoryDto("BEAUTY_HEALTH", "뷰티·헬스"), + new CategoryDto("FASHION_ACCESSORY", "패션·악세서리") ); UserAgreementInfo agreementInfo = new UserAgreementInfo(true, true, true, true, false); OnboardingResponse response = new OnboardingResponse( - "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "수집", - "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg", - categoryDtos, - agreementInfo + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "수집", + "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg", + categoryDtos, + agreementInfo ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willReturn(response); + .willReturn(response); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) - .andExpect(jsonPath("$.data.nickname").value("수집")) - .andExpect(jsonPath("$.data.profileImageUrl").exists()) - .andExpect(jsonPath("$.data.categories").isArray()) - .andExpect(jsonPath("$.data.agreements.ageVerified").value(true)) - .andDo(document("user/onboarding-success", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부 (필수)"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (필수)"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의 (필수)"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의 (필수)"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의 (선택)"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임 (최대 11자)"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상 (red, blue, yellow, purple)"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (최소 1개, Category ENUM name)") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("온보딩 완료 응답 데이터"), - fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID)"), - fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), - fieldWithPath("data.categories").type(JsonFieldType.ARRAY).description("선택한 카테고리 목록"), - fieldWithPath("data.categories[].name").type(JsonFieldType.STRING).description("카테고리 ENUM name"), - fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 한글 라벨"), - fieldWithPath("data.agreements").type(JsonFieldType.OBJECT).description("약관 동의 정보"), - fieldWithPath("data.agreements.ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("data.agreements.serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("data.agreements.privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("data.agreements.locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("data.agreements.marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("a1b2c3d4-e5f6-7890-abcd-ef1234567890")) + .andExpect(jsonPath("$.data.nickname").value("수집")) + .andExpect(jsonPath("$.data.profileImageUrl").exists()) + .andExpect(jsonPath("$.data.categories").isArray()) + .andExpect(jsonPath("$.data.agreements.ageVerified").value(true)) + .andDo(document("user/onboarding-success", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부 (필수)"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (필수)"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의 (필수)"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의 (필수)"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의 (선택)"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임 (최대 11자)"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상 (red, blue, yellow, purple)"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (최소 1개, Category ENUM name)") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("온보딩 완료 응답 데이터"), + fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID (UUID)"), + fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("data.categories").type(JsonFieldType.ARRAY).description("선택한 카테고리 목록"), + fieldWithPath("data.categories[].name").type(JsonFieldType.STRING).description("카테고리 ENUM name"), + fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 한글 라벨"), + fieldWithPath("data.agreements").type(JsonFieldType.OBJECT).description("약관 동의 정보"), + fieldWithPath("data.agreements.ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("data.agreements.serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("data.agreements.privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("data.agreements.locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("data.agreements.marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -269,36 +269,36 @@ void completeOnboarding_success() throws Exception { void completeOnboarding_requiredAgreementNotChecked() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, false, true, true, false, - "수집", "red", List.of("FOOD_SNACK") + true, false, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_INPUT, "필수 약관에 모두 동의해야 합니다.")); + .willThrow(new BusinessException(ErrorCode.INVALID_INPUT, "필수 약관에 모두 동의해야 합니다.")); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("필수 약관에 모두 동의해야 합니다.")) - .andDo(document("user/onboarding-required-agreement-not-checked", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (false)"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("필수 약관에 모두 동의해야 합니다.")) + .andDo(document("user/onboarding-required-agreement-not-checked", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의 (false)"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -306,36 +306,36 @@ void completeOnboarding_requiredAgreementNotChecked() throws Exception { void completeOnboarding_alreadyCompleted() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "blue", List.of("FOOD_SNACK", "BEAUTY_HEALTH") + true, true, true, true, false, + "수집", "blue", List.of("FOOD_SNACK", "BEAUTY_HEALTH") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.ONBOARDING_ALREADY_COMPLETED)); + .willThrow(new BusinessException(ErrorCode.ONBOARDING_ALREADY_COMPLETED)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("이미 온보딩을 완료한 사용자입니다.")) - .andDo(document("user/onboarding-already-completed", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("이미 온보딩을 완료한 사용자입니다.")) + .andDo(document("user/onboarding-already-completed", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -343,36 +343,36 @@ void completeOnboarding_alreadyCompleted() throws Exception { void completeOnboarding_invalidCategory() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "yellow", List.of("INVALID_CATEGORY", "BEAUTY_HEALTH") + true, true, true, true, false, + "수집", "yellow", List.of("INVALID_CATEGORY", "BEAUTY_HEALTH") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_CATEGORY)); + .willThrow(new BusinessException(ErrorCode.INVALID_CATEGORY)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("유효하지 않은 카테고리입니다.")) - .andDo(document("user/onboarding-invalid-category", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (유효하지 않은 카테고리 포함)") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 카테고리입니다.")) + .andDo(document("user/onboarding-invalid-category", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록 (유효하지 않은 카테고리 포함)") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -380,36 +380,36 @@ void completeOnboarding_invalidCategory() throws Exception { void completeOnboarding_invalidProfileColor() throws Exception { // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", "invalid_color", List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "invalid_color", List.of("FOOD_SNACK") ); given(userService.completeOnboarding(any(), any(OnboardingRequest.class))) - .willThrow(new BusinessException(ErrorCode.INVALID_PROFILE_IMAGE_COLOR)); + .willThrow(new BusinessException(ErrorCode.INVALID_PROFILE_IMAGE_COLOR)); // when & then mockMvc.perform(post("/api/users/onboarding") - .header("Authorization", "Bearer valid_access_token") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("유효하지 않은 프로필 이미지 색상입니다.")) - .andDo(document("user/onboarding-invalid-profile-color", - getDocumentRequest(), - getDocumentResponse(), - requestFields( - fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), - fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), - fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), - fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), - fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), - fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), - fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("유효하지 않은 프로필 이미지 색상"), - fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") - ), - responseFields(errorResponseFields()) - )); + .header("Authorization", "Bearer valid_access_token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("유효하지 않은 프로필 이미지 색상입니다.")) + .andDo(document("user/onboarding-invalid-profile-color", + getDocumentRequest(), + getDocumentResponse(), + requestFields( + fieldWithPath("ageVerified").type(JsonFieldType.BOOLEAN).description("만 14세 이상 여부"), + fieldWithPath("serviceTerms").type(JsonFieldType.BOOLEAN).description("서비스 이용약관 동의"), + fieldWithPath("privacyRequired").type(JsonFieldType.BOOLEAN).description("개인정보 수집 및 이용 동의"), + fieldWithPath("locationService").type(JsonFieldType.BOOLEAN).description("위치기반 서비스 이용약관 동의"), + fieldWithPath("marketingConsent").type(JsonFieldType.BOOLEAN).description("마케팅 수신 동의"), + fieldWithPath("nickname").type(JsonFieldType.STRING).description("사용자 닉네임"), + fieldWithPath("profileImageColor").type(JsonFieldType.STRING).description("유효하지 않은 프로필 이미지 색상"), + fieldWithPath("categories").type(JsonFieldType.ARRAY).description("관심 카테고리 목록") + ), + responseFields(errorResponseFields()) + )); } @Test @@ -420,17 +420,17 @@ void withdraw_success() throws Exception { // when & then mockMvc.perform(delete("/api/users/me") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("회원탈퇴가 완료되었습니다.")) - .andDo(document("user/withdraw", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("회원탈퇴가 완료되었습니다.")) + .andDo(document("user/withdraw", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -438,151 +438,157 @@ void withdraw_success() throws Exception { void getMyProfile_success() throws Exception { // given UserProfileResponse response = new UserProfileResponse( - "a1b2c3d4...", - "테스트닉네임", - "test@kakao.com", - "https://example.com/profile.jpg" + "a1b2c3d4...", + "테스트닉네임", + "test@kakao.com", + "https://example.com/profile.jpg" ); given(userService.getUserProfile(any())).willReturn(response); // when & then mockMvc.perform(get("/api/users/me") - .header("Authorization", "Bearer valid_access_token")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value("a1b2c3d4...")) - .andDo(document("user/my-profile", - getDocumentRequest(), - getDocumentResponse(), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 프로필 정보"), - fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID"), - fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), - fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), - fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value("a1b2c3d4...")) + .andDo(document("user/my-profile", + getDocumentRequest(), + getDocumentResponse(), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("사용자 프로필 정보"), + fieldWithPath("data.userId").type(JsonFieldType.STRING).description("사용자 ID"), + fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.profileImageUrl").type(JsonFieldType.STRING).description("프로필 이미지 URL"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("내가 등록한 기념품 목록을 조회한다.") void getMySouvenirs_success() throws Exception { - // given List content = List.of( - new MySouvenirResponse( - 1L, - "https://example.com/image1.jpg", - "KR", - LocalDateTime.of(2024, 1, 15, 10, 30), - LocalDateTime.of(2024, 1, 20, 15, 0) - ), - new MySouvenirResponse( - 2L, - "https://example.com/image2.jpg", - "FR", - LocalDateTime.of(2024, 1, 10, 9, 0), - LocalDateTime.of(2024, 1, 10, 9, 0) - ) + new MySouvenirResponse( + 1L, + "https://example.com/image1.jpg", + "KR", + LocalDateTime.of(2024, 1, 15, 10, 30), + LocalDateTime.of(2024, 1, 20, 15, 0), + 5L, + true + ), + new MySouvenirResponse( + 2L, + "https://example.com/image2.jpg", + "FR", + LocalDateTime.of(2024, 1, 10, 9, 0), + LocalDateTime.of(2024, 1, 10, 9, 0), + 0L, + false + ) ); MySouvenirListResponse response = createMockMySouvenirListResponse( - content, 1, 1, 2L, 12, true, true, false, false + content, 1, 1, 2L, 12, true, true, false, false ); given(userService.getMySouvenirs(any(), eq(1), eq(12))).willReturn(response); - // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호 (기본값: 1)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), - fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL"), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 1)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.content[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @DisplayName("기념품 목록 조회 시 페이지 번호를 지정할 수 있다.") void getMySouvenirs_withPage() throws Exception { - // given List content = List.of( - new MySouvenirResponse( - 3L, - null, - "KR", - LocalDateTime.of(2024, 1, 5, 10, 0), - LocalDateTime.of(2024, 1, 5, 10, 0) - ) + new MySouvenirResponse( + 3L, + null, + "KR", + LocalDateTime.of(2024, 1, 5, 10, 0), + LocalDateTime.of(2024, 1, 5, 10, 0), + 0L, + false + ) ); MySouvenirListResponse response = createMockMySouvenirListResponse( - content, 2, 3, 25L, 12, false, false, true, true + content, 2, 3, 25L, 12, false, false, true, true ); given(userService.getMySouvenirs(any(), eq(2), eq(12))).willReturn(response); - // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "2") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs-page-2", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호"), - parameterWithName("size").description("페이지 크기") - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), - fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), - fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), - fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), - fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "2") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs-page-2", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("기념품 목록"), + fieldWithPath("data.content[].id").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("기념품 국가 코드"), + fieldWithPath("data.content[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.content[].updatedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.content[].wishlistCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } @Test @@ -590,39 +596,39 @@ void getMySouvenirs_withPage() throws Exception { void getMySouvenirs_empty() throws Exception { // given MySouvenirListResponse response = createMockMySouvenirListResponse( - List.of(), 1, 0, 0L, 12, true, true, false, false + List.of(), 1, 0, 0L, 12, true, true, false, false ); given(userService.getMySouvenirs(any(), eq(1), eq(12))).willReturn(response); // when & then mockMvc.perform(get("/api/users/me/souvenirs") - .header("Authorization", "Bearer valid_access_token") - .param("page", "1") - .param("size", "12")) - .andDo(print()) - .andExpect(status().isOk()) - .andDo(document("user/my-souvenirs-empty", - getDocumentRequest(), - getDocumentResponse(), - queryParameters( - parameterWithName("page").description("페이지 번호").optional(), - parameterWithName("size").description("페이지 크기").optional() - ), - apiResponseFields( - fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), - fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("빈 기념품 목록"), - fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), - fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), - fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), - fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), - fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), - fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() - ) - )); + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-souvenirs-empty", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("기념품 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("빈 기념품 목록"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 기념품 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); } } diff --git a/src/test/java/com/souzip/domain/user/entity/UserTest.java b/src/test/java/com/souzip/domain/user/entity/UserTest.java index 79d20520..49b74bf1 100644 --- a/src/test/java/com/souzip/domain/user/entity/UserTest.java +++ b/src/test/java/com/souzip/domain/user/entity/UserTest.java @@ -1,9 +1,11 @@ package com.souzip.domain.user.entity; -import java.time.LocalDateTime; +import com.souzip.shared.domain.Provider; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; + import static org.assertj.core.api.Assertions.assertThat; class UserTest { @@ -11,28 +13,20 @@ class UserTest { @Test @DisplayName("anonymize 호출 시 개인정보가 익명화된다.") void anonymize_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); - // when user.anonymize(); - // then - assertThat(user.getName()).isEqualTo("탈퇴한사용자"); assertThat(user.getNickname()).isEqualTo("탈퇴한사용자"); } @Test - @DisplayName("restore 호출 시 name이 복구되고 nickname은 빈값으로 초기화된다.") + @DisplayName("restore 호출 시 nickname은 빈값으로 초기화된다.") void restore_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "탈퇴한사용자", "탈퇴한사용자", "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); - // when user.restore("테스트유저"); - // then - assertThat(user.getName()).isEqualTo("테스트유저"); assertThat(user.getNickname()).isEmpty(); assertThat(user.getDeleted()).isFalse(); assertThat(user.getRestoredAt()).isNotNull(); @@ -44,16 +38,13 @@ void restore_success() { @Test @DisplayName("updateLastLoginAt 호출 시 lastLoginAt이 현재 시간으로 설정된다.") void updateLastLoginAt_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트", "테스트", "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); LocalDateTime before = LocalDateTime.now(); - // when user.updateLastLoginAt(); - // then assertThat(user.getLastLoginAt()).isNotNull(); assertThat(user.getLastLoginAt()).isAfterOrEqualTo(before); assertThat(user.getLastLoginAt()).isBeforeOrEqualTo(LocalDateTime.now()); } -} +} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java b/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java index 00e41ca6..9f11b256 100644 --- a/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/ProfileImageServiceTest.java @@ -1,7 +1,7 @@ package com.souzip.domain.user.service; -import com.souzip.global.exception.BusinessException; -import com.souzip.global.exception.ErrorCode; +import com.souzip.shared.exception.BusinessException; +import com.souzip.shared.exception.ErrorCode; import com.souzip.adapter.config.ObjectStorageProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,11 +21,11 @@ class ProfileImageServiceTest { @BeforeEach void setUp() { properties = new ObjectStorageProperties( - "https://kr.object.ncloudstorage.com", - "kr-standard", - "souzip-dev-images", - "test-access-key", - "test-secret-key" + "https://kr.object.ncloudstorage.com", + "kr-standard", + "souzip-dev-images", + "test-access-key", + "test-secret-key" ); profileImageService = new ProfileImageService(properties); } @@ -49,8 +49,8 @@ void resolveProfileImageUrl_allValidColors(String color) { // then assertThat(url) - .startsWith("https://kr.object.ncloudstorage.com/souzip-dev-images/profile/") - .endsWith(color + ".png"); + .startsWith("https://kr.object.ncloudstorage.com/souzip-dev-images/profile/") + .endsWith(color + ".png"); } @ParameterizedTest @@ -82,9 +82,9 @@ void resolveProfileImageUrl_withNullOrEmpty_returnsDefault(String input) { void resolveProfileImageUrl_withInvalidColor_throwsException(String invalidColor) { // when & then assertThatThrownBy(() -> profileImageService.resolveProfileImageUrl(invalidColor)) - .isInstanceOf(BusinessException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PROFILE_IMAGE_COLOR) - .hasMessageContaining("유효하지 않은 프로필 이미지 색상입니다."); + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.INVALID_PROFILE_IMAGE_COLOR) + .hasMessageContaining("유효하지 않은 프로필 이미지 색상입니다."); } @Test @@ -95,7 +95,7 @@ void resolveProfileImageUrl_urlFormat() { // then assertThat(url) - .matches("https://.*\\.ncloudstorage\\.com/.*/profile/[a-z]+\\.png"); + .matches("https://.*\\.ncloudstorage\\.com/.*/profile/[a-z]+\\.png"); } @Test @@ -106,7 +106,7 @@ void getAvailableColors() { // then assertThat(colors) - .containsExactlyInAnyOrder("red", "blue", "yellow", "purple") - .hasSize(4); + .containsExactlyInAnyOrder("red", "blue", "yellow", "purple") + .hasSize(4); } } diff --git a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java index 3b442968..d0ce4e93 100644 --- a/src/test/java/com/souzip/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/souzip/domain/user/service/UserServiceTest.java @@ -1,19 +1,15 @@ package com.souzip.domain.user.service; -import com.souzip.domain.auth.entity.RefreshToken; -import com.souzip.domain.auth.repository.RefreshTokenRepository; +import com.souzip.auth.application.required.RefreshTokenRepository; import com.souzip.domain.user.dto.NicknameCheckResponse; import com.souzip.domain.user.dto.OnboardingRequest; import com.souzip.domain.user.dto.OnboardingResponse; -import com.souzip.domain.user.entity.Provider; import com.souzip.domain.user.entity.User; import com.souzip.domain.user.entity.UserAgreement; import com.souzip.domain.user.repository.UserAgreementRepository; import com.souzip.domain.user.repository.UserRepository; -import com.souzip.global.exception.BusinessException; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; +import com.souzip.shared.domain.Provider; +import com.souzip.shared.exception.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,15 +17,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; +import java.util.Optional; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class UserServiceTest { @@ -52,60 +47,45 @@ class UserServiceTest { @Test @DisplayName("사용 가능한 닉네임을 확인한다.") void checkNickname_available() { - // given - String nickname = "새로운닉네임"; - given(userRepository.existsByNickname(nickname)).willReturn(false); + given(userRepository.existsByNickname("새로운닉네임")).willReturn(false); - // when - NicknameCheckResponse response = userService.checkNickname(nickname); + NicknameCheckResponse response = userService.checkNickname("새로운닉네임"); - // then assertThat(response.isAvailable()).isTrue(); assertThat(response.getMessage()).isEqualTo("사용 가능한 닉네임입니다."); - verify(userRepository).existsByNickname(nickname); + verify(userRepository).existsByNickname("새로운닉네임"); } @Test @DisplayName("이미 사용 중인 닉네임을 확인한다.") void checkNickname_unavailable() { - // given - String nickname = "중복닉네임"; - given(userRepository.existsByNickname(nickname)).willReturn(true); + given(userRepository.existsByNickname("중복닉네임")).willReturn(true); - // when - NicknameCheckResponse response = userService.checkNickname(nickname); + NicknameCheckResponse response = userService.checkNickname("중복닉네임"); - // then assertThat(response.isAvailable()).isFalse(); assertThat(response.getMessage()).isEqualTo("이미 사용 중인 닉네임입니다."); - verify(userRepository).existsByNickname(nickname); } @Test @DisplayName("온보딩 시 닉네임이 중복되면 에러가 발생한다.") void completeOnboarding_nicknameAlreadyExists() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "중복닉네임", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "중복닉네임", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userRepository.existsByNickname("중복닉네임")).willReturn(true); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 사용 중인 닉네임입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 사용 중인 닉네임입니다."); - verify(userRepository).existsByNickname("중복닉네임"); verify(userAgreementRepository, never()).save(any()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); } @@ -113,28 +93,18 @@ void completeOnboarding_nicknameAlreadyExists() { @Test @DisplayName("온보딩을 완료하면 User와 UserAgreement가 저장된다.") void completeOnboarding_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", "https://kakao.com/profile.jpg"); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, - true, - true, - true, - false, - "수집", - "red", - List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") + true, true, true, true, false, + "수집", "red", + List.of("FOOD_SNACK", "BEAUTY_HEALTH", "FASHION_ACCESSORY") ); String expectedImageUrl = "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg"; - UserAgreement agreement = UserAgreement.of( - spyUser, - true, true, true, true, false - ); + UserAgreement agreement = UserAgreement.of(spyUser, true, true, true, true, false); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); @@ -145,15 +115,12 @@ void completeOnboarding_success() { given(spyUser.getNickname()).willReturn("수집"); given(spyUser.getProfileImageUrl()).willReturn(expectedImageUrl); - // when OnboardingResponse response = userService.completeOnboarding(1L, request); - // then assertThat(response.userId()).isEqualTo("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); assertThat(response.nickname()).isEqualTo("수집"); assertThat(response.profileImageUrl()).isEqualTo(expectedImageUrl); assertThat(response.categories()).hasSize(3); - assertThat(response.agreements()).isNotNull(); assertThat(response.agreements().ageVerified()).isTrue(); assertThat(response.agreements().marketingConsent()).isFalse(); @@ -165,29 +132,20 @@ void completeOnboarding_success() { @Test @DisplayName("필수 약관에 동의하지 않으면 온보딩에 실패한다.") void completeOnboarding_requiredAgreementNotChecked() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, - false, - true, - true, - false, - "수집", - "red", - List.of("FOOD_SNACK") + true, false, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("필수 약관에 모두 동의해야 합니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("필수 약관에 모두 동의해야 합니다."); verify(userAgreementRepository, never()).save(any()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); @@ -196,26 +154,21 @@ void completeOnboarding_requiredAgreementNotChecked() { @Test @DisplayName("이미 약관에 동의한 사용자는 중복 저장되지 않는다.") void completeOnboarding_agreementAlreadyExists() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userAgreementRepository.existsByUser(spyUser)).willReturn(true); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 약관에 동의한 사용자입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 약관에 동의한 사용자입니다."); verify(userAgreementRepository, never()).save(any()); } @@ -223,25 +176,20 @@ void completeOnboarding_agreementAlreadyExists() { @Test @DisplayName("이미 온보딩을 완료한 사용자는 다시 온보딩할 수 없다.") void completeOnboarding_alreadyCompleted() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "수집", "수집", - "test@kakao.com", "https://kr.object.ncloudstorage.com/souzip-dev-images/profile/red.svg"); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "새닉네임", - "blue", - List.of("FOOD_SNACK") + true, true, true, true, false, + "새닉네임", "blue", List.of("FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(false); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("이미 온보딩을 완료한 사용자입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 온보딩을 완료한 사용자입니다."); verify(profileImageService, never()).resolveProfileImageUrl(anyString()); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); @@ -250,26 +198,21 @@ void completeOnboarding_alreadyCompleted() { @Test @DisplayName("유효하지 않은 카테고리로 온보딩 시 에러가 발생한다.") void completeOnboarding_invalidCategory() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "카카오사용자", "카카오사용자", - "test@kakao.com", null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", - "red", - List.of("INVALID_CATEGORY", "FOOD_SNACK") + true, true, true, true, false, + "수집", "red", List.of("INVALID_CATEGORY", "FOOD_SNACK") ); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); given(spyUser.needsOnboarding()).willReturn(true); given(userAgreementRepository.existsByUser(spyUser)).willReturn(false); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(1L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("유효하지 않은 카테고리입니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("유효하지 않은 카테고리입니다."); verify(spyUser, never()).completeOnboarding(anyString(), anyString(), any()); } @@ -277,50 +220,33 @@ void completeOnboarding_invalidCategory() { @Test @DisplayName("존재하지 않는 사용자로 온보딩 시 에러가 발생한다.") void completeOnboarding_userNotFound() { - // given OnboardingRequest request = new OnboardingRequest( - true, true, true, true, false, - "수집", - "red", - List.of("FOOD_SNACK") + true, true, true, true, false, + "수집", "red", List.of("FOOD_SNACK") ); given(userRepository.findById(999L)).willReturn(Optional.empty()); - // when & then assertThatThrownBy(() -> userService.completeOnboarding(999L, request)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); } @Test @DisplayName("회원탈퇴 시 User는 익명화되고 약관 동의도 삭제된다.") void withdraw_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", null, null); + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); - RefreshToken refreshToken = RefreshToken.of( - spyUser, - "refresh_token", - LocalDateTime.now().plusDays(30) - ); - - UserAgreement agreement = UserAgreement.of( - spyUser, - true, true, true, true, false - ); + UserAgreement agreement = UserAgreement.of(spyUser, true, true, true, true, false); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.of(refreshToken)); given(userAgreementRepository.findByUser(spyUser)).willReturn(Optional.of(agreement)); - // when userService.withdraw(1L); - // then verify(spyUser).anonymize(); - verify(refreshTokenRepository).delete(refreshToken); + verify(refreshTokenRepository).deleteByUserId(1L); verify(userAgreementRepository).delete(agreement); verify(userRepository).delete(spyUser); } @@ -328,33 +254,42 @@ void withdraw_success() { @Test @DisplayName("존재하지 않는 사용자로 회원탈퇴 시 에러가 발생한다.") void withdraw_withNotExistUser_throwsException() { - // given given(userRepository.findById(999L)).willReturn(Optional.empty()); - // when & then assertThatThrownBy(() -> userService.withdraw(999L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다."); + .isInstanceOf(BusinessException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다."); } @Test - @DisplayName("Refresh Token과 약관 동의가 없어도 회원탈퇴는 성공한다.") - void withdraw_withoutRefreshTokenAndAgreement_success() { - // given - User user = User.of(Provider.KAKAO, "kakao123", "테스트유저", "테스트", null, null); + @DisplayName("약관 동의가 없어도 회원탈퇴는 성공한다.") + void withdraw_withoutAgreement_success() { + User user = User.of(Provider.KAKAO, "kakao123", "test@kakao.com"); User spyUser = spy(user); given(userRepository.findById(1L)).willReturn(Optional.of(spyUser)); - given(refreshTokenRepository.findByUser(spyUser)).willReturn(Optional.empty()); given(userAgreementRepository.findByUser(spyUser)).willReturn(Optional.empty()); - // when userService.withdraw(1L); - // then verify(spyUser).anonymize(); + verify(refreshTokenRepository).deleteByUserId(1L); verify(userRepository).delete(spyUser); - verify(refreshTokenRepository, never()).delete(any()); verify(userAgreementRepository, never()).delete(any()); } -} + + @Test + @DisplayName("탈퇴된 유저 삭제 쿼리를 호출하고 삭제 결과를 반환한다") + void deleteWithdrawnUsers_callsRepositoryAndReturnsCount() { + // given + given(userRepository.deleteByDeletedTrue()) + .willReturn(3L); + + // when + long result = userService.deleteWithdrawnUsers(); + + // then + verify(userRepository).deleteByDeletedTrue(); + assertThat(result).isEqualTo(3L); + } +} \ No newline at end of file diff --git a/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java b/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java new file mode 100644 index 00000000..afc90a45 --- /dev/null +++ b/src/test/java/com/souzip/domain/wishlist/controller/WishlistControllerTest.java @@ -0,0 +1,315 @@ +package com.souzip.domain.wishlist.controller; + +import com.souzip.auth.adapter.security.annotation.CurrentUserId; +import com.souzip.docs.RestDocsSupport; +import com.souzip.domain.wishlist.dto.MyWishlistListResponse; +import com.souzip.domain.wishlist.dto.MyWishlistResponse; +import com.souzip.domain.wishlist.dto.WishlistResponse; +import com.souzip.domain.wishlist.service.WishlistService; +import com.souzip.shared.common.dto.pagination.PaginationResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.souzip.docs.ApiDocumentUtils.getDocumentRequest; +import static com.souzip.docs.ApiDocumentUtils.getDocumentResponse; +import static com.souzip.docs.CommonDocumentation.apiResponseFields; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class WishlistControllerTest extends RestDocsSupport { + + private final WishlistService wishlistService = mock(WishlistService.class); + + @Override + protected Object initController() { + return new WishlistController(wishlistService); + } + + @BeforeEach + void setUpWithResolver(RestDocumentationContextProvider provider) { + this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setCustomArgumentResolvers( + new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUserId.class) + && parameter.getParameterType().equals(Long.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return 1L; + } + }, + new PageableHandlerMethodArgumentResolver() + ) + .apply(documentationConfiguration(provider)) + .build(); + } + + private MyWishlistListResponse createMockMyWishlistListResponse( + List content, + int currentPage, int totalPages, long totalItems, int pageSize, + boolean first, boolean last, boolean hasNext, boolean hasPrevious + ) { + MyWishlistListResponse response = mock(MyWishlistListResponse.class); + PaginationResponse.PageInfo pageInfo = mock(PaginationResponse.PageInfo.class); + + given(response.content()).willReturn(content); + given(response.pagination()).willReturn(pageInfo); + given(pageInfo.getCurrentPage()).willReturn(currentPage); + given(pageInfo.getTotalPages()).willReturn(totalPages); + given(pageInfo.getTotalItems()).willReturn(totalItems); + given(pageInfo.getPageSize()).willReturn(pageSize); + given(pageInfo.isFirst()).willReturn(first); + given(pageInfo.isLast()).willReturn(last); + given(pageInfo.isHasNext()).willReturn(hasNext); + given(pageInfo.isHasPrevious()).willReturn(hasPrevious); + + return response; + } + + @DisplayName("찜 등록") + @Test + void addWishlist() throws Exception { + Long souvenirId = 1L; + + given(wishlistService.addWishlist(1L, souvenirId)) + .willReturn(WishlistResponse.of(souvenirId, true)); + + mockMvc.perform(post("/api/wishlists/{souvenirId}", souvenirId) + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirId").value(souvenirId)) + .andExpect(jsonPath("$.data.wishlisted").value(true)) + .andDo(document("wishlists/add", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("souvenirId").description("찜할 기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.wishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (true: 찜 등록됨)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @DisplayName("찜 취소") + @Test + void removeWishlist() throws Exception { + Long souvenirId = 1L; + + given(wishlistService.removeWishlist(1L, souvenirId)) + .willReturn(WishlistResponse.of(souvenirId, false)); + + mockMvc.perform(delete("/api/wishlists/{souvenirId}", souvenirId) + .header("Authorization", "Bearer valid_access_token")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.souvenirId").value(souvenirId)) + .andExpect(jsonPath("$.data.wishlisted").value(false)) + .andDo(document("wishlists/remove", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("souvenirId").description("찜 취소할 기념품 ID") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("응답 데이터"), + fieldWithPath("data.souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.wishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부 (false: 찜 취소됨)"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("내가 찜한 기념품 목록을 조회한다.") + void getMyWishlists_success() throws Exception { + List content = List.of( + new MyWishlistResponse( + 1L, + "글로시에 립밤", + "US", + "https://example.com/image1.jpg", + LocalDateTime.of(2024, 1, 15, 10, 30), + true + ), + new MyWishlistResponse( + 2L, + "말차초콜릿", + "JP", + "https://example.com/image2.jpg", + LocalDateTime.of(2024, 1, 10, 9, 0), + true + ) + ); + + MyWishlistListResponse response = createMockMyWishlistListResponse( + content, 1, 1, 2L, 12, true, true, false, false + ); + + given(wishlistService.getMyWishlist(any(), any())).willReturn(response); + + mockMvc.perform(get("/api/users/me/wishlists") + .header("Authorization", "Bearer valid_access_token") + .param("page", "0") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-wishlists", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 12)").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("찜한 기념품 목록"), + fieldWithPath("data.content[].souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.STRING).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].wishedAt").type(JsonFieldType.STRING).description("찜한 시간"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 찜 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("찜한 기념품 목록 조회 시 페이지 번호를 지정할 수 있다.") + void getMyWishlists_withPage() throws Exception { + List content = List.of( + new MyWishlistResponse( + 3L, + "전통 부채", + "KR", + null, + LocalDateTime.of(2024, 1, 5, 10, 0), + true + ) + ); + + MyWishlistListResponse response = createMockMyWishlistListResponse( + content, 2, 3, 25L, 12, false, false, true, true + ); + + given(wishlistService.getMyWishlist(any(), any())).willReturn(response); + + mockMvc.perform(get("/api/users/me/wishlists") + .header("Authorization", "Bearer valid_access_token") + .param("page", "1") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-wishlists-page-2", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호"), + parameterWithName("size").description("페이지 크기") + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("찜한 기념품 목록"), + fieldWithPath("data.content[].souvenirId").type(JsonFieldType.NUMBER).description("기념품 ID"), + fieldWithPath("data.content[].name").type(JsonFieldType.STRING).description("기념품명"), + fieldWithPath("data.content[].countryCode").type(JsonFieldType.STRING).description("국가 코드"), + fieldWithPath("data.content[].thumbnailUrl").type(JsonFieldType.NULL).description("썸네일 이미지 URL").optional(), + fieldWithPath("data.content[].wishedAt").type(JsonFieldType.STRING).description("찜한 시간"), + fieldWithPath("data.content[].isWishlisted").type(JsonFieldType.BOOLEAN).description("찜 여부"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 찜 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } + + @Test + @DisplayName("찜한 기념품이 없으면 빈 목록을 반환한다.") + void getMyWishlists_empty() throws Exception { + MyWishlistListResponse response = createMockMyWishlistListResponse( + List.of(), 1, 0, 0L, 12, true, true, false, false + ); + + given(wishlistService.getMyWishlist(any(), any())).willReturn(response); + + mockMvc.perform(get("/api/users/me/wishlists") + .header("Authorization", "Bearer valid_access_token") + .param("page", "0") + .param("size", "12")) + .andDo(print()) + .andExpect(status().isOk()) + .andDo(document("user/my-wishlists-empty", + getDocumentRequest(), + getDocumentResponse(), + queryParameters( + parameterWithName("page").description("페이지 번호").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + apiResponseFields( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("찜 목록 응답"), + fieldWithPath("data.content").type(JsonFieldType.ARRAY).description("빈 찜 목록"), + fieldWithPath("data.pagination").type(JsonFieldType.OBJECT).description("페이지네이션 정보"), + fieldWithPath("data.pagination.currentPage").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("data.pagination.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("data.pagination.totalItems").type(JsonFieldType.NUMBER).description("전체 찜 개수"), + fieldWithPath("data.pagination.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("data.pagination.first").type(JsonFieldType.BOOLEAN).description("첫 번째 페이지 여부"), + fieldWithPath("data.pagination.last").type(JsonFieldType.BOOLEAN).description("마지막 페이지 여부"), + fieldWithPath("data.pagination.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("data.pagination.hasPrevious").type(JsonFieldType.BOOLEAN).description("이전 페이지 존재 여부"), + fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지").optional() + ) + )); + } +} diff --git a/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java b/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java new file mode 100644 index 00000000..41090b8d --- /dev/null +++ b/src/test/java/com/souzip/domain/wishlist/entity/WishlistTest.java @@ -0,0 +1,27 @@ +package com.souzip.domain.wishlist.entity; + +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class WishlistTest { + + @Test + @DisplayName("of 호출 시 user와 souvenir가 올바르게 설정된다.") + void of_success() { + // given + User user = mock(User.class); + Souvenir souvenir = mock(Souvenir.class); + + // when + Wishlist wishlist = Wishlist.of(user, souvenir); + + // then + assertThat(wishlist.getUser()).isEqualTo(user); + assertThat(wishlist.getSouvenir()).isEqualTo(souvenir); + } +} diff --git a/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java b/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java new file mode 100644 index 00000000..c5544bf6 --- /dev/null +++ b/src/test/java/com/souzip/domain/wishlist/service/WishlistServiceTest.java @@ -0,0 +1,148 @@ +package com.souzip.domain.wishlist.service; + +import com.souzip.domain.souvenir.entity.Souvenir; +import com.souzip.domain.souvenir.repository.SouvenirRepository; +import com.souzip.domain.user.entity.User; +import com.souzip.domain.user.repository.UserRepository; +import com.souzip.domain.wishlist.dto.WishlistResponse; +import com.souzip.domain.wishlist.entity.Wishlist; +import com.souzip.domain.wishlist.repository.WishlistRepository; +import com.souzip.shared.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class WishlistServiceTest { + + @Mock + private WishlistRepository wishlistRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private SouvenirRepository souvenirRepository; + + @InjectMocks + private WishlistService wishlistService; + + @Test + @DisplayName("찜 등록에 성공한다.") + void addWishlist_success() { + // given + Long userId = 1L; + Long souvenirId = 1L; + + User user = mock(User.class); + Souvenir souvenir = mock(Souvenir.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.of(souvenir)); + given(wishlistRepository.save(any(Wishlist.class))).willReturn(any()); + + // when + WishlistResponse response = wishlistService.addWishlist(userId, souvenirId); + + // then + assertThat(response.souvenirId()).isEqualTo(souvenirId); + assertThat(response.wishlisted()).isTrue(); + verify(wishlistRepository).save(any(Wishlist.class)); + } + + @Test + @DisplayName("이미 찜한 기념품을 다시 찜하면 에러가 발생한다.") + void addWishlist_alreadyWishlisted() { + // given + Long userId = 1L; + Long souvenirId = 1L; + + User user = mock(User.class); + Souvenir souvenir = mock(Souvenir.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.of(souvenir)); + given(wishlistRepository.save(any(Wishlist.class))).willThrow(DataIntegrityViolationException.class); + + // when & then + assertThatThrownBy(() -> wishlistService.addWishlist(userId, souvenirId)) + .isInstanceOf(BusinessException.class); + + verify(wishlistRepository).save(any(Wishlist.class)); + } + + @Test + @DisplayName("존재하지 않는 기념품을 찜하면 에러가 발생한다.") + void addWishlist_souvenirNotFound() { + // given + Long userId = 1L; + Long souvenirId = 999L; + + User user = mock(User.class); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(souvenirRepository.findByIdAndDeletedFalse(souvenirId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> wishlistService.addWishlist(userId, souvenirId)) + .isInstanceOf(BusinessException.class); + + verify(wishlistRepository, never()).save(any()); + } + + @Test + @DisplayName("찜 취소에 성공한다.") + void removeWishlist_success() { + // given + Long userId = 1L; + Long souvenirId = 1L; + String userUuid = "1ffc89fd-bb85-489e-9218-63c4f9680b27"; + + User user = mock(User.class); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(user.getUserId()).willReturn(userUuid); + given(wishlistRepository.existsByUserUserIdAndSouvenirId(userUuid, souvenirId)).willReturn(true); + + // when + WishlistResponse response = wishlistService.removeWishlist(userId, souvenirId); + + // then + assertThat(response.souvenirId()).isEqualTo(souvenirId); + assertThat(response.wishlisted()).isFalse(); + verify(wishlistRepository).deleteByUserIdAndSouvenirId(userId, souvenirId); + } + + @Test + @DisplayName("찜하지 않은 기념품을 취소하면 에러가 발생한다.") + void removeWishlist_notFound() { + // given + Long userId = 1L; + Long souvenirId = 1L; + String userUuid = "1ffc89fd-bb85-489e-9218-63c4f9680b27"; + + User user = mock(User.class); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(user.getUserId()).willReturn(userUuid); + given(wishlistRepository.existsByUserUserIdAndSouvenirId(userUuid, souvenirId)).willReturn(false); + + // when & then + assertThatThrownBy(() -> wishlistService.removeWishlist(userId, souvenirId)) + .isInstanceOf(BusinessException.class); + + verify(wishlistRepository, never()).deleteByUserIdAndSouvenirId(any(), any()); + } +} diff --git a/src/test/java/com/souzip/global/aop/MdcTraceIdTest.java b/src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java similarity index 98% rename from src/test/java/com/souzip/global/aop/MdcTraceIdTest.java rename to src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java index 5f0d6273..d082026b 100644 --- a/src/test/java/com/souzip/global/aop/MdcTraceIdTest.java +++ b/src/test/java/com/souzip/shared/aop/MdcTraceIdTest.java @@ -1,4 +1,4 @@ -package com.souzip.global.aop; +package com.souzip.shared.aop; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java similarity index 97% rename from src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java rename to src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java index 39255358..750f6ef7 100644 --- a/src/test/java/com/souzip/global/common/dto/pagination/PaginationRequestTest.java +++ b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationRequestTest.java @@ -1,4 +1,4 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java similarity index 82% rename from src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java rename to src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java index 2271c4b5..0dd702eb 100644 --- a/src/test/java/com/souzip/global/common/dto/pagination/PaginationResponseTest.java +++ b/src/test/java/com/souzip/shared/common/dto/pagination/PaginationResponseTest.java @@ -1,6 +1,7 @@ -package com.souzip.global.common.dto.pagination; +package com.souzip.shared.common.dto.pagination; import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -15,21 +16,21 @@ class PaginationResponseTest { @DisplayName("페이지 정보 확인") @CsvSource({ - "0, 10, 23, 1, 3, true, false, true, false", - "1, 10, 30, 2, 3, false, false, true, true", - "2, 10, 23, 3, 3, false, true, false, true" + "0, 10, 23, 1, 3, true, false, true, false", + "1, 10, 30, 2, 3, false, false, true, true", + "2, 10, 23, 3, 3, false, true, false, true" }) @ParameterizedTest void pageInfo( - int pageNumber, - int pageSize, - long totalElements, - int expectedCurrentPage, - int expectedTotalPages, - boolean expectedFirst, - boolean expectedLast, - boolean expectedHasNext, - boolean expectedHasPrevious + int pageNumber, + int pageSize, + long totalElements, + int expectedCurrentPage, + int expectedTotalPages, + boolean expectedFirst, + boolean expectedLast, + boolean expectedHasNext, + boolean expectedHasPrevious ) { // given List content = List.of("item1", "item2", "item3"); diff --git a/src/test/java/com/souzip/global/exception/BusinessExceptionTest.java b/src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java similarity index 96% rename from src/test/java/com/souzip/global/exception/BusinessExceptionTest.java rename to src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java index bb274d21..f486b543 100644 --- a/src/test/java/com/souzip/global/exception/BusinessExceptionTest.java +++ b/src/test/java/com/souzip/shared/exception/BusinessExceptionTest.java @@ -1,4 +1,4 @@ -package com.souzip.global.exception; +package com.souzip.shared.exception; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From 56c3c4a3ba1c7d79dc89c6dee77c3b36d42c80fa Mon Sep 17 00:00:00 2001 From: Jin Beom Kim <119301068+bum0w0@users.noreply.github.com> Date: Wed, 13 May 2026 23:43:58 +0900 Subject: [PATCH 08/10] =?UTF-8?q?DB=20=EC=9E=90=EB=8F=99=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20(#260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [SOU-593] 기념품 조회 응답에 찜 수/여부 추가 (#250) * feat: 찜 수 집계 쿼리 추가 * feat: 찜 수 및 찜 ID 목록 조회 쿼리 추가 * feat: 기념품 상세 조회에 찜 수/여부 추가 * feat: 근처 기념품 목록 조회에 찜 수/여부 추가 * feat: 내 기념품 목록 조회에 찜 수/여부 추가 * feat: 일반 추천 기념품 조회에 찜 수/여부 추가 * feat: AI 추천 기념품 조회에 찜 수/여부 추가 * test: 찜 수/여부 추가에 따른 테스트 수정 * [SOU-571] 찜 기능 테스트 코드 및 API 문서 작성 (#251) * feat: 찜 기능 테스트 코드 및 API 문서 작성 * feat: 찜 목록 조회 응답에 isWishlisted 필드 추가 * [SOU-601] 탈퇴 유저 삭제 배치 스케줄러 구현 (#252) * feat: 탈퇴 유저 삭제 배치 스케줄러 추가 * feat: 탈퇴 유저 삭제 로직 반환값 추가 및 테스트 수정 * chore: 불필요한 import 구문 제거 * fix: userId UUID 타입 불일치로 인한 NumberFormatException 수정 (#253) * feat: 탈퇴 유저 배치 삭제로 정책 변경 및 테스트 수정 (#254) * feat: 정보 동의한 회원들에게만 알림을 발송하도록 수정 (#255) * refactor: auth 바운디드 컨텍스트 수정 * fix: 깃 충돌 수정 * refactor: 정책에 따라 테스트 코드 수정 * [SOU-622] Docker DB 전환 및 자동 백업 파이프라인 구축 * feat: db 백업 자동화 스크립트 추가 (#259) --------- Co-authored-by: johoon030 <142702766+hoonjo123@users.noreply.github.com> Co-authored-by: Develop-KIM Co-authored-by: monkey <105265603+Develop-KIM@users.noreply.github.com> --- deploy/shared/db-backup.sh | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 deploy/shared/db-backup.sh diff --git a/deploy/shared/db-backup.sh b/deploy/shared/db-backup.sh new file mode 100644 index 00000000..0ef6bc03 --- /dev/null +++ b/deploy/shared/db-backup.sh @@ -0,0 +1,65 @@ +#!/bin/bash +DATE=$(date +%Y-%m-%d_%H%M) +BACKUP_DIR="/home/souzip-prod/backups" +BACKUP_FILE="$BACKUP_DIR/souzip-$DATE.dump" +BUCKET="souzip-db-backup" +RETENTION_DAYS=30 + +if [ -f "/home/souzip-prod/souzip/deploy/prod/.env" ]; then + export PROD_POSTGRES_USER=$(grep '^PROD_POSTGRES_USER=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-) + export PROD_POSTGRES_PASSWORD=$(grep '^PROD_POSTGRES_PASSWORD=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-) + export PROD_DB_DISCORD_WEBHOOK_URL=$(grep '^PROD_DB_DISCORD_WEBHOOK_URL=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-) + export NCP_ACCESS_KEY=$(grep '^NCP_ACCESS_KEY=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-) + export NCP_SECRET_KEY=$(grep '^NCP_SECRET_KEY=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-) +fi + +S3CMD_OPTS="--access_key=$NCP_ACCESS_KEY --secret_key=$NCP_SECRET_KEY --host=kr.object.ncloudstorage.com --host-bucket=%(bucket)s.kr.object.ncloudstorage.com" + +mkdir -p $BACKUP_DIR + +docker exec -e PGPASSWORD=$PROD_POSTGRES_PASSWORD souzip-prod-db \ + pg_dump -U $PROD_POSTGRES_USER -d souzip_prod -F c > $BACKUP_FILE + +if [ $? -ne 0 ]; then + echo "[ERROR] DB 백업 실패 - $DATE" + curl -s -H "Content-Type: application/json" \ + -X POST \ + -d "{\"username\": \"Souzip Bot\", \"content\": \"@here DB 백업 실패 - $DATE\"}" \ + "$PROD_DB_DISCORD_WEBHOOK_URL" > /dev/null + exit 1 +fi +echo "[INFO] DB 백업 완료 - $BACKUP_FILE" + +s3cmd $S3CMD_OPTS put $BACKUP_FILE s3://$BUCKET/souzip-$DATE.dump + +if [ $? -ne 0 ]; then + echo "[ERROR] Object Storage 업로드 실패 - $DATE" + curl -s -H "Content-Type: application/json" \ + -X POST \ + -d "{\"username\": \"Souzip Bot\", \"content\": \"@here DB 백업 업로드 실패 - $DATE\"}" \ + "$PROD_DB_DISCORD_WEBHOOK_URL" > /dev/null + exit 1 +fi +echo "[INFO] Object Storage 업로드 완료" + +# 로컬 오래된 백업 삭제 +find $BACKUP_DIR -name "souzip-*.dump" -mtime +$RETENTION_DAYS -delete + +# Object Storage 오래된 백업 삭제 +s3cmd $S3CMD_OPTS ls s3://$BUCKET/ | awk '{print $4}' | while read file; do + file_date=$(echo $file | grep -oP '\d{4}-\d{2}-\d{2}') + if [ ! -z "$file_date" ]; then + days_old=$(( ( $(date +%s) - $(date -d "$file_date" +%s) ) / 86400 )) + if [ $days_old -gt $RETENTION_DAYS ]; then + s3cmd $S3CMD_OPTS del $file + echo "[INFO] 오래된 백업 삭제 - $file" + fi + fi +done + +echo "[INFO] DB 백업 프로세스 완료 - $DATE" + +curl -s -H "Content-Type: application/json" \ + -X POST \ + -d "{\"username\": \"Souzip Bot\", \"embeds\": [{\"title\": \"DB 백업 완료\", \"color\": 3066993, \"fields\": [{\"name\": \"날짜\", \"value\": \"$DATE\", \"inline\": true}, {\"name\": \"파일\", \"value\": \"souzip-$DATE.dump\", \"inline\": true}]}]}" \ + "$PROD_DB_DISCORD_WEBHOOK_URL" > /dev/null \ No newline at end of file From 42833f1869ee431a30142cd4aadabc96172eac91 Mon Sep 17 00:00:00 2001 From: bum0w0 Date: Wed, 13 May 2026 23:53:53 +0900 Subject: [PATCH 09/10] =?UTF-8?q?chore:=20db=20=EB=B0=B1=EC=97=85=20?= =?UTF-8?q?=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/prod-cicd.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/prod-cicd.yaml b/.github/workflows/prod-cicd.yaml index 442faca3..0dda8930 100644 --- a/.github/workflows/prod-cicd.yaml +++ b/.github/workflows/prod-cicd.yaml @@ -122,6 +122,7 @@ jobs: CLOVA_STUDIO_API_URL=${{ secrets.CLOVA_STUDIO_API_URL }} DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} PROD_DISCORD_WEBHOOK_URL=${{ secrets.PROD_DISCORD_WEBHOOK_URL }} + PROD_DB_DISCORD_WEBHOOK_URL=${{ secrets.PROD_DB_DISCORD_WEBHOOK_URL }} PROD_API_DOCS_URL=${{ secrets.PROD_API_DOCS_URL }} ADMIN_INITIAL_USERNAME=${{ secrets.ADMIN_INITIAL_USERNAME }} ADMIN_INITIAL_PASSWORD=${{ secrets.ADMIN_INITIAL_PASSWORD }} From 5628c92166ff0b1975e3c0bd9efea19ac84ddde8 Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Fri, 22 May 2026 18:27:01 +0900 Subject: [PATCH 10/10] fix: remove stale global AppConfig after rollback --- .../com/souzip/global/config/AppConfig.java | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/main/java/com/souzip/global/config/AppConfig.java diff --git a/src/main/java/com/souzip/global/config/AppConfig.java b/src/main/java/com/souzip/global/config/AppConfig.java deleted file mode 100644 index 232db199..00000000 --- a/src/main/java/com/souzip/global/config/AppConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.souzip.global.config; - -import com.souzip.global.oauth.OAuthProperties; -import com.souzip.global.security.jwt.JwtProperties; -import com.souzip.adapter.config.ObjectStorageProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties({ - JwtProperties.class, - OAuthProperties.class, - ObjectStorageProperties.class, - ClovaStudioProperties.class, - AppleMigrationConfig.class, - CorsProperties.class -}) -public class AppConfig { -}