From ab3266f14850327f9d8ae3c79feb005bb9868747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 12:46:44 +0900 Subject: [PATCH 1/4] Revert "Merge pull request #308 from DevKor-github/develop" This reverts commit 784af1a7953d07fa8de2e02d12e13a8a3f71544e, reversing changes made to ea371d64e8d16cbd28a066e61fb2541f9472c83d. --- .github/workflows/cd-dev.yml | 5 +- .github/workflows/cd.yml | 4 + .gitignore | 5 - .../ble/event/PlaceBecameVacantEvent.java | 10 - .../ble/repository/BLEDataRepository.java | 1 - .../domain/ble/service/BLEService.java | 28 -- .../repository/CategoryRepository.java | 11 - .../controller/AdminStoreController.java | 124 ------ .../character/controller/StoreController.java | 129 ------ .../dto/request/CreateCharacterReq.java | 35 -- .../dto/request/ModifyCharacterReq.java | 35 -- .../dto/response/CreateCharacterRes.java | 15 - .../dto/response/DeleteCharacterRes.java | 7 - .../dto/response/EquipCharacterRes.java | 15 - .../response/GetAdminCharacterListRes.java | 16 - .../dto/response/GetAdminCharacterRes.java | 44 --- .../dto/response/GetMyCharacterListRes.java | 22 -- .../dto/response/GetMyCharacterRes.java | 32 -- .../dto/response/GetStoreCharacterRes.java | 46 --- .../character/dto/response/GetStoreRes.java | 19 - .../dto/response/GrantCharacterRes.java | 15 - .../dto/response/ModifyCharacterRes.java | 7 - .../dto/response/PurchaseCharacterRes.java | 35 -- .../dto/response/UnequipCharacterRes.java | 7 - .../domain/character/entity/KoCharacter.java | 79 ---- .../character/entity/PurchaseStatus.java | 15 - .../character/entity/UserCharacter.java | 40 -- .../event/CharacterUnlockedEvent.java | 9 - .../repository/CharacterRepository.java | 16 - .../repository/UserCharacterRepository.java | 19 - .../character/service/AdminStoreService.java | 155 -------- .../service/CharacterDataSeeder.java | 96 ----- .../character/service/StoreService.java | 212 ---------- .../notification/client/ExpoPushApi.java | 25 -- .../notification/client/ExpoPushClient.java | 165 -------- .../client/ExpoPushClientException.java | 31 -- .../config/ExpoPushFeignConfig.java | 37 -- .../config/ExpoPushProperties.java | 28 -- .../config/ExpoPushPropertiesConfig.java | 14 - .../config/PushReceiptWorkerProperties.java | 22 -- .../config/PushRecoveryWorkerProperties.java | 24 -- .../config/PushWorkerProperties.java | 26 -- .../AdminNotificationController.java | 160 -------- .../AdminSurveyPushScheduleController.java | 44 --- .../NotificationTestController.java | 71 ---- .../PushInstallationController.java | 126 ------ .../SurveyNotificationController.java | 38 -- .../dto/expo/ExpoPushErrorDetails.java | 9 - .../dto/expo/ExpoPushReceipt.java | 11 - .../dto/expo/ExpoPushRequest.java | 13 - .../dto/expo/ExpoPushResponse.java | 10 - .../notification/dto/expo/ExpoPushTicket.java | 12 - .../dto/expo/ExpoReceiptRequest.java | 8 - .../dto/expo/ExpoReceiptResponse.java | 10 - .../notification/dto/payload/PushPayload.java | 24 -- .../dto/request/AdminPushDispatchReq.java | 20 - .../request/AdminSurveyPushScheduleReq.java | 10 - .../dto/request/NotificationTestReq.java | 19 - .../dto/request/PushDispatchCommand.java | 23 -- .../request/PushInstallationRegisterReq.java | 33 -- .../dto/request/UpdatePushEventFlagReq.java | 6 - .../response/AdminPushDispatchDetailRes.java | 58 --- .../response/AdminPushDispatchPreviewRes.java | 9 - .../response/AdminPushDispatchSummaryRes.java | 47 --- .../dto/response/AdminPushEventFlagRes.java | 9 - .../response/AdminPushInstallationRes.java | 32 -- .../response/AdminSurveyPushScheduleRes.java | 10 - .../AdminSurveyPushScheduleStageRes.java | 21 - .../dto/response/NotificationTestRes.java | 13 - .../dto/response/PushDispatchEnqueueRes.java | 21 - .../dto/response/SurveyReminderRes.java | 12 - .../dto/worker/PushReceiptItem.java | 8 - .../dto/worker/PushReceiptWorkerResult.java | 11 - .../notification/dto/worker/PushSendItem.java | 10 - .../dto/worker/PushWorkerDispatchResult.java | 11 - .../notification/entity/PushDispatch.java | 156 -------- .../notification/entity/PushInstallation.java | 94 ----- .../notification/entity/PushMessage.java | 290 -------------- .../entity/SurveyPushSchedule.java | 122 ------ .../notification/entity/type/AppVariant.java | 29 -- .../entity/type/NotificationType.java | 5 - .../entity/type/PushActionType.java | 12 - .../entity/type/PushDispatchStatus.java | 9 - .../entity/type/PushEventType.java | 18 - .../entity/type/PushMessageStatus.java | 10 - .../notification/entity/type/PushMode.java | 6 - .../entity/type/PushTargetType.java | 8 - .../entity/type/SurveyNotificationStage.java | 8 - .../entity/type/SurveyPushScheduleStatus.java | 8 - .../type/SurveyReminderSuppressedBy.java | 10 - .../factory/PushPayloadFactory.java | 123 ------ .../CharacterUnlockedPushEventListener.java | 91 ----- .../CrowdVacantPushEventListener.java | 128 ------ .../ReportResolvedPushEventListener.java | 91 ----- .../repository/PushDispatchRepository.java | 31 -- .../PushInstallationRepository.java | 61 --- .../repository/PushMessageRepository.java | 98 ----- .../SurveyPushScheduleRepository.java | 51 --- .../resolver/PushTargetResolver.java | 124 ------ .../PushMessageRecoveryScheduler.java | 20 - .../scheduler/PushMessageWorkerScheduler.java | 20 - .../scheduler/PushReceiptWorkerScheduler.java | 20 - .../service/AdminNotificationService.java | 206 ---------- .../service/NotificationTestService.java | 205 ---------- .../service/PushDispatchService.java | 125 ------ .../service/PushEventFlagService.java | 88 ----- .../service/PushInstallationService.java | 120 ------ .../service/PushMessageClaimService.java | 299 -------------- .../service/PushMessageDispatchWorker.java | 47 --- .../service/PushMessageRecoveryService.java | 112 ------ .../service/PushMessageRecoveryWorker.java | 21 - .../service/PushReceiptClaimService.java | 281 ------------- .../service/PushReceiptWorker.java | 47 --- .../service/SurveyPushScheduleService.java | 280 ------------- .../service/SurveyPushScheduleWorker.java | 203 ---------- .../template/DomainPushContentFactory.java | 95 ----- .../notification/template/PushContent.java | 7 - .../validation/PushActionValidator.java | 110 ------ .../domain/place/entity/PlaceType.java | 3 +- .../report/event/ReportResolvedEvent.java | 10 - .../domain/report/service/ReportService.java | 26 -- .../dto/response/SearchPlaceReviewRes.java | 14 +- .../domain/review/service/ReviewService.java | 24 +- .../suggestion/entity/SuggestionType.java | 3 +- .../controller/UsageSurveyController.java | 52 --- .../RecordUsageSurveyDismissalReq.java | 10 - .../request/SubmitUsageSurveyResponseReq.java | 9 - .../SubmitUsageSurveyResponseRes.java | 30 -- .../dto/response/UsageSurveyStatusRes.java | 10 - .../entity/UsageSurveyDismissReason.java | 6 - .../entity/UsageSurveyDismissal.java | 49 --- .../entity/UsageSurveyQuestion.java | 8 - .../entity/UsageSurveyResponse.java | 55 --- .../UsageSurveyDismissalRepository.java | 7 - .../UsageSurveyResponseRepository.java | 12 - .../service/UsageSurveyService.java | 137 ------- .../user/controller/UserController.java | 11 - .../user/dto/request/AdminLoginReq.java | 15 - .../user/dto/response/AdminLoginRes.java | 24 -- .../user/dto/response/GetUserInfoRes.java | 3 - .../user/dto/response/LoginUserRes.java | 1 - .../teamcback/domain/user/entity/Level.java | 30 +- .../teamcback/domain/user/entity/User.java | 34 -- .../user/repository/UserRepository.java | 37 -- .../scheduler/UserLevelBackfillRunner.java | 45 --- .../domain/user/service/UserService.java | 58 +-- .../domain/user/validator/KakaoValidator.java | 28 +- .../global/aop/UpdateScoreAspect.java | 24 +- .../teamcback/global/config/TimeConfig.java | 16 - .../global/jwt/JwtAuthorizationFilter.java | 1 + .../global/response/CommonResponse.java | 2 +- .../teamcback/global/response/ResultCode.java | 27 +- .../global/security/SecurityConfig.java | 30 +- .../com/teamcback/infra/s3/FilePath.java | 3 +- src/main/resources/application.yml | 37 -- .../domain/ble/service/BLEServiceTest.java | 144 ------- .../repository/CharacterRepositoryTest.java | 65 ---- .../UserCharacterRepositoryTest.java | 86 ---- .../service/AdminStoreServiceTest.java | 209 ---------- .../service/CharacterDataSeederTest.java | 92 ----- .../character/service/StoreServiceTest.java | 315 --------------- ...shNotificationPipelineIntegrationTest.java | 344 ---------------- .../AdminNotificationControllerTest.java | 66 ---- .../factory/PushPayloadFactoryTest.java | 36 -- ...haracterUnlockedPushEventListenerTest.java | 123 ------ .../CrowdVacantPushEventListenerTest.java | 152 -------- ...DomainPushEventListenerAnnotationTest.java | 39 -- .../ReportResolvedPushEventListenerTest.java | 97 ----- .../PushMessageRepositoryQueryTest.java | 42 -- .../resolver/PushTargetResolverTest.java | 78 ---- .../PushMessageRecoverySchedulerTest.java | 36 -- .../service/AdminNotificationServiceTest.java | 368 ------------------ .../service/NotificationTestServiceTest.java | 193 --------- .../service/PushEventFlagServiceTest.java | 96 ----- .../service/PushMessageClaimServiceTest.java | 200 ---------- .../PushMessageRecoveryServiceTest.java | 229 ----------- .../service/PushReceiptClaimServiceTest.java | 305 --------------- .../SurveyPushScheduleServiceTest.java | 322 --------------- .../service/SurveyPushScheduleWorkerTest.java | 276 ------------- .../DomainPushContentFactoryTest.java | 78 ---- .../validation/PushActionValidatorTest.java | 38 -- .../report/service/ReportServiceTest.java | 118 ------ .../service/UsageSurveyServiceTest.java | 142 ------- .../domain/user/entity/LevelTest.java | 63 --- .../UserRepositoryBackfillTest.java | 91 ----- .../service/UserServiceAdminLoginTest.java | 118 ------ .../service/UserServiceDeleteUserTest.java | 80 ---- .../service/UserServiceGetUserInfoTest.java | 80 ---- .../service/UserServiceReleaseLoginTest.java | 79 ---- .../user/validator/KakaoValidatorTest.java | 65 ---- .../global/aop/UpdateScoreAspectTest.java | 142 +++++++ .../global/aop/UpdateScoreLevelTest.java | 68 ---- src/test/resources/application-test.yml | 101 ----- src/test/resources/logback-spring.xml | 13 - src/test/resources/logback-test.xml | 12 - 195 files changed, 210 insertions(+), 12370 deletions(-) delete mode 100644 src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java delete mode 100644 src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java delete mode 100644 src/main/java/devkor/com/teamcback/global/config/TimeConfig.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java delete mode 100644 src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java create mode 100644 src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java delete mode 100644 src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java delete mode 100644 src/test/resources/application-test.yml delete mode 100644 src/test/resources/logback-spring.xml delete mode 100644 src/test/resources/logback-test.xml diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 3312f967..dc0ba501 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,7 +1,6 @@ name: CI/CD using github actions & docker on: - workflow_dispatch: push: branches: - develop @@ -54,6 +53,10 @@ jobs: needs: push_to_registry runs-on: ubuntu-latest steps: + - name: Get Github action IP + id: ip + uses: haythem/public-ip@v1.2 + - name: Deploy to prod if: contains(github.ref, 'develop') || contains(github.ref, 'main') uses: appleboy/ssh-action@master diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index cf73b241..199d0ce8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -56,6 +56,10 @@ jobs: needs: push_to_registry runs-on: ubuntu-latest steps: + - name: Get Github action IP + id: ip + uses: haythem/public-ip@v1.2 + - name: Deploy to prod if: contains(github.ref, 'develop') || contains(github.ref, 'main') uses: appleboy/ssh-action@master diff --git a/.gitignore b/.gitignore index 4055adb4..9b084d21 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,3 @@ out/ ###claude### .claude .serena - -# Local-only config -/src/main/resources/application-local.yml -/.env.local -/docker-compose.local.yml \ No newline at end of file diff --git a/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java deleted file mode 100644 index ae3ca230..00000000 --- a/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.ble.event; - -import java.time.LocalDateTime; - -public record PlaceBecameVacantEvent( - Long placeId, - Long bleDataId, - LocalDateTime occurredAt -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java index 3ca5fb51..5e3c6854 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java @@ -11,6 +11,5 @@ public interface BLEDataRepository extends JpaRepository { Optional findTopByDeviceOrderByLastTimeDesc(BLEDevice device); - Optional findTopByDeviceOrderByLastTimeDescIdDesc(BLEDevice device); List findAllByDeviceAndLastTimeBetweenOrderByLastTimeAsc(BLEDevice device, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java index d5a876f7..ecddca8b 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java @@ -9,7 +9,6 @@ import devkor.com.teamcback.domain.ble.entity.BLEData; import devkor.com.teamcback.domain.ble.entity.BLEDevice; import devkor.com.teamcback.domain.ble.entity.BLEstatus; -import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; import devkor.com.teamcback.domain.place.entity.Place; @@ -18,7 +17,6 @@ import devkor.com.teamcback.global.response.ResultCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,7 +34,6 @@ public class BLEService { private final BLEDeviceRepository bledeviceRepository; private final BLEDataRepository bleDataRepository; private final PlaceRepository placeRepository; - private final ApplicationEventPublisher eventPublisher; // 평균 구해올 시간대 라벨 private static final int[] TIME_SLOTS = {7, 10, 13, 16, 19, 22}; @@ -49,11 +46,6 @@ public class BLEService { @Transactional public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { BLEDevice bleDevice = bledeviceRepository.findByDeviceName(updateBLEReq.getDeviceName()); - if (bleDevice == null) { - throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); - } - BLEData previousData = bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(bleDevice) - .orElse(null); int capacity = bleDevice.getCapacity(); int people = getBlEPeople(updateBLEReq.getLastCount(), bleDevice); double final_ratio = (double) people / capacity; @@ -69,29 +61,9 @@ public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { bleData.setLastTime(updateBLEReq.getLastTime()); bleDataRepository.save(bleData); - if (becameVacant(previousData, status) && bleDevice.getPlace() != null) { - eventPublisher.publishEvent(new PlaceBecameVacantEvent( - bleDevice.getPlace().getId(), - bleData.getId(), - bleData.getLastTime() - )); - } - return new UpdateBLERes(bleData); } - private boolean becameVacant( - BLEData previousData, - BLEstatus newStatus - ) { - if (!BLEstatus.VACANT.equals(newStatus) || previousData == null) { - return false; - } - - return BLEstatus.AVAILABLE.equals(previousData.getLastStatus()) - || BLEstatus.CROWDED.equals(previousData.getLastStatus()); - } - private int getBlEPeople(int lastCount, BLEDevice bleDevice) { if (bleDevice == null) throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); double ratio = bleDevice.getRatio(); diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java index aa5a3ff7..3051eaa1 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java @@ -27,15 +27,4 @@ List findCategoriesByUserAndLocationTypeAndLocationId( @Query("SELECT c FROM Category c LEFT JOIN FETCH c.categoryBookmarkList WHERE c.user = :user") List findByUser(@Param("user") User user); - - @Query(""" - SELECT DISTINCT c.user.userId FROM CategoryBookmark cb - JOIN cb.category c - JOIN cb.bookmark b - WHERE c.user IS NOT NULL AND b.locationType = :locationType AND b.locationId = :locationId - """) - List findDistinctUserIdsByLocationTypeAndLocationId( - @Param("locationType") LocationType locationType, - @Param("locationId") Long locationId - ); } diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java deleted file mode 100644 index 87a76219..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java +++ /dev/null @@ -1,124 +0,0 @@ -package devkor.com.teamcback.domain.character.controller; - -import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; -import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; -import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; -import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; -import devkor.com.teamcback.domain.character.service.AdminStoreService; -import devkor.com.teamcback.global.response.CommonResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/admin/store") -public class AdminStoreController { - private final AdminStoreService adminStoreService; - - /** - * 캐릭터 목록 조회 (비활성 포함) - */ - @Operation(summary = "관리자 캐릭터 목록 조회", description = "비활성 캐릭터를 포함한 전체 캐릭터 조회") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - }) - @GetMapping("") - public CommonResponse getCharacterList() { - return CommonResponse.success(adminStoreService.getCharacterList()); - } - - /** - * 캐릭터 생성 - * @param req 캐릭터 정보 (이미지, 가격 포함) - */ - @Operation(summary = "캐릭터 생성", description = "캐릭터 생성 (이미지 필수, 가격은 0 이상의 포인트)") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "400", description = "잘못된 입력", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public CommonResponse createCharacter( - @Parameter(description = "캐릭터 정보") - @ModelAttribute CreateCharacterReq req) { - return CommonResponse.success(adminStoreService.createCharacter(req)); - } - - /** - * 캐릭터 수정 - * @param characterId 캐릭터 ID - * @param req 캐릭터 정보 (이미지 미첨부 시 기존 이미지 유지) - */ - @Operation(summary = "캐릭터 수정", description = "캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지)") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @PutMapping(value = "/{characterId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public CommonResponse modifyCharacter( - @Parameter(description = "캐릭터 ID", example = "1") - @PathVariable(name = "characterId") Long characterId, - @Parameter(description = "캐릭터 정보") - @ModelAttribute ModifyCharacterReq req) { - return CommonResponse.success(adminStoreService.modifyCharacter(characterId, req)); - } - - /** - * 캐릭터 삭제 - * @param characterId 캐릭터 ID - */ - @Operation(summary = "캐릭터 삭제", description = "캐릭터 삭제 (구매한 사용자가 있으면 삭제 불가, isActive=false로 숨김 처리 권장)") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "409", description = "구매한 사용자가 있는 캐릭터", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @DeleteMapping("/{characterId}") - public CommonResponse deleteCharacter( - @Parameter(description = "캐릭터 ID", example = "1") - @PathVariable(name = "characterId") Long characterId) { - return CommonResponse.success(adminStoreService.deleteCharacter(characterId)); - } - - /** - * 캐릭터 수동 지급 - * @param characterId 캐릭터 ID - * @param userId 사용자 ID - */ - @Operation(summary = "캐릭터 수동 지급", description = "특정 사용자에게 캐릭터 지급 (이벤트 보상용, 포인트 차감 없음)") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @PostMapping("/{characterId}/grant/{userId}") - public CommonResponse grantCharacter( - @Parameter(description = "캐릭터 ID", example = "1") - @PathVariable(name = "characterId") Long characterId, - @Parameter(description = "사용자 ID", example = "1") - @PathVariable(name = "userId") Long userId) { - return CommonResponse.success(adminStoreService.grantCharacter(characterId, userId)); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java deleted file mode 100644 index d745a92c..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java +++ /dev/null @@ -1,129 +0,0 @@ -package devkor.com.teamcback.domain.character.controller; - -import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; -import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; -import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; -import devkor.com.teamcback.domain.character.service.StoreService; -import devkor.com.teamcback.global.response.CommonResponse; -import devkor.com.teamcback.global.security.UserDetailsImpl; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/store") -public class StoreController { - private final StoreService storeService; - - /** - * 스토어 조회 (보유 포인트 + 캐릭터 목록) - * @param userDetail 사용자 정보 - */ - @Operation(summary = "스토어 조회", description = "보유 포인트와 전체 캐릭터 목록(보유/구매 가능/포인트 부족) 조회") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @GetMapping("") - public CommonResponse getStore( - @Parameter(description = "사용자 정보") - @AuthenticationPrincipal UserDetailsImpl userDetail) { - return CommonResponse.success(storeService.getStore(userDetail.getUser().getUserId())); - } - - /** - * 내 보유 캐릭터 목록 조회 - * @param userDetail 사용자 정보 - */ - @Operation(summary = "보유 캐릭터 목록 조회", description = "보유 포인트, 구매한 캐릭터, 대표 장착 캐릭터 조회") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @GetMapping("/my") - public CommonResponse getMyCharacters( - @Parameter(description = "사용자 정보") - @AuthenticationPrincipal UserDetailsImpl userDetail) { - return CommonResponse.success(storeService.getMyCharacters(userDetail.getUser().getUserId())); - } - - /** - * 캐릭터 구매 - * @param userDetail 사용자 정보 - * @param characterId 캐릭터 ID - */ - @Operation(summary = "캐릭터 구매", description = "보유 포인트를 차감하여 캐릭터 구매") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "400", description = "포인트 부족 또는 비활성 캐릭터", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @PostMapping("/{characterId}/purchase") - public CommonResponse purchaseCharacter( - @Parameter(description = "사용자 정보") - @AuthenticationPrincipal UserDetailsImpl userDetail, - @Parameter(description = "캐릭터 ID", example = "1") - @PathVariable(name = "characterId") Long characterId) { - return CommonResponse.success(storeService.purchaseCharacter(userDetail.getUser().getUserId(), characterId)); - } - - /** - * 대표 캐릭터 장착 - * @param userDetail 사용자 정보 - * @param characterId 캐릭터 ID - */ - @Operation(summary = "대표 캐릭터 장착", description = "구매한 캐릭터를 대표 캐릭터로 장착") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "400", description = "보유하지 않은 캐릭터", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @PutMapping("/{characterId}/equip") - public CommonResponse equipCharacter( - @Parameter(description = "사용자 정보") - @AuthenticationPrincipal UserDetailsImpl userDetail, - @Parameter(description = "캐릭터 ID", example = "1") - @PathVariable(name = "characterId") Long characterId) { - return CommonResponse.success(storeService.equipCharacter(userDetail.getUser().getUserId(), characterId)); - } - - /** - * 대표 캐릭터 장착 해제 - * @param userDetail 사용자 정보 - */ - @Operation(summary = "대표 캐릭터 장착 해제", description = "대표 캐릭터 장착 해제") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "Not Found", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - @DeleteMapping("/equip") - public CommonResponse unequipCharacter( - @Parameter(description = "사용자 정보") - @AuthenticationPrincipal UserDetailsImpl userDetail) { - return CommonResponse.success(storeService.unequipCharacter(userDetail.getUser().getUserId())); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java deleted file mode 100644 index 2b4f2e27..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java +++ /dev/null @@ -1,35 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; -import lombok.Setter; -import org.springframework.web.multipart.MultipartFile; - -@Schema(description = "저장할 캐릭터 정보") -@Getter -@Setter -public class CreateCharacterReq { - @Schema(description = "캐릭터 이름", example = "아기 호랑이") - private String name; - - @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") - private String description; - - @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") - private String quote; - - @Schema(description = "구매 가격 (포인트)", example = "10") - private Integer price; - - @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") - private Integer requiredLevel = 1; - - @Schema(description = "정렬 순서", example = "1") - private Integer displayOrder = 0; - - @Schema(description = "노출 여부", example = "true") - private boolean isActive = true; - - @Schema(description = "캐릭터 이미지") - private MultipartFile image; -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java deleted file mode 100644 index 9b74723c..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java +++ /dev/null @@ -1,35 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; -import lombok.Setter; -import org.springframework.web.multipart.MultipartFile; - -@Schema(description = "수정할 캐릭터 정보") -@Getter -@Setter -public class ModifyCharacterReq { - @Schema(description = "캐릭터 이름", example = "아기 호랑이") - private String name; - - @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") - private String description; - - @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") - private String quote; - - @Schema(description = "구매 가격 (포인트)", example = "10") - private Integer price; - - @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") - private Integer requiredLevel = 1; - - @Schema(description = "정렬 순서", example = "1") - private Integer displayOrder = 0; - - @Schema(description = "노출 여부", example = "true") - private boolean isActive = true; - - @Schema(description = "캐릭터 이미지 (미첨부 시 기존 이미지 유지)") - private MultipartFile image; -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java deleted file mode 100644 index 07d3aca0..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java +++ /dev/null @@ -1,15 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -@Schema(description = "캐릭터 생성 결과") -@Getter -public class CreateCharacterRes { - @Schema(description = "생성된 캐릭터 ID", example = "1") - private Long characterId; - - public CreateCharacterRes(Long characterId) { - this.characterId = characterId; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java deleted file mode 100644 index fb5df029..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java +++ /dev/null @@ -1,7 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "캐릭터 삭제 결과") -public class DeleteCharacterRes { -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java deleted file mode 100644 index 604091b7..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java +++ /dev/null @@ -1,15 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -@Schema(description = "대표 캐릭터 장착 결과") -@Getter -public class EquipCharacterRes { - @Schema(description = "장착한 캐릭터 ID", example = "1") - private Long characterId; - - public EquipCharacterRes(Long characterId) { - this.characterId = characterId; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java deleted file mode 100644 index 5715e38d..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java +++ /dev/null @@ -1,16 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import lombok.Getter; - -@Schema(description = "관리자용 캐릭터 목록") -@Getter -public class GetAdminCharacterListRes { - @Schema(description = "캐릭터 목록") - private List characterList; - - public GetAdminCharacterListRes(List characterList) { - this.characterList = characterList; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java deleted file mode 100644 index a4195d80..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java +++ /dev/null @@ -1,44 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import lombok.Getter; - -@Schema(description = "관리자용 캐릭터 정보") -@Getter -public class GetAdminCharacterRes { - @Schema(description = "캐릭터 ID", example = "1") - private Long characterId; - @Schema(description = "캐릭터 이름", example = "아기 호랑이") - private String name; - @Schema(description = "캐릭터 설명") - private String description; - @Schema(description = "캐릭터 대사") - private String quote; - @Schema(description = "캐릭터 이미지 URL") - private String imageUrl; - @Schema(description = "구매 가격 (포인트)", example = "10") - private Integer price; - @Schema(description = "해금 레벨", example = "2") - private Integer requiredLevel; - @Schema(description = "정렬 순서", example = "1") - private Integer displayOrder; - @Schema(description = "노출 여부", example = "true") - private boolean isActive; - @Schema(description = "생성 일시") - private LocalDateTime createdAt; - - public GetAdminCharacterRes(KoCharacter character) { - this.characterId = character.getCharacterId(); - this.name = character.getName(); - this.description = character.getDescription(); - this.quote = character.getQuote(); - this.imageUrl = character.getImageUrl(); - this.price = character.getPrice(); - this.requiredLevel = character.getRequiredLevel(); - this.displayOrder = character.getDisplayOrder(); - this.isActive = character.isActive(); - this.createdAt = character.getCreatedAt(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java deleted file mode 100644 index 912995a8..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java +++ /dev/null @@ -1,22 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import lombok.Getter; - -@Schema(description = "보유 캐릭터 목록") -@Getter -public class GetMyCharacterListRes { - @Schema(description = "보유 포인트", example = "25") - private Long point; - @Schema(description = "대표 장착 캐릭터 ID (미장착 시 null)", example = "1") - private Long equippedCharacterId; - @Schema(description = "보유 캐릭터 목록") - private List characterList; - - public GetMyCharacterListRes(Long point, Long equippedCharacterId, List characterList) { - this.point = point; - this.equippedCharacterId = equippedCharacterId; - this.characterList = characterList; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java deleted file mode 100644 index cfe4fbcd..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java +++ /dev/null @@ -1,32 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import lombok.Getter; - -@Schema(description = "보유 캐릭터 정보") -@Getter -public class GetMyCharacterRes { - @Schema(description = "캐릭터 ID", example = "1") - private Long characterId; - @Schema(description = "캐릭터 이름", example = "아기 호랑이") - private String name; - @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") - private String quote; - @Schema(description = "캐릭터 이미지 URL") - private String imageUrl; - @Schema(description = "구매 일시") - private LocalDateTime purchasedAt; - @Schema(description = "대표 장착 여부", example = "true") - private boolean isEquipped; - - public GetMyCharacterRes(UserCharacter userCharacter, boolean isEquipped) { - this.characterId = userCharacter.getCharacter().getCharacterId(); - this.name = userCharacter.getCharacter().getName(); - this.quote = userCharacter.getCharacter().getQuote(); - this.imageUrl = userCharacter.getCharacter().getImageUrl(); - this.purchasedAt = userCharacter.getCreatedAt(); - this.isEquipped = isEquipped; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java deleted file mode 100644 index 6396151f..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java +++ /dev/null @@ -1,46 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.PurchaseStatus; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import lombok.Getter; - -@Schema(description = "스토어 캐릭터 정보") -@Getter -public class GetStoreCharacterRes { - @Schema(description = "캐릭터 ID", example = "1") - private Long characterId; - @Schema(description = "캐릭터 이름", example = "아기 호랑이") - private String name; - @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") - private String description; - @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") - private String quote; - @Schema(description = "캐릭터 이미지 URL") - private String imageUrl; - @Schema(description = "구매 가격 (포인트)", example = "10") - private Integer price; - @Schema(description = "해금 레벨", example = "2") - private Integer requiredLevel; - @Schema(description = "구매 상태 (OWNED/PURCHASABLE/LOCKED/NOT_ENOUGH_POINT)", example = "PURCHASABLE") - private PurchaseStatus status; - @Schema(description = "구매 일시 (미보유 시 null)") - private LocalDateTime purchasedAt; - @Schema(description = "대표 장착 여부", example = "false") - private boolean isEquipped; - - public GetStoreCharacterRes(KoCharacter character, PurchaseStatus status, - LocalDateTime purchasedAt, boolean isEquipped) { - this.characterId = character.getCharacterId(); - this.name = character.getName(); - this.description = character.getDescription(); - this.quote = character.getQuote(); - this.imageUrl = character.getImageUrl(); - this.price = character.getPrice(); - this.requiredLevel = character.getRequiredLevel(); - this.status = status; - this.purchasedAt = purchasedAt; - this.isEquipped = isEquipped; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java deleted file mode 100644 index 8501940e..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java +++ /dev/null @@ -1,19 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import lombok.Getter; - -@Schema(description = "스토어 정보 (보유 포인트 + 캐릭터 목록)") -@Getter -public class GetStoreRes { - @Schema(description = "보유 포인트", example = "25") - private Long point; - @Schema(description = "캐릭터 목록") - private List characterList; - - public GetStoreRes(Long point, List characterList) { - this.point = point; - this.characterList = characterList; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java deleted file mode 100644 index 3f196fce..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java +++ /dev/null @@ -1,15 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -@Schema(description = "캐릭터 수동 지급 결과") -@Getter -public class GrantCharacterRes { - @Schema(description = "지급된 사용자 캐릭터 ID", example = "1") - private Long userCharacterId; - - public GrantCharacterRes(Long userCharacterId) { - this.userCharacterId = userCharacterId; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java deleted file mode 100644 index 560da964..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java +++ /dev/null @@ -1,7 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "캐릭터 수정 결과") -public class ModifyCharacterRes { -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java deleted file mode 100644 index 287453b8..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java +++ /dev/null @@ -1,35 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDateTime; -import lombok.Getter; - -@Schema(description = "캐릭터 구매 결과") -@Getter -public class PurchaseCharacterRes { - @Schema(description = "캐릭터 ID", example = "1") - private Long characterId; - @Schema(description = "캐릭터 이름", example = "아기 호랑이") - private String name; - @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") - private String quote; - @Schema(description = "캐릭터 이미지 URL") - private String imageUrl; - @Schema(description = "지불한 포인트", example = "10") - private Integer price; - @Schema(description = "구매 후 잔여 포인트", example = "15") - private Long remainingPoint; - @Schema(description = "구매 일시") - private LocalDateTime purchasedAt; - - public PurchaseCharacterRes(UserCharacter userCharacter, Long remainingPoint) { - this.characterId = userCharacter.getCharacter().getCharacterId(); - this.name = userCharacter.getCharacter().getName(); - this.quote = userCharacter.getCharacter().getQuote(); - this.imageUrl = userCharacter.getCharacter().getImageUrl(); - this.price = userCharacter.getCharacter().getPrice(); - this.remainingPoint = remainingPoint; - this.purchasedAt = userCharacter.getCreatedAt(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java deleted file mode 100644 index 429bc45b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java +++ /dev/null @@ -1,7 +0,0 @@ -package devkor.com.teamcback.domain.character.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "대표 캐릭터 장착 해제 결과") -public class UnequipCharacterRes { -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java deleted file mode 100644 index e89a95f7..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java +++ /dev/null @@ -1,79 +0,0 @@ -package devkor.com.teamcback.domain.character.entity; - -import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; -import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnDefault; - -// java.lang.Character와의 충돌을 피하기 위해 KoCharacter로 명명 (koyeon의 Ko- 접두어 선례) -@Entity -@Getter -@Table(name = "tb_character") -@NoArgsConstructor -public class KoCharacter extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long characterId; - - @Column(nullable = false, unique = true) - private String name; - - @Column(length = 500) - private String description; - - @Column(length = 200) - private String quote; // 캐릭터 클릭 시 보여줄 대사 - - @Column(nullable = false) - private String imageUrl; - - @Column(nullable = false) - private Integer price; // 구매 가격 (포인트) - - // 해금 레벨: 사용자 레벨이 이 값 이상이어야 구매 가능 (1이면 제한 없음) - @ColumnDefault("1") - @Column(nullable = false) - private Integer requiredLevel = 1; - - @Column(nullable = false) - private Integer displayOrder = 0; - - @Column(nullable = false) - private boolean isActive = true; - - public KoCharacter(String name, String description, String quote, String imageUrl, - Integer price, Integer requiredLevel, Integer displayOrder, boolean isActive) { - this.name = name; - this.description = description; - this.quote = quote; - this.imageUrl = imageUrl; - this.price = price; - this.requiredLevel = requiredLevel; - this.displayOrder = displayOrder; - this.isActive = isActive; - } - - public KoCharacter(CreateCharacterReq req, String imageUrl) { - this(req.getName(), req.getDescription(), req.getQuote(), imageUrl, req.getPrice(), - req.getRequiredLevel(), req.getDisplayOrder(), req.isActive()); - } - - public void update(ModifyCharacterReq req, String imageUrl) { - this.name = req.getName(); - this.description = req.getDescription(); - this.quote = req.getQuote(); - this.imageUrl = imageUrl; - this.price = req.getPrice(); - this.requiredLevel = req.getRequiredLevel(); - this.displayOrder = req.getDisplayOrder(); - this.isActive = req.isActive(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java deleted file mode 100644 index 8e2d7bd1..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java +++ /dev/null @@ -1,15 +0,0 @@ -package devkor.com.teamcback.domain.character.entity; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum PurchaseStatus { - OWNED("보유"), - PURCHASABLE("구매 가능"), - LOCKED("미해금"), // 해금 레벨 미달 (포인트와 무관하게 구매 불가) - NOT_ENOUGH_POINT("포인트 부족"); - - private final String name; -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java deleted file mode 100644 index 47f153b2..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java +++ /dev/null @@ -1,40 +0,0 @@ -package devkor.com.teamcback.domain.character.entity; - -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import devkor.com.teamcback.domain.user.entity.User; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import lombok.Getter; -import lombok.NoArgsConstructor; - -// createdAt(BaseEntity)이 획득일. UNIQUE(user_id, character_id)가 동시 중복 해금의 최종 방어선 -@Entity -@Getter -@Table(name = "tb_user_character", - uniqueConstraints = @UniqueConstraint(name = "uk_user_character", columnNames = {"user_id", "character_id"})) -@NoArgsConstructor -public class UserCharacter extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long userCharacterId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "character_id", nullable = false) - private KoCharacter character; - - public UserCharacter(User user, KoCharacter character) { - this.user = user; - this.character = character; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java deleted file mode 100644 index 0c6f0c6c..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.character.event; - -public record CharacterUnlockedEvent( - Long userId, - Long characterId, - Long userCharacterId, - String characterName -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java deleted file mode 100644 index 6d206087..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package devkor.com.teamcback.domain.character.repository; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CharacterRepository extends JpaRepository { - List findAllByIsActiveTrueOrderByDisplayOrderAsc(); - - List findAllByOrderByDisplayOrderAsc(); - - boolean existsByName(String name); - - Optional findByName(String name); -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java deleted file mode 100644 index 13045b54..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package devkor.com.teamcback.domain.character.repository; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import devkor.com.teamcback.domain.user.entity.User; -import java.util.List; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserCharacterRepository extends JpaRepository { - @EntityGraph(attributePaths = "character") - List findAllByUser(User user); - - boolean existsByUserAndCharacter(User user, KoCharacter character); - - boolean existsByCharacter(KoCharacter character); - - void deleteAllByUser(User user); -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java deleted file mode 100644 index c7fb35f0..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java +++ /dev/null @@ -1,155 +0,0 @@ -package devkor.com.teamcback.domain.character.service; - -import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; -import static devkor.com.teamcback.global.response.ResultCode.CHARACTER_IN_USE; -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; - -import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; -import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; -import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; -import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.user.entity.Level; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.infra.s3.FilePath; -import devkor.com.teamcback.infra.s3.S3Util; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@Service -@RequiredArgsConstructor -public class AdminStoreService { - private final CharacterRepository characterRepository; - private final UserCharacterRepository userCharacterRepository; - private final UserRepository userRepository; - private final S3Util s3Util; - private final ApplicationEventPublisher eventPublisher; - - /** - * 캐릭터 목록 조회 (비활성 포함) - */ - @Transactional(readOnly = true) - public GetAdminCharacterListRes getCharacterList() { - List characterList = characterRepository.findAllByOrderByDisplayOrderAsc().stream() - .map(GetAdminCharacterRes::new) - .toList(); - - return new GetAdminCharacterListRes(characterList); - } - - /** - * 캐릭터 생성 - */ - @Transactional - public CreateCharacterRes createCharacter(CreateCharacterReq req) { - validatePrice(req.getPrice()); - validateRequiredLevel(req.getRequiredLevel()); - - String imageUrl = uploadImage(req.getImage()); - if(imageUrl == null) throw new GlobalException(INVALID_INPUT); // 이미지 필수 - - KoCharacter character = characterRepository.save(new KoCharacter(req, imageUrl)); - - return new CreateCharacterRes(character.getCharacterId()); - } - - /** - * 캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지) - */ - @Transactional - public ModifyCharacterRes modifyCharacter(Long characterId, ModifyCharacterReq req) { - validatePrice(req.getPrice()); - validateRequiredLevel(req.getRequiredLevel()); - - KoCharacter character = findCharacter(characterId); - - String imageUrl = uploadImage(req.getImage()); - if(imageUrl == null) { - imageUrl = character.getImageUrl(); - } else if(character.getImageUrl() != null) { - s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); - } - - character.update(req, imageUrl); - - return new ModifyCharacterRes(); - } - - /** - * 캐릭터 삭제 (구매한 사용자가 있으면 불가 - isActive=false로 숨김 처리 안내) - */ - @Transactional - public DeleteCharacterRes deleteCharacter(Long characterId) { - KoCharacter character = findCharacter(characterId); - - if(userCharacterRepository.existsByCharacter(character)) { - throw new GlobalException(CHARACTER_IN_USE); - } - - if(character.getImageUrl() != null) { - s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); - } - characterRepository.delete(character); - - return new DeleteCharacterRes(); - } - - /** - * 캐릭터 수동 지급 (이벤트 보상 등, 포인트 차감 없음) - */ - @Transactional - public GrantCharacterRes grantCharacter(Long characterId, Long userId) { - KoCharacter character = findCharacter(characterId); - User user = userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); - - if(userCharacterRepository.existsByUserAndCharacter(user, character)) { - throw new GlobalException(ALREADY_OWNED_CHARACTER); - } - - UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); - eventPublisher.publishEvent(new CharacterUnlockedEvent( - user.getUserId(), - character.getCharacterId(), - userCharacter.getUserCharacterId(), - character.getName() - )); - - return new GrantCharacterRes(userCharacter.getUserCharacterId()); - } - - private void validatePrice(Integer price) { - if(price == null || price < 0) throw new GlobalException(INVALID_INPUT); - } - - private void validateRequiredLevel(Integer requiredLevel) { - int maxLevel = Level.values()[Level.values().length - 1].getLevelNumber(); - if(requiredLevel == null || requiredLevel < 1 || requiredLevel > maxLevel) { - throw new GlobalException(INVALID_INPUT); - } - } - - private String uploadImage(MultipartFile image) { - if(image == null || image.isEmpty()) return null; - return s3Util.uploadFile(image, FilePath.CHARACTER); - } - - private KoCharacter findCharacter(Long characterId) { - return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java deleted file mode 100644 index eb28f367..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java +++ /dev/null @@ -1,96 +0,0 @@ -package devkor.com.teamcback.domain.character.service; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Profile; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Component; - -/** - * 캐릭터 확정 데이터 시더 (기획 문서 "포인트 상점 기능 > 캐릭터 DB 확정" 기준, 2026-07-29). - * 이름 존재 여부로 행 단위 멱등 처리, 멀티 인스턴스 동시 부팅은 name UNIQUE 제약이 방어. - * 기획 확정 전에 시드했던 구 플레이스홀더 5종은 보유자가 없을 때만 정리한다. - */ -@Slf4j -@Component -@Profile("!test") -@RequiredArgsConstructor -public class CharacterDataSeeder implements ApplicationRunner { - private final CharacterRepository characterRepository; - private final UserCharacterRepository userCharacterRepository; - - // 기획 확정 전 플레이스홀더 시드 이름 (확정 DB와 이름이 달라 공존하게 되므로 제거 대상) - private static final List LEGACY_SEED_NAMES = - List.of("아기 호랑이", "학생 호랑이", "청년 호랑이", "석사 호랑이", "호랑이 대장"); - - @Value("${character.image.base-url}") - private String imageBaseUrl; - - // 메서드 전체를 @Transactional로 묶으면 중복 키 예외를 잡아도 트랜잭션이 rollback-only로 오염되어 - // 커밋 시 UnexpectedRollbackException으로 부팅이 실패한다. save()별 개별 트랜잭션으로 둔다. - @Override - public void run(ApplicationArguments args) { - removeLegacySeeds(); - - List seeds = List.of( - new KoCharacter("애기호랑이", "기본 아바타", - "나 호랑이 맞아요?", imageUrl("01_aegi"), 0, 1, 1, true), - new KoCharacter("꼬마호랑이", "레벨 2 달성 시 해금", - "엄마가 발이 크면 키 크는 거래요. 저 발 엄청 커요!", imageUrl("02_kkoma"), 15, 2, 2, true), - new KoCharacter("포동호랑이", "레벨 3 달성 시 해금", - "어디든 가고 싶어요! 이따 어디 갈까요? 미래관? 과도?", imageUrl("03_podong"), 20, 3, 3, true), - new KoCharacter("학생호랑이", "레벨 4 달성 시 해금", - "안경 쓰면 똑똑해보이잖아요. 머릿속에 들어오는 건 없어요.", imageUrl("04_haksaeng"), 25, 4, 4, true), - new KoCharacter("어른호랑이", "레벨 5 달성 시 해금", - "뭐든 다 할 수 있을 것 같아요. 제가 못할 리 없죠!", imageUrl("05_eoreun"), 30, 5, 5, true), - new KoCharacter("피곤 호랑이", "포인트로 바로 구매 가능", - "대체 며칠째 밤샘인지.. 근데 오늘이 무슨 요일이죠?", imageUrl("06_pigon"), 35, 1, 6, true), - new KoCharacter("과잠 호랑이", "포인트로 바로 구매 가능", - "대학생, 원래 이렇게 힘든 거였어요? 살려주세요…", imageUrl("07_gwajam"), 45, 1, 7, true), - new KoCharacter("로봇호랑이", "포인트로 바로 구매 가능", - "다치기 싫어서 강철 슈트 입었어요. 강해보이죠?", imageUrl("08_robot"), 50, 1, 8, true), - new KoCharacter("천사 호랑이", "포인트로 바로 구매 가능", - "하루에 한번은 좋은 일이 생길 거예요!", imageUrl("09_cheonsa"), 80, 1, 9, true), - new KoCharacter("악마 호랑이", "포인트로 바로 구매 가능", - "화가 난 것 같지만 사실 부끄러움을 숨기고 있는 거에요…", imageUrl("10_akma"), 80, 1, 10, true) - ); - - int saved = 0; - for (KoCharacter seed : seeds) { - if(characterRepository.existsByName(seed.getName())) continue; - try { - characterRepository.save(seed); - saved++; - } catch (DataIntegrityViolationException e) { // 다른 인스턴스가 먼저 적재한 경우 - log.info("캐릭터 시드 중복 감지, 건너뜀: {}", seed.getName()); - } - } - if(saved > 0) { - log.info("캐릭터 시드 데이터 {}건 적재 완료", saved); - } - } - - private void removeLegacySeeds() { - for (String legacyName : LEGACY_SEED_NAMES) { - characterRepository.findByName(legacyName).ifPresent(legacy -> { - if(userCharacterRepository.existsByCharacter(legacy)) { // 보유자가 있으면 수동 정리 필요 - log.warn("구 플레이스홀더 캐릭터에 보유자가 있어 삭제하지 않음: {}", legacyName); - return; - } - characterRepository.delete(legacy); - log.info("구 플레이스홀더 캐릭터 삭제: {}", legacyName); - }); - } - } - - private String imageUrl(String fileName) { - return imageBaseUrl + "/" + fileName + ".png"; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java deleted file mode 100644 index 5ee5fd66..00000000 --- a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java +++ /dev/null @@ -1,212 +0,0 @@ -package devkor.com.teamcback.domain.character.service; - -import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; -import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_CHARACTER; -import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_LEVEL; -import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_POINT; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; -import static devkor.com.teamcback.global.response.ResultCode.NOT_OWNED_CHARACTER; - -import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; -import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; -import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.PurchaseStatus; -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.user.entity.Level; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class StoreService { - private final CharacterRepository characterRepository; - private final UserCharacterRepository userCharacterRepository; - private final UserRepository userRepository; - private final ApplicationEventPublisher eventPublisher; - - /** - * 스토어 조회 (보유 포인트 + 캐릭터 목록) - * 정렬: 1순위 보유(해금 레벨순→가격순) → 2순위 구매 가능(가격 낮은순) - * → 3순위 미해금·레벨 미달(해금 레벨순) → 4순위 포인트 부족(가격 낮은순) - */ - @Transactional(readOnly = true) - public GetStoreRes getStore(Long userId) { - User user = findUser(userId); - List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); - - Map ownedMap = userCharacterRepository.findAllByUser(user).stream() - .collect(Collectors.toMap(uc -> uc.getCharacter().getCharacterId(), Function.identity())); - - long point = user.getPoint(); - int userLevel = levelNumberOf(user); - Map statusMap = characters.stream() - .collect(Collectors.toMap(KoCharacter::getCharacterId, - c -> getPurchaseStatus(c, ownedMap.containsKey(c.getCharacterId()), userLevel, point))); - - List characterList = characters.stream() - .sorted(storeOrder(statusMap)) - .map(character -> { - UserCharacter owned = ownedMap.get(character.getCharacterId()); - boolean isEquipped = character.getCharacterId().equals(user.getEquippedCharacterId()); - return new GetStoreCharacterRes(character, statusMap.get(character.getCharacterId()), - owned == null ? null : owned.getCreatedAt(), isEquipped); - }) - .toList(); - - return new GetStoreRes(user.getPoint(), characterList); - } - - /** - * 내 보유 캐릭터 목록 조회 - */ - @Transactional(readOnly = true) - public GetMyCharacterListRes getMyCharacters(Long userId) { - User user = findUser(userId); - - List characterList = userCharacterRepository.findAllByUser(user).stream() - .map(uc -> new GetMyCharacterRes(uc, - uc.getCharacter().getCharacterId().equals(user.getEquippedCharacterId()))) - .toList(); - - return new GetMyCharacterListRes(user.getPoint(), user.getEquippedCharacterId(), characterList); - } - - /** - * 캐릭터 구매 (포인트 차감) - */ - @Transactional - public PurchaseCharacterRes purchaseCharacter(Long userId, Long characterId) { - User user = findUser(userId); - KoCharacter character = findCharacter(characterId); - - if(!character.isActive()) throw new GlobalException(INACTIVE_CHARACTER); - - if(userCharacterRepository.existsByUserAndCharacter(user, character)) { - throw new GlobalException(ALREADY_OWNED_CHARACTER); - } - - // 해금 레벨 미달이면 포인트와 무관하게 구매 불가 - if(levelNumberOf(user) < character.getRequiredLevel()) { - throw new GlobalException(INSUFFICIENT_LEVEL); - } - - // 잔액 검증과 차감을 단일 조건부 UPDATE로 수행 (동시 구매 시 이중 차감 방지) - if(userRepository.deductPoint(userId, character.getPrice()) == 0) { - throw new GlobalException(INSUFFICIENT_POINT); - } - - // deductPoint가 영속성 컨텍스트를 비우므로 차감이 반영된 상태로 재조회 - user = findUser(userId); - character = findCharacter(characterId); - - try { - UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); - eventPublisher.publishEvent(new CharacterUnlockedEvent( - user.getUserId(), - character.getCharacterId(), - userCharacter.getUserCharacterId(), - character.getName() - )); - return new PurchaseCharacterRes(userCharacter, user.getPoint()); - } catch (DataIntegrityViolationException e) { // 동시 중복 구매는 UNIQUE 제약으로 차단 (롤백으로 차감 복구) - throw new GlobalException(ALREADY_OWNED_CHARACTER); - } - } - - /** - * 대표 캐릭터 장착 - */ - @Transactional - public EquipCharacterRes equipCharacter(Long userId, Long characterId) { - User user = findUser(userId); - KoCharacter character = findCharacter(characterId); - - if(!userCharacterRepository.existsByUserAndCharacter(user, character)) { - throw new GlobalException(NOT_OWNED_CHARACTER); - } - - user.updateEquippedCharacter(character.getCharacterId()); - - return new EquipCharacterRes(character.getCharacterId()); - } - - /** - * 대표 캐릭터 장착 해제 - */ - @Transactional - public UnequipCharacterRes unequipCharacter(Long userId) { - User user = findUser(userId); - user.updateEquippedCharacter(null); - - return new UnequipCharacterRes(); - } - - private PurchaseStatus getPurchaseStatus(KoCharacter character, boolean owned, int userLevel, long point) { - if(owned) return PurchaseStatus.OWNED; - if(userLevel < character.getRequiredLevel()) return PurchaseStatus.LOCKED; - return point >= character.getPrice() ? PurchaseStatus.PURCHASABLE : PurchaseStatus.NOT_ENOUGH_POINT; - } - - private Comparator storeOrder(Map statusMap) { - return Comparator - .comparingInt((KoCharacter c) -> statusTier(statusMap.get(c.getCharacterId()))) - .thenComparingInt(c -> primarySortKey(statusMap.get(c.getCharacterId()), c)) - .thenComparingInt(c -> secondarySortKey(statusMap.get(c.getCharacterId()), c)) - .thenComparing(KoCharacter::getDisplayOrder) - .thenComparing(KoCharacter::getCharacterId); - } - - private int statusTier(PurchaseStatus status) { - return switch (status) { - case OWNED -> 0; - case PURCHASABLE -> 1; - case LOCKED -> 2; - case NOT_ENOUGH_POINT -> 3; - }; - } - - private int primarySortKey(PurchaseStatus status, KoCharacter character) { - return switch (status) { - case OWNED, LOCKED -> character.getRequiredLevel(); // 레벨순 - case PURCHASABLE, NOT_ENOUGH_POINT -> character.getPrice(); // 포인트 낮은순 - }; - } - - private int secondarySortKey(PurchaseStatus status, KoCharacter character) { - return status == PurchaseStatus.OWNED ? character.getPrice() : 0; // 보유는 레벨순 다음 포인트순 - } - - private int levelNumberOf(User user) { - // 백필 전 레거시 행 방어: level 컬럼이 비어 있으면 score로 계산 - Level level = user.getLevel() != null ? user.getLevel() : Level.fromScore(user.getScore()); - return level.getLevelNumber(); - } - - private User findUser(Long userId) { - return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); - } - - private KoCharacter findCharacter(Long characterId) { - return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java deleted file mode 100644 index 9c67ed8b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java +++ /dev/null @@ -1,25 +0,0 @@ -package devkor.com.teamcback.domain.notification.client; - -import devkor.com.teamcback.domain.notification.config.ExpoPushFeignConfig; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptRequest; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; -import java.util.List; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - -@FeignClient( - name = "expoPushApi", - url = "${push.expo.base-url}", - configuration = ExpoPushFeignConfig.class -) -public interface ExpoPushApi { - - @PostMapping("/send") - ExpoPushResponse send(@RequestBody List requests); - - @PostMapping("/getReceipts") - ExpoReceiptResponse getReceipts(@RequestBody ExpoReceiptRequest request); -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java deleted file mode 100644 index 1fc0ba38..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java +++ /dev/null @@ -1,165 +0,0 @@ -package devkor.com.teamcback.domain.notification.client; - -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptRequest; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; -import feign.FeignException; -import feign.RetryableException; -import feign.codec.DecodeException; -import feign.codec.EncodeException; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ExpoPushClient { - - private static final int MAX_SEND_REQUEST_COUNT = 100; - private static final int MAX_RECEIPT_ID_COUNT = 1000; - - private final ExpoPushApi expoPushApi; - - public ExpoPushResponse send(List requests) { - validateSendRequests(requests); - - try { - ExpoPushResponse response = expoPushApi.send(requests); - validateSendResponse(response, requests.size()); - return response; - } catch (ExpoPushClientException e) { - throw e; - } catch (RetryableException e) { - throw requestFailed(null, true, e); - } catch (DecodeException e) { - throw parsingFailed(e); - } catch (EncodeException e) { - throw invalidInput(e); - } catch (FeignException e) { - throw requestFailed(e.status(), isRetryable(e.status()), e); - } - } - - public ExpoReceiptResponse getReceipts(List ticketIds) { - validateReceiptIds(ticketIds); - - try { - ExpoReceiptResponse response = expoPushApi.getReceipts(new ExpoReceiptRequest(ticketIds)); - if (response == null) { - throw parsingFailed(); - } - return response; - } catch (ExpoPushClientException e) { - throw e; - } catch (RetryableException e) { - throw requestFailed(null, true, e); - } catch (DecodeException e) { - throw parsingFailed(e); - } catch (EncodeException e) { - throw invalidInput(e); - } catch (FeignException e) { - throw requestFailed(e.status(), isRetryable(e.status()), e); - } - } - - private void validateSendResponse( - ExpoPushResponse response, - int requestCount - ) { - if (response == null || response.data() == null || response.data().size() != requestCount) { - throw parsingFailed(); - } - } - - private void validateSendRequests(List requests) { - if (requests == null || requests.isEmpty()) { - throw invalidInput(); - } - - if (requests.size() > MAX_SEND_REQUEST_COUNT) { - throw invalidInput(); - } - - boolean hasInvalidRequest = requests.stream() - .anyMatch(request -> request == null || !hasText(request.to())); - - if (hasInvalidRequest) { - throw invalidInput(); - } - } - - private void validateReceiptIds(List ticketIds) { - if (ticketIds == null || ticketIds.isEmpty()) { - throw invalidInput(); - } - - if (ticketIds.size() > MAX_RECEIPT_ID_COUNT) { - throw invalidInput(); - } - - boolean hasBlankTicketId = ticketIds.stream() - .anyMatch(ticketId -> !hasText(ticketId)); - - if (hasBlankTicketId) { - throw invalidInput(); - } - } - - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } - - private boolean isRetryable(int status) { - return status == HttpStatus.TOO_MANY_REQUESTS.value() - || (status >= 500 && status < 600); - } - - private ExpoPushClientException invalidInput() { - return new ExpoPushClientException( - "Invalid Expo push client request", - HttpStatus.BAD_REQUEST.value(), - false - ); - } - - private ExpoPushClientException invalidInput(Throwable cause) { - return new ExpoPushClientException( - "Invalid Expo push client request", - HttpStatus.BAD_REQUEST.value(), - false, - cause - ); - } - - private ExpoPushClientException parsingFailed() { - return new ExpoPushClientException( - "Failed to parse Expo push response", - null, - false - ); - } - - private ExpoPushClientException parsingFailed(Throwable cause) { - return new ExpoPushClientException( - "Failed to parse Expo push response", - null, - false, - cause - ); - } - - private ExpoPushClientException requestFailed( - Integer httpStatus, - boolean retryable, - Throwable cause - ) { - return new ExpoPushClientException( - "Expo push request failed", - httpStatus, - retryable, - cause - ); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java deleted file mode 100644 index 09856dc0..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java +++ /dev/null @@ -1,31 +0,0 @@ -package devkor.com.teamcback.domain.notification.client; - -import lombok.Getter; - -@Getter -public class ExpoPushClientException extends RuntimeException { - - private final Integer httpStatus; - private final boolean retryable; - - public ExpoPushClientException( - String message, - Integer httpStatus, - boolean retryable - ) { - super(message); - this.httpStatus = httpStatus; - this.retryable = retryable; - } - - public ExpoPushClientException( - String message, - Integer httpStatus, - boolean retryable, - Throwable cause - ) { - super(message, cause); - this.httpStatus = httpStatus; - this.retryable = retryable; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java deleted file mode 100644 index 267b1f85..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package devkor.com.teamcback.domain.notification.config; - -import feign.Request; -import feign.RequestInterceptor; -import feign.Retryer; -import java.util.concurrent.TimeUnit; -import org.springframework.context.annotation.Bean; -import org.springframework.http.HttpHeaders; -import org.springframework.util.StringUtils; - -public class ExpoPushFeignConfig { - - @Bean - public Request.Options expoPushRequestOptions(ExpoPushProperties properties) { - return new Request.Options( - properties.connectTimeout().toMillis(), - TimeUnit.MILLISECONDS, - properties.readTimeout().toMillis(), - TimeUnit.MILLISECONDS, - true - ); - } - - @Bean - public RequestInterceptor expoPushAuthorizationInterceptor(ExpoPushProperties properties) { - return template -> { - if (StringUtils.hasText(properties.accessToken())) { - template.header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.accessToken()); - } - }; - } - - @Bean - public Retryer expoPushRetryer() { - return Retryer.NEVER_RETRY; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java deleted file mode 100644 index 089a0248..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java +++ /dev/null @@ -1,28 +0,0 @@ -package devkor.com.teamcback.domain.notification.config; - -import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "push.expo") -public record ExpoPushProperties( - String baseUrl, - String accessToken, - Duration connectTimeout, - Duration readTimeout -) { - - public ExpoPushProperties { - if (baseUrl == null || baseUrl.isBlank()) { - baseUrl = "https://exp.host/--/api/v2/push"; - } - if (accessToken == null) { - accessToken = ""; - } - if (connectTimeout == null) { - connectTimeout = Duration.ofSeconds(3); - } - if (readTimeout == null) { - readTimeout = Duration.ofSeconds(10); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java deleted file mode 100644 index 5bef5bd8..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java +++ /dev/null @@ -1,14 +0,0 @@ -package devkor.com.teamcback.domain.notification.config; - -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties({ - ExpoPushProperties.class, - PushWorkerProperties.class, - PushReceiptWorkerProperties.class, - PushRecoveryWorkerProperties.class -}) -public class ExpoPushPropertiesConfig { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java deleted file mode 100644 index c543652b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java +++ /dev/null @@ -1,22 +0,0 @@ -package devkor.com.teamcback.domain.notification.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "push.receipt-worker") -public record PushReceiptWorkerProperties( - boolean enabled, - int batchSize, - int maxReceiptAttempts -) { - - private static final int MAX_EXPO_RECEIPT_BATCH_SIZE = 1000; - - public PushReceiptWorkerProperties { - if (batchSize <= 0 || batchSize > MAX_EXPO_RECEIPT_BATCH_SIZE) { - batchSize = MAX_EXPO_RECEIPT_BATCH_SIZE; - } - if (maxReceiptAttempts <= 0) { - maxReceiptAttempts = 3; - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java deleted file mode 100644 index 5d4172df..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java +++ /dev/null @@ -1,24 +0,0 @@ -package devkor.com.teamcback.domain.notification.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "push.recovery-worker") -public record PushRecoveryWorkerProperties( - boolean enabled, - String cron, - long staleThresholdMinutes, - int batchSize -) { - - private static final int DEFAULT_BATCH_SIZE = 100; - private static final long DEFAULT_STALE_THRESHOLD_MINUTES = 30L; - - public PushRecoveryWorkerProperties { - if (staleThresholdMinutes <= 0) { - staleThresholdMinutes = DEFAULT_STALE_THRESHOLD_MINUTES; - } - if (batchSize <= 0) { - batchSize = DEFAULT_BATCH_SIZE; - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java deleted file mode 100644 index 3845cf4e..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java +++ /dev/null @@ -1,26 +0,0 @@ -package devkor.com.teamcback.domain.notification.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "push.worker") -public record PushWorkerProperties( - boolean enabled, - int batchSize, - int maxSendAttempts, - long retryDelayMs -) { - - private static final int MAX_EXPO_BATCH_SIZE = 100; - - public PushWorkerProperties { - if (batchSize <= 0 || batchSize > MAX_EXPO_BATCH_SIZE) { - batchSize = MAX_EXPO_BATCH_SIZE; - } - if (maxSendAttempts <= 0) { - maxSendAttempts = 3; - } - if (retryDelayMs <= 0) { - retryDelayMs = 30_000L; - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java deleted file mode 100644 index 825f0a87..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java +++ /dev/null @@ -1,160 +0,0 @@ -package devkor.com.teamcback.domain.notification.controller; - -import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; -import devkor.com.teamcback.domain.notification.dto.request.UpdatePushEventFlagReq; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; -import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.service.AdminNotificationService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.CommonResponse; -import devkor.com.teamcback.global.response.ResultCode; -import devkor.com.teamcback.global.security.UserDetailsImpl; -import java.util.List; - -import io.swagger.v3.oas.annotations.Operation; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/admin/notifications") -public class AdminNotificationController { - - private static final String DEFAULT_PAGE = "1"; - private static final String DEFAULT_SIZE = "20"; - - private final AdminNotificationService adminNotificationService; - private final PushEventFlagService pushEventFlagService; - - @Operation( - summary = "푸시 대상 installation 검색", - description = """ - userId 또는 installationId를 기준으로 - 푸시 발송 대상 기기를 조회합니다. - ExpoPushToken 원문은 응답하지 않습니다. - """ - ) - @GetMapping("/installations/search") - public CommonResponse> searchInstallations( - @RequestParam(required = false) Long userId, - @RequestParam(required = false) String installationId - ) { - return CommonResponse.success(adminNotificationService.searchInstallations(userId, installationId)); - } - - @Operation( - summary = "관리자 푸시 발송 미리보기", - description = """ - 푸시를 실제로 생성하지 않고 - 대상 기기 수와 최종 payload를 확인합니다. - PushDispatch와 PushMessage는 저장하지 않습니다. - """ - ) - @PostMapping("/dispatches/preview") - public CommonResponse preview( - @RequestBody AdminPushDispatchReq request - ) { - return CommonResponse.success(adminNotificationService.preview(request)); - } - - - @Operation( - summary = "관리자 푸시 수동 발송", - description = """ - 관리자가 입력한 내용으로 - PushDispatch와 PushMessage를 생성합니다. - 실제 Expo 전송은 기존 비동기 worker가 처리합니다. - """ - ) - @PostMapping("/dispatches") - public CommonResponse enqueue( - @AuthenticationPrincipal UserDetailsImpl userDetail, - @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, - @RequestBody AdminPushDispatchReq request - ) { - if (userDetail == null) { - throw new GlobalException(UNAUTHORIZED); - } - - return CommonResponse.success(adminNotificationService.enqueue( - userDetail.getUser().getUserId(), - idempotencyKey, - request - )); - } - - @Operation( - summary = "관리자 푸시 발송 이력 조회", - description = """ - 관리자 푸시 발송 내역을 최신순으로 조회합니다. - appVariant와 발송 상태로 필터링할 수 있습니다. - """ - ) - - @GetMapping("/dispatches") - public CommonResponse> getDispatches( - @RequestParam(defaultValue = DEFAULT_PAGE) int page, - @RequestParam(defaultValue = DEFAULT_SIZE) int size, - @RequestParam(required = false) AppVariant appVariant, - @RequestParam(required = false) PushDispatchStatus status - ) { - return CommonResponse.success(adminNotificationService.getDispatches( - page, - size, - appVariant, - status - )); - } - - @Operation( - summary = "관리자 푸시 발송 상세 조회", - description = """ - 발송 기본 정보와 전체 대상 수, - 메시지 상태별 처리 건수를 조회합니다. - ExpoPushToken과 개별 메시지 전체 목록은 반환하지 않습니다. - """ - ) - @GetMapping("/dispatches/{dispatchId}") - public CommonResponse getDispatch( - @PathVariable Long dispatchId - ) { - return CommonResponse.success(adminNotificationService.getDispatch(dispatchId)); - } - - @GetMapping("/event-flags") - public CommonResponse> getEventFlags() { - return CommonResponse.success(pushEventFlagService.getFlags()); - } - - @PatchMapping("/event-flags/{eventType}") - public CommonResponse updateEventFlag( - @PathVariable PushEventType eventType, - @RequestBody UpdatePushEventFlagReq request - ) { - if (request == null || request.enabled() == null) { - throw new GlobalException(ResultCode.INVALID_INPUT); - } - - return CommonResponse.success(pushEventFlagService.updateFlag(eventType, request.enabled())); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java deleted file mode 100644 index 7840c637..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java +++ /dev/null @@ -1,44 +0,0 @@ -package devkor.com.teamcback.domain.notification.controller; - -import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; -import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; -import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; -import devkor.com.teamcback.global.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/admin/notifications/survey-schedules") -public class AdminSurveyPushScheduleController { - - private final SurveyPushScheduleService surveyPushScheduleService; - - @PutMapping("/{surveyKey}") - public CommonResponse upsertSurveySchedules( - @PathVariable String surveyKey, - @RequestBody AdminSurveyPushScheduleReq request - ) { - return CommonResponse.success(surveyPushScheduleService.upsertAdminSchedules(surveyKey, request)); - } - - @GetMapping("/{surveyKey}") - public CommonResponse getSurveySchedules( - @PathVariable String surveyKey - ) { - return CommonResponse.success(surveyPushScheduleService.getAdminSchedules(surveyKey)); - } - - @DeleteMapping("/{surveyKey}") - public CommonResponse cancelSurveySchedules( - @PathVariable String surveyKey - ) { - return CommonResponse.success(surveyPushScheduleService.cancelSchedules(surveyKey)); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java deleted file mode 100644 index 9ff41f88..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java +++ /dev/null @@ -1,71 +0,0 @@ -package devkor.com.teamcback.domain.notification.controller; - -import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; -import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; -import devkor.com.teamcback.domain.notification.service.NotificationTestService; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.CommonResponse; -import devkor.com.teamcback.global.security.UserDetailsImpl; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/notifications") -public class NotificationTestController { - - private final NotificationTestService notificationTestService; - - /** - * DEV/PREVIEW 환경에서 실제 기기 푸시 수신을 확인하는 API입니다. - * 테스트 코드가 아닌 애플리케이션 기능입니다. - */ - @Operation( - summary = "푸시 알림 테스트 발송", - description = """ - 인증된 사용자의 본인 DEV/PREVIEW installation 한 대에 - 실제 테스트 푸시를 발송합니다. - PRODUCTION installation은 사용할 수 없습니다. - """ - ) - @PostMapping("/test") - public ResponseEntity> sendTest( - @Parameter(hidden = true) - @AuthenticationPrincipal UserDetailsImpl userDetail, - - @Parameter( - description = "중복 발송 방지를 위한 멱등성 키", - required = true, - example = "7b347ad7-6138-4cb7-af7d-f5201703a596" - ) - @RequestHeader(value = "Idempotency-Key", required = false) - String idempotencyKey, - - @Valid @RequestBody NotificationTestReq request - ) { - if (userDetail == null) { - throw new GlobalException(UNAUTHORIZED); - } - - Long userId = userDetail.getUser().getUserId(); - - return ResponseEntity.ok(CommonResponse.success( - notificationTestService.sendTest( - userId, - idempotencyKey, - request - ) - )); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java deleted file mode 100644 index 2d9925e0..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java +++ /dev/null @@ -1,126 +0,0 @@ -package devkor.com.teamcback.domain.notification.controller; - - -import devkor.com.teamcback.domain.notification.dto.request.PushInstallationRegisterReq; -import devkor.com.teamcback.domain.notification.service.PushInstallationService; -import devkor.com.teamcback.global.response.CommonResponse; -import devkor.com.teamcback.global.security.UserDetailsImpl; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/* -@Tag( - name = "푸시 알림 설치 관리", - description = "로그인 사용자의 Expo Push installation 등록, 갱신 및 비활성화 API" -) - */ -@Validated -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/notifications/installations") -public class PushInstallationController { - - private final PushInstallationService pushInstallationService; - - @Operation( - summary = "푸시 알림 installation 등록 및 갱신", - description = """ - 현재 로그인 사용자의 Expo Push installation을 등록합니다. - 동일한 installationId로 다시 요청하면 새 행을 생성하지 않고 - 기존 installation의 토큰, 사용자 및 앱 환경 정보를 갱신합니다. - """ - ) - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "등록 또는 갱신 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청값", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = CommonResponse.class)) - ) - }) - @PutMapping("/{installationId}") - public ResponseEntity> register( - @Parameter(hidden = true) - @AuthenticationPrincipal UserDetailsImpl userDetail, - - @Parameter( - description = "앱 설치를 식별하는 고유 ID", - example = "11111111-2222-4333-8444-555555555555", - required = true - ) - @PathVariable - @NotBlank - @Size(max = 64) - String installationId, - - @Valid - @RequestBody - PushInstallationRegisterReq request - ) { - Long userId = userDetail.getUser().getUserId(); - - pushInstallationService.register( - userId, - installationId, - request.expoPushToken(), - request.appVariant() - ); - - return ResponseEntity.ok(CommonResponse.success()); - } - - @Operation( - summary = "푸시 알림 installation 비활성화", - description = """ - 현재 로그인 사용자가 소유한 installation을 비활성화합니다. - 대상이 없거나 이미 비활성화된 경우에도 성공으로 처리합니다. - """ - ) - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "비활성화 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패", - content = @Content(schema = @Schema(implementation = CommonResponse.class)) - ) - }) - @DeleteMapping("/{installationId}") - public ResponseEntity> deactivate( - @Parameter(hidden = true) - @AuthenticationPrincipal UserDetailsImpl userDetail, - - @Parameter( - description = "비활성화할 앱 설치 식별자", - example = "11111111-2222-4333-8444-555555555555", - required = true - ) - @PathVariable - @NotBlank - @Size(max = 64) - String installationId - ) { - Long userId = userDetail.getUser().getUserId(); - - pushInstallationService.deactivate( - userId, - installationId - ); - - return ResponseEntity.ok(CommonResponse.success()); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java deleted file mode 100644 index a415ea11..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java +++ /dev/null @@ -1,38 +0,0 @@ -package devkor.com.teamcback.domain.notification.controller; - -import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; -import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.CommonResponse; -import devkor.com.teamcback.global.security.UserDetailsImpl; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/notifications/surveys") -public class SurveyNotificationController { - - private final SurveyPushScheduleService surveyPushScheduleService; - - @PostMapping("/{surveyKey}/reminders") - public CommonResponse remindAfterLater( - @AuthenticationPrincipal UserDetailsImpl userDetail, - @PathVariable String surveyKey - ) { - if (userDetail == null) { - throw new GlobalException(UNAUTHORIZED); - } - - return CommonResponse.success(surveyPushScheduleService.remindAfterLater( - surveyKey, - userDetail.getUser().getUserId() - )); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java deleted file mode 100644 index 7fe319fe..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record ExpoPushErrorDetails( - String error -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java deleted file mode 100644 index 2a4cdec9..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java +++ /dev/null @@ -1,11 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record ExpoPushReceipt( - String status, - String message, - ExpoPushErrorDetails details -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java deleted file mode 100644 index 98b19656..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record ExpoPushRequest( - String to, - String title, - String body, - String sound, - Object data -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java deleted file mode 100644 index 25649876..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.List; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record ExpoPushResponse( - List data -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java deleted file mode 100644 index 71bdae8b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java +++ /dev/null @@ -1,12 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record ExpoPushTicket( - String status, - String id, - String message, - ExpoPushErrorDetails details -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java deleted file mode 100644 index 11e0f82f..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import java.util.List; - -public record ExpoReceiptRequest( - List ids -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java deleted file mode 100644 index e1dd3769..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.expo; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.Map; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record ExpoReceiptResponse( - Map data -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java deleted file mode 100644 index 2f032793..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java +++ /dev/null @@ -1,24 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.payload; - -import java.util.Map; - -public record PushPayload( - String title, - String body, - PushPayloadData data -) { - - public record PushPayloadData( - int version, - String notificationId, - String appVariant, - PushPayloadAction action - ) { - } - - public record PushPayloadAction( - String type, - Map params - ) { - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java deleted file mode 100644 index 80608a33..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java +++ /dev/null @@ -1,20 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.request; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import java.util.Map; - -public record AdminPushDispatchReq( - PushMode mode, - AppVariant appVariant, - PushTargetType targetType, - String targetValue, - String title, - String body, - PushActionType actionType, - Map actionParams, - Boolean confirm -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java deleted file mode 100644 index d9c352af..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.request; - -import java.time.LocalDateTime; - -public record AdminSurveyPushScheduleReq( - LocalDateTime startNotificationAt, - LocalDateTime deadlineNotificationAt, - Integer rewardPoint -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java deleted file mode 100644 index cf482998..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java +++ /dev/null @@ -1,19 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.request; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record NotificationTestReq( - @NotNull - @Min(1) - @Max(1) - Integer schemaVersion, - - @NotBlank - @Size(max = 64) - String installationId -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java deleted file mode 100644 index 3eab2f55..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java +++ /dev/null @@ -1,23 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.request; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import java.util.Map; - -public record PushDispatchCommand( - NotificationType notificationType, - PushMode mode, - AppVariant appVariant, - PushTargetType targetType, - String targetValue, - String title, - String body, - PushActionType actionType, - Map actionParams, - String idempotencyKey, - Long createdBy -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java deleted file mode 100644 index cafce71d..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java +++ /dev/null @@ -1,33 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.request; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -@Schema(description = "Expo Push installation 등록 요청") -public record PushInstallationRegisterReq( - - @Schema(description = "요청 스키마 버전", example = "1", allowableValues = {"1"}) - @NotNull - @Min(1) - @Max(1) - Integer schemaVersion, - - @Schema(description = "Expo에서 발급된 Push Token", example = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]") - @NotBlank - String expoPushToken, - - @Schema(description = "토큰이 발급된 앱 빌드 환경", example = "dev", - allowableValues = { - "dev", - "preview", - "production" - } - ) - @NotNull - AppVariant appVariant -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java deleted file mode 100644 index 70a5508e..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java +++ /dev/null @@ -1,6 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.request; - -public record UpdatePushEventFlagReq( - Boolean enabled -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java deleted file mode 100644 index 3ac470f8..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java +++ /dev/null @@ -1,58 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import java.time.LocalDateTime; -import java.util.Map; - -public record AdminPushDispatchDetailRes( - Long dispatchId, - NotificationType notificationType, - PushMode mode, - AppVariant appVariant, - PushTargetType targetType, - String targetValue, - String title, - String body, - PushActionType actionType, - String actionParams, - int recipientCount, - PushDispatchStatus status, - Map messageStatusCounts, - String idempotencyKey, - Long createdBy, - LocalDateTime createdAt, - LocalDateTime completedAt -) { - - public AdminPushDispatchDetailRes( - PushDispatch dispatch, - Map messageStatusCounts - ) { - this( - dispatch.getPushDispatchId(), - dispatch.getNotificationType(), - dispatch.getMode(), - dispatch.getAppVariant(), - dispatch.getTargetType(), - dispatch.getTargetValue(), - dispatch.getTitle(), - dispatch.getBody(), - dispatch.getActionType(), - dispatch.getActionParams(), - dispatch.getRecipientCount(), - dispatch.getStatus(), - messageStatusCounts, - dispatch.getIdempotencyKey(), - dispatch.getCreatedBy(), - dispatch.getCreatedAt(), - dispatch.getCompletedAt() - ); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java deleted file mode 100644 index 3a32b5bf..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; - -public record AdminPushDispatchPreviewRes( - int recipientCount, - PushPayload payload -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java deleted file mode 100644 index c97e69c6..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java +++ /dev/null @@ -1,47 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import java.time.LocalDateTime; - -public record AdminPushDispatchSummaryRes( - Long dispatchId, - NotificationType notificationType, - PushMode mode, - AppVariant appVariant, - PushTargetType targetType, - String targetValue, - String title, - String body, - PushActionType actionType, - int recipientCount, - PushDispatchStatus status, - Long createdBy, - LocalDateTime createdAt, - LocalDateTime completedAt -) { - - public AdminPushDispatchSummaryRes(PushDispatch dispatch) { - this( - dispatch.getPushDispatchId(), - dispatch.getNotificationType(), - dispatch.getMode(), - dispatch.getAppVariant(), - dispatch.getTargetType(), - dispatch.getTargetValue(), - dispatch.getTitle(), - dispatch.getBody(), - dispatch.getActionType(), - dispatch.getRecipientCount(), - dispatch.getStatus(), - dispatch.getCreatedBy(), - dispatch.getCreatedAt(), - dispatch.getCompletedAt() - ); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java deleted file mode 100644 index 79df8c2a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; - -public record AdminPushEventFlagRes( - PushEventType eventType, - boolean enabled -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java deleted file mode 100644 index f2cc6bc9..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java +++ /dev/null @@ -1,32 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import java.time.LocalDateTime; - -public record AdminPushInstallationRes( - Long userId, - String installationId, - AppVariant appVariant, - boolean active, - LocalDateTime lastActiveAt, - LocalDateTime createdAt, - LocalDateTime modifiedAt, - LocalDateTime deactivatedAt -) { - - public AdminPushInstallationRes(PushInstallation installation) { - this( - installation.getUserId(), - installation.getInstallationId(), - installation.getAppVariant(), - installation.isActive(), - installation.isActive() - ? installation.getModifiedAt() - : installation.getDeactivatedAt(), - installation.getCreatedAt(), - installation.getModifiedAt(), - installation.getDeactivatedAt() - ); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java deleted file mode 100644 index 3a2428b4..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -public record AdminSurveyPushScheduleRes( - String surveyKey, - int rewardPoint, - AdminSurveyPushScheduleStageRes started, - AdminSurveyPushScheduleStageRes dMinus3, - AdminSurveyPushScheduleStageRes deadline -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java deleted file mode 100644 index 5428cc6b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java +++ /dev/null @@ -1,21 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; -import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; -import java.time.LocalDateTime; - -public record AdminSurveyPushScheduleStageRes( - SurveyPushScheduleStatus status, - LocalDateTime scheduledAt -) { - - public static AdminSurveyPushScheduleStageRes from(SurveyPushSchedule schedule) { - if (schedule == null) { - return null; - } - return new AdminSurveyPushScheduleStageRes( - schedule.getStatus(), - schedule.getScheduledAt() - ); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java deleted file mode 100644 index 32d659d0..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java +++ /dev/null @@ -1,13 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; - -public record NotificationTestRes( - String notificationId, - String installationId, - AppVariant appVariant, - PushMessageStatus messageStatus, - String ticketId -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java deleted file mode 100644 index 37923008..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java +++ /dev/null @@ -1,21 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import lombok.Getter; - -@Getter -public class PushDispatchEnqueueRes { - - private final Long dispatchId; - private final int recipientCount; - private final PushDispatchStatus status; - private final String idempotencyKey; - - public PushDispatchEnqueueRes(PushDispatch dispatch) { - this.dispatchId = dispatch.getPushDispatchId(); - this.recipientCount = dispatch.getRecipientCount(); - this.status = dispatch.getStatus(); - this.idempotencyKey = dispatch.getIdempotencyKey(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java deleted file mode 100644 index 0b1e704f..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java +++ /dev/null @@ -1,12 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.response; - -import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; -import java.time.LocalDateTime; - -public record SurveyReminderRes( - String surveyKey, - boolean scheduled, - LocalDateTime scheduledAt, - SurveyReminderSuppressedBy suppressedBy -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java deleted file mode 100644 index b6bda67b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java +++ /dev/null @@ -1,8 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.worker; - -public record PushReceiptItem( - Long pushMessageId, - Long pushDispatchId, - String expoTicketId -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java deleted file mode 100644 index 0e22543a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java +++ /dev/null @@ -1,11 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.worker; - -public record PushReceiptWorkerResult( - int claimedCount, - int checkedCount -) { - - public static PushReceiptWorkerResult empty() { - return new PushReceiptWorkerResult(0, 0); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java deleted file mode 100644 index 8a861bb5..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.worker; - -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; - -public record PushSendItem( - Long pushMessageId, - Long pushDispatchId, - ExpoPushRequest request -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java deleted file mode 100644 index dccff995..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java +++ /dev/null @@ -1,11 +0,0 @@ -package devkor.com.teamcback.domain.notification.dto.worker; - -public record PushWorkerDispatchResult( - int claimedCount, - int sentCount -) { - - public static PushWorkerDispatchResult empty() { - return new PushWorkerDispatchResult(0, 0); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java deleted file mode 100644 index 1a6a8468..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java +++ /dev/null @@ -1,156 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity; - -import devkor.com.teamcback.domain.notification.entity.type.*; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import java.time.LocalDateTime; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table( - name = "tb_push_dispatch", - uniqueConstraints = { - @UniqueConstraint( - name = "uk_push_dispatch_idempotency_key", - columnNames = "idempotency_key" - ) - }, - indexes = { - @Index( - name = "idx_push_dispatch_status_created_at", - columnList = "status, created_at" - ) - } -) -@NoArgsConstructor -@Getter -public class PushDispatch { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "push_dispatch_id") - private Long pushDispatchId; - - @Enumerated(EnumType.STRING) - @Column(name = "notification_type", nullable = false, length = 40) - private NotificationType notificationType; - - @Enumerated(EnumType.STRING) - @Column(name = "mode", nullable = false, length = 20) - private PushMode mode; - - @Enumerated(EnumType.STRING) - @Column(name = "app_variant", nullable = false, length = 20) - private AppVariant appVariant; - - @Enumerated(EnumType.STRING) - @Column(name = "target_type", nullable = false, length = 30) - private PushTargetType targetType; - - @Column(name = "target_value", nullable = false, length = 128) - private String targetValue; - - @Column(name = "title", nullable = false, length = 200) - private String title; - - @Column(name = "body", nullable = false, length = 1024) - private String body; - - @Enumerated(EnumType.STRING) - @Column(name = "action_type", nullable = false, length = 40) - private PushActionType actionType; - - @Column(name = "action_params", nullable = false, length = 2048) - private String actionParams; - - @Column(name = "recipient_count", nullable = false) - private int recipientCount; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 30) - private PushDispatchStatus status; - - @Column(name = "idempotency_key", nullable = false, length = 128) - private String idempotencyKey; - - @Column(name = "created_by", nullable = false) - private Long createdBy; - - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "completed_at") - private LocalDateTime completedAt; - - public PushDispatch( - NotificationType notificationType, - PushMode mode, - AppVariant appVariant, - PushTargetType targetType, - String targetValue, - String title, - String body, - PushActionType actionType, - String actionParams, - String idempotencyKey, - Long createdBy, - LocalDateTime createdAt - ) { - this.notificationType = notificationType; - this.mode = mode; - this.appVariant = appVariant; - this.targetType = targetType; - this.targetValue = targetValue; - this.title = title; - this.body = body; - this.actionType = actionType; - this.actionParams = actionParams; - this.recipientCount = 0; - this.status = PushDispatchStatus.QUEUED; - this.idempotencyKey = idempotencyKey; - this.createdBy = createdBy; - this.createdAt = createdAt; - this.completedAt = null; - } - - public void updateRecipientCount(int recipientCount) { - this.recipientCount = recipientCount; - } - - public void updateStatusFromMessageSummary( - long queuedCount, - long sendingCount, - long successCount, - long failedCount, - LocalDateTime now - ) { - if (queuedCount > 0 || sendingCount > 0) { - this.status = PushDispatchStatus.PROCESSING; - return; - } - - if (failedCount == 0 && successCount == recipientCount) { - this.status = PushDispatchStatus.COMPLETED; - this.completedAt = now; - return; - } - - if (failedCount == recipientCount) { - this.status = PushDispatchStatus.FAILED; - this.completedAt = now; - return; - } - - this.status = PushDispatchStatus.PARTIAL_FAILED; - this.completedAt = now; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java deleted file mode 100644 index 83d751ff..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java +++ /dev/null @@ -1,94 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity; - -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Entity -@Table( - name = "tb_push_installation", - uniqueConstraints = { - @UniqueConstraint( - name = "uk_push_installation_installation_id", - columnNames = "installation_id" - ), - @UniqueConstraint( - name = "uk_push_installation_expo_push_token", - columnNames = "expo_push_token" - ) - }, - indexes = { - @Index( - name = "idx_push_installation_user_variant_active", - columnList = "user_id, app_variant, active" - ) - } -) -@NoArgsConstructor -@Getter -public class PushInstallation extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "push_installation_id") - private Long pushInstallationId; - - @Column(name = "user_id", nullable = false) - private Long userId; - - @Column(name = "installation_id", nullable = false, length = 64) - private String installationId; - - @Column(name = "expo_push_token", nullable = false, length = 255) - private String expoPushToken; - - @Enumerated(EnumType.STRING) - @Column(name = "app_variant", nullable = false, length = 20) - private AppVariant appVariant; - - @Column(name = "active", nullable = false) - private boolean active = true; - - @Column(name = "deactivated_at") - private LocalDateTime deactivatedAt; - - public PushInstallation( - Long userId, - String installationId, - String expoPushToken, - AppVariant appVariant - ) { - this.userId = userId; - this.installationId = installationId; - this.expoPushToken = expoPushToken; - this.appVariant = appVariant; - this.active = true; - } - - public void register( - Long userId, - String installationId, - String expoPushToken, - AppVariant appVariant - ) { - this.userId = userId; - this.installationId = installationId; - this.expoPushToken = expoPushToken; - this.appVariant = appVariant; - this.active = true; - this.deactivatedAt = null; - } - - public void deactivate(LocalDateTime now) { - if (!active) { - return; - } - - this.active = false; - this.deactivatedAt = now; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java deleted file mode 100644 index d7a2f1c4..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java +++ /dev/null @@ -1,290 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity; - -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table( - name = "tb_push_message", - indexes = { - @Index( - name = "idx_push_message_dispatch", - columnList = "push_dispatch_id" - ), - @Index( - name = "idx_push_message_status_next_retry_at", - columnList = "status, next_retry_at" - ) - } -) -@NoArgsConstructor -@Getter -public class PushMessage { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "push_message_id") - private Long pushMessageId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "push_dispatch_id", nullable = false) - private PushDispatch dispatch; - - @Column(name = "push_installation_id", nullable = false) - private Long pushInstallationId; - - @Column(name = "installation_id", nullable = false, length = 64) - private String installationId; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 30) - private PushMessageStatus status; - - @Column(name = "expo_ticket_id", length = 255) - private String expoTicketId; - - @Column(name = "ticket_status", length = 40) - private String ticketStatus; - - @Column(name = "ticket_error", length = 1024) - private String ticketError; - - @Column(name = "receipt_status", length = 40) - private String receiptStatus; - - @Column(name = "receipt_error", length = 1024) - private String receiptError; - - @Column(name = "send_attempts", nullable = false) - private int sendAttempts; - - @Column(name = "receipt_attempts", nullable = false) - private int receiptAttempts; - - @Column(name = "next_retry_at") - private LocalDateTime nextRetryAt; - - @Column(name = "sent_at") - private LocalDateTime sentAt; - - @Column(name = "receipt_available_at") - private LocalDateTime receiptAvailableAt; - - @Column(name = "receipt_checked_at") - private LocalDateTime receiptCheckedAt; - - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - public PushMessage( - PushDispatch dispatch, - PushInstallation installation, - LocalDateTime now - ) { - this.dispatch = dispatch; - this.pushInstallationId = installation.getPushInstallationId(); - this.installationId = installation.getInstallationId(); - this.status = PushMessageStatus.QUEUED; - this.expoTicketId = null; - this.ticketStatus = null; - this.ticketError = null; - this.receiptStatus = null; - this.receiptError = null; - this.sendAttempts = 0; - this.receiptAttempts = 0; - this.nextRetryAt = null; - this.sentAt = null; - this.receiptAvailableAt = null; - this.receiptCheckedAt = null; - this.createdAt = now; - this.updatedAt = now; - } - - public void recordTicket( - String ticketStatus, - String expoTicketId, - String ticketError, - LocalDateTime now - ) { - this.ticketStatus = ticketStatus; - this.expoTicketId = expoTicketId; - this.ticketError = ticketError; - this.sendAttempts += 1; - this.sentAt = now; - this.updatedAt = now; - this.status = "ok".equals(ticketStatus) - ? PushMessageStatus.RECEIPT_PENDING - : PushMessageStatus.FAILED; - this.receiptAvailableAt = "ok".equals(ticketStatus) ? now.plusMinutes(15) : null; - this.nextRetryAt = null; - } - - public void markSending(LocalDateTime now) { - this.status = PushMessageStatus.SENDING; - this.updatedAt = now; - } - - public void markReceiptChecking(LocalDateTime now) { - this.status = PushMessageStatus.SENDING; - this.updatedAt = now; - } - - public void recordReceipt( - String receiptStatus, - String receiptError, - LocalDateTime now - ) { - this.receiptStatus = receiptStatus; - this.receiptError = "ok".equals(receiptStatus) ? null : receiptError; - this.receiptAttempts += 1; - this.receiptCheckedAt = now; - this.receiptAvailableAt = null; - this.updatedAt = now; - this.status = "ok".equals(receiptStatus) - ? PushMessageStatus.DELIVERED - : PushMessageStatus.FAILED; - } - - public void scheduleReceiptRetry( - String receiptStatus, - String receiptError, - int maxReceiptAttempts, - LocalDateTime nextReceiptAvailableAt, - LocalDateTime now - ) { - this.receiptStatus = receiptStatus; - this.receiptError = receiptError; - this.receiptAttempts += 1; - this.receiptCheckedAt = now; - this.updatedAt = now; - if (receiptAttempts < maxReceiptAttempts) { - this.status = PushMessageStatus.RECEIPT_PENDING; - this.receiptAvailableAt = nextReceiptAvailableAt; - return; - } - this.status = PushMessageStatus.FAILED; - this.receiptAvailableAt = null; - } - - public void recordReceiptExpired(LocalDateTime now) { - this.receiptStatus = "expired"; - this.receiptError = "receipt_expired"; - this.receiptCheckedAt = now; - this.receiptAvailableAt = null; - this.updatedAt = now; - this.status = PushMessageStatus.FAILED; - } - - public void recordRetryableTicketError( - String ticketStatus, - String ticketError, - int maxSendAttempts, - LocalDateTime nextRetryAt, - LocalDateTime now - ) { - this.ticketStatus = ticketStatus; - this.expoTicketId = null; - this.ticketError = ticketError; - this.sendAttempts += 1; - this.sentAt = now; - this.updatedAt = now; - if (sendAttempts < maxSendAttempts) { - this.status = PushMessageStatus.QUEUED; - this.nextRetryAt = nextRetryAt; - this.receiptAvailableAt = null; - return; - } - this.status = PushMessageStatus.FAILED; - this.nextRetryAt = null; - this.receiptAvailableAt = null; - } - - public void recordClientError( - boolean retryable, - LocalDateTime now - ) { - this.ticketStatus = "client_error"; - this.ticketError = retryable ? "retryable" : "non_retryable"; - this.sendAttempts += 1; - this.updatedAt = now; - this.status = PushMessageStatus.FAILED; - this.nextRetryAt = null; - this.receiptAvailableAt = null; - } - - public void recordClientError( - boolean retryable, - String ticketError, - int maxSendAttempts, - LocalDateTime nextRetryAt, - LocalDateTime now - ) { - this.ticketStatus = "client_error"; - this.expoTicketId = null; - this.ticketError = ticketError; - this.sendAttempts += 1; - this.updatedAt = now; - if (retryable && sendAttempts < maxSendAttempts) { - this.status = PushMessageStatus.QUEUED; - this.nextRetryAt = nextRetryAt; - this.receiptAvailableAt = null; - return; - } - this.status = PushMessageStatus.FAILED; - this.nextRetryAt = null; - this.receiptAvailableAt = null; - } - - public void recordSkipped( - String ticketStatus, - String ticketError, - LocalDateTime now - ) { - this.ticketStatus = ticketStatus; - this.expoTicketId = null; - this.ticketError = ticketError; - this.status = PushMessageStatus.FAILED; - this.nextRetryAt = null; - this.receiptAvailableAt = null; - this.updatedAt = now; - } - - public void recoverInterruptedSendingWithoutTicket( - String ticketError, - LocalDateTime now - ) { - this.ticketStatus = "worker_interrupted"; - this.expoTicketId = null; - this.ticketError = ticketError; - this.status = PushMessageStatus.FAILED; - this.nextRetryAt = null; - this.receiptAvailableAt = null; - this.updatedAt = now; - } - - public void recoverInterruptedSendingWithTicket(LocalDateTime now) { - this.status = PushMessageStatus.RECEIPT_PENDING; - this.nextRetryAt = null; - if (this.receiptAvailableAt == null) { - this.receiptAvailableAt = now; - } - this.updatedAt = now; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java deleted file mode 100644 index 7ca41786..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java +++ /dev/null @@ -1,122 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity; - -import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; -import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import java.time.LocalDateTime; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table( - name = "tb_survey_push_schedule", - uniqueConstraints = { - @UniqueConstraint( - name = "uk_survey_push_schedule_idempotency_key", - columnNames = "idempotency_key" - ) - }, - indexes = { - @Index( - name = "idx_survey_push_schedule_status_scheduled_at", - columnList = "status, scheduled_at" - ) - } -) -@NoArgsConstructor -@Getter -public class SurveyPushSchedule { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "survey_push_schedule_id") - private Long surveyPushScheduleId; - - @Column(name = "survey_key", nullable = false, length = 64) - private String surveyKey; - - @Enumerated(EnumType.STRING) - @Column(name = "notification_stage", nullable = false, length = 40) - private SurveyNotificationStage notificationStage; - - @Column(name = "target_user_id") - private Long targetUserId; - - @Column(name = "scheduled_at", nullable = false) - private LocalDateTime scheduledAt; - - @Column(name = "reward_point", nullable = false) - private int rewardPoint; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 30) - private SurveyPushScheduleStatus status; - - @Column(name = "idempotency_key", nullable = false, length = 128) - private String idempotencyKey; - - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "processed_at") - private LocalDateTime processedAt; - - public SurveyPushSchedule( - String surveyKey, - SurveyNotificationStage notificationStage, - Long targetUserId, - LocalDateTime scheduledAt, - int rewardPoint, - String idempotencyKey, - LocalDateTime createdAt - ) { - this.surveyKey = surveyKey; - this.notificationStage = notificationStage; - this.targetUserId = targetUserId; - this.scheduledAt = scheduledAt; - this.rewardPoint = rewardPoint; - this.status = SurveyPushScheduleStatus.PENDING; - this.idempotencyKey = idempotencyKey; - this.createdAt = createdAt; - this.processedAt = null; - } - - public boolean isPending() { - return SurveyPushScheduleStatus.PENDING.equals(status); - } - - public void updatePendingSchedule( - LocalDateTime scheduledAt, - int rewardPoint - ) { - if (!isPending()) { - return; - } - this.scheduledAt = scheduledAt; - this.rewardPoint = rewardPoint; - } - - public void complete(LocalDateTime now) { - this.status = SurveyPushScheduleStatus.COMPLETED; - this.processedAt = now; - } - - public void cancel(LocalDateTime now) { - this.status = SurveyPushScheduleStatus.CANCELLED; - this.processedAt = now; - } - - public void skip(LocalDateTime now) { - this.status = SurveyPushScheduleStatus.SKIPPED; - this.processedAt = now; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java deleted file mode 100644 index d228d6f3..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java +++ /dev/null @@ -1,29 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; - -import java.util.Locale; - -public enum AppVariant { - - DEV, - PREVIEW, - PRODUCTION; - - @JsonCreator(mode = JsonCreator.Mode.DELEGATING) - public static AppVariant from(String value) { - if (value == null) { - return null; - } - - return AppVariant.valueOf( - value.trim().toUpperCase(Locale.ROOT) - ); - } - - @JsonValue - public String toValue() { - return name().toLowerCase(Locale.ROOT); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java deleted file mode 100644 index 8ea93bef..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java +++ /dev/null @@ -1,5 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum NotificationType { - GENERAL -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java deleted file mode 100644 index 7b110df6..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java +++ /dev/null @@ -1,12 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum PushActionType { - HOME, - NOTICE, - MY_PAGE, - BUS_STOP, - BUILDING_DETAIL, - PLACE_DETAIL, - CHARACTER_STORE, - TEST -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java deleted file mode 100644 index 91110c5e..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum PushDispatchStatus { - QUEUED, - PROCESSING, - COMPLETED, - PARTIAL_FAILED, - FAILED -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java deleted file mode 100644 index 3445875f..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java +++ /dev/null @@ -1,18 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum PushEventType { - CROWD("push:event:crowd-enabled"), - REPORT("push:event:report-enabled"), - CHARACTER("push:event:character-enabled"), - SURVEY("push:event:survey-enabled"); - - private final String redisKey; - - PushEventType(String redisKey) { - this.redisKey = redisKey; - } - - public String redisKey() { - return redisKey; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java deleted file mode 100644 index 7e750938..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum PushMessageStatus { - QUEUED, - SENDING, - TICKET_RECEIVED, - RECEIPT_PENDING, - DELIVERED, - FAILED -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java deleted file mode 100644 index 7068edf0..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum PushMode { - TEST, - ACTUAL -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java deleted file mode 100644 index 296d2715..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java +++ /dev/null @@ -1,8 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum PushTargetType { - INSTALLATION, - USER, - USER_GROUP, - ALL -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java deleted file mode 100644 index 8891eac6..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java +++ /dev/null @@ -1,8 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum SurveyNotificationStage { - STARTED, - D_MINUS_3, - DEADLINE, - REMIND_AFTER_LATER -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java deleted file mode 100644 index 8e06baed..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum SurveyPushScheduleStatus { - PENDING, - COMPLETED, - CANCELLED, - SKIPPED -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java deleted file mode 100644 index 61fb3b1f..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.notification.entity.type; - -public enum SurveyReminderSuppressedBy { - NONE, - D3, - DEADLINE, - EXPIRED, - ALREADY_PROCESSED, - ALREADY_PARTICIPATED -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java deleted file mode 100644 index 4f93898f..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java +++ /dev/null @@ -1,123 +0,0 @@ -package devkor.com.teamcback.domain.notification.factory; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.validation.PushActionValidator; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; - -@Component -@RequiredArgsConstructor -public class PushPayloadFactory { - - private static final int PAYLOAD_VERSION = 1; - private static final int MAX_PAYLOAD_BYTES = 4096; - private static final String SIZE_VALIDATION_NOTIFICATION_ID = "00000000-0000-4000-8000-000000000000"; - - private final PushActionValidator actionValidator; - private final ObjectMapper objectMapper; - - public PushPayload create( - String notificationId, - String title, - String body, - PushMode mode, - AppVariant appVariant, - PushActionType actionType, - Map actionParams - ) { - validateText(notificationId); - validateText(title); - validateText(body); - - Map normalizedActionParams = actionValidator.validateAndNormalize( - actionType, - mode, - appVariant, - actionParams - ); - - PushPayload payload = new PushPayload( - title, - body, - new PushPayload.PushPayloadData( - PAYLOAD_VERSION, - notificationId, - appVariant.toValue(), - new PushPayload.PushPayloadAction( - actionType.name(), - normalizedActionParams - ) - ) - ); - - validatePayloadSize(payload); - return payload; - } - - public PushPayload createForPreDispatchValidation( - String title, - String body, - PushMode mode, - AppVariant appVariant, - PushActionType actionType, - Map actionParams - ) { - // PushMessage uses an identity Long, so the worker must create the final payload with the real message id and re-check size. - return create( - SIZE_VALIDATION_NOTIFICATION_ID, - title, - body, - mode, - appVariant, - actionType, - actionParams - ); - } - - public String serializeActionParams(Map actionParams) { - try { - return objectMapper.writeValueAsString(actionParams); - } catch (JsonProcessingException e) { - throw new GlobalException(INVALID_INPUT); - } - } - - public Map deserializeActionParams(String actionParams) { - if (actionParams == null || actionParams.isBlank()) { - return Map.of(); - } - - try { - return objectMapper.readValue(actionParams, new TypeReference<>() { - }); - } catch (JsonProcessingException e) { - throw new GlobalException(INVALID_INPUT); - } - } - - private void validateText(String value) { - if (value == null || value.isBlank()) { - throw new GlobalException(INVALID_INPUT); - } - } - - private void validatePayloadSize(PushPayload payload) { - try { - if (objectMapper.writeValueAsBytes(payload).length > MAX_PAYLOAD_BYTES) { - throw new GlobalException(INVALID_INPUT); - } - } catch (JsonProcessingException e) { - throw new GlobalException(INVALID_INPUT); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java deleted file mode 100644 index db24ed9a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java +++ /dev/null @@ -1,91 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.service.PushDispatchService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; -import devkor.com.teamcback.domain.notification.template.PushContent; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class CharacterUnlockedPushEventListener { - - private static final Long SYSTEM_CREATED_BY = 0L; - - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchService pushDispatchService; - private final PushEventFlagService pushEventFlagService; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(CharacterUnlockedEvent event) { - if (!pushEventFlagService.isEnabled(PushEventType.CHARACTER)) { - return; - } - - for (AppVariant targetAppVariant : targetAppVariants()) { - try { - if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( - event.userId(), - targetAppVariant - )) { - continue; - } - - PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); - pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - targetAppVariant, - PushTargetType.USER, - String.valueOf(event.userId()), - content.title(), - content.body(), - PushActionType.CHARACTER_STORE, - Map.of(), - "character-unlock:%d:%d:%d:%s".formatted( - event.userId(), - event.characterId(), - event.userCharacterId(), - targetAppVariant.name().toLowerCase() - ), - SYSTEM_CREATED_BY - )); - } catch (Exception e) { - log.warn( - "character unlock push failed: userId={}, characterId={}, userCharacterId={}, appVariant={}, error={}", - event.userId(), - event.characterId(), - event.userCharacterId(), - targetAppVariant, - e.getMessage() - ); - } - } - } - - private List targetAppVariants() { - List configuredVariants = pushEventFlagService.getTargetAppVariants(); - return configuredVariants == null || configuredVariants.isEmpty() - ? List.of(AppVariant.PRODUCTION) - : configuredVariants; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java deleted file mode 100644 index 80c08dc6..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java +++ /dev/null @@ -1,128 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; -import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; -import devkor.com.teamcback.domain.common.LocationType; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import devkor.com.teamcback.domain.notification.service.PushDispatchService; -import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; -import devkor.com.teamcback.domain.notification.template.PushContent; -import devkor.com.teamcback.domain.place.entity.Place; -import devkor.com.teamcback.domain.place.repository.PlaceRepository; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class CrowdVacantPushEventListener { - - private static final Long SYSTEM_CREATED_BY = 0L; - - private final PlaceRepository placeRepository; - private final CategoryRepository categoryRepository; - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchService pushDispatchService; - private final PushEventFlagService pushEventFlagService; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(PlaceBecameVacantEvent event) { - if (!pushEventFlagService.isEnabled(PushEventType.CROWD)) { - return; - } - - try { - Place place = placeRepository.findById(event.placeId()) - .orElse(null); - if (place == null) { - log.warn("crowd vacant push skipped: place not found, placeId={}", event.placeId()); - return; - } - - Set userIds = new LinkedHashSet<>( - categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId( - LocationType.PLACE, - event.placeId() - ) - ); - if (userIds.isEmpty()) { - return; - } - - PushContent content = DomainPushContentFactory.placeBecameVacant( - place.getBuilding() == null ? null : place.getBuilding().getName(), - place.getName() - ); - for (Long userId : userIds) { - enqueueIfPushTargetExists(event, userId, content); - } - } catch (Exception e) { - log.warn( - "crowd vacant push failed: placeId={}, bleDataId={}, error={}", - event.placeId(), - event.bleDataId(), - e.getMessage() - ); - } - } - - private void enqueueIfPushTargetExists( - PlaceBecameVacantEvent event, - Long userId, - PushContent content - ) { - if (userId == null) { - return; - } - - for (AppVariant targetAppVariant : targetAppVariants()) { - if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, targetAppVariant)) { - continue; - } - - pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - targetAppVariant, - PushTargetType.USER, - String.valueOf(userId), - content.title(), - content.body(), - PushActionType.PLACE_DETAIL, - Map.of("placeId", event.placeId()), - "crowd-vacant:%d:%d:%d:%s".formatted( - event.placeId(), - userId, - event.bleDataId(), - targetAppVariant.name().toLowerCase() - ), - SYSTEM_CREATED_BY - )); - } - } - - private List targetAppVariants() { - List configuredVariants = pushEventFlagService.getTargetAppVariants(); - return configuredVariants == null || configuredVariants.isEmpty() - ? List.of(AppVariant.PRODUCTION) - : configuredVariants; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java deleted file mode 100644 index 1a58fd09..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java +++ /dev/null @@ -1,91 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.service.PushDispatchService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; -import devkor.com.teamcback.domain.notification.template.PushContent; -import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ReportResolvedPushEventListener { - - private static final Long SYSTEM_CREATED_BY = 0L; - - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchService pushDispatchService; - private final PushEventFlagService pushEventFlagService; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(ReportResolvedEvent event) { - if (!pushEventFlagService.isEnabled(PushEventType.REPORT) || event.reporterUserId() == null) { - return; - } - - for (AppVariant targetAppVariant : targetAppVariants()) { - try { - if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( - event.reporterUserId(), - targetAppVariant - )) { - continue; - } - - PushContent content = DomainPushContentFactory.reportResolved(); - pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - targetAppVariant, - PushTargetType.USER, - String.valueOf(event.reporterUserId()), - content.title(), - content.body(), - PushActionType.HOME, - Map.of(), - "report-result:%d:%s:%d:%s".formatted( - event.reportId(), - event.finalStatus().name(), - event.reporterUserId(), - targetAppVariant.name().toLowerCase() - ), - SYSTEM_CREATED_BY - )); - } catch (Exception e) { - log.warn( - "report result push failed: reportId={}, reporterUserId={}, finalStatus={}, appVariant={}, error={}", - event.reportId(), - event.reporterUserId(), - event.finalStatus(), - targetAppVariant, - e.getMessage() - ); - } - } - } - - private List targetAppVariants() { - List configuredVariants = pushEventFlagService.getTargetAppVariants(); - return configuredVariants == null || configuredVariants.isEmpty() - ? List.of(AppVariant.PRODUCTION) - : configuredVariants; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java deleted file mode 100644 index 6571ae74..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package devkor.com.teamcback.domain.notification.repository; - -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import java.util.Optional; -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.Query; -import org.springframework.data.repository.query.Param; - -public interface PushDispatchRepository extends JpaRepository { - - Optional findByIdempotencyKey( - String idempotencyKey - ); - - @Query(""" - SELECT d - FROM PushDispatch d - WHERE (:appVariant IS NULL OR d.appVariant = :appVariant) - AND (:status IS NULL OR d.status = :status) - ORDER BY d.createdAt DESC, d.pushDispatchId DESC - """) - Page findAdminDispatches( - @Param("appVariant") AppVariant appVariant, - @Param("status") PushDispatchStatus status, - Pageable pageable - ); -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java deleted file mode 100644 index 96ec8433..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java +++ /dev/null @@ -1,61 +0,0 @@ -package devkor.com.teamcback.domain.notification.repository; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface PushInstallationRepository extends JpaRepository { - - Optional findByInstallationId( - String installationId - ); - - Optional findByExpoPushToken( - String expoPushToken - ); - - Optional findByInstallationIdAndUserId( - String installationId, - Long userId - ); - - List findAllByUserIdAndActiveTrue( - Long userId - ); - - List findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc( - Long userId - ); - - Optional findByInstallationIdAndAppVariantAndActiveTrue( - String installationId, - AppVariant appVariant - ); - - List findAllByUserIdAndAppVariantAndActiveTrue( - Long userId, - AppVariant appVariant - ); - - List findAllByAppVariantAndActiveTrue( - AppVariant appVariant - ); - - boolean existsByUserIdAndAppVariantAndActiveTrue( - Long userId, - AppVariant appVariant - ); - - boolean existsByAppVariantAndActiveTrue( - AppVariant appVariant - ); - - Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( - Long pushInstallationId, - String installationId, - AppVariant appVariant - ); -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java deleted file mode 100644 index d9e0a380..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java +++ /dev/null @@ -1,98 +0,0 @@ -package devkor.com.teamcback.domain.notification.repository; - -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Collection; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface PushMessageRepository extends JpaRepository { - - List findAllByDispatch( - PushDispatch dispatch - ); - - @Query( - value = """ - SELECT * - FROM tb_push_message - WHERE status = 'QUEUED' - AND (next_retry_at IS NULL OR next_retry_at <= :now) - ORDER BY created_at ASC, push_message_id ASC - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - List findDueQueuedForUpdateSkipLocked( - @Param("now") LocalDateTime now, - @Param("limit") int limit - ); - - @Query( - value = """ - SELECT * - FROM tb_push_message - WHERE status = 'RECEIPT_PENDING' - AND expo_ticket_id IS NOT NULL - AND expo_ticket_id <> '' - AND receipt_available_at IS NOT NULL - AND receipt_available_at <= :now - ORDER BY receipt_available_at ASC, push_message_id ASC - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - List findDueReceiptPendingForUpdateSkipLocked( - @Param("now") LocalDateTime now, - @Param("limit") int limit - ); - - @Query( - value = """ - SELECT * - FROM tb_push_message - WHERE status = 'SENDING' - AND updated_at <= :staleBefore - ORDER BY updated_at ASC, push_message_id ASC - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - List findStaleSendingForUpdateSkipLocked( - @Param("staleBefore") LocalDateTime staleBefore, - @Param("limit") int limit - ); - - @EntityGraph(attributePaths = "dispatch") - List findAllByPushMessageIdIn( - Collection pushMessageIds - ); - - @Query(""" - SELECT m.dispatch.pushDispatchId AS dispatchId, - m.status AS status, - COUNT(m) AS count - FROM PushMessage m - WHERE m.dispatch.pushDispatchId IN :dispatchIds - GROUP BY m.dispatch.pushDispatchId, m.status - """) - List countStatusesByDispatchIds( - @Param("dispatchIds") Collection dispatchIds - ); - - interface PushDispatchMessageStatusCount { - Long getDispatchId(); - - PushMessageStatus getStatus(); - - long getCount(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java deleted file mode 100644 index 9cd0b446..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java +++ /dev/null @@ -1,51 +0,0 @@ -package devkor.com.teamcback.domain.notification.repository; - -import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; -import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; -import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface SurveyPushScheduleRepository extends JpaRepository { - - Optional findByIdempotencyKey(String idempotencyKey); - - List findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(String surveyKey); - - List findAllBySurveyKeyAndNotificationStageIn( - String surveyKey, - Collection notificationStages - ); - - Optional findBySurveyKeyAndNotificationStage( - String surveyKey, - SurveyNotificationStage notificationStage - ); - - @Query( - value = """ - SELECT * - FROM tb_survey_push_schedule - WHERE status = 'PENDING' - AND scheduled_at <= :now - ORDER BY scheduled_at ASC, survey_push_schedule_id ASC - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - List findDuePendingForUpdateSkipLocked( - @Param("now") LocalDateTime now, - @Param("limit") int limit - ); - - List findAllByStatusAndScheduledAtLessThanEqualOrderByScheduledAtAscSurveyPushScheduleIdAsc( - SurveyPushScheduleStatus status, - LocalDateTime now - ); -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java deleted file mode 100644 index 60fe13b1..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java +++ /dev/null @@ -1,124 +0,0 @@ -package devkor.com.teamcback.domain.notification.resolver; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; -import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_REQUEST; - -@Component -@RequiredArgsConstructor -public class PushTargetResolver { - - private final PushInstallationRepository pushInstallationRepository; - - public List resolve( - PushTargetType targetType, - String targetValue, - AppVariant appVariant - ) { - return resolve(targetType, targetValue, appVariant, false); - } - - public List resolveForPreview( - PushTargetType targetType, - String targetValue, - AppVariant appVariant - ) { - return resolve(targetType, targetValue, appVariant, true); - } - - private List resolve( - PushTargetType targetType, - String targetValue, - AppVariant appVariant, - boolean allowEmpty - ) { - if (targetType == null || targetValue == null || targetValue.isBlank() || appVariant == null) { - throw new GlobalException(INVALID_INPUT); - } - - List installations = switch (targetType) { - case INSTALLATION -> resolveInstallation(targetValue, appVariant); - case USER -> resolveUser(targetValue, appVariant); - case USER_GROUP -> throw new GlobalException(UNSUPPORTED_REQUEST); - case ALL -> resolveAll(targetValue, appVariant); - }; - - List distinctInstallations = distinctByInstallation(installations); - - if (distinctInstallations.isEmpty() && !allowEmpty) { - throw new GlobalException(INVALID_INPUT); - } - - return distinctInstallations; - } - - private List resolveInstallation( - String installationId, - AppVariant appVariant - ) { - return pushInstallationRepository.findByInstallationIdAndAppVariantAndActiveTrue( - installationId, - appVariant - ) - .map(List::of) - .orElseGet(List::of); - } - - private List resolveUser( - String targetValue, - AppVariant appVariant - ) { - Long userId = parsePositiveLong(targetValue); - - return pushInstallationRepository.findAllByUserIdAndAppVariantAndActiveTrue( - userId, - appVariant - ); - } - - private List resolveAll( - String targetValue, - AppVariant appVariant - ) { - if (!"ALL".equals(targetValue)) { - throw new GlobalException(INVALID_INPUT); - } - - return pushInstallationRepository.findAllByAppVariantAndActiveTrue(appVariant); - } - - private List distinctByInstallation(List installations) { - Map distinct = new LinkedHashMap<>(); - - installations.forEach(installation -> - distinct.putIfAbsent( - installation.getInstallationId(), - installation - ) - ); - - return distinct.values().stream().toList(); - } - - private Long parsePositiveLong(String value) { - try { - Long parsed = Long.parseLong(value); - if (parsed <= 0) { - throw new GlobalException(INVALID_INPUT); - } - return parsed; - } catch (NumberFormatException e) { - throw new GlobalException(INVALID_INPUT); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java deleted file mode 100644 index 29961b2e..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java +++ /dev/null @@ -1,20 +0,0 @@ -package devkor.com.teamcback.domain.notification.scheduler; - -import devkor.com.teamcback.domain.notification.service.PushMessageRecoveryWorker; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(prefix = "push.recovery-worker", name = "enabled", havingValue = "true") -public class PushMessageRecoveryScheduler { - - private final PushMessageRecoveryWorker pushMessageRecoveryWorker; - - @Scheduled(cron = "${push.recovery-worker.cron}") - public void recoverStaleSendingMessages() { - pushMessageRecoveryWorker.recoverStaleSendingMessages(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java deleted file mode 100644 index 9b2067d8..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java +++ /dev/null @@ -1,20 +0,0 @@ -package devkor.com.teamcback.domain.notification.scheduler; - -import devkor.com.teamcback.domain.notification.service.PushMessageDispatchWorker; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(prefix = "push.worker", name = "enabled", havingValue = "true") -public class PushMessageWorkerScheduler { - - private final PushMessageDispatchWorker pushMessageDispatchWorker; - - @Scheduled(fixedDelayString = "${push.worker.fixed-delay-ms:5000}") - public void dispatchQueuedMessages() { - pushMessageDispatchWorker.dispatchPending(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java deleted file mode 100644 index a7608f91..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java +++ /dev/null @@ -1,20 +0,0 @@ -package devkor.com.teamcback.domain.notification.scheduler; - -import devkor.com.teamcback.domain.notification.service.PushReceiptWorker; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(prefix = "push.receipt-worker", name = "enabled", havingValue = "true") -public class PushReceiptWorkerScheduler { - - private final PushReceiptWorker pushReceiptWorker; - - @Scheduled(fixedDelayString = "${push.receipt-worker.fixed-delay-ms:60000}") - public void checkPendingReceipts() { - pushReceiptWorker.checkPendingReceipts(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java deleted file mode 100644 index c529ee4b..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java +++ /dev/null @@ -1,206 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; -import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; -import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_REQUEST; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AdminNotificationService { - - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchRepository pushDispatchRepository; - private final PushMessageRepository pushMessageRepository; - private final PushTargetResolver pushTargetResolver; - private final PushPayloadFactory pushPayloadFactory; - private final PushDispatchService pushDispatchService; - - @Value("${push.admin.production-enabled:false}") - private boolean productionEnabled; - - public List searchInstallations( - Long userId, - String installationId - ) { - if ((userId == null && !hasText(installationId)) - || (userId != null && hasText(installationId))) { - throw new GlobalException(INVALID_INPUT); - } - - if (userId != null) { - return pushInstallationRepository.findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc(userId) - .stream() - .map(AdminPushInstallationRes::new) - .toList(); - } - - return pushInstallationRepository.findByInstallationId(installationId) - .stream() - .map(AdminPushInstallationRes::new) - .toList(); - } - - public AdminPushDispatchPreviewRes preview(AdminPushDispatchReq request) { - validateTargetRules(request); - - List installations = pushTargetResolver.resolveForPreview( - request.targetType(), - request.targetValue(), - request.appVariant() - ); - - PushPayload payload = pushPayloadFactory.createForPreDispatchValidation( - request.title(), - request.body(), - request.mode(), - request.appVariant(), - request.actionType(), - request.actionParams() - ); - - return new AdminPushDispatchPreviewRes( - installations.size(), - payload - ); - } - - @Transactional - public PushDispatchEnqueueRes enqueue( - Long adminUserId, - String idempotencyKey, - AdminPushDispatchReq request - ) { - validateTargetRules(request); - validateProductionGate(request); - - return pushDispatchService.enqueue(new PushDispatchCommand( - NotificationType.GENERAL, - request.mode(), - request.appVariant(), - request.targetType(), - request.targetValue(), - request.title(), - request.body(), - request.actionType(), - request.actionParams(), - idempotencyKey, - adminUserId - )); - } - - public Page getDispatches( - int page, - int size, - AppVariant appVariant, - PushDispatchStatus status - ) { - if (page < 1 || size < 1) { - throw new GlobalException(INVALID_INPUT); - } - - Pageable pageable = PageRequest.of(page - 1, size); - return pushDispatchRepository.findAdminDispatches(appVariant, status, pageable) - .map(AdminPushDispatchSummaryRes::new); - } - - public AdminPushDispatchDetailRes getDispatch(Long dispatchId) { - if (dispatchId == null) { - throw new GlobalException(INVALID_INPUT); - } - - PushDispatch dispatch = pushDispatchRepository.findById(dispatchId) - .orElseThrow(() -> new GlobalException(INVALID_INPUT)); - - Map statusCounts = zeroStatusCounts(); - pushMessageRepository.countStatusesByDispatchIds(List.of(dispatchId)) - .forEach(count -> statusCounts.put(count.getStatus(), count.getCount())); - - return new AdminPushDispatchDetailRes(dispatch, statusCounts); - } - - private void validateTargetRules(AdminPushDispatchReq request) { - if (request == null - || request.mode() == null - || request.appVariant() == null - || request.targetType() == null) { - throw new GlobalException(INVALID_INPUT); - } - - if (PushTargetType.USER_GROUP.equals(request.targetType())) { - throw new GlobalException(UNSUPPORTED_REQUEST); - } - - if (PushMode.TEST.equals(request.mode()) - && !PushTargetType.INSTALLATION.equals(request.targetType())) { - throw new GlobalException(INVALID_INPUT); - } - - if (PushMode.ACTUAL.equals(request.mode()) - && !PushTargetType.INSTALLATION.equals(request.targetType()) - && !PushTargetType.USER.equals(request.targetType()) - && !PushTargetType.ALL.equals(request.targetType())) { - throw new GlobalException(UNSUPPORTED_REQUEST); - } - } - - private void validateProductionGate(AdminPushDispatchReq request) { - if (!PushMode.ACTUAL.equals(request.mode()) - || !AppVariant.PRODUCTION.equals(request.appVariant())) { - return; - } - - if (!productionEnabled) { - throw new GlobalException(FORBIDDEN); - } - - if (!Boolean.TRUE.equals(request.confirm())) { - throw new GlobalException(INVALID_INPUT); - } - } - - private Map zeroStatusCounts() { - Map statusCounts = new EnumMap<>(PushMessageStatus.class); - for (PushMessageStatus status : PushMessageStatus.values()) { - statusCounts.put(status, 0L); - } - return statusCounts; - } - - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java deleted file mode 100644 index a3592b5a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java +++ /dev/null @@ -1,205 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; -import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; - -import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_NON_RETRYABLE_ERROR; -import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN_PUSH_INSTALLATION; -import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_PUSH_INSTALLATION; -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PUSH_INSTALLATION; -import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_PUSH_INSTALLATION_VARIANT; - -@Service -@RequiredArgsConstructor -public class NotificationTestService { - - private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; - private static final int SCHEMA_VERSION = 1; - private static final String TEST_TITLE = "고대로 테스트 알림"; - private static final String TEST_BODY = "푸시 알림 연결이 정상적으로 동작합니다."; - - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchRepository pushDispatchRepository; - private final PushMessageRepository pushMessageRepository; - private final PushPayloadFactory pushPayloadFactory; - private final Clock clock; - - public NotificationTestRes sendTest( - Long userId, - String idempotencyKey, - NotificationTestReq request - ) { - validateRequest(userId, idempotencyKey, request); - - PushInstallation installation = findAndValidateInstallation( - userId, - request.installationId() - ); - - return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) - .map(dispatch -> responseFromExistingDispatch(dispatch, installation)) - .orElseGet(() -> enqueue( - userId, - idempotencyKey, - installation - )); - } - - private NotificationTestRes enqueue( - Long userId, - String idempotencyKey, - PushInstallation installation - ) { - LocalDateTime now = LocalDateTime.now(clock); - - try { - PushDispatch dispatch = pushDispatchRepository.saveAndFlush(new PushDispatch( - NotificationType.GENERAL, - PushMode.TEST, - installation.getAppVariant(), - PushTargetType.INSTALLATION, - installation.getInstallationId(), - TEST_TITLE, - TEST_BODY, - PushActionType.TEST, - pushPayloadFactory.serializeActionParams(Collections.emptyMap()), - idempotencyKey, - userId, - now - )); - - PushMessage message = pushMessageRepository.saveAndFlush(new PushMessage( - dispatch, - installation, - now - )); - - dispatch.updateRecipientCount(1); - pushDispatchRepository.save(dispatch); - - return response(dispatch, message); - } catch (DataIntegrityViolationException e) { - return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) - .map(dispatchFromRace -> responseFromExistingDispatch(dispatchFromRace, installation)) - .orElseThrow(() -> new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR)); - } - } - - private NotificationTestRes responseFromExistingDispatch( - PushDispatch dispatch, - PushInstallation installation - ) { - List messages = pushMessageRepository.findAllByDispatch(dispatch); - - if (messages.size() != 1) { - throw new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR); - } - - PushMessage message = messages.get(0); - - if (!installation.getInstallationId().equals(message.getInstallationId())) { - throw new GlobalException(INVALID_INPUT); - } - - return response(dispatch, message); - } - - private NotificationTestRes response( - PushDispatch dispatch, - PushMessage message - ) { - return new NotificationTestRes( - String.valueOf(message.getPushMessageId()), - message.getInstallationId(), - dispatch.getAppVariant(), - message.getStatus(), - message.getExpoTicketId() - ); - } - - private PushInstallation findAndValidateInstallation( - Long userId, - String installationId - ) { - PushInstallation installation = pushInstallationRepository.findByInstallationId(installationId) - .orElseThrow(() -> new GlobalException(NOT_FOUND_PUSH_INSTALLATION)); - - if (!userId.equals(installation.getUserId())) { - throw new GlobalException(FORBIDDEN_PUSH_INSTALLATION); - } - - if (!installation.isActive()) { - throw new GlobalException(INACTIVE_PUSH_INSTALLATION); - } - - if (AppVariant.PRODUCTION.equals(installation.getAppVariant())) { - throw new GlobalException(UNSUPPORTED_PUSH_INSTALLATION_VARIANT); - } - - if (!AppVariant.DEV.equals(installation.getAppVariant()) - && !AppVariant.PREVIEW.equals(installation.getAppVariant())) { - throw new GlobalException(UNSUPPORTED_PUSH_INSTALLATION_VARIANT); - } - - if (!hasText(installation.getExpoPushToken())) { - throw new GlobalException(INVALID_INPUT); - } - - return installation; - } - - private void validateRequest( - Long userId, - String idempotencyKey, - NotificationTestReq request - ) { - if (userId == null || request == null || request.schemaVersion() == null - || request.schemaVersion() != SCHEMA_VERSION) { - throw new GlobalException(INVALID_INPUT); - } - - validateText(request.installationId(), 64); - validateText(idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); - - try { - UUID.fromString(idempotencyKey); - } catch (IllegalArgumentException e) { - throw new GlobalException(INVALID_INPUT); - } - } - - private void validateText( - String value, - int maxLength - ) { - if (!hasText(value) || value.length() > maxLength) { - throw new GlobalException(INVALID_INPUT); - } - } - - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java deleted file mode 100644 index e0127478..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java +++ /dev/null @@ -1,125 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class PushDispatchService { - - private static final int MAX_TITLE_LENGTH = 200; - private static final int MAX_BODY_LENGTH = 1024; - private static final int MAX_TARGET_VALUE_LENGTH = 128; - private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; - - private final PushDispatchRepository pushDispatchRepository; - private final PushMessageRepository pushMessageRepository; - private final PushPayloadFactory pushPayloadFactory; - private final PushTargetResolver pushTargetResolver; - private final Clock clock; - - @Transactional - public PushDispatchEnqueueRes enqueue(PushDispatchCommand command) { - validateCommand(command); - - PushPayload payload = pushPayloadFactory.createForPreDispatchValidation( - command.title(), - command.body(), - command.mode(), - command.appVariant(), - command.actionType(), - command.actionParams() - ); - - return pushDispatchRepository.findByIdempotencyKey(command.idempotencyKey()) - .map(PushDispatchEnqueueRes::new) - .orElseGet(() -> createDispatch(command, payload)); - } - - private PushDispatchEnqueueRes createDispatch( - PushDispatchCommand command, - PushPayload payload - ) { - List installations = pushTargetResolver.resolve( - command.targetType(), - command.targetValue(), - command.appVariant() - ); - - LocalDateTime now = LocalDateTime.now(clock); - - PushDispatch dispatch = pushDispatchRepository.save( - new PushDispatch( - command.notificationType(), - command.mode(), - command.appVariant(), - command.targetType(), - command.targetValue(), - command.title(), - command.body(), - command.actionType(), - pushPayloadFactory.serializeActionParams(payload.data().action().params()), - command.idempotencyKey(), - command.createdBy(), - now - ) - ); - - List messages = installations.stream() - .map(installation -> new PushMessage( - dispatch, - installation, - now - )) - .toList(); - - pushMessageRepository.saveAll(messages); - dispatch.updateRecipientCount(messages.size()); - - return new PushDispatchEnqueueRes(dispatch); - } - - private void validateCommand(PushDispatchCommand command) { - if (command == null - || command.notificationType() == null - || command.mode() == null - || command.appVariant() == null - || command.targetType() == null - || command.actionType() == null - || command.createdBy() == null) { - throw new GlobalException(INVALID_INPUT); - } - - validateText(command.targetValue(), MAX_TARGET_VALUE_LENGTH); - validateText(command.title(), MAX_TITLE_LENGTH); - validateText(command.body(), MAX_BODY_LENGTH); - validateText(command.idempotencyKey(), MAX_IDEMPOTENCY_KEY_LENGTH); - } - - private void validateText( - String value, - int maxLength - ) { - if (value == null || value.isBlank() || value.length() > maxLength) { - throw new GlobalException(INVALID_INPUT); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java deleted file mode 100644 index 91f8cbb5..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java +++ /dev/null @@ -1,88 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PushEventFlagService { - - private final StringRedisTemplate redisTemplate; - - @Value("${push.event.crowd-enabled:false}") - private boolean crowdDefaultEnabled; - - @Value("${push.event.report-enabled:false}") - private boolean reportDefaultEnabled; - - @Value("${push.event.character-enabled:false}") - private boolean characterDefaultEnabled; - - @Value("${push.event.survey-enabled:false}") - private boolean surveyDefaultEnabled; - - @Value("${push.event.target-app-variants:PRODUCTION}") - private String targetAppVariants; - - public List getTargetAppVariants() { - List configuredVariants = Arrays.stream(targetAppVariants.split(",")) - .map(String::trim) - .filter(value -> !value.isEmpty()) - .map(value -> AppVariant.valueOf(value.toUpperCase(Locale.ROOT))) - .distinct() - .toList(); - return configuredVariants.isEmpty() - ? List.of(AppVariant.PRODUCTION) - : configuredVariants; - } - - public boolean isEnabled(PushEventType eventType) { - String redisValue = getRedisValue(eventType); - if ("true".equalsIgnoreCase(redisValue)) { - return true; - } - if ("false".equalsIgnoreCase(redisValue)) { - return false; - } - return defaultEnabled(eventType); - } - - public List getFlags() { - return Arrays.stream(PushEventType.values()) - .map(eventType -> new AdminPushEventFlagRes(eventType, isEnabled(eventType))) - .toList(); - } - - public AdminPushEventFlagRes updateFlag( - PushEventType eventType, - boolean enabled - ) { - redisTemplate.opsForValue().set(eventType.redisKey(), Boolean.toString(enabled)); - return new AdminPushEventFlagRes(eventType, isEnabled(eventType)); - } - - private String getRedisValue(PushEventType eventType) { - try { - return redisTemplate.opsForValue().get(eventType.redisKey()); - } catch (RuntimeException e) { - return null; - } - } - - private boolean defaultEnabled(PushEventType eventType) { - return switch (eventType) { - case CROWD -> crowdDefaultEnabled; - case REPORT -> reportDefaultEnabled; - case CHARACTER -> characterDefaultEnabled; - case SURVEY -> surveyDefaultEnabled; - }; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java deleted file mode 100644 index 8a37340a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java +++ /dev/null @@ -1,120 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class PushInstallationService { - - private final PushInstallationRepository repository; - private final Clock clock; - - @Transactional - public void register( - Long userId, - String installationId, - String expoPushToken, - AppVariant appVariant - ) { - Optional installationMatch = repository.findByInstallationId(installationId); - - Optional tokenMatch = repository.findByExpoPushToken(expoPushToken); - - if (installationMatch.isEmpty() && tokenMatch.isEmpty()) { - repository.save( - new PushInstallation( - userId, - installationId, - expoPushToken, - appVariant - ) - ); - return; - } - - if (installationMatch.isPresent() && tokenMatch.isEmpty()) { - installationMatch.get().register( - userId, - installationId, - expoPushToken, - appVariant - ); - return; - } - - if (installationMatch.isEmpty()) { - tokenMatch.get().register( - userId, - installationId, - expoPushToken, - appVariant - ); - return; - } - - PushInstallation installationEntity = installationMatch.get(); - - PushInstallation tokenEntity = tokenMatch.get(); - - if (installationEntity == tokenEntity - || installationEntity.getPushInstallationId() - .equals(tokenEntity.getPushInstallationId())) { - - installationEntity.register( - userId, - installationId, - expoPushToken, - appVariant - ); - return; - } - - repository.delete(tokenEntity); - repository.flush(); - - installationEntity.register( - userId, - installationId, - expoPushToken, - appVariant - ); - } - - @Transactional - public void deactivate( - Long userId, - String installationId - ) { - LocalDateTime now = LocalDateTime.now(clock); - - repository.findByInstallationIdAndUserId( - installationId, - userId - ) - .ifPresent(installation -> - installation.deactivate(now) - ); - } - - @Transactional - public void deactivateAll( - Long userId - ) { - LocalDateTime now = LocalDateTime.now(clock); - - repository.findAllByUserIdAndActiveTrue(userId) - .forEach(installation -> - installation.deactivate(now) - ); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java deleted file mode 100644 index f1301b39..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java +++ /dev/null @@ -1,299 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.config.PushWorkerProperties; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class PushMessageClaimService { - - private static final String DEFAULT_SOUND = "default"; - private static final String TICKET_STATUS_ERROR = "error"; - private static final String RETRYABLE_TICKET_ERROR = "MessageRateExceeded"; - private static final String DEVICE_NOT_REGISTERED_ERROR = "DeviceNotRegistered"; - - private final PushMessageRepository pushMessageRepository; - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchRepository pushDispatchRepository; - private final PushPayloadFactory pushPayloadFactory; - private final PushWorkerProperties pushWorkerProperties; - private final Clock clock; - - @Transactional - public List claimDueMessages() { - LocalDateTime now = LocalDateTime.now(clock); - List messages = pushMessageRepository.findDueQueuedForUpdateSkipLocked( - now, - pushWorkerProperties.batchSize() - ); - - if (messages.isEmpty()) { - return List.of(); - } - - Set dispatchIds = new HashSet<>(); - List sendItems = messages.stream() - .peek(message -> { - message.markSending(now); - dispatchIds.add(message.getDispatch().getPushDispatchId()); - }) - .map(message -> createSendItem(message, now)) - .filter(item -> item != null) - .toList(); - - refreshDispatchStatuses(dispatchIds, now); - return sendItems; - } - - @Transactional - public void recordTickets( - List items, - List tickets - ) { - if (items.isEmpty()) { - return; - } - - LocalDateTime now = LocalDateTime.now(clock); - LocalDateTime nextRetryAt = now.plusNanos(pushWorkerProperties.retryDelayMs() * 1_000_000L); - Map messageMap = findMessageMap(items); - Set dispatchIds = new HashSet<>(); - - for (int i = 0; i < items.size(); i += 1) { - PushSendItem item = items.get(i); - PushMessage message = messageMap.get(item.pushMessageId()); - if (message == null) { - continue; - } - - ExpoPushTicket ticket = tickets == null || i >= tickets.size() ? null : tickets.get(i); - String ticketError = ticketError(ticket); - if (isDeviceNotRegisteredTicket(ticket)) { - message.recordTicket( - ticket.status(), - ticket.id(), - ticketError, - now - ); - pushInstallationRepository.findById(message.getPushInstallationId()) - .ifPresent(installation -> installation.deactivate(now)); - } else if (isRetryableTicket(ticket)) { - message.recordRetryableTicketError( - ticket.status(), - ticketError, - pushWorkerProperties.maxSendAttempts(), - nextRetryAt, - now - ); - } else { - message.recordTicket( - ticket == null ? "missing_ticket" : ticket.status(), - ticket == null ? null : ticket.id(), - ticketError, - now - ); - } - dispatchIds.add(item.pushDispatchId()); - } - - refreshDispatchStatuses(dispatchIds, now); - } - - @Transactional - public void recordClientError( - List items, - boolean retryable, - String ticketError - ) { - if (items.isEmpty()) { - return; - } - - LocalDateTime now = LocalDateTime.now(clock); - LocalDateTime nextRetryAt = now.plusNanos(pushWorkerProperties.retryDelayMs() * 1_000_000L); - Map messageMap = findMessageMap(items); - Set dispatchIds = new HashSet<>(); - - items.forEach(item -> { - PushMessage message = messageMap.get(item.pushMessageId()); - if (message == null) { - return; - } - message.recordClientError( - retryable, - truncate(ticketError), - pushWorkerProperties.maxSendAttempts(), - nextRetryAt, - now - ); - dispatchIds.add(item.pushDispatchId()); - }); - - refreshDispatchStatuses(dispatchIds, now); - } - - private PushSendItem createSendItem( - PushMessage message, - LocalDateTime now - ) { - PushDispatch dispatch = message.getDispatch(); - PushInstallation installation = pushInstallationRepository - .findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( - message.getPushInstallationId(), - message.getInstallationId(), - dispatch.getAppVariant() - ) - .orElse(null); - - if (installation == null) { - message.recordSkipped( - "installation_inactive", - "inactive_or_variant_mismatch", - now - ); - return null; - } - - try { - PushPayload payload = pushPayloadFactory.create( - String.valueOf(message.getPushMessageId()), - dispatch.getTitle(), - dispatch.getBody(), - dispatch.getMode(), - dispatch.getAppVariant(), - dispatch.getActionType(), - pushPayloadFactory.deserializeActionParams(dispatch.getActionParams()) - ); - - return new PushSendItem( - message.getPushMessageId(), - dispatch.getPushDispatchId(), - new ExpoPushRequest( - installation.getExpoPushToken(), - payload.title(), - payload.body(), - DEFAULT_SOUND, - payload.data() - ) - ); - } catch (RuntimeException e) { - message.recordSkipped( - "invalid_payload", - "payload_validation_failed", - now - ); - return null; - } - } - - private Map findMessageMap(List items) { - List messageIds = items.stream() - .map(PushSendItem::pushMessageId) - .toList(); - - return pushMessageRepository.findAllByPushMessageIdIn(messageIds) - .stream() - .collect(Collectors.toMap( - PushMessage::getPushMessageId, - message -> message - )); - } - - private void refreshDispatchStatuses( - Collection dispatchIds, - LocalDateTime now - ) { - if (dispatchIds.isEmpty()) { - return; - } - - Map> countsByDispatchId = new HashMap<>(); - pushMessageRepository.countStatusesByDispatchIds(dispatchIds) - .forEach(count -> countsByDispatchId - .computeIfAbsent( - count.getDispatchId(), - ignored -> new EnumMap<>(PushMessageStatus.class) - ) - .put(count.getStatus(), count.getCount())); - - pushDispatchRepository.findAllById(dispatchIds) - .forEach(dispatch -> { - EnumMap counts = countsByDispatchId.getOrDefault( - dispatch.getPushDispatchId(), - new EnumMap<>(PushMessageStatus.class) - ); - dispatch.updateStatusFromMessageSummary( - count(counts, PushMessageStatus.QUEUED) - + count(counts, PushMessageStatus.TICKET_RECEIVED) - + count(counts, PushMessageStatus.RECEIPT_PENDING), - count(counts, PushMessageStatus.SENDING), - count(counts, PushMessageStatus.DELIVERED), - count(counts, PushMessageStatus.FAILED), - now - ); - }); - } - - private long count( - EnumMap counts, - PushMessageStatus status - ) { - return counts.getOrDefault(status, 0L); - } - - private boolean isRetryableTicket(ExpoPushTicket ticket) { - return ticket != null - && TICKET_STATUS_ERROR.equals(ticket.status()) - && RETRYABLE_TICKET_ERROR.equals(ticketError(ticket)); - } - - private boolean isDeviceNotRegisteredTicket(ExpoPushTicket ticket) { - return ticket != null - && TICKET_STATUS_ERROR.equals(ticket.status()) - && ticket.details() != null - && DEVICE_NOT_REGISTERED_ERROR.equals(ticket.details().error()); - } - - private String ticketError(ExpoPushTicket ticket) { - if (ticket == null) { - return "missing_ticket"; - } - - if (ticket.details() != null && ticket.details().error() != null) { - return truncate(ticket.details().error()); - } - - return truncate(ticket.message()); - } - - private String truncate(String value) { - if (value == null || value.length() <= 1024) { - return value; - } - return value.substring(0, 1024); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java deleted file mode 100644 index e2e43f50..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java +++ /dev/null @@ -1,47 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.client.ExpoPushClient; -import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; -import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; -import devkor.com.teamcback.domain.notification.dto.worker.PushWorkerDispatchResult; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class PushMessageDispatchWorker { - - private final PushMessageClaimService pushMessageClaimService; - private final ExpoPushClient expoPushClient; - - public PushWorkerDispatchResult dispatchPending() { - List items = pushMessageClaimService.claimDueMessages(); - if (items.isEmpty()) { - return PushWorkerDispatchResult.empty(); - } - - try { - ExpoPushResponse response = expoPushClient.send(items.stream() - .map(PushSendItem::request) - .toList()); - pushMessageClaimService.recordTickets(items, response.data()); - return new PushWorkerDispatchResult(items.size(), items.size()); - } catch (ExpoPushClientException e) { - pushMessageClaimService.recordClientError( - items, - e.isRetryable(), - e.getHttpStatus() == null ? e.getMessage() : "http_" + e.getHttpStatus() - ); - log.warn( - "Expo push send failed. retryable={}, itemCount={}", - e.isRetryable(), - items.size() - ); - return new PushWorkerDispatchResult(items.size(), 0); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java deleted file mode 100644 index 32aed3d4..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java +++ /dev/null @@ -1,112 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.config.PushRecoveryWorkerProperties; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class PushMessageRecoveryService { - - private static final String WORKER_INTERRUPTED = "worker_interrupted"; - - private final PushMessageRepository pushMessageRepository; - private final PushDispatchRepository pushDispatchRepository; - private final PushRecoveryWorkerProperties pushRecoveryWorkerProperties; - private final Clock clock; - - @Transactional - public int recoverStaleSendingMessages() { - LocalDateTime now = LocalDateTime.now(clock); - LocalDateTime staleBefore = now.minusMinutes(pushRecoveryWorkerProperties.staleThresholdMinutes()); - List messages = pushMessageRepository.findStaleSendingForUpdateSkipLocked( - staleBefore, - pushRecoveryWorkerProperties.batchSize() - ); - - if (messages.isEmpty()) { - return 0; - } - - Set dispatchIds = new HashSet<>(); - messages.forEach(message -> { - dispatchIds.add(message.getDispatch().getPushDispatchId()); - if (hasText(message.getExpoTicketId())) { - message.recoverInterruptedSendingWithTicket(now); - return; - } - message.recoverInterruptedSendingWithoutTicket(WORKER_INTERRUPTED, now); - }); - - refreshDispatchStatuses(dispatchIds, now); - return messages.size(); - } - - private void refreshDispatchStatuses( - Collection dispatchIds, - LocalDateTime now - ) { - if (dispatchIds.isEmpty()) { - return; - } - - Map> countsByDispatchId = new HashMap<>(); - pushMessageRepository.countStatusesByDispatchIds(dispatchIds) - .forEach(count -> countsByDispatchId - .computeIfAbsent( - count.getDispatchId(), - ignored -> new EnumMap<>(PushMessageStatus.class) - ) - .put(count.getStatus(), count.getCount())); - - pushDispatchRepository.findAllById(dispatchIds) - .forEach(dispatch -> updateDispatchStatus(dispatch, countsByDispatchId, now)); - } - - private void updateDispatchStatus( - PushDispatch dispatch, - Map> countsByDispatchId, - LocalDateTime now - ) { - EnumMap counts = countsByDispatchId.getOrDefault( - dispatch.getPushDispatchId(), - new EnumMap<>(PushMessageStatus.class) - ); - - dispatch.updateStatusFromMessageSummary( - count(counts, PushMessageStatus.QUEUED) - + count(counts, PushMessageStatus.TICKET_RECEIVED) - + count(counts, PushMessageStatus.RECEIPT_PENDING), - count(counts, PushMessageStatus.SENDING), - count(counts, PushMessageStatus.DELIVERED), - count(counts, PushMessageStatus.FAILED), - now - ); - } - - private long count( - EnumMap counts, - PushMessageStatus status - ) { - return counts.getOrDefault(status, 0L); - } - - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java deleted file mode 100644 index 6ac46825..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java +++ /dev/null @@ -1,21 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class PushMessageRecoveryWorker { - - private final PushMessageRecoveryService pushMessageRecoveryService; - - public int recoverStaleSendingMessages() { - int recoveredCount = pushMessageRecoveryService.recoverStaleSendingMessages(); - if (recoveredCount > 0) { - log.warn("Recovered stale SENDING push messages. count={}", recoveredCount); - } - return recoveredCount; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java deleted file mode 100644 index fb477a4d..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java +++ /dev/null @@ -1,281 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.config.PushReceiptWorkerProperties; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushReceipt; -import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.time.Clock; -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class PushReceiptClaimService { - - private static final String RECEIPT_STATUS_OK = "ok"; - private static final String RECEIPT_STATUS_ERROR = "error"; - private static final String RECEIPT_STATUS_NOT_READY = "not_ready"; - private static final String CLIENT_ERROR_STATUS = "client_error"; - private static final String DEVICE_NOT_REGISTERED_ERROR = "DeviceNotRegistered"; - - private final PushMessageRepository pushMessageRepository; - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchRepository pushDispatchRepository; - private final PushReceiptWorkerProperties pushReceiptWorkerProperties; - private final Clock clock; - - @Transactional - public List claimDueReceipts() { - LocalDateTime now = LocalDateTime.now(clock); - List messages = pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked( - now, - pushReceiptWorkerProperties.batchSize() - ); - - if (messages.isEmpty()) { - return List.of(); - } - - Set dispatchIds = new HashSet<>(); - List receiptItems = messages.stream() - .map(message -> claimMessage(message, now, dispatchIds)) - .filter(item -> item != null) - .toList(); - - refreshDispatchStatuses(dispatchIds, now); - return receiptItems; - } - - @Transactional - public void recordReceipts( - List items, - Map receiptMap - ) { - if (items.isEmpty()) { - return; - } - - LocalDateTime now = LocalDateTime.now(clock); - Map messageMap = findMessageMap(items); - Set dispatchIds = new HashSet<>(); - - for (PushReceiptItem item : items) { - PushMessage message = messageMap.get(item.pushMessageId()); - if (message == null) { - continue; - } - - ExpoPushReceipt receipt = receiptMap == null ? null : receiptMap.get(item.expoTicketId()); - if (receipt == null) { - scheduleRetry(message, RECEIPT_STATUS_NOT_READY, "receipt_not_ready", now); - } else if (isDeviceNotRegisteredReceipt(receipt)) { - message.recordReceipt( - receipt.status(), - receiptError(receipt), - now - ); - pushInstallationRepository.findById(message.getPushInstallationId()) - .ifPresent(installation -> installation.deactivate(now)); - } else { - message.recordReceipt( - receipt.status(), - receiptError(receipt), - now - ); - } - dispatchIds.add(item.pushDispatchId()); - } - - refreshDispatchStatuses(dispatchIds, now); - } - - @Transactional - public void recordClientError( - List items, - boolean retryable, - String receiptError - ) { - if (items.isEmpty()) { - return; - } - - LocalDateTime now = LocalDateTime.now(clock); - Map messageMap = findMessageMap(items); - Set dispatchIds = new HashSet<>(); - - items.forEach(item -> { - PushMessage message = messageMap.get(item.pushMessageId()); - if (message == null) { - return; - } - if (retryable) { - scheduleRetry(message, CLIENT_ERROR_STATUS, truncate(receiptError), now); - } else { - message.recordReceipt( - CLIENT_ERROR_STATUS, - truncate(receiptError), - now - ); - } - dispatchIds.add(item.pushDispatchId()); - }); - - refreshDispatchStatuses(dispatchIds, now); - } - - private PushReceiptItem claimMessage( - PushMessage message, - LocalDateTime now, - Set dispatchIds - ) { - dispatchIds.add(message.getDispatch().getPushDispatchId()); - - if (isReceiptExpired(message, now)) { - message.recordReceiptExpired(now); - return null; - } - - message.markReceiptChecking(now); - return new PushReceiptItem( - message.getPushMessageId(), - message.getDispatch().getPushDispatchId(), - message.getExpoTicketId() - ); - } - - private void scheduleRetry( - PushMessage message, - String receiptStatus, - String receiptError, - LocalDateTime now - ) { - if (isReceiptExpired(message, now)) { - message.recordReceiptExpired(now); - return; - } - - message.scheduleReceiptRetry( - receiptStatus, - receiptError, - pushReceiptWorkerProperties.maxReceiptAttempts(), - now.plusMinutes(nextBackoffMinutes(message.getReceiptAttempts())), - now - ); - } - - private long nextBackoffMinutes(int currentReceiptAttempts) { - int retryNumber = Math.max(0, currentReceiptAttempts); - return 1L << Math.min(retryNumber, 2); - } - - private boolean isReceiptExpired( - PushMessage message, - LocalDateTime now - ) { - return message.getSentAt() == null - || message.getSentAt().plusHours(24).isBefore(now); - } - - private Map findMessageMap(List items) { - List messageIds = items.stream() - .map(PushReceiptItem::pushMessageId) - .toList(); - - return pushMessageRepository.findAllByPushMessageIdIn(messageIds) - .stream() - .collect(Collectors.toMap( - PushMessage::getPushMessageId, - message -> message - )); - } - - private void refreshDispatchStatuses( - Collection dispatchIds, - LocalDateTime now - ) { - if (dispatchIds.isEmpty()) { - return; - } - - Map> countsByDispatchId = new HashMap<>(); - pushMessageRepository.countStatusesByDispatchIds(dispatchIds) - .forEach(count -> countsByDispatchId - .computeIfAbsent( - count.getDispatchId(), - ignored -> new EnumMap<>(PushMessageStatus.class) - ) - .put(count.getStatus(), count.getCount())); - - pushDispatchRepository.findAllById(dispatchIds) - .forEach(dispatch -> updateDispatchStatus(dispatch, countsByDispatchId, now)); - } - - private void updateDispatchStatus( - PushDispatch dispatch, - Map> countsByDispatchId, - LocalDateTime now - ) { - EnumMap counts = countsByDispatchId.getOrDefault( - dispatch.getPushDispatchId(), - new EnumMap<>(PushMessageStatus.class) - ); - - dispatch.updateStatusFromMessageSummary( - count(counts, PushMessageStatus.QUEUED) - + count(counts, PushMessageStatus.TICKET_RECEIVED) - + count(counts, PushMessageStatus.RECEIPT_PENDING), - count(counts, PushMessageStatus.SENDING), - count(counts, PushMessageStatus.DELIVERED), - count(counts, PushMessageStatus.FAILED), - now - ); - } - - private long count( - EnumMap counts, - PushMessageStatus status - ) { - return counts.getOrDefault(status, 0L); - } - - private boolean isDeviceNotRegisteredReceipt(ExpoPushReceipt receipt) { - return receipt != null - && RECEIPT_STATUS_ERROR.equals(receipt.status()) - && receipt.details() != null - && DEVICE_NOT_REGISTERED_ERROR.equals(receipt.details().error()); - } - - private String receiptError(ExpoPushReceipt receipt) { - if (receipt == null || RECEIPT_STATUS_OK.equals(receipt.status())) { - return null; - } - - if (receipt.details() != null && receipt.details().error() != null) { - return truncate(receipt.details().error()); - } - - return truncate(receipt.message()); - } - - private String truncate(String value) { - if (value == null || value.length() <= 1024) { - return value; - } - return value.substring(0, 1024); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java deleted file mode 100644 index a5319bad..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java +++ /dev/null @@ -1,47 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.client.ExpoPushClient; -import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; -import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; -import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptWorkerResult; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class PushReceiptWorker { - - private final PushReceiptClaimService pushReceiptClaimService; - private final ExpoPushClient expoPushClient; - - public PushReceiptWorkerResult checkPendingReceipts() { - List items = pushReceiptClaimService.claimDueReceipts(); - if (items.isEmpty()) { - return PushReceiptWorkerResult.empty(); - } - - try { - ExpoReceiptResponse response = expoPushClient.getReceipts(items.stream() - .map(PushReceiptItem::expoTicketId) - .toList()); - pushReceiptClaimService.recordReceipts(items, response.data()); - return new PushReceiptWorkerResult(items.size(), items.size()); - } catch (ExpoPushClientException e) { - pushReceiptClaimService.recordClientError( - items, - e.isRetryable(), - e.getHttpStatus() == null ? e.getMessage() : "http_" + e.getHttpStatus() - ); - log.warn( - "Expo push receipt request failed. retryable={}, itemCount={}", - e.isRetryable(), - items.size() - ); - return new PushReceiptWorkerResult(items.size(), 0); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java deleted file mode 100644 index b27189b4..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java +++ /dev/null @@ -1,280 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; -import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleStageRes; -import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; -import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; -import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; -import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; -import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.EnumMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class SurveyPushScheduleService { - - private static final int MAX_SURVEY_KEY_LENGTH = 64; - private static final List ADMIN_STAGES = List.of( - SurveyNotificationStage.STARTED, - SurveyNotificationStage.D_MINUS_3, - SurveyNotificationStage.DEADLINE - ); - - private final SurveyPushScheduleRepository surveyPushScheduleRepository; - private final Clock clock; - - @Transactional - public AdminSurveyPushScheduleRes upsertAdminSchedules( - String surveyKey, - AdminSurveyPushScheduleReq request - ) { - validateSurveyKey(surveyKey); - validateAdminRequest(request); - - LocalDateTime dMinus3At = request.deadlineNotificationAt().minusDays(3); - if (!request.startNotificationAt().isBefore(dMinus3At)) { - throw new GlobalException(INVALID_INPUT); - } - - upsertSchedule( - surveyKey, - SurveyNotificationStage.STARTED, - null, - request.startNotificationAt(), - request.rewardPoint() - ); - upsertSchedule( - surveyKey, - SurveyNotificationStage.D_MINUS_3, - null, - dMinus3At, - request.rewardPoint() - ); - upsertSchedule( - surveyKey, - SurveyNotificationStage.DEADLINE, - null, - request.deadlineNotificationAt(), - request.rewardPoint() - ); - - return getAdminSchedules(surveyKey); - } - - public AdminSurveyPushScheduleRes getAdminSchedules(String surveyKey) { - validateSurveyKey(surveyKey); - - Map schedules = new EnumMap<>(SurveyNotificationStage.class); - surveyPushScheduleRepository.findAllBySurveyKeyAndNotificationStageIn(surveyKey, ADMIN_STAGES) - .forEach(schedule -> schedules.put(schedule.getNotificationStage(), schedule)); - - int rewardPoint = schedules.values() - .stream() - .findFirst() - .map(SurveyPushSchedule::getRewardPoint) - .orElse(0); - - return new AdminSurveyPushScheduleRes( - surveyKey, - rewardPoint, - AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.STARTED)), - AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.D_MINUS_3)), - AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.DEADLINE)) - ); - } - - @Transactional - public AdminSurveyPushScheduleRes cancelSchedules(String surveyKey) { - validateSurveyKey(surveyKey); - - LocalDateTime now = LocalDateTime.now(clock); - surveyPushScheduleRepository.findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(surveyKey) - .stream() - .filter(SurveyPushSchedule::isPending) - .forEach(schedule -> schedule.cancel(now)); - - return getAdminSchedules(surveyKey); - } - - @Transactional - public SurveyReminderRes remindAfterLater( - String surveyKey, - Long userId - ) { - validateSurveyKey(surveyKey); - if (userId == null || userId <= 0) { - throw new GlobalException(INVALID_INPUT); - } - - SurveyPushSchedule deadline = surveyPushScheduleRepository - .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.DEADLINE) - .orElseThrow(() -> new GlobalException(INVALID_INPUT)); - - LocalDateTime now = LocalDateTime.now(clock); - LocalDateTime remindAt = now.plusDays(1); - String idempotencyKey = idempotencyKey( - surveyKey, - SurveyNotificationStage.REMIND_AFTER_LATER, - userId - ); - - SurveyReminderSuppressedBy suppressedBy = suppressionForReminder(surveyKey, remindAt, deadline.getScheduledAt()); - if (!SurveyReminderSuppressedBy.NONE.equals(suppressedBy)) { - cancelPendingReminderIfExists(idempotencyKey, now); - return new SurveyReminderRes(surveyKey, false, null, suppressedBy); - } - - return surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) - .map(existing -> updateExistingReminder(surveyKey, remindAt, deadline.getRewardPoint(), existing)) - .orElseGet(() -> createReminder(surveyKey, userId, remindAt, deadline.getRewardPoint(), idempotencyKey)); - } - - private void upsertSchedule( - String surveyKey, - SurveyNotificationStage stage, - Long targetUserId, - LocalDateTime scheduledAt, - int rewardPoint - ) { - String idempotencyKey = idempotencyKey(surveyKey, stage, targetUserId); - surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) - .ifPresentOrElse( - schedule -> schedule.updatePendingSchedule(scheduledAt, rewardPoint), - () -> surveyPushScheduleRepository.save(new SurveyPushSchedule( - surveyKey, - stage, - targetUserId, - scheduledAt, - rewardPoint, - idempotencyKey, - LocalDateTime.now(clock) - )) - ); - } - - private SurveyReminderRes updateExistingReminder( - String surveyKey, - LocalDateTime remindAt, - int rewardPoint, - SurveyPushSchedule existing - ) { - if (!existing.isPending()) { - return new SurveyReminderRes( - surveyKey, - false, - null, - SurveyReminderSuppressedBy.ALREADY_PROCESSED - ); - } - - existing.updatePendingSchedule(remindAt, rewardPoint); - return new SurveyReminderRes( - surveyKey, - true, - existing.getScheduledAt(), - SurveyReminderSuppressedBy.NONE - ); - } - - private SurveyReminderRes createReminder( - String surveyKey, - Long userId, - LocalDateTime remindAt, - int rewardPoint, - String idempotencyKey - ) { - // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. - SurveyPushSchedule saved = surveyPushScheduleRepository.save(new SurveyPushSchedule( - surveyKey, - SurveyNotificationStage.REMIND_AFTER_LATER, - userId, - remindAt, - rewardPoint, - idempotencyKey, - LocalDateTime.now(clock) - )); - - return new SurveyReminderRes( - surveyKey, - true, - saved.getScheduledAt(), - SurveyReminderSuppressedBy.NONE - ); - } - - private void cancelPendingReminderIfExists( - String idempotencyKey, - LocalDateTime now - ) { - surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) - .filter(SurveyPushSchedule::isPending) - .ifPresent(schedule -> schedule.cancel(now)); - } - - private SurveyReminderSuppressedBy suppressionForReminder( - String surveyKey, - LocalDateTime remindAt, - LocalDateTime deadlineAt - ) { - if (remindAt.isAfter(deadlineAt)) { - return SurveyReminderSuppressedBy.EXPIRED; - } - - LocalDate remindDate = remindAt.toLocalDate(); - if (remindDate.equals(deadlineAt.toLocalDate())) { - return SurveyReminderSuppressedBy.DEADLINE; - } - - return surveyPushScheduleRepository - .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.D_MINUS_3) - .map(SurveyPushSchedule::getScheduledAt) - .map(LocalDateTime::toLocalDate) - .filter(remindDate::equals) - .map(ignored -> SurveyReminderSuppressedBy.D3) - .orElse(SurveyReminderSuppressedBy.NONE); - } - - private void validateAdminRequest(AdminSurveyPushScheduleReq request) { - if (request == null - || request.startNotificationAt() == null - || request.deadlineNotificationAt() == null - || request.rewardPoint() == null - || request.rewardPoint() < 0) { - throw new GlobalException(INVALID_INPUT); - } - - if (!request.startNotificationAt().isBefore(request.deadlineNotificationAt())) { - throw new GlobalException(INVALID_INPUT); - } - } - - private void validateSurveyKey(String surveyKey) { - if (surveyKey == null || surveyKey.isBlank() || surveyKey.length() > MAX_SURVEY_KEY_LENGTH) { - throw new GlobalException(INVALID_INPUT); - } - } - - public static String idempotencyKey( - String surveyKey, - SurveyNotificationStage stage, - Long targetUserId - ) { - if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(stage)) { - return "survey:" + surveyKey + ":" + stage.name() + ":" + targetUserId; - } - return "survey:" + surveyKey + ":" + stage.name(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java deleted file mode 100644 index b5ec71b9..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java +++ /dev/null @@ -1,203 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; -import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; -import devkor.com.teamcback.domain.notification.template.PushContent; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.time.Clock; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -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; - -@Slf4j -@Component -@RequiredArgsConstructor -public class SurveyPushScheduleWorker { - - private static final int BATCH_SIZE = 50; - private static final Long SYSTEM_CREATED_BY = 0L; - private static final String ALL_TARGET_VALUE = "ALL"; - - private final SurveyPushScheduleRepository surveyPushScheduleRepository; - private final PushInstallationRepository pushInstallationRepository; - private final PushDispatchService pushDispatchService; - private final PushEventFlagService pushEventFlagService; - private final Clock clock; - - @Scheduled(fixedDelayString = "${push.survey.poll-interval-ms:60000}") - @Transactional - public void processDueSchedules() { - processDueSchedulesOnce(); - } - - public int processDueSchedulesOnce() { - if (!pushEventFlagService.isEnabled(PushEventType.SURVEY)) { - return 0; - } - - LocalDateTime now = LocalDateTime.now(clock); - List schedules = surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked( - now, - BATCH_SIZE - ); - - schedules.forEach(schedule -> processSchedule(schedule, now)); - return schedules.size(); - } - - private void processSchedule( - SurveyPushSchedule schedule, - LocalDateTime now - ) { - if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage()) - && cancelReminderByLatestPriority(schedule, now)) { - return; - } - - List activeTargetVariants = activeTargetVariants(schedule); - if (activeTargetVariants.isEmpty()) { - schedule.skip(now); - return; - } - - try { - activeTargetVariants.forEach(appVariant -> pushDispatchService.enqueue(command(schedule, appVariant))); - schedule.complete(now); - } catch (GlobalException e) { - log.warn( - "Survey push schedule processing failed. scheduleId={}, stage={}, resultCode={}", - schedule.getSurveyPushScheduleId(), - schedule.getNotificationStage(), - e.getResultCode() - ); - } catch (RuntimeException e) { - log.warn( - "Unexpected survey push schedule processing failure. scheduleId={}, stage={}, exception={}", - schedule.getSurveyPushScheduleId(), - schedule.getNotificationStage(), - e.getClass().getSimpleName() - ); - } - } - - private boolean cancelReminderByLatestPriority( - SurveyPushSchedule schedule, - LocalDateTime now - ) { - // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. - LocalDate executionDate = schedule.getScheduledAt().toLocalDate(); - - SurveyPushSchedule deadline = surveyPushScheduleRepository - .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.DEADLINE) - .orElse(null); - if (deadline == null) { - schedule.cancel(now); - return true; - } - - if (executionDate.equals(deadline.getScheduledAt().toLocalDate()) - || now.isAfter(deadline.getScheduledAt())) { - schedule.cancel(now); - return true; - } - - return surveyPushScheduleRepository - .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.D_MINUS_3) - .map(SurveyPushSchedule::getScheduledAt) - .map(LocalDateTime::toLocalDate) - .filter(executionDate::equals) - .map(ignored -> { - schedule.cancel(now); - return true; - }) - .orElse(false); - } - - private PushDispatchCommand command( - SurveyPushSchedule schedule, - AppVariant appVariant - ) { - PushContent content = content(schedule); - - return new PushDispatchCommand( - NotificationType.GENERAL, - PushMode.ACTUAL, - appVariant, - targetType(schedule), - targetValue(schedule), - content.title(), - content.body(), - PushActionType.HOME, - Map.of(), - "%s:%s".formatted(schedule.getIdempotencyKey(), appVariant.name().toLowerCase()), - SYSTEM_CREATED_BY - ); - } - - private PushContent content(SurveyPushSchedule schedule) { - return switch (schedule.getNotificationStage()) { - case STARTED -> DomainPushContentFactory.surveyStarted(); - case D_MINUS_3 -> DomainPushContentFactory.surveyDMinus3(); - case DEADLINE -> DomainPushContentFactory.surveyDeadline(schedule.getRewardPoint()); - case REMIND_AFTER_LATER -> DomainPushContentFactory.surveyRemindAfterLater(schedule.getRewardPoint()); - }; - } - - private PushTargetType targetType(SurveyPushSchedule schedule) { - if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { - return PushTargetType.USER; - } - return PushTargetType.ALL; - } - - private String targetValue(SurveyPushSchedule schedule) { - if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { - return String.valueOf(schedule.getTargetUserId()); - } - return ALL_TARGET_VALUE; - } - - private List activeTargetVariants(SurveyPushSchedule schedule) { - return targetAppVariants().stream() - .filter(appVariant -> hasActiveTarget(schedule, appVariant)) - .toList(); - } - - private boolean hasActiveTarget( - SurveyPushSchedule schedule, - AppVariant appVariant - ) { - if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { - return schedule.getTargetUserId() != null - && pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( - schedule.getTargetUserId(), - appVariant - ); - } - - return pushInstallationRepository.existsByAppVariantAndActiveTrue(appVariant); - } - - private List targetAppVariants() { - List configuredVariants = pushEventFlagService.getTargetAppVariants(); - return configuredVariants == null || configuredVariants.isEmpty() - ? List.of(AppVariant.PRODUCTION) - : configuredVariants; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java deleted file mode 100644 index a3041462..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java +++ /dev/null @@ -1,95 +0,0 @@ -package devkor.com.teamcback.domain.notification.template; - -public final class DomainPushContentFactory { - - private static final String VACANT_TITLE = - "기다리던 자리가 생겼어요!"; - - private static final String REPORT_RESOLVED_TITLE = - "신고 처리 결과를 확인해주세요."; - - private static final String REPORT_RESOLVED_BODY = - "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."; - - private static final String CHARACTER_UNLOCKED_TITLE = - "새 캐릭터가 기다리고 있어요!"; - - private DomainPushContentFactory() { - } - - public static PushContent placeBecameVacant( - String buildingName, - String placeName - ) { - String location = joinNonBlank(buildingName, placeName); - - return new PushContent( - VACANT_TITLE, - location + "이 한산해요. 방문하기 전 현황을 확인해보세요." - ); - } - - public static PushContent reportResolved() { - return new PushContent( - REPORT_RESOLVED_TITLE, - REPORT_RESOLVED_BODY - ); - } - - public static PushContent characterUnlocked(String characterName) { - String name = isBlank(characterName) - ? "새로운 캐릭터" - : characterName.trim(); - - return new PushContent( - CHARACTER_UNLOCKED_TITLE, - name + "을 만나러 가볼까요?" - ); - } - - public static PushContent surveyStarted() { - return new PushContent( - "고대로를 함께 만들어주세요!", - "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." - ); - } - - public static PushContent surveyDMinus3() { - return new PushContent( - "단 3초! 고대로의 개선을 위해 도와주세요", - "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." - ); - } - - public static PushContent surveyDeadline(int rewardPoint) { - return new PushContent( - "설문이 오늘 마감돼요!", - "설문에 참여하면 " + rewardPoint + " 포인트를 받을 수 있어요.(5초 소요)" - ); - } - - public static PushContent surveyRemindAfterLater(int rewardPoint) { - return new PushContent( - "잠깐, 설문을 잊지 않으셨나요?", - "지금 투표에 참여하고 " + rewardPoint + "포인트를 받아보세요.(5초 소요)" - ); - } - - private static String joinNonBlank( - String first, - String second - ) { - String firstValue = isBlank(first) ? "" : first.trim(); - String secondValue = isBlank(second) ? "" : second.trim(); - - String result = (firstValue + " " + secondValue).trim(); - - return result.isBlank() - ? "즐겨찾기한 공간" - : result; - } - - private static boolean isBlank(String value) { - return value == null || value.isBlank(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java deleted file mode 100644 index dd8a3747..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java +++ /dev/null @@ -1,7 +0,0 @@ -package devkor.com.teamcback.domain.notification.template; - -public record PushContent( - String title, - String body -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java deleted file mode 100644 index 70c78479..00000000 --- a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java +++ /dev/null @@ -1,110 +0,0 @@ -package devkor.com.teamcback.domain.notification.validation; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; -import org.springframework.stereotype.Component; - -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; - -@Component -public class PushActionValidator { - - private static final Set ACTIONS_WITHOUT_PARAMS = Set.of( - PushActionType.HOME, - PushActionType.NOTICE, - PushActionType.MY_PAGE, - PushActionType.CHARACTER_STORE, - PushActionType.TEST - ); - - public Map validateAndNormalize( - PushActionType actionType, - PushMode mode, - AppVariant appVariant, - Map params - ) { - if (actionType == null || mode == null || appVariant == null) { - throw new GlobalException(INVALID_INPUT); - } - - Map safeParams = params == null ? Collections.emptyMap() : params; - - if (PushActionType.TEST.equals(actionType) && ( - AppVariant.PRODUCTION.equals(appVariant) - || PushMode.ACTUAL.equals(mode) - )) { - throw new GlobalException(INVALID_INPUT); - } - - if (ACTIONS_WITHOUT_PARAMS.contains(actionType)) { - validateNoParams(safeParams); - return Collections.emptyMap(); - } - - return switch (actionType) { - case BUS_STOP -> validateSinglePositiveLong(safeParams, "stopId"); - case BUILDING_DETAIL -> validateSinglePositiveLong(safeParams, "buildingId"); - case PLACE_DETAIL -> validateSinglePositiveLong(safeParams, "placeId"); - default -> throw new GlobalException(INVALID_INPUT); - }; - } - - private void validateNoParams(Map params) { - if (!params.isEmpty()) { - throw new GlobalException(INVALID_INPUT); - } - } - - private Map validateSinglePositiveLong( - Map params, - String requiredKey - ) { - if (params.size() != 1 || !params.containsKey(requiredKey)) { - throw new GlobalException(INVALID_INPUT); - } - - Long value = parsePositiveLong(params.get(requiredKey)); - - Map normalized = new LinkedHashMap<>(); - normalized.put(requiredKey, value); - return normalized; - } - - private Long parsePositiveLong(Object value) { - Long parsed; - - if (value instanceof Integer integerValue) { - parsed = integerValue.longValue(); - } else if (value instanceof Long longValue) { - parsed = longValue; - } else if (value instanceof String stringValue) { - parsed = parseString(stringValue); - } else { - throw new GlobalException(INVALID_INPUT); - } - - if (parsed <= 0) { - throw new GlobalException(INVALID_INPUT); - } - - return parsed; - } - - private Long parseString(String value) { - if (value == null || value.isBlank()) { - throw new GlobalException(INVALID_INPUT); - } - - try { - return Long.parseLong(value); - } catch (NumberFormatException e) { - throw new GlobalException(INVALID_INPUT); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java index 26e2b787..cf15e2bc 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java +++ b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java @@ -41,8 +41,7 @@ public enum PlaceType { CAFE_TEMP("임시용 외부 카페", new String[] {}), CONV_TEMP("임시용 외부 편의점", new String[] {}), CAFT_TEMP("임시용 외부 식당", new String[] {}), - REUSABLE_CUP_RETURN("다회용컵 반납함", new String[] {"다회용컵반납함", "리필로드"}), - PHARMACY("약국", new String[]{}),; + REUSABLE_CUP_RETURN("다회용컵 반납함", new String[] {"다회용컵반납함", "리필로드"}),; private final String name; private final String[] nickname; diff --git a/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java deleted file mode 100644 index 025c971c..00000000 --- a/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.report.event; - -import devkor.com.teamcback.domain.report.entity.ReportStatus; - -public record ReportResolvedEvent( - Long reportId, - Long reporterUserId, - ReportStatus finalStatus -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java index 60c5d81c..ad81dfbd 100644 --- a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java +++ b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java @@ -6,7 +6,6 @@ import devkor.com.teamcback.domain.report.entity.Report; import devkor.com.teamcback.domain.report.entity.ReportStatus; import devkor.com.teamcback.domain.report.entity.TargetType; -import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import devkor.com.teamcback.domain.report.repository.ReportRepository; import devkor.com.teamcback.domain.review.entity.Review; import devkor.com.teamcback.domain.review.repository.ReviewRepository; @@ -15,7 +14,6 @@ import devkor.com.teamcback.global.exception.exception.GlobalException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,7 +31,6 @@ public class ReportService { private final ReportRepository reportRepository; private final ReviewRepository reviewRepository; private final UserRepository userRepository; - private final ApplicationEventPublisher eventPublisher; /** * 리뷰에 대한 신고 작성 @@ -101,7 +98,6 @@ public GetReportListRes getReportList(ReportStatus status) { public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatusReq req) { // 신고 Report report = findReport(reportId); - ReportStatus previousStatus = report.getStatus(); // 신고 상태 수정 report.setStatus(req.getStatus()); @@ -119,31 +115,9 @@ public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatu } } - if (shouldPublishReportResolved(previousStatus, req.getStatus())) { - eventPublisher.publishEvent(new ReportResolvedEvent( - report.getId(), - report.getReporter() == null ? null : report.getReporter().getUserId(), - req.getStatus() - )); - } - return new UpdateReportStatusRes(); } - private boolean shouldPublishReportResolved( - ReportStatus previousStatus, - ReportStatus newStatus - ) { - return PENDING.equals(previousStatus) - && isFinalStatus(newStatus); - } - - private boolean isFinalStatus(ReportStatus status) { - return RESOLVED.equals(status) - || REJECTED.equals(status) - || EXPIRED.equals(status); - } - /** * 신고 유효일 체크하고 상태 수정 */ diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java index a68756b0..b9f71187 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java @@ -14,12 +14,6 @@ public class SearchPlaceReviewRes { @Schema(description = "리뷰 사용자 id") private Long userId; - @Schema(description = "리뷰 작성자 닉네임") - private String username; - - @Schema(description = "리뷰 작성자 프로필 이미지 URL") - private String profileImageUrl; - @Schema(description = "리뷰 id") private Long reviewId; @@ -35,14 +29,8 @@ public class SearchPlaceReviewRes { @Schema(description = "리뷰별 사진 목록") private List reviewImageRes; - public SearchPlaceReviewRes( - Review review, - List reviewImageRes, - String profileImageUrl - ) { + public SearchPlaceReviewRes(Review review, List reviewImageRes) { this.userId = review.getUser() != null ? review.getUser().getUserId() : null; - this.username = review.getUser() != null ? review.getUser().getUsername() : null; - this.profileImageUrl = profileImageUrl; this.reviewId = review.getId(); this.isRevisit = review.isRevisit(); this.comment = review.getComment(); diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java index 610a71d0..19e5df9a 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -3,7 +3,6 @@ import devkor.com.teamcback.domain.common.entity.File; import devkor.com.teamcback.domain.common.repository.FileRepository; import devkor.com.teamcback.domain.common.util.FileUtil; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.domain.place.repository.PlaceRepository; @@ -44,7 +43,6 @@ public class ReviewService { private final PlaceReviewTagMapRepository placeReviewTagMapRepository; private final FileRepository fileRepository; private final UserRepository userRepository; - private final CharacterRepository characterRepository; private final FileUtil fileUtil; /** @@ -90,17 +88,6 @@ public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { // 리뷰 최신순 조회 List reviewList = reviewRepository.findAllByPlaceAndIsReportedOrderByCreatedAtDesc(place, false); - List equippedCharacterIds = reviewList.stream() - .map(Review::getUser) - .filter(user -> user != null && user.getEquippedCharacterId() != null) - .map(User::getEquippedCharacterId) - .distinct() - .toList(); - Map equippedCharacterImages = new HashMap<>(); - characterRepository.findAllById(equippedCharacterIds).forEach(character -> - equippedCharacterImages.put(character.getCharacterId(), character.getImageUrl()) - ); - // 리뷰별 이미지 조회(썸네일) List reviewImageList = new ArrayList<>(); @@ -111,13 +98,7 @@ public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { // 썸네일 이미지 추출 List imageResList = reviewFiles.stream().map(file -> new SearchReviewImageRes(file.getId(), file.getThumbSavedName())).toList(); - Long equippedCharacterId = review.getUser() != null - ? review.getUser().getEquippedCharacterId() - : null; - String profileImageUrl = equippedCharacterId != null - ? equippedCharacterImages.get(equippedCharacterId) - : null; - reviewImageList.add(new SearchPlaceReviewRes(review, imageResList, profileImageUrl)); + reviewImageList.add(new SearchPlaceReviewRes(review, imageResList)); } // 리뷰 전체 이미지 조회(원본 - 10장까지) @@ -297,8 +278,7 @@ private Review findReviewById(Long reviewId) { private void checkReviewPlaceType(Place place) { // 식당, 카페만 조회 가능하도록 제한 if(place.getType() != PlaceType.CAFETERIA && place.getType() != PlaceType.CAFE - && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP - && place.getType() != PlaceType.CONV_TEMP && place.getType() != PlaceType.PHARMACY) { + && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP && place.getType() != PlaceType.CONV_TEMP) { throw new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE); } } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java b/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java index a4520272..e28ae98f 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java @@ -16,7 +16,6 @@ public enum SuggestionType { FEATURE_SUGGESTION("추천 기능"), INCONVENIENCE("불편 사항"), QUESTION("질의 사항"), - OTHER("기타"), - POPUP("팝업 추가 답변"); + OTHER("기타"); private final String type; } diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java deleted file mode 100644 index 401b50c4..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java +++ /dev/null @@ -1,52 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.controller; - -import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; -import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; -import devkor.com.teamcback.domain.usagesurvey.dto.response.SubmitUsageSurveyResponseRes; -import devkor.com.teamcback.domain.usagesurvey.dto.response.UsageSurveyStatusRes; -import devkor.com.teamcback.domain.usagesurvey.service.UsageSurveyService; -import devkor.com.teamcback.global.response.CommonResponse; -import devkor.com.teamcback.global.security.UserDetailsImpl; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/usage-surveys") -public class UsageSurveyController { - private final UsageSurveyService usageSurveyService; - - @GetMapping("/status") - public CommonResponse getStatus( - @AuthenticationPrincipal UserDetailsImpl userDetail - ) { - return CommonResponse.success( - usageSurveyService.getStatus(userDetail.getUser().getUserId()) - ); - } - - @PostMapping("/responses") - public CommonResponse submitResponse( - @AuthenticationPrincipal UserDetailsImpl userDetail, - @RequestBody SubmitUsageSurveyResponseReq req - ) { - return CommonResponse.success(usageSurveyService.submitResponse( - userDetail.getUser().getUserId(), - req - )); - } - - @PostMapping("/dismissals") - public CommonResponse recordDismissal( - @AuthenticationPrincipal UserDetailsImpl userDetail, - @RequestBody RecordUsageSurveyDismissalReq req - ) { - usageSurveyService.recordDismissal(userDetail.getUser().getUserId(), req); - return CommonResponse.success(null); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java deleted file mode 100644 index a22561d2..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.dto.request; - -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; - -public record RecordUsageSurveyDismissalReq( - UsageSurveyQuestion questionKey, - UsageSurveyDismissReason reason -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java deleted file mode 100644 index 791ea24a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.dto.request; - -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; - -public record SubmitUsageSurveyResponseReq( - UsageSurveyQuestion questionKey, - String optionKey -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java deleted file mode 100644 index 102a3e54..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java +++ /dev/null @@ -1,30 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.dto.response; - -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; -import devkor.com.teamcback.global.response.ScoreUpdateResponse; -import lombok.Getter; -import lombok.Setter; - -@Getter -public class SubmitUsageSurveyResponseRes implements ScoreUpdateResponse { - private final UsageSurveyQuestion questionKey; - private final String optionKey; - private final int rewardPoint; - - @Setter - private boolean levelUp; - @Setter - private Long currentScore; - @Setter - private boolean scoreGained; - - public SubmitUsageSurveyResponseRes( - UsageSurveyQuestion questionKey, - String optionKey, - int rewardPoint - ) { - this.questionKey = questionKey; - this.optionKey = optionKey; - this.rewardPoint = rewardPoint; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java deleted file mode 100644 index de45ca24..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java +++ /dev/null @@ -1,10 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.dto.response; - -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; -import java.util.List; - -public record UsageSurveyStatusRes( - List answeredQuestionKeys, - int rewardPoint -) { -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java deleted file mode 100644 index 6951e557..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java +++ /dev/null @@ -1,6 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.entity; - -public enum UsageSurveyDismissReason { - LATER, - AUTO_DISMISS -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java deleted file mode 100644 index ddb56b6a..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java +++ /dev/null @@ -1,49 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.entity; - -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import devkor.com.teamcback.domain.user.entity.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor -@Table(name = "tb_usage_survey_dismissal") -public class UsageSurveyDismissal extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Enumerated(EnumType.STRING) - @Column(name = "question_key", nullable = false, length = 40) - private UsageSurveyQuestion questionKey; - - @Enumerated(EnumType.STRING) - @Column(name = "dismiss_reason", nullable = false, length = 30) - private UsageSurveyDismissReason dismissReason; - - public UsageSurveyDismissal( - User user, - UsageSurveyQuestion questionKey, - UsageSurveyDismissReason dismissReason - ) { - this.user = user; - this.questionKey = questionKey; - this.dismissReason = dismissReason; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java deleted file mode 100644 index e3b15b13..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java +++ /dev/null @@ -1,8 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.entity; - -public enum UsageSurveyQuestion { - INSTALL_REASON, - RECENT_USE_REASON, - DESIRED_FEATURE, - DISAPPOINTMENT -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java deleted file mode 100644 index 826694c7..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java +++ /dev/null @@ -1,55 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.entity; - -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import devkor.com.teamcback.domain.user.entity.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor -@Table( - name = "tb_usage_survey_response", - uniqueConstraints = @UniqueConstraint( - name = "uk_usage_survey_response_user_question", - columnNames = {"user_id", "question_key"} - ) -) -public class UsageSurveyResponse extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Enumerated(EnumType.STRING) - @Column(name = "question_key", nullable = false, length = 40) - private UsageSurveyQuestion questionKey; - - @Column(name = "option_key", nullable = false, length = 60) - private String optionKey; - - public UsageSurveyResponse( - User user, - UsageSurveyQuestion questionKey, - String optionKey - ) { - this.user = user; - this.questionKey = questionKey; - this.optionKey = optionKey; - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java deleted file mode 100644 index 2fb11415..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.repository; - -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UsageSurveyDismissalRepository extends JpaRepository { -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java deleted file mode 100644 index 9dd9ceaa..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.repository; - -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UsageSurveyResponseRepository extends JpaRepository { - boolean existsByUserUserIdAndQuestionKey(Long userId, UsageSurveyQuestion questionKey); - - List findAllByUserUserId(Long userId); -} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java deleted file mode 100644 index e9c72bba..00000000 --- a/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java +++ /dev/null @@ -1,137 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.service; - -import static devkor.com.teamcback.global.response.ResultCode.ALREADY_ANSWERED_USAGE_SURVEY; -import static devkor.com.teamcback.global.response.ResultCode.INVALID_USAGE_SURVEY_OPTION; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; - -import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; -import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; -import devkor.com.teamcback.domain.usagesurvey.dto.response.SubmitUsageSurveyResponseRes; -import devkor.com.teamcback.domain.usagesurvey.dto.response.UsageSurveyStatusRes; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; -import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyDismissalRepository; -import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyResponseRepository; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.annotation.UpdateScore; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class UsageSurveyService { - public static final int REWARD_POINT = 10; - - private static final Map> ALLOWED_OPTIONS = Map.of( - UsageSurveyQuestion.INSTALL_REASON, Set.of( - "COMPLEX_CAMPUS_ROUTES", - "SCHOOL_INFO", - "FACILITIES", - "LOUNGE_CROWDING", - "BUILDING_INTERIOR" - ), - UsageSurveyQuestion.RECENT_USE_REASON, Set.of( - "ROUTE", - "CROWDING", - "FACILITIES", - "MEAL_SHUTTLE", - "OTHER" - ), - UsageSurveyQuestion.DESIRED_FEATURE, Set.of( - "SCHEDULE_ROUTE", - "STUDY_ROOM_LINK", - "CUP_AVAILABILITY", - "QUIET_ALERT", - "COMMUNITY" - ), - UsageSurveyQuestion.DISAPPOINTMENT, Set.of( - "STALE_INFO", - "NAVIGATION_DIFFICULTY", - "MISSING_REVIEW_OUTLET", - "CROWD_MISMATCH", - "OTHER" - ) - ); - - private final UsageSurveyResponseRepository responseRepository; - private final UsageSurveyDismissalRepository dismissalRepository; - private final UserRepository userRepository; - - @Transactional(readOnly = true) - public UsageSurveyStatusRes getStatus(Long userId) { - findUser(userId); - List answeredQuestionKeys = responseRepository - .findAllByUserUserId(userId) - .stream() - .map(UsageSurveyResponse::getQuestionKey) - .distinct() - .toList(); - return new UsageSurveyStatusRes(answeredQuestionKeys, REWARD_POINT); - } - - @Transactional - @UpdateScore(addScore = REWARD_POINT) - public SubmitUsageSurveyResponseRes submitResponse( - Long userId, - SubmitUsageSurveyResponseReq req - ) { - validateResponse(req); - if (responseRepository.existsByUserUserIdAndQuestionKey(userId, req.questionKey())) { - throw new GlobalException(ALREADY_ANSWERED_USAGE_SURVEY); - } - - User user = findUser(userId); - responseRepository.save(new UsageSurveyResponse( - user, - req.questionKey(), - req.optionKey() - )); - return new SubmitUsageSurveyResponseRes( - req.questionKey(), - req.optionKey(), - REWARD_POINT - ); - } - - @Transactional - public void recordDismissal( - Long userId, - RecordUsageSurveyDismissalReq req - ) { - if (req == null || req.questionKey() == null) { - throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); - } - User user = findUser(userId); - UsageSurveyDismissReason reason = req.reason() == null - ? UsageSurveyDismissReason.LATER - : req.reason(); - dismissalRepository.save(new UsageSurveyDismissal( - user, - req.questionKey(), - reason - )); - } - - private void validateResponse(SubmitUsageSurveyResponseReq req) { - if (req == null || req.questionKey() == null || req.optionKey() == null) { - throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); - } - Set options = ALLOWED_OPTIONS.get(req.questionKey()); - if (options == null || !options.contains(req.optionKey())) { - throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); - } - } - - private User findUser(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java index 0814ac83..30af7515 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java +++ b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java @@ -1,9 +1,7 @@ package devkor.com.teamcback.domain.user.controller; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; -import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; -import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -61,15 +59,6 @@ public CommonResponse releaseLogin( return CommonResponse.success(userService.releaseLogin(loginUserReq)); } - /** - * 관리자 대시보드 소셜 로그인 - */ - @Operation(summary = "관리자 소셜 로그인", description = "Google 또는 Kakao ID Token을 검증하고 기존 ADMIN 계정에만 토큰을 발급") - @PostMapping("/login/admin") - public CommonResponse adminLogin(@RequestBody AdminLoginReq adminLoginReq) { - return CommonResponse.success(userService.adminLogin(adminLoginReq)); - } - /** * 자동 로그인 */ diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java deleted file mode 100644 index dd8c20e4..00000000 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java +++ /dev/null @@ -1,15 +0,0 @@ -package devkor.com.teamcback.domain.user.dto.request; - -import devkor.com.teamcback.domain.user.entity.Provider; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; - -@Getter -@Schema(description = "관리자 소셜 로그인 요청") -public class AdminLoginReq { - @Schema(description = "관리자 소셜 로그인 제공자", allowableValues = {"GOOGLE", "KAKAO"}) - private Provider provider; - - @Schema(description = "소셜 로그인에서 발급받은 OIDC ID Token") - private String token; -} diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java deleted file mode 100644 index 16ba6507..00000000 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java +++ /dev/null @@ -1,24 +0,0 @@ -package devkor.com.teamcback.domain.user.dto.response; - -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.User; -import lombok.Getter; - -@Getter -public class AdminLoginRes { - private final String accessToken; - private final String refreshToken; - private final Long userId; - private final String username; - private final String email; - private final Provider provider; - - public AdminLoginRes(String accessToken, String refreshToken, User user) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.userId = user.getUserId(); - this.username = user.getUsername(); - this.email = user.getEmail(); - this.provider = user.getProvider(); - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java index 27bddead..2c7ea2d1 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java @@ -21,8 +21,6 @@ public class GetUserInfoRes { private int level; @Schema(description = "score", example = "15") private Long score; - @Schema(description = "보유 포인트 (스토어 재화)", example = "15") - private Long point; @Schema(description = "remainScoreToNextLevel", example = "10") private Long remainScoreToNextLevel; @Schema(description = "percent", example = "75") @@ -40,7 +38,6 @@ public GetUserInfoRes(User user, Long categoryCount, int level, Long remainScore this.role = user.getRole(); this.level = level; this.score = user.getScore(); - this.point = user.getPoint(); this.remainScoreToNextLevel = remainScoreToNextLevel; this.percent = percent; this.isUpgraded = isUpgraded; diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java index 4ec6835e..5b648f3e 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java @@ -9,5 +9,4 @@ public class LoginUserRes { String accessToken; String refreshToken; String code; - String loginKey; } diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java index 8fa2d4d5..996f80cf 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java @@ -6,31 +6,31 @@ @Getter @AllArgsConstructor public enum Level { - LEVEL1(0, 1) { // 0~4점 + LEVEL1(0, 1, ProfileImage.getUrlByLevel(1)) { // 0~4점 @Override public Level getNextLevel() { return LEVEL2; } }, - LEVEL2(5, 2) { // 5~19점 + LEVEL2(5, 2, ProfileImage.getUrlByLevel(2)) { // 5~19점 @Override public Level getNextLevel() { return LEVEL3; } }, - LEVEL3(20, 3) { //20~39점 + LEVEL3(20, 3, ProfileImage.getUrlByLevel(3)) { //20~39점 @Override public Level getNextLevel() { return LEVEL4; } }, - LEVEL4(40, 4) { //40~59점 + LEVEL4(40, 4, ProfileImage.getUrlByLevel(4)) { //40~59점 @Override public Level getNextLevel() { return LEVEL5; } }, - LEVEL5(60, 5) { //60점 이상 + LEVEL5(60, 5, ProfileImage.getUrlByLevel(5)) { //60점 이상 @Override public Level getNextLevel() { return null; @@ -38,23 +38,7 @@ public Level getNextLevel() { }, ; private final int minScore; private final int levelNumber; + private final String profileImage; public abstract Level getNextLevel(); - - // 엔티티 필드로 사용되는 enum이 Hibernate 메타데이터 빌드 시점에 초기화되므로, - // ProfileImage 빈 생성 전에 접근하지 않도록 이미지 URL은 호출 시점에 조회한다. - public String getProfileImage() { - return ProfileImage.getUrlByLevel(levelNumber); - } - - public static Level fromScore(long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - Level result = LEVEL1; - for (Level level : values()) { - if (score >= level.getMinScore() && level.getMinScore() >= result.getMinScore()) { - result = level; - } - } - return result; - } -} +} \ No newline at end of file diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index aa33455c..52bb55ed 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -12,9 +12,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.annotations.ColumnDefault; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; @Entity @Getter @@ -41,23 +38,9 @@ public class User extends BaseEntity { @Column(nullable = false) private Long score = 0L; - // 스토어에서 사용하는 차감형 재화. score(레벨용 누적치)와 같은 양으로 적립되고 구매 시에만 차감된다. - // 컬럼을 nullable로 두는 이유: 기존 행은 NULL로 생성되고, NULL 여부가 "백필 전" 표식이 되어 백필이 영원히 멱등해진다. - private Long point = 0L; - - // MySQL native enum 타입 대신 VARCHAR로 저장하여 레벨 추가 시 ALTER 없이 확장 가능하게 유지 - @Enumerated(EnumType.STRING) - @JdbcTypeCode(SqlTypes.VARCHAR) - @ColumnDefault("'LEVEL1'") - @Column(nullable = false) - private Level level = Level.LEVEL1; - @Column(nullable = false) private boolean isUpgraded = false; - // 대표 캐릭터 (tb_character 논리 참조, 미장착 시 null) - private Long equippedCharacterId; - @Setter @Column(unique = true) private String code; @@ -77,7 +60,6 @@ public void updateUsername(String username) { public void updateScore(Long score, boolean isUpgraded) { this.score = score; - this.level = Level.fromScore(score); // score와 level이 발산하지 않도록 단일 지점에서 갱신 this.isUpgraded = isUpgraded; } @@ -85,20 +67,4 @@ public void updateUpgraded(boolean isUpgraded) { this.isUpgraded = isUpgraded; } - public void syncLevel() { - this.level = Level.fromScore(this.score); - } - - public Long getPoint() { // 백필 전 NULL 방어 - return point == null ? 0L : point; - } - - public void addPoint(long amount) { - this.point = Math.max(0, getPoint() + amount); - } - - public void updateEquippedCharacter(Long characterId) { - this.equippedCharacterId = characterId; - } - } diff --git a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java index 3db5c675..820a9bc0 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java @@ -3,10 +3,6 @@ import devkor.com.teamcback.domain.user.entity.Provider; import devkor.com.teamcback.domain.user.entity.User; 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.transaction.annotation.Transactional; public interface UserRepository extends JpaRepository { boolean existsByUsernameAndUserIdNot(String username, Long id); @@ -16,37 +12,4 @@ public interface UserRepository extends JpaRepository { User findByEmailAndProvider(String email, Provider provider); User findByUserId(long userId); - - /** - * score 기준으로 level 컬럼 백필 (레벨 구간: 0/5/20/40/60, Level enum과 동기화 유지) - * level이 null이거나 score와 불일치하는 행만 갱신하므로 멱등 - */ - @Transactional - @Modifying - @Query(value = """ - UPDATE tb_user SET level = - CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' - WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END - WHERE level IS NULL OR level <> - CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' - WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END - """, nativeQuery = true) - int backfillLevels(); - - /** - * point 컬럼 백필: 기존 사용자는 지금까지 적립한 score만큼 포인트를 보유한 것으로 초기화. - * NULL(백필 전 표식)인 행만 갱신하므로 재실행해도 이미 사용한 포인트가 복구되지 않는다 (멱등) - */ - @Transactional - @Modifying - @Query(value = "UPDATE tb_user SET point = score WHERE point IS NULL", nativeQuery = true) - int backfillPoints(); - - /** - * 포인트 차감. 잔액 검증과 차감을 단일 UPDATE로 수행하여 동시 구매 시 이중 차감을 방지한다. - * @return 1이면 차감 성공, 0이면 잔액 부족 - */ - @Modifying(flushAutomatically = true, clearAutomatically = true) - @Query("update User u set u.point = u.point - :price where u.userId = :userId and u.point >= :price") - int deductPoint(@Param("userId") Long userId, @Param("price") int price); } diff --git a/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java deleted file mode 100644 index 183e0ded..00000000 --- a/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java +++ /dev/null @@ -1,45 +0,0 @@ -package devkor.com.teamcback.domain.user.scheduler; - -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.redis.RedisLockUtil; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -/** - * level/point 컬럼 도입에 따른 기존 사용자 백필. - * - level: score와 불일치하는 행만 교정 (정상 상태에서는 0건 갱신) - * - point: NULL인 행만 score 값으로 초기화 (사용 후 0이 된 잔액은 다시 채워지지 않음) - * 매 부팅 시 실행되지만 두 쿼리 모두 멱등. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class UserLevelBackfillRunner { - private final UserRepository userRepository; - private final RedisLockUtil redisLockUtil; - - @Value("${metrics.environment}") - private String env; - - // 트랜잭션 경계는 UserRepository.backfillLevels()에 둔다. 리스너 메서드를 @Transactional로 감싸면 - // UPDATE 실패를 catch해도 rollback-only 커밋 예외가 리스너 밖으로 나가 부팅이 실패한다. - @EventListener(ApplicationReadyEvent.class) - public void backfillUserLevels() { - if(env.equals("test")) return; // H2 테스트 환경은 create-drop이라 백필 불필요 + Redis 미기동 - - try { - redisLockUtil.executeWithLock("user_level_backfill_lock", 1, 300, () -> { - int levelUpdated = userRepository.backfillLevels(); - int pointUpdated = userRepository.backfillPoints(); - log.info("사용자 백필 완료: level {}건, point {}건 갱신", levelUpdated, pointUpdated); - return null; - }); - } catch (Exception e) { - log.error("backfillUserLevels() 작업 실패: {}", e.getMessage(), e); - } - } -} diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index c7f4c85d..4b5831a5 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -7,14 +7,10 @@ import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.notification.service.PushInstallationService; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; -import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; -import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -40,6 +36,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Arrays; +import java.util.Comparator; + import static devkor.com.teamcback.global.response.ResultCode.*; @Slf4j @@ -51,13 +50,11 @@ public class UserService { private final BookmarkRepository bookmarkRepository; private final UserBookmarkLogRepository userBookmarkLogRepository; private final SuggestionRepository suggestionRepository; - private final UserCharacterRepository userCharacterRepository; private final JwtUtil jwtUtil; private final KakaoValidator kakaoValidator; private final GoogleValidator googleValidator; private final AppleValidator appleValidator; private final PasswordEncoder passwordEncoder; - private final PushInstallationService pushInstallationService; private static final String DEFAULT_NAME = "호랑이"; private static final String DEFAULT_CATEGORY = "내 장소"; @@ -72,11 +69,7 @@ public class UserService { @Transactional public GetUserInfoRes getUserInfo(Long userId) { User user = findUser(userId); - Level level = user.getLevel(); - if(level == null) { // 백필 전 레거시 행 방어 (백필 완료 후엔 도달하지 않음) - user.syncLevel(); - level = user.getLevel(); - } + Level level = getLevel(user.getScore()); Level nextLevel = level.getNextLevel(); Long remainScoreToNextLevel = nextLevel == null ? 0 : nextLevel.getMinScore() - user.getScore(); int percent = 100; @@ -116,12 +109,7 @@ public LoginUserRes releaseLogin(LoginUserReq loginUserReq) { String rawCode = UUID.randomUUID().toString(); user.setCode(passwordEncoder.encode(rawCode)); - return new LoginUserRes( - jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), - jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), - rawCode, - email - ); + return new LoginUserRes(jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), rawCode); } private String validateToken(Provider provider, String token) { @@ -133,32 +121,6 @@ private String validateToken(Provider provider, String token) { }; } - /** - * 관리자 대시보드용 소셜 로그인. - * 일반 로그인과 달리 신규 사용자를 만들지 않으며 기존 ADMIN 계정만 토큰을 발급한다. - */ - @Transactional(readOnly = true) - public AdminLoginRes adminLogin(AdminLoginReq adminLoginReq) { - Provider provider = adminLoginReq.getProvider(); - if(provider != Provider.GOOGLE && provider != Provider.KAKAO) { - throw new GlobalException(INVALID_INPUT); - } - - String email = provider == Provider.KAKAO - ? kakaoValidator.validateAdminToken(adminLoginReq.getToken()) - : googleValidator.validateToken(adminLoginReq.getToken()); - User user = userRepository.findByEmailAndProvider(email, provider); - if(user == null || user.getRole() != Role.ADMIN) { - throw new GlobalException(FORBIDDEN); - } - - return new AdminLoginRes( - jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), - jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), - user - ); - } - /** * 자동 로그인 */ @@ -221,9 +183,7 @@ public DeleteUserRes deleteUser(Long userId) { } userBookmarkLogRepository.deleteAll(userBookmarkLogRepository.findByUser(user)); - pushInstallationService.deactivateAll(user.getUserId()); // suggestionRepository.deleteAll(suggestionRepository.findByUser(user)); - userCharacterRepository.deleteAllByUser(user); userRepository.delete(user); return new DeleteUserRes(); @@ -254,6 +214,14 @@ private void checkUsernameAvailability(User user, String username) { } } + private Level getLevel(Long score) { + // score >= minScore 인 경우 중 가장 높은 레벨 반환 + return Arrays.stream(Level.values()) + .filter(lv -> score >= lv.getMinScore()) + .max(Comparator.comparingInt(Level::getMinScore)) + .orElse(Level.LEVEL1); + } + private User findUser(Long userId) { return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); } diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java index 7b3826d1..9415ebb0 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java @@ -25,25 +25,15 @@ public class KakaoValidator{ private String ISS; @Value("${jwt.social.kakao.aud}") private String AUD; - @Value("${jwt.social.kakao.admin-aud:}") - private String ADMIN_AUD; public OIDCPublicKeysResponse getCachedData() { return kakaoClient.getPublicKeys(); } public String validateToken(String token) { - return validateToken(token, AUD); - } - - public String validateAdminToken(String token) { - return validateToken(token, ADMIN_AUD.isBlank() ? AUD : ADMIN_AUD); - } - - private String validateToken(String token, String audience) { try { // 카카오 id_token 정보 - String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, audience, ISS); + String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, AUD, ISS); // 공개키 가져오기 OIDCPublicKeysResponse publicKeysResponse = getCachedData(); @@ -56,7 +46,7 @@ private String validateToken(String token, String audience) { OIDCDecodePayload payload = oidcUtil.getOIDCTokenBody(token, oidcPublicKeyDto.getN(), oidcPublicKeyDto.getE()); - return resolveEmail(payload); + return payload.getEmail(); } catch(GlobalException e) { redisUtil.deleteCache("kakao::data"); throw new GlobalException(e.getResultCode()); @@ -64,18 +54,4 @@ private String validateToken(String token, String audience) { throw new GlobalException(INVALID_TOKEN); } } - - String resolveEmail(OIDCDecodePayload payload) { - String email = payload.getEmail(); - if (email != null && !email.isBlank()) { - return email; - } - - String subject = payload.getSub(); - if (subject == null || subject.isBlank()) { - throw new GlobalException(INVALID_TOKEN); - } - - return "kakao_" + subject + "@noemail.kodaero.invalid"; - } } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index 5e8c6071..c0ba2a22 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -27,6 +27,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Comparator; import static devkor.com.teamcback.global.response.ResultCode.*; @@ -334,30 +336,34 @@ private void injectScoreInfo(Object result, User user, boolean isLevelUp, boolea public void increaseScore(User user, int addScore) { long newScore = user.getScore() + addScore; // 전후 레벨 계산 - Level beforeLv = Level.fromScore(user.getScore()); - Level afterLv = Level.fromScore(newScore); + Level beforeLv = getLevel(user.getScore()); + Level afterLv = getLevel(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); - user.addPoint(addScore); // 스토어 재화는 score와 같은 양으로 적립 } /** * 점수 차이만큼 업데이트 (증가 또는 감소) */ private void updateScoreWithDiff(User user, int scoreDiff) { - long oldScore = user.getScore(); - long newScore = Math.max(0, oldScore + scoreDiff); // 최소 0점 + long newScore = Math.max(0, user.getScore() + scoreDiff); // 최소 0점 // 전후 레벨 계산 - Level beforeLv = Level.fromScore(oldScore); - Level afterLv = Level.fromScore(newScore); + Level beforeLv = getLevel(user.getScore()); + Level afterLv = getLevel(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); - // 리뷰 삭제 후 재작성으로 포인트를 무한 적립하는 것을 막기 위해 차감도 동일하게 반영 (최소 0) - user.addPoint(newScore - oldScore); + } + + private Level getLevel(Long score) { + // score >= minScore 인 경우 중 가장 높은 레벨 반환 + return Arrays.stream(Level.values()) + .filter(lv -> score >= lv.getMinScore()) + .max(Comparator.comparingInt(Level::getMinScore)) + .orElse(Level.LEVEL1); } } diff --git a/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java b/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java deleted file mode 100644 index 1d7057ac..00000000 --- a/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package devkor.com.teamcback.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.time.Clock; -import java.time.ZoneId; - -@Configuration -public class TimeConfig { - - @Bean - public Clock clock() { - return Clock.system(ZoneId.of("Asia/Seoul")); - } -} diff --git a/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java b/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java index 72080a15..434654cf 100644 --- a/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java +++ b/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java @@ -53,6 +53,7 @@ protected void doFilterInternal( throws ServletException, IOException { String accessToken = jwtUtil.getAccessTokenFromHeader(request); + log.info("Access Token: {}", accessToken); // access token 비어있으면 인증 미처리 if (!StringUtils.hasText(accessToken)) { diff --git a/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java b/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java index cf9b1072..ee250761 100644 --- a/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java +++ b/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java @@ -33,5 +33,5 @@ public CommonResponse(ResultCode resultCode, T data) { public static CommonResponse success(T data) { return new CommonResponse<>(ResultCode.SUCCESS, data); } - public static CommonResponse success() {return new CommonResponse<>(ResultCode.SUCCESS);} + } diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 9287f63a..bad5cd71 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -102,32 +102,9 @@ public enum ResultCode { COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), // 신고 16000번대 - NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."), - - // notification 17000 - NOT_FOUND_PUSH_INSTALLATION(HttpStatus.NOT_FOUND, 17000, "Push installation not found."), - FORBIDDEN_PUSH_INSTALLATION(HttpStatus.FORBIDDEN, 17001, "Push installation is not owned by the current user."), - INACTIVE_PUSH_INSTALLATION(HttpStatus.BAD_REQUEST, 17002, "Push installation is inactive."), - UNSUPPORTED_PUSH_INSTALLATION_VARIANT(HttpStatus.BAD_REQUEST, 17003, "Push installation variant is not supported for test push."), - EXPO_PUSH_RETRYABLE_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 17004, "Expo push request failed with retryable error."), - EXPO_PUSH_NON_RETRYABLE_ERROR(HttpStatus.BAD_GATEWAY, 17005, "Expo push request failed with non-retryable error."), - EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."), - - // 캐릭터 18000번대 - NOT_FOUND_CHARACTER(HttpStatus.NOT_FOUND, 18000, "캐릭터를 찾을 수 없습니다."), - ALREADY_OWNED_CHARACTER(HttpStatus.CONFLICT, 18001, "이미 보유한 캐릭터입니다."), - INSUFFICIENT_POINT(HttpStatus.BAD_REQUEST, 18002, "포인트가 부족합니다."), - NOT_OWNED_CHARACTER(HttpStatus.BAD_REQUEST, 18003, "보유하지 않은 캐릭터입니다."), - CHARACTER_IN_USE(HttpStatus.CONFLICT, 18004, "사용자가 보유 중인 캐릭터는 삭제할 수 없습니다."), - INACTIVE_CHARACTER(HttpStatus.BAD_REQUEST, 18005, "비활성화된 캐릭터입니다."), - INSUFFICIENT_LEVEL(HttpStatus.BAD_REQUEST, 18006, "레벨이 부족합니다."), - - // 사용 성향 조사 19000번대 - ALREADY_ANSWERED_USAGE_SURVEY(HttpStatus.CONFLICT, 19000, "이미 응답한 조사 문항입니다."), - INVALID_USAGE_SURVEY_OPTION(HttpStatus.BAD_REQUEST, 19001, "유효하지 않은 조사 응답입니다."); - + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); private final HttpStatus status; private final int code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index 90e13621..77695172 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -82,24 +82,20 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ); http.authorizeHttpRequests((authorizeHttpRequests) -> - authorizeHttpRequests - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 - .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() - .requestMatchers("/api/users/login/**").permitAll() // 로그인은 허용 - .requestMatchers("/api/users/**").authenticated() - .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 - .requestMatchers("/api/categories/**").authenticated() - .requestMatchers("/api/bookmarks/**").authenticated() - .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 - .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 - .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 - .requestMatchers("/api/store/**").authenticated() // 캐릭터 스토어는 로그인 필요 - .requestMatchers("/api/usage-surveys/**").authenticated() // 사용 성향 조사는 로그인 필요 - .requestMatchers(HttpMethod.POST, "/api/notifications/test").authenticated() - .anyRequest().permitAll() + authorizeHttpRequests + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 + .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() + .requestMatchers("/api/users/login/**").permitAll() // 로그인은 허용 + .requestMatchers("/api/users/**").authenticated() + .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 + .requestMatchers("/api/categories/**").authenticated() + .requestMatchers("/api/bookmarks/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 + .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 + .anyRequest().permitAll() ).exceptionHandling(ex -> ex - .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 - .authenticationEntryPoint(customAuthenticationEntryPoint()) // 인증 실패 시 + .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 + .authenticationEntryPoint(customAuthenticationEntryPoint()) // 인증 실패 시 ); http.logout( diff --git a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java index 8e3fab61..4c75cbbe 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java @@ -11,8 +11,7 @@ public enum FilePath { // 파일 경로를 나타내는 상수를 정의 PLACE("place/"), BUILDING_IMAGE("buildingImage/"), SUGGESTION("suggestion/"), - REVIEW("review/"), - CHARACTER("character/"); + REVIEW("review/"); private final String path; // 경로를 저장하는 final 필드 } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4e0a16d6..64b76d02 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,7 +75,6 @@ jwt: kakao: iss: ${KAKAO_ISS} aud: ${KAKAO_AUD} - admin-aud: ${KAKAO_ADMIN_AUD:} google: iss: ${GOOGLE_ISS} aud: ${GOOGLE_AUD} @@ -120,10 +119,6 @@ profile: lv4-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv4.jpg lv5-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv5.jpg -character: - image: # 캐릭터 이미지 (피그마 확정 이미지를 이 경로 규칙대로 S3에 업로드: {base-url}/{NN_slug}.png) - base-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/character - place: default-image: cafe: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/place/default_image/CAFE.jpg @@ -157,35 +152,3 @@ management: staff: emails: leeyejin113@gmail.com,pingdoll3110@naver.com,ku.kodaero@gmail.com - -push: - expo: - base-url: https://exp.host/--/api/v2/push - access-token: ${EXPO_ACCESS_TOKEN:} - connect-timeout: 3s - read-timeout: 10s - event: - # Dev server sets DEV,PREVIEW; production safely defaults to PRODUCTION. - target-app-variants: ${PUSH_EVENT_TARGET_APP_VARIANTS:PRODUCTION} - crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} - report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} - character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} - survey-enabled: ${PUSH_EVENT_SURVEY_ENABLED:false} - survey: - poll-interval-ms: ${PUSH_SURVEY_POLL_INTERVAL_MS:60000} - worker: - enabled: ${PUSH_WORKER_ENABLED:false} - fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} - batch-size: ${PUSH_WORKER_BATCH_SIZE:100} - max-send-attempts: ${PUSH_WORKER_MAX_SEND_ATTEMPTS:3} - retry-delay-ms: ${PUSH_WORKER_RETRY_DELAY_MS:30000} - receipt-worker: - enabled: ${PUSH_RECEIPT_WORKER_ENABLED:false} - fixed-delay-ms: ${PUSH_RECEIPT_WORKER_FIXED_DELAY_MS:60000} - batch-size: ${PUSH_RECEIPT_WORKER_BATCH_SIZE:1000} - max-receipt-attempts: ${PUSH_RECEIPT_WORKER_MAX_RECEIPT_ATTEMPTS:3} - recovery-worker: - enabled: ${PUSH_RECOVERY_WORKER_ENABLED:false} - cron: "${PUSH_RECOVERY_WORKER_CRON:0 0 4 * * *}" - stale-threshold-minutes: ${PUSH_RECOVERY_STALE_THRESHOLD_MINUTES:30} - batch-size: ${PUSH_RECOVERY_BATCH_SIZE:100} diff --git a/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java deleted file mode 100644 index 25caebab..00000000 --- a/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package devkor.com.teamcback.domain.ble.service; - -import devkor.com.teamcback.domain.ble.dto.request.UpdateBLEReq; -import devkor.com.teamcback.domain.ble.entity.BLEData; -import devkor.com.teamcback.domain.ble.entity.BLEDevice; -import devkor.com.teamcback.domain.ble.entity.BLEstatus; -import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; -import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; -import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; -import devkor.com.teamcback.domain.place.entity.Place; -import devkor.com.teamcback.domain.place.repository.PlaceRepository; -import java.time.LocalDateTime; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BLEServiceTest { - - @Mock - private BLEDeviceRepository bleDeviceRepository; - - @Mock - private BLEDataRepository bleDataRepository; - - @Mock - private PlaceRepository placeRepository; - - @Mock - private ApplicationEventPublisher eventPublisher; - - private BLEService bleService; - private BLEDevice device; - - @BeforeEach - void setUp() { - bleService = new BLEService( - bleDeviceRepository, - bleDataRepository, - placeRepository, - eventPublisher - ); - - Place place = new Place(); - ReflectionTestUtils.setField(place, "id", 10L); - - device = new BLEDevice(); - ReflectionTestUtils.setField(device, "id", 3L); - device.setDeviceName("device-1"); - device.setCapacity(100); - device.setDefaultCount(0); - device.setRatio(1); - device.setPlace(place); - - when(bleDeviceRepository.findByDeviceName("device-1")).thenReturn(device); - when(bleDataRepository.save(any(BLEData.class))).thenAnswer(invocation -> { - BLEData saved = invocation.getArgument(0); - ReflectionTestUtils.setField(saved, "id", 99L); - return saved; - }); - } - - @Test - void publishesEventWhenAvailableBecomesVacant() { - when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) - .thenReturn(Optional.of(data(BLEstatus.AVAILABLE))); - - bleService.updateBLE(req(20)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PlaceBecameVacantEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); - assertThat(captor.getValue().placeId()).isEqualTo(10L); - assertThat(captor.getValue().bleDataId()).isEqualTo(99L); - } - - @Test - void publishesEventWhenCrowdedBecomesVacant() { - when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) - .thenReturn(Optional.of(data(BLEstatus.CROWDED))); - - bleService.updateBLE(req(20)); - - verify(eventPublisher).publishEvent(any(PlaceBecameVacantEvent.class)); - } - - @Test - void doesNotPublishForRepeatedVacant() { - when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) - .thenReturn(Optional.of(data(BLEstatus.VACANT))); - - bleService.updateBLE(req(20)); - - verify(eventPublisher, never()).publishEvent(any()); - } - - @Test - void doesNotPublishWhenPreviousDataDoesNotExist() { - when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) - .thenReturn(Optional.empty()); - - bleService.updateBLE(req(20)); - - verify(eventPublisher, never()).publishEvent(any()); - } - - @Test - void doesNotPublishWhenPreviousStatusIsFailure() { - when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) - .thenReturn(Optional.of(data(BLEstatus.FAILURE))); - - bleService.updateBLE(req(20)); - - verify(eventPublisher, never()).publishEvent(any()); - } - - private UpdateBLEReq req(int lastCount) { - UpdateBLEReq req = new UpdateBLEReq(); - req.setDeviceName("device-1"); - req.setLastCount(lastCount); - req.setLastTime(LocalDateTime.parse("2026-08-05T10:00:00")); - return req; - } - - private BLEData data(BLEstatus status) { - BLEData data = new BLEData(); - data.setDevice(device); - data.setLastStatus(status); - data.setLastCount(50); - data.setLastTime(LocalDateTime.parse("2026-08-05T09:59:00")); - return data; - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java deleted file mode 100644 index bca6aedd..00000000 --- a/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package devkor.com.teamcback.domain.character.repository; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import devkor.com.teamcback.global.config.QueryDslConfig; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.test.context.ActiveProfiles; - -@DataJpaTest -@ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 -class CharacterRepositoryTest { - @Autowired - CharacterRepository characterRepository; - - @BeforeEach - void setUp() { - characterRepository.save(new KoCharacter("두번째", null, null, "url2", 20, 2, 2, true)); - characterRepository.save(new KoCharacter("첫번째", null, null, "url1", 10, 1, 1, true)); - characterRepository.save(new KoCharacter("비활성", null, null, "url3", 30, 3, 3, false)); - } - - @DisplayName("활성 캐릭터만 정렬 순서대로 조회") - @Test - void findAllByIsActiveTrueOrderByDisplayOrderAsc() { - List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); - - assertEquals(2, characters.size()); - assertEquals("첫번째", characters.get(0).getName()); - assertEquals("두번째", characters.get(1).getName()); - } - - @DisplayName("관리자 조회는 비활성 포함") - @Test - void findAllByOrderByDisplayOrderAsc() { - assertEquals(3, characterRepository.findAllByOrderByDisplayOrderAsc().size()); - } - - @DisplayName("이름 존재 여부 확인 (시더 멱등 처리용)") - @Test - void existsByName() { - assertTrue(characterRepository.existsByName("첫번째")); - assertFalse(characterRepository.existsByName("없는이름")); - } - - @DisplayName("이름 중복 저장 시 제약 위반 (멀티 인스턴스 시드 레이스 방어)") - @Test - void duplicateNameThrows() { - assertThrows(DataIntegrityViolationException.class, () -> - characterRepository.saveAndFlush(new KoCharacter("첫번째", null, null, "url", 10, 1, 9, true))); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java deleted file mode 100644 index eff13a19..00000000 --- a/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package devkor.com.teamcback.domain.character.repository; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import devkor.com.teamcback.global.config.QueryDslConfig; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.test.context.ActiveProfiles; - -@DataJpaTest -@ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 -class UserCharacterRepositoryTest { - @Autowired - UserCharacterRepository userCharacterRepository; - - @Autowired - CharacterRepository characterRepository; - - @Autowired - UserRepository userRepository; - - User user1; - User user2; - KoCharacter character; - - @BeforeEach - void setUp() { - user1 = userRepository.save(new User("user1", "user1@test.com", Role.USER, Provider.KAKAO)); - user2 = userRepository.save(new User("user2", "user2@test.com", Role.USER, Provider.KAKAO)); - character = characterRepository.save(new KoCharacter("캐릭터", null, null, "url", 10, 1, 1, true)); - - userCharacterRepository.save(new UserCharacter(user1, character)); - } - - @DisplayName("같은 사용자-캐릭터 중복 저장 시 제약 위반 (동시 해금 방어선)") - @Test - void duplicateClaimThrows() { - assertThrows(DataIntegrityViolationException.class, () -> - userCharacterRepository.saveAndFlush(new UserCharacter(user1, character))); - } - - @DisplayName("사용자별 보유 캐릭터만 조회") - @Test - void findAllByUser() { - List owned = userCharacterRepository.findAllByUser(user1); - - assertEquals(1, owned.size()); - assertEquals(character.getCharacterId(), owned.get(0).getCharacter().getCharacterId()); - assertTrue(userCharacterRepository.findAllByUser(user2).isEmpty()); - } - - @DisplayName("보유 여부 확인") - @Test - void existsByUserAndCharacter() { - assertTrue(userCharacterRepository.existsByUserAndCharacter(user1, character)); - assertTrue(userCharacterRepository.existsByCharacter(character)); - } - - @DisplayName("회원 탈퇴 시 본인 보유 이력만 삭제") - @Test - void deleteAllByUser() { - userCharacterRepository.save(new UserCharacter(user2, character)); - - userCharacterRepository.deleteAllByUser(user1); - - assertTrue(userCharacterRepository.findAllByUser(user1).isEmpty()); - assertEquals(1, userCharacterRepository.findAllByUser(user2).size()); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java deleted file mode 100644 index 09020cbb..00000000 --- a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package devkor.com.teamcback.domain.character.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; -import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; -import devkor.com.teamcback.infra.s3.FilePath; -import devkor.com.teamcback.infra.s3.S3Util; -import java.util.Optional; -import org.mockito.ArgumentCaptor; -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.context.ApplicationEventPublisher; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class AdminStoreServiceTest { - @InjectMocks - AdminStoreService adminStoreService; - - @Mock - CharacterRepository characterRepository; - @Mock - UserCharacterRepository userCharacterRepository; - @Mock - UserRepository userRepository; - @Mock - S3Util s3Util; - @Mock - ApplicationEventPublisher eventPublisher; - - @DisplayName("캐릭터 생성 시 S3 업로드 후 URL과 가격 저장") - @Test - void createCharacter() { - MockMultipartFile image = new MockMultipartFile("image", "tiger.png", "image/png", new byte[] {1}); - CreateCharacterReq req = new CreateCharacterReq(); - req.setName("아기 호랑이"); - req.setPrice(10); - req.setDisplayOrder(1); - req.setImage(image); - - when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("https://s3/character/tiger.png"); - when(characterRepository.save(any(KoCharacter.class))).thenAnswer(invocation -> { - KoCharacter character = invocation.getArgument(0); - ReflectionTestUtils.setField(character, "characterId", 1L); - return character; - }); - - CreateCharacterRes res = adminStoreService.createCharacter(req); - - assertEquals(1L, res.getCharacterId()); - verify(s3Util).uploadFile(image, FilePath.CHARACTER); - } - - @DisplayName("이미지 없이 캐릭터 생성 시 예외") - @Test - void createCharacterWithoutImage() { - CreateCharacterReq req = new CreateCharacterReq(); - req.setName("아기 호랑이"); - req.setPrice(10); - - GlobalException e = assertThrows(GlobalException.class, - () -> adminStoreService.createCharacter(req)); - assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); - verify(characterRepository, never()).save(any()); - } - - @DisplayName("가격이 없거나 음수면 생성 불가") - @Test - void createCharacterInvalidPrice() { - CreateCharacterReq req = new CreateCharacterReq(); - req.setName("아기 호랑이"); - req.setPrice(-1); - - GlobalException e = assertThrows(GlobalException.class, - () -> adminStoreService.createCharacter(req)); - assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); - - req.setPrice(null); - assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); - } - - @DisplayName("해금 레벨이 1~5 범위를 벗어나면 생성 불가") - @Test - void createCharacterInvalidRequiredLevel() { - CreateCharacterReq req = new CreateCharacterReq(); - req.setName("아기 호랑이"); - req.setPrice(10); - req.setRequiredLevel(0); - - GlobalException e = assertThrows(GlobalException.class, - () -> adminStoreService.createCharacter(req)); - assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); - - req.setRequiredLevel(6); // Level enum 최대(5) 초과 - assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); - verify(characterRepository, never()).save(any()); - } - - @DisplayName("캐릭터 수정: 이미지 미첨부 시 기존 이미지 유지, 첨부 시 교체 후 기존 파일 삭제") - @Test - void modifyCharacter() { - KoCharacter character = new KoCharacter("애기호랑이", null, "옛 대사", "old-url", 0, 1, 1, true); - when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); - - devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq req = - new devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq(); - req.setName("애기호랑이"); - req.setQuote("나 호랑이 맞아요?"); - req.setPrice(0); - req.setRequiredLevel(1); - req.setDisplayOrder(1); - - // 이미지 미첨부 → 기존 URL 유지, S3 접근 없음 - adminStoreService.modifyCharacter(1L, req); - assertEquals("old-url", character.getImageUrl()); - assertEquals("나 호랑이 맞아요?", character.getQuote()); - verify(s3Util, never()).deleteFile(any(String.class), any(FilePath.class)); - - // 이미지 첨부 → 새 URL로 교체 + 기존 S3 파일 삭제 - MockMultipartFile image = new MockMultipartFile("image", "new.png", "image/png", new byte[] {1}); - req.setImage(image); - when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("new-url"); - - adminStoreService.modifyCharacter(1L, req); - assertEquals("new-url", character.getImageUrl()); - verify(s3Util).deleteFile(eq("old-url"), eq(FilePath.CHARACTER)); - } - - @DisplayName("구매한 사용자가 있는 캐릭터 삭제 시 예외") - @Test - void deleteOwnedCharacterRejected() { - KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); - when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByCharacter(character)).thenReturn(true); - - GlobalException e = assertThrows(GlobalException.class, - () -> adminStoreService.deleteCharacter(1L)); - assertEquals(ResultCode.CHARACTER_IN_USE, e.getResultCode()); - verify(characterRepository, never()).delete(any()); - } - - @DisplayName("구매자가 없으면 삭제 성공 (S3 이미지도 삭제)") - @Test - void deleteCharacter() { - KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); - when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByCharacter(character)).thenReturn(false); - - adminStoreService.deleteCharacter(1L); - - verify(s3Util).deleteFile(eq("url"), eq(FilePath.CHARACTER)); - verify(characterRepository).delete(character); - } - - @DisplayName("수동 지급: 이미 보유 시 예외, 미보유 시 무료 지급") - @Test - void grantCharacter() { - KoCharacter character = new KoCharacter("이벤트 캐릭터", null, null, "url", 100, 1, 1, true); - ReflectionTestUtils.setField(character, "characterId", 1L); - User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - ReflectionTestUtils.setField(user, "userId", 2L); - when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); - when(userRepository.findById(2L)).thenReturn(Optional.of(user)); - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); - - GlobalException e = assertThrows(GlobalException.class, - () -> adminStoreService.grantCharacter(1L, 2L)); - assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); - verify(eventPublisher, never()).publishEvent(any()); - - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))).thenAnswer(invocation -> { - UserCharacter userCharacter = invocation.getArgument(0); - ReflectionTestUtils.setField(userCharacter, "userCharacterId", 5L); - return userCharacter; - }); - - assertEquals(5L, adminStoreService.grantCharacter(1L, 2L).getUserCharacterId()); - assertEquals(0L, user.getPoint()); // 지급은 포인트를 건드리지 않음 - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertEquals(2L, eventCaptor.getValue().userId()); - assertEquals(5L, eventCaptor.getValue().userCharacterId()); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java deleted file mode 100644 index b88946b7..00000000 --- a/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package devkor.com.teamcback.domain.character.service; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -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.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class CharacterDataSeederTest { - @InjectMocks - CharacterDataSeeder seeder; - - @Mock - CharacterRepository characterRepository; - @Mock - UserCharacterRepository userCharacterRepository; - - @BeforeEach - void setUp() { - ReflectionTestUtils.setField(seeder, "imageBaseUrl", "https://s3/character"); - } - - @DisplayName("첫 실행 시 확정 캐릭터 10건 적재") - @Test - void seedOnFirstRun() { - when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); - when(characterRepository.existsByName(anyString())).thenReturn(false); - - seeder.run(null); - - verify(characterRepository, times(10)).save(any(KoCharacter.class)); - } - - @DisplayName("이미 적재된 경우 저장하지 않음 (멱등)") - @Test - void skipOnSecondRun() { - when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); - when(characterRepository.existsByName(anyString())).thenReturn(true); - - seeder.run(null); - - verify(characterRepository, never()).save(any(KoCharacter.class)); - } - - @DisplayName("동시 부팅 레이스: 중복 키 예외가 나도 나머지 시드를 계속 적재") - @Test - void continueSeedingAfterDuplicateKeyRace() { - when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); - when(characterRepository.existsByName(anyString())).thenReturn(false); - // 첫 번째 save는 다른 인스턴스가 먼저 적재해 UNIQUE 위반, 이후는 성공 - when(characterRepository.save(any(KoCharacter.class))) - .thenThrow(new org.springframework.dao.DataIntegrityViolationException("uk name")) - .thenAnswer(invocation -> invocation.getArgument(0)); - - seeder.run(null); // 예외가 전파되면 부팅 실패 → 테스트 실패 - - verify(characterRepository, times(10)).save(any(KoCharacter.class)); - } - - @DisplayName("구 플레이스홀더는 보유자가 없으면 삭제, 있으면 유지") - @Test - void removeLegacySeeds() { - KoCharacter orphanLegacy = new KoCharacter("아기 호랑이", null, null, "url", 0, 1, 1, true); - KoCharacter ownedLegacy = new KoCharacter("호랑이 대장", null, null, "url", 60, 5, 5, true); - when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); - when(characterRepository.findByName("아기 호랑이")).thenReturn(Optional.of(orphanLegacy)); - when(characterRepository.findByName("호랑이 대장")).thenReturn(Optional.of(ownedLegacy)); - when(userCharacterRepository.existsByCharacter(orphanLegacy)).thenReturn(false); - when(userCharacterRepository.existsByCharacter(ownedLegacy)).thenReturn(true); - when(characterRepository.existsByName(anyString())).thenReturn(true); // 시드 자체는 스킵 - - seeder.run(null); - - verify(characterRepository).delete(orphanLegacy); - verify(characterRepository, never()).delete(ownedLegacy); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java deleted file mode 100644 index 76914615..00000000 --- a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java +++ /dev/null @@ -1,315 +0,0 @@ -package devkor.com.teamcback.domain.character.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; -import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; -import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; -import devkor.com.teamcback.domain.character.entity.KoCharacter; -import devkor.com.teamcback.domain.character.entity.PurchaseStatus; -import devkor.com.teamcback.domain.character.entity.UserCharacter; -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.character.repository.CharacterRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; -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; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class StoreServiceTest { - @InjectMocks - StoreService storeService; - - @Mock - CharacterRepository characterRepository; - @Mock - UserCharacterRepository userCharacterRepository; - @Mock - UserRepository userRepository; - @Mock - ApplicationEventPublisher eventPublisher; - - static final Long USER_ID = 1L; - static final Long CHARACTER_ID = 10L; - - User user; - KoCharacter character; // 해금 레벨 1, 가격 10 - - @BeforeEach - void setUp() { - user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - ReflectionTestUtils.setField(user, "userId", USER_ID); - - character = newCharacter(CHARACTER_ID, "아기 호랑이", 10, 1, 1, true); - } - - private KoCharacter newCharacter(Long id, String name, int price, int requiredLevel, int order, boolean active) { - KoCharacter c = new KoCharacter(name, null, name + " 대사", "url", price, requiredLevel, order, active); - ReflectionTestUtils.setField(c, "characterId", id); - return c; - } - - @DisplayName("레벨 충족 + 포인트 충분이면 구매 성공") - @Test - void purchaseCharacter() { - user.addPoint(25); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); - when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) - .thenAnswer(invocation -> { - UserCharacter userCharacter = invocation.getArgument(0); - ReflectionTestUtils.setField(userCharacter, "userCharacterId", 55L); - return userCharacter; - }); - - PurchaseCharacterRes res = storeService.purchaseCharacter(USER_ID, CHARACTER_ID); - - assertEquals(CHARACTER_ID, res.getCharacterId()); - assertEquals(10, res.getPrice()); - verify(userRepository).deductPoint(USER_ID, 10); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); - verify(eventPublisher).publishEvent(eventCaptor.capture()); - assertEquals(USER_ID, eventCaptor.getValue().userId()); - assertEquals(CHARACTER_ID, eventCaptor.getValue().characterId()); - assertEquals(55L, eventCaptor.getValue().userCharacterId()); - } - - @DisplayName("해금 레벨 미달이면 포인트가 충분해도 구매 불가") - @Test - void purchaseInsufficientLevel() { - KoCharacter level3Character = newCharacter(CHARACTER_ID, "청년 호랑이", 10, 3, 3, true); - user.addPoint(100); // 포인트는 충분 - // 유저는 score 0 → LEVEL1 - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(level3Character)); - when(userCharacterRepository.existsByUserAndCharacter(user, level3Character)).thenReturn(false); - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.INSUFFICIENT_LEVEL, e.getResultCode()); - verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); - - // 레벨 3 도달 시 구매 가능 - user.updateScore(20L, false); // LEVEL3 - when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); - when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - assertEquals(CHARACTER_ID, storeService.purchaseCharacter(USER_ID, CHARACTER_ID).getCharacterId()); - } - - @DisplayName("포인트 부족 시 구매 실패") - @Test - void purchaseInsufficientPoint() { - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - when(userRepository.deductPoint(USER_ID, 10)).thenReturn(0); // 잔액 부족 - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.INSUFFICIENT_POINT, e.getResultCode()); - verify(userCharacterRepository, never()).saveAndFlush(any()); - } - - @DisplayName("이미 보유한 캐릭터 구매 시 예외 (차감 없음)") - @Test - void purchaseAlreadyOwned() { - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); - verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); - verify(eventPublisher, never()).publishEvent(any()); - } - - @DisplayName("비활성 캐릭터는 구매 불가") - @Test - void purchaseInactiveRejected() { - KoCharacter inactive = newCharacter(CHARACTER_ID, "숨김 캐릭터", 10, 1, 9, false); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(inactive)); - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.INACTIVE_CHARACTER, e.getResultCode()); - } - - @DisplayName("동시 중복 구매 시 제약 위반을 이미 보유로 매핑 (롤백으로 차감 복구)") - @Test - void purchaseRaceMappedToAlreadyOwned() { - user.addPoint(25); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); - when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) - .thenThrow(new DataIntegrityViolationException("uk_user_character")); - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); - verify(eventPublisher, never()).publishEvent(any()); - } - - @DisplayName("미보유 캐릭터 장착 시 예외, 보유 캐릭터는 장착/해제 성공") - @Test - void equipCharacter() { - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.equipCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.NOT_OWNED_CHARACTER, e.getResultCode()); - - when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); - storeService.equipCharacter(USER_ID, CHARACTER_ID); - assertEquals(CHARACTER_ID, user.getEquippedCharacterId()); - - storeService.unequipCharacter(USER_ID); - assertNull(user.getEquippedCharacterId()); - } - - @DisplayName("스토어 목록: 4단계 상태 판정 (보유/구매가능/미해금/포인트부족)") - @Test - void getStoreStatuses() { - user.updateScore(5L, false); // LEVEL2 - user.addPoint(10); - user.updateEquippedCharacter(CHARACTER_ID); - - KoCharacter ownedCharacter = character; // 보유 + 장착 - KoCharacter purchasableCharacter = newCharacter(11L, "학생 호랑이", 10, 2, 2, true); // 레벨·포인트 충족 - KoCharacter lockedCharacter = newCharacter(12L, "청년 호랑이", 5, 3, 3, true); // 레벨 미달 (포인트는 충분해도 LOCKED) - KoCharacter expensiveCharacter = newCharacter(13L, "비싼 호랑이", 999, 1, 4, true); // 레벨 충족, 포인트 부족 - - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) - .thenReturn(List.of(ownedCharacter, purchasableCharacter, lockedCharacter, expensiveCharacter)); - when(userCharacterRepository.findAllByUser(user)) - .thenReturn(List.of(new UserCharacter(user, ownedCharacter))); - - GetStoreRes res = storeService.getStore(USER_ID); - List list = res.getCharacterList(); - - assertEquals(10L, res.getPoint()); - assertEquals(PurchaseStatus.OWNED, statusOf(list, CHARACTER_ID)); - assertEquals(PurchaseStatus.PURCHASABLE, statusOf(list, 11L)); - assertEquals(PurchaseStatus.LOCKED, statusOf(list, 12L)); - assertEquals(PurchaseStatus.NOT_ENOUGH_POINT, statusOf(list, 13L)); - } - - @DisplayName("스토어 정렬: 보유(레벨→포인트) → 구매가능(포인트) → 미해금(레벨) → 포인트부족(포인트)") - @Test - void getStoreSorting() { - user.updateScore(5L, false); // LEVEL2 - user.addPoint(20); - - // displayOrder는 전부 역순(9~1)으로 줘서 정렬이 displayOrder가 아닌 스펙 기준임을 증명 - KoCharacter ownedHighLevel = newCharacter(21L, "보유-레벨2", 5, 2, 9, true); - KoCharacter ownedLowLevel = newCharacter(22L, "보유-레벨1", 10, 1, 8, true); - KoCharacter purchasableExpensive = newCharacter(23L, "구매가능-20p", 20, 1, 7, true); - KoCharacter purchasableCheap = newCharacter(24L, "구매가능-5p", 5, 2, 6, true); - KoCharacter lockedLevel5 = newCharacter(25L, "미해금-레벨5", 0, 5, 5, true); - KoCharacter lockedLevel3 = newCharacter(26L, "미해금-레벨3", 0, 3, 4, true); - KoCharacter poorExpensive = newCharacter(27L, "포인트부족-99p", 99, 1, 3, true); - KoCharacter poorCheap = newCharacter(28L, "포인트부족-30p", 30, 2, 2, true); - - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) - .thenReturn(List.of(ownedHighLevel, ownedLowLevel, purchasableExpensive, purchasableCheap, - lockedLevel5, lockedLevel3, poorExpensive, poorCheap)); - when(userCharacterRepository.findAllByUser(user)) - .thenReturn(List.of(new UserCharacter(user, ownedHighLevel), new UserCharacter(user, ownedLowLevel))); - - List list = storeService.getStore(USER_ID).getCharacterList(); - List orderedIds = list.stream().map(GetStoreCharacterRes::getCharacterId).toList(); - - assertEquals(List.of( - 22L, 21L, // 1순위 보유: 레벨1(가격10) → 레벨2(가격5) - 24L, 23L, // 2순위 구매가능: 5p → 20p - 26L, 25L, // 3순위 미해금: 레벨3 → 레벨5 - 28L, 27L // 4순위 포인트부족: 30p → 99p - ), orderedIds); - } - - @DisplayName("지급받아 보유 중인 캐릭터는 레벨 미달이어도 '이미 보유' 응답 (검증 순서)") - @Test - void purchaseOwnedTakesPrecedenceOverLevel() { - // 운영자 지급으로 레벨 5 캐릭터를 보유한 레벨 1 사용자 - KoCharacter grantedCharacter = newCharacter(CHARACTER_ID, "어른호랑이", 30, 5, 5, true); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(grantedCharacter)); - when(userCharacterRepository.existsByUserAndCharacter(user, grantedCharacter)).thenReturn(true); - - GlobalException e = assertThrows(GlobalException.class, - () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); - assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); // INSUFFICIENT_LEVEL이 아니어야 함 - } - - @DisplayName("가격 0 캐릭터는 포인트 0이어도 구매 가능 상태 (경계값)") - @Test - void freeCharacterPurchasableWithZeroPoint() { - // 신규 사용자: score 0, point 0, LEVEL1 - KoCharacter freeCharacter = newCharacter(CHARACTER_ID, "애기호랑이", 0, 1, 1, true); - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(List.of(freeCharacter)); - when(userCharacterRepository.findAllByUser(user)).thenReturn(List.of()); - - GetStoreRes res = storeService.getStore(USER_ID); - - assertEquals(PurchaseStatus.PURCHASABLE, res.getCharacterList().get(0).getStatus()); - } - - @DisplayName("내 보유 캐릭터: 포인트·대표 캐릭터·장착 플래그·대사 반환") - @Test - void getMyCharacters() { - user.addPoint(7); - user.updateEquippedCharacter(CHARACTER_ID); - KoCharacter otherCharacter = newCharacter(11L, "꼬마호랑이", 15, 2, 2, true); - - when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); - when(userCharacterRepository.findAllByUser(user)).thenReturn( - List.of(new UserCharacter(user, character), new UserCharacter(user, otherCharacter))); - - var res = storeService.getMyCharacters(USER_ID); - - assertEquals(7L, res.getPoint()); - assertEquals(CHARACTER_ID, res.getEquippedCharacterId()); - assertEquals(2, res.getCharacterList().size()); - assertEquals(true, res.getCharacterList().get(0).isEquipped()); // 장착한 캐릭터 - assertEquals(false, res.getCharacterList().get(1).isEquipped()); // 미장착 보유 캐릭터 - assertEquals("아기 호랑이 대사", res.getCharacterList().get(0).getQuote()); - } - - private PurchaseStatus statusOf(List list, Long characterId) { - return list.stream().filter(c -> c.getCharacterId().equals(characterId)).findFirst().orElseThrow().getStatus(); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java deleted file mode 100644 index f312a787..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java +++ /dev/null @@ -1,344 +0,0 @@ -package devkor.com.teamcback.domain.notification; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import devkor.com.teamcback.domain.notification.service.NotificationTestService; -import devkor.com.teamcback.domain.notification.service.PushMessageDispatchWorker; -import devkor.com.teamcback.domain.notification.service.PushReceiptWorker; -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ConcurrentLinkedQueue; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.util.ReflectionTestUtils; -import org.redisson.api.RedissonClient; - -import static org.assertj.core.api.Assertions.assertThat; - -@ActiveProfiles("test") -@SpringBootTest(properties = { - "spring.datasource.driver-class-name=org.h2.Driver", - "spring.datasource.url=jdbc:h2:mem:push_pipeline;MODE=MySQL;DATABASE_TO_LOWER=TRUE;NON_KEYWORDS=YEAR,END;DB_CLOSE_DELAY=-1", - "spring.datasource.username=sa", - "spring.datasource.password=", - "spring.jpa.hibernate.ddl-auto=create-drop", - "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", - "spring.sql.init.mode=never", - "spring.cache.type=simple", - "spring.data.redis.host=localhost", - "spring.data.redis.port=6379", - "spring.data.redis.password=test", - "push.worker.enabled=false", - "push.receipt-worker.enabled=false", - "jwt.secret.key=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", - "jwt.admin.token=test-admin-token", - "jwt.social.kakao.iss=test-kakao-iss", - "jwt.social.kakao.aud=test-kakao-aud", - "jwt.social.google.iss=test-google-iss", - "jwt.social.google.aud=test-google-aud", - "jwt.social.apple.iss=test-apple-iss", - "jwt.social.apple.aud=test-apple-aud", - "jwt.social.apple.dev-aud=test-apple-dev-aud", - "metrics.environment=test", - "staff.emails=test@example.com", - "cloud.aws.s3.bucket=test-bucket", - "cloud.aws.credentials.access-key=test-access-key", - "cloud.aws.credentials.secret-key=test-secret-key", - "cloud.aws.region.static=ap-northeast-2", - "date.api.holiday.end-point=http://localhost", - "date.api.holiday.encoded-key=test-encoded-key", - "date.api.holiday.decoded-key=test-decoded-key", - "spring.mail.host=localhost", - "management.health.mail.enabled=false" -}) -class PushNotificationPipelineIntegrationTest { - - private static final String EXPO_PUSH_TOKEN = "ExponentPushToken[pipeline-test-token]"; - private static final String TICKET_ID = "ticket-pipeline-1"; - private static final MockExpoServer EXPO_SERVER = new MockExpoServer(); - - @Autowired - private NotificationTestService notificationTestService; - - @Autowired - private PushMessageDispatchWorker pushMessageDispatchWorker; - - @Autowired - private PushReceiptWorker pushReceiptWorker; - - @Autowired - private PushInstallationRepository pushInstallationRepository; - - @Autowired - private PushDispatchRepository pushDispatchRepository; - - @Autowired - private PushMessageRepository pushMessageRepository; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private RedissonClient redissonClient; - - @DynamicPropertySource - static void expoProperties(DynamicPropertyRegistry registry) { - EXPO_SERVER.start(); - registry.add("push.expo.base-url", EXPO_SERVER::baseUrl); - } - - @AfterAll - static void stopExpoServer() { - EXPO_SERVER.stop(); - } - - @BeforeEach - void setUp() { - EXPO_SERVER.reset(); - pushMessageRepository.deleteAll(); - pushDispatchRepository.deleteAll(); - pushInstallationRepository.deleteAll(); - } - - @Test - void testNotificationPipelineQueuesSendsReceiptsAndCompletesDispatch() throws Exception { - PushInstallation installation = pushInstallationRepository.save(new PushInstallation( - 1L, - "install-pipeline", - EXPO_PUSH_TOKEN, - AppVariant.DEV - )); - - notificationTestService.sendTest( - 1L, - UUID.randomUUID().toString(), - new NotificationTestReq(1, installation.getInstallationId()) - ); - - List dispatches = pushDispatchRepository.findAll(); - List messages = pushMessageRepository.findAll(); - assertThat(dispatches).hasSize(1); - assertThat(messages).hasSize(1); - - PushDispatch dispatch = dispatches.get(0); - PushMessage queuedMessage = messages.get(0); - assertThat(dispatch.getRecipientCount()).isEqualTo(1); - assertThat(queuedMessage.getStatus()).isEqualTo(PushMessageStatus.QUEUED); - assertThat(EXPO_SERVER.requests()).isEmpty(); - - EXPO_SERVER.enqueueJson(200, """ - {"data":[{"status":"ok","id":"ticket-pipeline-1"}]} - """); - pushMessageDispatchWorker.dispatchPending(); - - PushMessage receiptPendingMessage = pushMessageRepository.findById(queuedMessage.getPushMessageId()).orElseThrow(); - assertThat(receiptPendingMessage.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); - assertThat(receiptPendingMessage.getExpoTicketId()).isEqualTo(TICKET_ID); - assertThat(receiptPendingMessage.getReceiptAvailableAt()).isNotNull(); - - RecordedRequest sendRequest = EXPO_SERVER.takeOnlyRequest(); - assertThat(sendRequest.path()).isEqualTo("/send"); - JsonNode sendBody = objectMapper.readTree(sendRequest.body()); - JsonNode firstSend = sendBody.get(0); - assertThat(firstSend.get("to").asText()).isEqualTo(EXPO_PUSH_TOKEN); - assertThat(firstSend.at("/data/action/type").asText()).isEqualTo(PushActionType.TEST.name()); - assertThat(firstSend.at("/data/notificationId").asText()) - .isEqualTo(String.valueOf(receiptPendingMessage.getPushMessageId())); - - ReflectionTestUtils.setField( - receiptPendingMessage, - "receiptAvailableAt", - LocalDateTime.now().minusMinutes(1) - ); - pushMessageRepository.saveAndFlush(receiptPendingMessage); - - EXPO_SERVER.reset(); - EXPO_SERVER.enqueueJson(200, """ - {"data":{"ticket-pipeline-1":{"status":"ok"}}} - """); - pushReceiptWorker.checkPendingReceipts(); - - PushMessage deliveredMessage = pushMessageRepository.findById(queuedMessage.getPushMessageId()).orElseThrow(); - PushDispatch completedDispatch = pushDispatchRepository.findById(dispatch.getPushDispatchId()).orElseThrow(); - assertThat(deliveredMessage.getStatus()).isEqualTo(PushMessageStatus.DELIVERED); - assertThat(deliveredMessage.getReceiptStatus()).isEqualTo("ok"); - assertThat(deliveredMessage.getReceiptCheckedAt()).isNotNull(); - assertThat(completedDispatch.getStatus()).isEqualTo(PushDispatchStatus.COMPLETED); - assertThat(pushMessageRepository.findById(deliveredMessage.getPushMessageId())).isPresent(); - assertThat(pushDispatchRepository.findById(completedDispatch.getPushDispatchId())).isPresent(); - - RecordedRequest receiptRequest = EXPO_SERVER.takeOnlyRequest(); - assertThat(receiptRequest.path()).isEqualTo("/getReceipts"); - JsonNode receiptBody = objectMapper.readTree(receiptRequest.body()); - JsonNode ids = receiptBody.get("ids"); - assertThat(ids) - .as("receipt request body: " + receiptRequest.body()) - .isNotNull(); - assertThat(ids.isArray()).isTrue(); - assertThat(ids.get(0).asText()).isEqualTo(TICKET_ID); - } - - @Test - void receiptDeviceNotRegisteredFailsMessageAndDeactivatesInstallation() { - PushInstallation installation = pushInstallationRepository.save(new PushInstallation( - 1L, - "install-device-not-registered", - EXPO_PUSH_TOKEN, - AppVariant.DEV - )); - - notificationTestService.sendTest( - 1L, - UUID.randomUUID().toString(), - new NotificationTestReq(1, installation.getInstallationId()) - ); - - EXPO_SERVER.enqueueJson(200, """ - {"data":[{"status":"ok","id":"ticket-pipeline-1"}]} - """); - pushMessageDispatchWorker.dispatchPending(); - - PushMessage receiptPendingMessage = pushMessageRepository.findAll().get(0); - ReflectionTestUtils.setField( - receiptPendingMessage, - "receiptAvailableAt", - LocalDateTime.now().minusMinutes(1) - ); - pushMessageRepository.saveAndFlush(receiptPendingMessage); - - EXPO_SERVER.reset(); - EXPO_SERVER.enqueueJson(200, """ - {"data":{"ticket-pipeline-1":{"status":"error","details":{"error":"DeviceNotRegistered"}}}} - """); - pushReceiptWorker.checkPendingReceipts(); - - PushMessage failedMessage = pushMessageRepository.findById(receiptPendingMessage.getPushMessageId()).orElseThrow(); - PushInstallation deactivatedInstallation = pushInstallationRepository.findById(installation.getPushInstallationId()) - .orElseThrow(); - PushDispatch failedDispatch = pushDispatchRepository.findAll().get(0); - - assertThat(failedMessage.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(failedMessage.getReceiptStatus()).isEqualTo("error"); - assertThat(failedMessage.getReceiptError()).isEqualTo("DeviceNotRegistered"); - assertThat(deactivatedInstallation.isActive()).isFalse(); - assertThat(failedDispatch.getStatus()).isEqualTo(PushDispatchStatus.FAILED); - } - - private record RecordedRequest(String path, String body) { - } - - private static final class MockExpoServer { - - private final ConcurrentLinkedQueue responses = new ConcurrentLinkedQueue<>(); - private final List requests = new ArrayList<>(); - private HttpServer server; - - void start() { - if (server != null) { - return; - } - - try { - server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); - } catch (IOException e) { - throw new IllegalStateException(e); - } - - server.createContext("/", this::handle); - server.start(); - } - - void stop() { - if (server != null) { - server.stop(0); - } - } - - String baseUrl() { - return "http://localhost:" + server.getAddress().getPort(); - } - - void reset() { - responses.clear(); - synchronized (requests) { - requests.clear(); - } - } - - void enqueueJson( - int status, - String body - ) { - responses.add(status + "\n" + body); - } - - List requests() { - synchronized (requests) { - return List.copyOf(requests); - } - } - - RecordedRequest takeOnlyRequest() { - List snapshot = requests(); - assertThat(snapshot).hasSize(1); - return snapshot.get(0); - } - - private void handle(HttpExchange exchange) throws IOException { - String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); - synchronized (requests) { - requests.add(new RecordedRequest(exchange.getRequestURI().getPath(), requestBody)); - } - - String rawResponse = responses.poll(); - if (rawResponse == null) { - write(exchange, 500, "{\"errors\":[{\"message\":\"missing mock response\"}]}"); - return; - } - - int delimiter = rawResponse.indexOf('\n'); - int status = Integer.parseInt(rawResponse.substring(0, delimiter)); - String body = rawResponse.substring(delimiter + 1); - write(exchange, status, body); - } - - private void write( - HttpExchange exchange, - int status, - String body - ) throws IOException { - byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(status, bytes.length); - try (OutputStream responseBody = exchange.getResponseBody()) { - responseBody.write(bytes); - } - } - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java deleted file mode 100644 index 55d5c870..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package devkor.com.teamcback.domain.notification.controller; - -import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.service.AdminNotificationService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@ExtendWith(MockitoExtension.class) -class AdminNotificationControllerTest { - - @Mock - private AdminNotificationService adminNotificationService; - - @Mock - private PushEventFlagService pushEventFlagService; - - private MockMvc mockMvc; - - @BeforeEach - void setUp() { - AdminNotificationController controller = new AdminNotificationController( - adminNotificationService, - pushEventFlagService - ); - mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); - } - - @Test - void updatedEventFlagIsReflectedInAdminApiQueryResult() throws Exception { - when(pushEventFlagService.updateFlag(PushEventType.REPORT, true)) - .thenReturn(new AdminPushEventFlagRes(PushEventType.REPORT, true)); - when(pushEventFlagService.getFlags()) - .thenReturn(List.of( - new AdminPushEventFlagRes(PushEventType.CROWD, false), - new AdminPushEventFlagRes(PushEventType.REPORT, true), - new AdminPushEventFlagRes(PushEventType.CHARACTER, false) - )); - - mockMvc.perform(patch("/api/admin/notifications/event-flags/REPORT") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"enabled\":true}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.eventType").value("REPORT")) - .andExpect(jsonPath("$.data.enabled").value(true)); - - mockMvc.perform(get("/api/admin/notifications/event-flags")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[1].eventType").value("REPORT")) - .andExpect(jsonPath("$.data[1].enabled").value(true)); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java deleted file mode 100644 index ce5248cb..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package devkor.com.teamcback.domain.notification.factory; - -import com.fasterxml.jackson.databind.ObjectMapper; -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.validation.PushActionValidator; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class PushPayloadFactoryTest { - - private final PushPayloadFactory factory = new PushPayloadFactory( - new PushActionValidator(), - new ObjectMapper() - ); - - @Test - void includesTargetAppVariantInDeepLinkPayload() { - PushPayload payload = factory.create( - "notification-1", - "title", - "body", - PushMode.ACTUAL, - AppVariant.PREVIEW, - PushActionType.HOME, - Map.of() - ); - - assertThat(payload.data().appVariant()).isEqualTo("preview"); - assertThat(payload.data().action().type()).isEqualTo("HOME"); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java deleted file mode 100644 index a04bd2e6..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.service.PushDispatchService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class CharacterUnlockedPushEventListenerTest { - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchService pushDispatchService; - - @Mock - private PushEventFlagService pushEventFlagService; - - private CharacterUnlockedPushEventListener listener; - - @BeforeEach - void setUp() { - listener = new CharacterUnlockedPushEventListener( - pushInstallationRepository, - pushDispatchService, - pushEventFlagService - ); - } - - @Test - void createsCharacterStoreDispatch() { - when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) - .thenReturn(true); - - listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - PushDispatchCommand command = captor.getValue(); - assertThat(command.targetType()).isEqualTo(PushTargetType.USER); - assertThat(command.actionType()).isEqualTo(PushActionType.CHARACTER_STORE); - assertThat(command.actionParams()).isEmpty(); - assertThat(command.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); - assertThat(command.body()).isEqualTo("아기 호랑이을 만나러 가볼까요?"); - assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44:production"); - } - - @Test - void createsSeparateDevAndProductionDispatches() { - when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); - when(pushEventFlagService.getTargetAppVariants()).thenReturn(List.of(AppVariant.DEV, AppVariant.PRODUCTION)); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.DEV)) - .thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) - .thenReturn(true); - - listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService, org.mockito.Mockito.times(2)).enqueue(captor.capture()); - assertThat(captor.getAllValues()) - .extracting(PushDispatchCommand::appVariant) - .containsExactly(AppVariant.DEV, AppVariant.PRODUCTION); - assertThat(captor.getAllValues()) - .extracting(PushDispatchCommand::idempotencyKey) - .containsExactly( - "character-unlock:7:4:44:dev", - "character-unlock:7:4:44:production" - ); - } - - @Test - void usesSafeBodyWhenCharacterNameIsBlank() { - when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) - .thenReturn(true); - - listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, " ")); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - assertThat(captor.getValue().body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); - } - - @Test - void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(false); - - listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } - - @Test - void doesNotCreateDispatchWhenUserHasNoProductionInstallation() { - when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) - .thenReturn(false); - - listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java deleted file mode 100644 index 8f0f3bf5..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; -import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; -import devkor.com.teamcback.domain.building.entity.Building; -import devkor.com.teamcback.domain.common.LocationType; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.service.PushDispatchService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import devkor.com.teamcback.domain.place.entity.Place; -import devkor.com.teamcback.domain.place.repository.PlaceRepository; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class CrowdVacantPushEventListenerTest { - - @Mock - private PlaceRepository placeRepository; - - @Mock - private CategoryRepository categoryRepository; - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchService pushDispatchService; - - @Mock - private PushEventFlagService pushEventFlagService; - - private CrowdVacantPushEventListener listener; - - @BeforeEach - void setUp() { - listener = new CrowdVacantPushEventListener( - placeRepository, - categoryRepository, - pushInstallationRepository, - pushDispatchService, - pushEventFlagService - ); - } - - @Test - void createsUserDispatchesForDistinctFavoriteUsers() { - when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); - when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); - when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) - .thenReturn(List.of(1L, 1L, 2L)); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) - .thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(2L, AppVariant.PRODUCTION)) - .thenReturn(true); - - listener.handle(event()); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService, times(2)).enqueue(captor.capture()); - - PushDispatchCommand first = captor.getAllValues().get(0); - assertThat(first.targetType()).isEqualTo(PushTargetType.USER); - assertThat(first.targetValue()).isEqualTo("1"); - assertThat(first.mode()).isEqualTo(PushMode.ACTUAL); - assertThat(first.appVariant()).isEqualTo(AppVariant.PRODUCTION); - assertThat(first.actionType()).isEqualTo(PushActionType.PLACE_DETAIL); - assertThat(first.actionParams()).containsEntry("placeId", 10L); - assertThat(first.title()).isEqualTo("기다리던 자리가 생겼어요!"); - assertThat(first.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); - assertThat(first.body()).doesNotContain("null"); - assertThat(first.idempotencyKey()).isEqualTo("crowd-vacant:10:1:99:production"); - } - - @Test - void doesNotCreateDispatchWhenNoFavoriteUsersExist() { - when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); - when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); - when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) - .thenReturn(List.of()); - - listener.handle(event()); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } - - @Test - void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(false); - - listener.handle(event()); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - verify(placeRepository, never()).findById(org.mockito.ArgumentMatchers.any()); - } - - @Test - void skipsUsersWithoutProductionInstallation() { - when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); - when(placeRepository.findById(10L)).thenReturn(Optional.of(place(null, "라운지"))); - when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) - .thenReturn(List.of(1L)); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) - .thenReturn(false); - - listener.handle(event()); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } - - private PlaceBecameVacantEvent event() { - return new PlaceBecameVacantEvent( - 10L, - 99L, - LocalDateTime.parse("2026-08-05T10:00:00") - ); - } - - private Place place( - String buildingName, - String placeName - ) { - Building building = new Building(); - ReflectionTestUtils.setField(building, "name", buildingName); - - Place place = new Place(); - ReflectionTestUtils.setField(place, "id", 10L); - place.setBuilding(building); - place.setName(placeName); - return place; - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java deleted file mode 100644 index baf20a3a..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; -import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; -import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; -import org.junit.jupiter.api.Test; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import static org.assertj.core.api.Assertions.assertThat; - -class DomainPushEventListenerAnnotationTest { - - @Test - void listenersRunAfterCommit() throws NoSuchMethodException { - assertAfterCommit(CrowdVacantPushEventListener.class, PlaceBecameVacantEvent.class); - assertAfterCommit(ReportResolvedPushEventListener.class, ReportResolvedEvent.class); - assertAfterCommit(CharacterUnlockedPushEventListener.class, CharacterUnlockedEvent.class); - } - - private void assertAfterCommit( - Class listenerClass, - Class eventClass - ) throws NoSuchMethodException { - TransactionalEventListener annotation = listenerClass - .getDeclaredMethod("handle", eventClass) - .getAnnotation(TransactionalEventListener.class); - Transactional transactional = listenerClass - .getDeclaredMethod("handle", eventClass) - .getAnnotation(Transactional.class); - - assertThat(annotation).isNotNull(); - assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); - assertThat(transactional).isNotNull(); - assertThat(transactional.propagation()).isEqualTo(Propagation.REQUIRES_NEW); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java deleted file mode 100644 index fce0fc11..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package devkor.com.teamcback.domain.notification.listener; - -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.service.PushDispatchService; -import devkor.com.teamcback.domain.notification.service.PushEventFlagService; -import devkor.com.teamcback.domain.report.entity.ReportStatus; -import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class ReportResolvedPushEventListenerTest { - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchService pushDispatchService; - - @Mock - private PushEventFlagService pushEventFlagService; - - private ReportResolvedPushEventListener listener; - - @BeforeEach - void setUp() { - listener = new ReportResolvedPushEventListener( - pushInstallationRepository, - pushDispatchService, - pushEventFlagService - ); - } - - @Test - void createsReporterDispatch() { - when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) - .thenReturn(true); - - listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - PushDispatchCommand command = captor.getValue(); - assertThat(command.targetType()).isEqualTo(PushTargetType.USER); - assertThat(command.targetValue()).isEqualTo("7"); - assertThat(command.actionType()).isEqualTo(PushActionType.HOME); - assertThat(command.actionParams()).isEmpty(); - assertThat(command.title()).isEqualTo("신고 처리 결과를 확인해주세요."); - assertThat(command.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); - assertThat(command.body()).doesNotContain("sensitive").doesNotContain("memo"); - assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7:production"); - } - - @Test - void doesNotCreateDispatchWhenFeatureFlagIsFalse() { - when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(false); - - listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } - - @Test - void doesNotCreateDispatchWhenReporterIsUnknown() { - when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); - - listener.handle(new ReportResolvedEvent(3L, null, ReportStatus.REJECTED)); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } - - @Test - void doesNotCreateDispatchWhenReporterHasNoProductionInstallation() { - when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) - .thenReturn(false); - - listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); - - verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java deleted file mode 100644 index 6539def0..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package devkor.com.teamcback.domain.notification.repository; - -import java.lang.reflect.Method; -import org.junit.jupiter.api.Test; -import org.springframework.data.jpa.repository.Query; - -import static org.assertj.core.api.Assertions.assertThat; - -class PushMessageRepositoryQueryTest { - - @Test - void dueReceiptQueryTargetsOnlyReceiptPendingMessagesWithAvailableReceipts() throws Exception { - Method method = PushMessageRepository.class.getMethod( - "findDueReceiptPendingForUpdateSkipLocked", - java.time.LocalDateTime.class, - int.class - ); - - String query = method.getAnnotation(Query.class).value(); - - assertThat(query).contains("status = 'RECEIPT_PENDING'"); - assertThat(query).contains("expo_ticket_id IS NOT NULL"); - assertThat(query).contains("receipt_available_at <= :now"); - assertThat(query).contains("FOR UPDATE SKIP LOCKED"); - } - - @Test - void staleSendingQueryTargetsOnlyOldSendingMessages() throws Exception { - Method method = PushMessageRepository.class.getMethod( - "findStaleSendingForUpdateSkipLocked", - java.time.LocalDateTime.class, - int.class - ); - - String query = method.getAnnotation(Query.class).value(); - - assertThat(query).contains("status = 'SENDING'"); - assertThat(query).contains("updated_at <= :staleBefore"); - assertThat(query).contains("LIMIT :limit"); - assertThat(query).contains("FOR UPDATE SKIP LOCKED"); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java deleted file mode 100644 index 3e48a5d6..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package devkor.com.teamcback.domain.notification.resolver; - -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PushTargetResolverTest { - - @Mock - private PushInstallationRepository pushInstallationRepository; - - private PushTargetResolver resolver; - - @BeforeEach - void setUp() { - resolver = new PushTargetResolver(pushInstallationRepository); - } - - @Test - void allTargetResolvesDistinctActiveProductionInstallations() { - PushInstallation first = new PushInstallation(1L, "install-1", "ExponentPushToken[first]", AppVariant.PRODUCTION); - PushInstallation duplicate = new PushInstallation(2L, "install-1", "ExponentPushToken[duplicate]", AppVariant.PRODUCTION); - PushInstallation second = new PushInstallation(3L, "install-2", "ExponentPushToken[second]", AppVariant.PRODUCTION); - when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) - .thenReturn(List.of(first, duplicate, second)); - - List resolved = resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION); - - assertThat(resolved).containsExactly(first, second); - } - - @Test - void allTargetRejectsNonAllTargetValue() { - assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "1", AppVariant.PRODUCTION)) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.INVALID_INPUT); - } - - @Test - void previewAllTargetAllowsZeroActiveInstallations() { - when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) - .thenReturn(List.of()); - - List resolved = resolver.resolveForPreview( - PushTargetType.ALL, - "ALL", - AppVariant.PRODUCTION - ); - - assertThat(resolved).isEmpty(); - } - - @Test - void actualAllTargetStillRejectsZeroActiveInstallations() { - when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) - .thenReturn(List.of()); - - assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION)) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.INVALID_INPUT); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java deleted file mode 100644 index ca7f3706..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package devkor.com.teamcback.domain.notification.scheduler; - -import devkor.com.teamcback.domain.notification.service.PushMessageRecoveryWorker; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -class PushMessageRecoverySchedulerTest { - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withBean(PushMessageRecoveryWorker.class, () -> mock(PushMessageRecoveryWorker.class)) - .withUserConfiguration(TestConfig.class); - - @Test - void schedulerIsDisabledByDefault() { - contextRunner.run(context -> assertThat(context) - .doesNotHaveBean(PushMessageRecoveryScheduler.class)); - } - - @Test - void schedulerIsDisabledWhenPropertyIsFalse() { - contextRunner - .withPropertyValues("push.recovery-worker.enabled=false") - .run(context -> assertThat(context) - .doesNotHaveBean(PushMessageRecoveryScheduler.class)); - } - - @Configuration - @Import(PushMessageRecoveryScheduler.class) - static class TestConfig { - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java deleted file mode 100644 index 22f47b77..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java +++ /dev/null @@ -1,368 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; -import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; -import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; -import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -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.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class AdminNotificationServiceTest { - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchRepository pushDispatchRepository; - - @Mock - private PushMessageRepository pushMessageRepository; - - @Mock - private PushTargetResolver pushTargetResolver; - - @Mock - private PushPayloadFactory pushPayloadFactory; - - @Mock - private PushDispatchService pushDispatchService; - - @Test - void searchInstallationsDoesNotExposeExpoPushToken() throws Exception { - PushInstallation installation = installation(AppVariant.DEV); - when(pushInstallationRepository.findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc(1L)) - .thenReturn(List.of(installation)); - - List response = service(false) - .searchInstallations(1L, null); - - String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response); - assertThat(json).contains("installationId"); - assertThat(json).doesNotContain("expoPushToken"); - assertThat(json).doesNotContain("ExponentPushToken"); - } - - @Test - void previewResolvesTargetAndCreatesPayloadWithoutSavingDispatchOrMessages() { - PushInstallation installation = installation(AppVariant.DEV); - PushPayload payload = payload(); - - when(pushTargetResolver.resolveForPreview(PushTargetType.INSTALLATION, "install-1", AppVariant.DEV)) - .thenReturn(List.of(installation)); - when(pushPayloadFactory.createForPreDispatchValidation( - "title", - "body", - PushMode.TEST, - AppVariant.DEV, - PushActionType.TEST, - Map.of() - )).thenReturn(payload); - - AdminPushDispatchPreviewRes response = service(false).preview(testRequest()); - - assertThat(response.recipientCount()).isEqualTo(1); - assertThat(response.payload()).isSameAs(payload); - verify(pushDispatchRepository, never()).save(any(PushDispatch.class)); - verify(pushMessageRepository, never()).saveAll(any()); - verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); - } - - @Test - void testModeRejectsUserTarget() { - AdminPushDispatchReq request = new AdminPushDispatchReq( - PushMode.TEST, - AppVariant.DEV, - PushTargetType.USER, - "1", - "title", - "body", - PushActionType.TEST, - Map.of(), - null - ); - - assertThatThrownBy(() -> service(false).preview(request)) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.INVALID_INPUT); - } - - @Test - void productionActualIsRejectedWhenProductionEnabledIsFalse() { - assertThatThrownBy(() -> service(false).enqueue( - 1L, - "key-1", - productionRequest(true) - )) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.FORBIDDEN); - - verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); - } - - @Test - void productionActualRequiresConfirmTrue() { - assertThatThrownBy(() -> service(true).enqueue( - 1L, - "key-1", - productionRequest(false) - )) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.INVALID_INPUT); - - verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); - } - - @Test - void enqueueDelegatesToPushDispatchService() { - PushDispatch dispatch = dispatch(PushMode.TEST, AppVariant.DEV, PushTargetType.INSTALLATION); - PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); - when(pushDispatchService.enqueue(any(PushDispatchCommand.class))) - .thenReturn(enqueueResponse); - - PushDispatchEnqueueRes response = service(false).enqueue( - 7L, - "key-1", - testRequest() - ); - - assertThat(response).isSameAs(enqueueResponse); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - PushDispatchCommand command = captor.getValue(); - assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); - assertThat(command.mode()).isEqualTo(PushMode.TEST); - assertThat(command.appVariant()).isEqualTo(AppVariant.DEV); - assertThat(command.targetType()).isEqualTo(PushTargetType.INSTALLATION); - assertThat(command.idempotencyKey()).isEqualTo("key-1"); - assertThat(command.createdBy()).isEqualTo(7L); - } - - @Test - void actualAllTargetsOnlyActiveInstallationsResolvedForTheVariant() { - PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.ALL); - PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); - when(pushTargetResolver.resolveForPreview(PushTargetType.ALL, "ALL", AppVariant.DEV)) - .thenReturn(List.of(installation(AppVariant.DEV))); - when(pushPayloadFactory.createForPreDispatchValidation( - "title", - "body", - PushMode.ACTUAL, - AppVariant.DEV, - PushActionType.HOME, - Map.of() - )).thenReturn(payload()); - when(pushDispatchService.enqueue(any(PushDispatchCommand.class))) - .thenReturn(enqueueResponse); - - AdminPushDispatchReq request = new AdminPushDispatchReq( - PushMode.ACTUAL, - AppVariant.DEV, - PushTargetType.ALL, - "ALL", - "title", - "body", - PushActionType.HOME, - Map.of(), - false - ); - - AdminPushDispatchPreviewRes preview = service(false).preview(request); - PushDispatchEnqueueRes response = service(false).enqueue(7L, "broadcast-dev-1", request); - - assertThat(preview.recipientCount()).isEqualTo(1); - assertThat(response).isSameAs(enqueueResponse); - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.ALL); - assertThat(captor.getValue().targetValue()).isEqualTo("ALL"); - assertThat(captor.getValue().appVariant()).isEqualTo(AppVariant.DEV); - } - - @Test - void getDispatchCountsAllMessageStatuses() { - PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.USER); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - dispatch.updateRecipientCount(5); - - when(pushDispatchRepository.findById(10L)).thenReturn(Optional.of(dispatch)); - when(pushMessageRepository.countStatusesByDispatchIds(List.of(10L))) - .thenReturn(List.of( - new StatusCount(10L, PushMessageStatus.QUEUED, 1L), - new StatusCount(10L, PushMessageStatus.SENDING, 1L), - new StatusCount(10L, PushMessageStatus.DELIVERED, 2L), - new StatusCount(10L, PushMessageStatus.FAILED, 1L) - )); - - AdminPushDispatchDetailRes response = service(false).getDispatch(10L); - - assertThat(response.recipientCount()).isEqualTo(5); - assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.QUEUED, 1L); - assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.SENDING, 1L); - assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.TICKET_RECEIVED, 0L); - assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.RECEIPT_PENDING, 0L); - assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.DELIVERED, 2L); - assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.FAILED, 1L); - } - - private AdminNotificationService service(boolean productionEnabled) { - AdminNotificationService service = new AdminNotificationService( - pushInstallationRepository, - pushDispatchRepository, - pushMessageRepository, - pushTargetResolver, - pushPayloadFactory, - pushDispatchService - ); - ReflectionTestUtils.setField(service, "productionEnabled", productionEnabled); - return service; - } - - private AdminPushDispatchReq testRequest() { - return new AdminPushDispatchReq( - PushMode.TEST, - AppVariant.DEV, - PushTargetType.INSTALLATION, - "install-1", - "title", - "body", - PushActionType.TEST, - Map.of(), - null - ); - } - - private AdminPushDispatchReq productionRequest(boolean confirm) { - return new AdminPushDispatchReq( - PushMode.ACTUAL, - AppVariant.PRODUCTION, - PushTargetType.INSTALLATION, - "install-1", - "title", - "body", - PushActionType.HOME, - Map.of(), - confirm - ); - } - - private PushInstallation installation(AppVariant appVariant) { - PushInstallation installation = new PushInstallation( - 1L, - "install-1", - "ExponentPushToken[token]", - appVariant - ); - ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); - ReflectionTestUtils.setField(installation, "createdAt", LocalDateTime.parse("2026-08-04T10:00:00")); - ReflectionTestUtils.setField(installation, "modifiedAt", LocalDateTime.parse("2026-08-04T11:00:00")); - return installation; - } - - private PushPayload payload() { - return new PushPayload( - "title", - "body", - new PushPayload.PushPayloadData( - 1, - "00000000-0000-4000-8000-000000000000", - "dev", - new PushPayload.PushPayloadAction(PushActionType.TEST.name(), Map.of()) - ) - ); - } - - private PushDispatch dispatch( - PushMode mode, - AppVariant appVariant, - PushTargetType targetType - ) { - PushDispatch dispatch = new PushDispatch( - NotificationType.GENERAL, - mode, - appVariant, - targetType, - targetType == PushTargetType.USER ? "1" : "install-1", - "title", - "body", - mode == PushMode.TEST ? PushActionType.TEST : PushActionType.HOME, - "{}", - "key-1", - 1L, - LocalDateTime.parse("2026-08-04T12:00:00") - ); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - return dispatch; - } - - private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { - - private final Long dispatchId; - private final PushMessageStatus status; - private final long count; - - private StatusCount( - Long dispatchId, - PushMessageStatus status, - long count - ) { - this.dispatchId = dispatchId; - this.status = status; - this.count = count; - } - - @Override - public Long getDispatchId() { - return dispatchId; - } - - @Override - public PushMessageStatus getStatus() { - return status; - } - - @Override - public long getCount() { - return count; - } - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java deleted file mode 100644 index e286a5a3..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java +++ /dev/null @@ -1,193 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.client.ExpoPushClient; -import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; -import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.lang.reflect.Field; -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class NotificationTestServiceTest { - - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-04T00:00:00Z"), - ZoneId.of("Asia/Seoul") - ); - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchRepository pushDispatchRepository; - - @Mock - private PushMessageRepository pushMessageRepository; - - @Mock - private PushPayloadFactory pushPayloadFactory; - - @Test - void sendTestEnqueuesQueuedMessageWithoutExpoClientDependency() { - PushInstallation installation = installation(); - when(pushInstallationRepository.findByInstallationId("install-1")) - .thenReturn(Optional.of(installation)); - when(pushDispatchRepository.findByIdempotencyKey("7b347ad7-6138-4cb7-af7d-f5201703a596")) - .thenReturn(Optional.empty()); - when(pushPayloadFactory.serializeActionParams(any())) - .thenReturn("{}"); - when(pushDispatchRepository.saveAndFlush(any(PushDispatch.class))) - .thenAnswer(invocation -> { - PushDispatch dispatch = invocation.getArgument(0); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - return dispatch; - }); - when(pushMessageRepository.saveAndFlush(any(PushMessage.class))) - .thenAnswer(invocation -> { - PushMessage message = invocation.getArgument(0); - ReflectionTestUtils.setField(message, "pushMessageId", 20L); - return message; - }); - - NotificationTestService service = service(); - NotificationTestRes response = service.sendTest( - 1L, - "7b347ad7-6138-4cb7-af7d-f5201703a596", - new NotificationTestReq(1, "install-1") - ); - - assertThat(response.notificationId()).isEqualTo("20"); - assertThat(response.messageStatus()).isEqualTo(PushMessageStatus.QUEUED); - assertThat(response.ticketId()).isNull(); - assertThat(hasExpoPushClientField()).isFalse(); - } - - @Test - void sendTestReturnsExistingDispatchMessageForSameIdempotencyKey() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = message(dispatch, installation); - ReflectionTestUtils.setField(message, "pushMessageId", 20L); - - when(pushInstallationRepository.findByInstallationId("install-1")) - .thenReturn(Optional.of(installation)); - when(pushDispatchRepository.findByIdempotencyKey("7b347ad7-6138-4cb7-af7d-f5201703a596")) - .thenReturn(Optional.of(dispatch)); - when(pushMessageRepository.findAllByDispatch(dispatch)) - .thenReturn(List.of(message)); - - NotificationTestRes response = service().sendTest( - 1L, - "7b347ad7-6138-4cb7-af7d-f5201703a596", - new NotificationTestReq(1, "install-1") - ); - - assertThat(response.notificationId()).isEqualTo("20"); - assertThat(response.messageStatus()).isEqualTo(PushMessageStatus.QUEUED); - verify(pushDispatchRepository, never()).saveAndFlush(any(PushDispatch.class)); - verify(pushMessageRepository, never()).saveAndFlush(any(PushMessage.class)); - } - - @Test - void receiptPendingDoesNotCompleteDispatch() { - PushDispatch dispatch = dispatch(); - dispatch.updateRecipientCount(1); - - dispatch.updateStatusFromMessageSummary( - 1, - 0, - 0, - 0, - java.time.LocalDateTime.now(CLOCK) - ); - - assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); - assertThat(dispatch.getCompletedAt()).isNull(); - } - - private NotificationTestService service() { - return new NotificationTestService( - pushInstallationRepository, - pushDispatchRepository, - pushMessageRepository, - pushPayloadFactory, - CLOCK - ); - } - - private PushInstallation installation() { - PushInstallation installation = new PushInstallation( - 1L, - "install-1", - "ExponentPushToken[token]", - AppVariant.DEV - ); - ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); - return installation; - } - - private PushDispatch dispatch() { - PushDispatch dispatch = new PushDispatch( - NotificationType.GENERAL, - PushMode.TEST, - AppVariant.DEV, - PushTargetType.INSTALLATION, - "install-1", - "title", - "body", - PushActionType.TEST, - "{}", - "7b347ad7-6138-4cb7-af7d-f5201703a596", - 1L, - java.time.LocalDateTime.now(CLOCK) - ); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - return dispatch; - } - - private PushMessage message( - PushDispatch dispatch, - PushInstallation installation - ) { - return new PushMessage( - dispatch, - installation, - java.time.LocalDateTime.now(CLOCK) - ); - } - - private boolean hasExpoPushClientField() { - return Arrays.stream(NotificationTestService.class.getDeclaredFields()) - .map(Field::getType) - .anyMatch(ExpoPushClient.class::equals); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java deleted file mode 100644 index 68063437..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PushEventFlagServiceTest { - - @Mock - private StringRedisTemplate redisTemplate; - - @Mock - private ValueOperations valueOperations; - - private PushEventFlagService service; - - @BeforeEach - void setUp() { - service = new PushEventFlagService(redisTemplate); - ReflectionTestUtils.setField(service, "crowdDefaultEnabled", false); - ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); - ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); - ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); - ReflectionTestUtils.setField(service, "targetAppVariants", "DEV,PREVIEW"); - - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - } - - @Test - void returnsConfiguredDevelopmentTargetVariants() { - service.isEnabled(PushEventType.CROWD); - - assertThat(service.getTargetAppVariants()) - .containsExactly(AppVariant.DEV, AppVariant.PREVIEW); - } - - @Test - void returnsYamlDefaultWhenRedisValueDoesNotExist() { - when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); - when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn(null); - when(valueOperations.get(PushEventType.SURVEY.redisKey())).thenReturn(null); - - assertThat(service.isEnabled(PushEventType.CROWD)).isFalse(); - assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); - assertThat(service.isEnabled(PushEventType.SURVEY)).isFalse(); - } - - @Test - void redisTrueOrFalseOverridesYamlDefault() { - when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn("true"); - when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn("false"); - - assertThat(service.isEnabled(PushEventType.CROWD)).isTrue(); - assertThat(service.isEnabled(PushEventType.REPORT)).isFalse(); - } - - @Test - void returnsYamlDefaultWhenRedisReadFails() { - when(valueOperations.get(PushEventType.REPORT.redisKey())).thenThrow(new RuntimeException("redis down")); - - assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); - } - - @Test - void updatedValueIsReflectedImmediatelyInQueryResult() { - when(valueOperations.get(PushEventType.CHARACTER.redisKey())) - .thenReturn(null) - .thenReturn("true") - .thenReturn("true"); - - assertThat(service.isEnabled(PushEventType.CHARACTER)).isFalse(); - - AdminPushEventFlagRes updated = service.updateFlag(PushEventType.CHARACTER, true); - assertThat(updated.enabled()).isTrue(); - - List flags = service.getFlags(); - assertThat(flags) - .filteredOn(flag -> flag.eventType() == PushEventType.CHARACTER) - .singleElement() - .extracting(AdminPushEventFlagRes::enabled) - .isEqualTo(true); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java deleted file mode 100644 index 4cf6eec4..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.config.PushWorkerProperties; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushErrorDetails; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; -import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PushMessageClaimServiceTest { - - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-04T00:00:00Z"), - ZoneId.of("Asia/Seoul") - ); - - @Mock - private PushMessageRepository pushMessageRepository; - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchRepository pushDispatchRepository; - - @Mock - private PushPayloadFactory pushPayloadFactory; - - @Test - void ticketOkStoresReceiptAvailableAtFifteenMinutesLater() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = message(dispatch, installation); - - stubRecordTickets(message, dispatch, PushMessageStatus.RECEIPT_PENDING); - - service().recordTickets( - List.of(new PushSendItem(20L, 10L, null)), - List.of(new ExpoPushTicket("ok", "ticket-1", null, null)) - ); - - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); - assertThat(message.getExpoTicketId()).isEqualTo("ticket-1"); - assertThat(message.getReceiptAvailableAt()) - .isEqualTo(LocalDateTime.now(CLOCK).plusMinutes(15)); - } - - @Test - void deviceNotRegisteredTicketFailsMessageAndDeactivatesInstallation() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = message(dispatch, installation); - - stubRecordTickets(message, dispatch, PushMessageStatus.FAILED); - when(pushInstallationRepository.findById(100L)) - .thenReturn(Optional.of(installation)); - - service().recordTickets( - List.of(new PushSendItem(20L, 10L, null)), - List.of(new ExpoPushTicket( - "error", - null, - null, - new ExpoPushErrorDetails("DeviceNotRegistered") - )) - ); - - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(message.getReceiptAvailableAt()).isNull(); - assertThat(installation.isActive()).isFalse(); - } - - private void stubRecordTickets( - PushMessage message, - PushDispatch dispatch, - PushMessageStatus status - ) { - when(pushMessageRepository.findAllByPushMessageIdIn(any(Collection.class))) - .thenReturn(List.of(message)); - when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) - .thenReturn(List.of(new StatusCount(10L, status, 1L))); - when(pushDispatchRepository.findAllById(any(Iterable.class))) - .thenReturn(List.of(dispatch)); - } - - private PushMessageClaimService service() { - return new PushMessageClaimService( - pushMessageRepository, - pushInstallationRepository, - pushDispatchRepository, - pushPayloadFactory, - new PushWorkerProperties(true, 100, 3, 30_000L), - CLOCK - ); - } - - private PushInstallation installation() { - PushInstallation installation = new PushInstallation( - 1L, - "install-1", - "ExponentPushToken[token]", - AppVariant.DEV - ); - ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); - return installation; - } - - private PushDispatch dispatch() { - PushDispatch dispatch = new PushDispatch( - NotificationType.GENERAL, - PushMode.TEST, - AppVariant.DEV, - PushTargetType.INSTALLATION, - "install-1", - "title", - "body", - PushActionType.TEST, - "{}", - "7b347ad7-6138-4cb7-af7d-f5201703a596", - 1L, - LocalDateTime.now(CLOCK) - ); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - dispatch.updateRecipientCount(1); - return dispatch; - } - - private PushMessage message( - PushDispatch dispatch, - PushInstallation installation - ) { - PushMessage message = new PushMessage( - dispatch, - installation, - LocalDateTime.now(CLOCK) - ); - ReflectionTestUtils.setField(message, "pushMessageId", 20L); - return message; - } - - private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { - - private final Long dispatchId; - private final PushMessageStatus status; - private final long count; - - private StatusCount( - Long dispatchId, - PushMessageStatus status, - long count - ) { - this.dispatchId = dispatchId; - this.status = status; - this.count = count; - } - - @Override - public Long getDispatchId() { - return dispatchId; - } - - @Override - public PushMessageStatus getStatus() { - return status; - } - - @Override - public long getCount() { - return count; - } - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java deleted file mode 100644 index 271233d3..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java +++ /dev/null @@ -1,229 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.config.PushRecoveryWorkerProperties; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Collection; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PushMessageRecoveryServiceTest { - - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-04T00:00:00Z"), - ZoneId.of("Asia/Seoul") - ); - - @Mock - private PushMessageRepository pushMessageRepository; - - @Mock - private PushDispatchRepository pushDispatchRepository; - - @Test - void staleSendingWithoutTicketFailsWithWorkerInterrupted() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = message(dispatch, installation); - message.markSending(LocalDateTime.now(CLOCK).minusMinutes(31)); - - stubStaleMessages(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); - - int recoveredCount = service(30, 100).recoverStaleSendingMessages(); - - assertThat(recoveredCount).isEqualTo(1); - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(message.getTicketStatus()).isEqualTo("worker_interrupted"); - assertThat(message.getTicketError()).isEqualTo("worker_interrupted"); - assertThat(message.getReceiptAvailableAt()).isNull(); - assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.FAILED); - } - - @Test - void staleSendingWithTicketRecoversToReceiptPendingWithoutResend() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = message(dispatch, installation); - message.recordTicket("ok", "ticket-1", null, LocalDateTime.now(CLOCK).minusMinutes(40)); - message.markReceiptChecking(LocalDateTime.now(CLOCK).minusMinutes(31)); - ReflectionTestUtils.setField(message, "receiptAvailableAt", null); - - stubStaleMessages(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.RECEIPT_PENDING, 1L))); - - int recoveredCount = service(30, 100).recoverStaleSendingMessages(); - - assertThat(recoveredCount).isEqualTo(1); - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); - assertThat(message.getExpoTicketId()).isEqualTo("ticket-1"); - assertThat(message.getReceiptAvailableAt()).isEqualTo(LocalDateTime.now(CLOCK)); - assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); - } - - @Test - void nonStaleSendingIsNotChangedWhenQueryDoesNotReturnIt() { - PushMessage message = message(dispatch(), installation()); - message.markSending(LocalDateTime.now(CLOCK).minusMinutes(29)); - when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) - .thenReturn(List.of()); - - int recoveredCount = service(30, 100).recoverStaleSendingMessages(); - - assertThat(recoveredCount).isZero(); - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.SENDING); - } - - @Test - void nonSendingMessageIsNotChangedWhenQueryDoesNotReturnIt() { - PushMessage message = message(dispatch(), installation()); - when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) - .thenReturn(List.of()); - - int recoveredCount = service(30, 100).recoverStaleSendingMessages(); - - assertThat(recoveredCount).isZero(); - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.QUEUED); - } - - @Test - void recoveryUsesConfiguredStaleThresholdAndBatchSize() { - when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(7))) - .thenReturn(List.of()); - - service(45, 7).recoverStaleSendingMessages(); - - ArgumentCaptor staleBeforeCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - verify(pushMessageRepository).findStaleSendingForUpdateSkipLocked(staleBeforeCaptor.capture(), eq(7)); - assertThat(staleBeforeCaptor.getValue()).isEqualTo(LocalDateTime.now(CLOCK).minusMinutes(45)); - } - - private PushMessageRecoveryService service( - long staleThresholdMinutes, - int batchSize - ) { - return new PushMessageRecoveryService( - pushMessageRepository, - pushDispatchRepository, - new PushRecoveryWorkerProperties(true, "0 0 4 * * *", staleThresholdMinutes, batchSize), - CLOCK - ); - } - - private void stubStaleMessages(List messages) { - when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) - .thenReturn(messages); - } - - private void stubStatusCounts( - PushDispatch dispatch, - List counts - ) { - when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) - .thenReturn(counts); - when(pushDispatchRepository.findAllById(any(Iterable.class))) - .thenReturn(List.of(dispatch)); - } - - private PushInstallation installation() { - PushInstallation installation = new PushInstallation( - 1L, - "install-1", - "ExponentPushToken[token]", - AppVariant.DEV - ); - ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); - return installation; - } - - private PushDispatch dispatch() { - PushDispatch dispatch = new PushDispatch( - NotificationType.GENERAL, - PushMode.TEST, - AppVariant.DEV, - PushTargetType.INSTALLATION, - "install-1", - "title", - "body", - PushActionType.TEST, - "{}", - "7b347ad7-6138-4cb7-af7d-f5201703a596", - 1L, - LocalDateTime.now(CLOCK) - ); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - dispatch.updateRecipientCount(1); - return dispatch; - } - - private PushMessage message( - PushDispatch dispatch, - PushInstallation installation - ) { - PushMessage message = new PushMessage( - dispatch, - installation, - LocalDateTime.now(CLOCK) - ); - ReflectionTestUtils.setField(message, "pushMessageId", 20L); - return message; - } - - private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { - - private final Long dispatchId; - private final PushMessageStatus status; - private final long count; - - private StatusCount( - Long dispatchId, - PushMessageStatus status, - long count - ) { - this.dispatchId = dispatchId; - this.status = status; - this.count = count; - } - - @Override - public Long getDispatchId() { - return dispatchId; - } - - @Override - public PushMessageStatus getStatus() { - return status; - } - - @Override - public long getCount() { - return count; - } - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java deleted file mode 100644 index 749e1c29..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java +++ /dev/null @@ -1,305 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.config.PushReceiptWorkerProperties; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushErrorDetails; -import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushReceipt; -import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; -import devkor.com.teamcback.domain.notification.entity.PushDispatch; -import devkor.com.teamcback.domain.notification.entity.PushInstallation; -import devkor.com.teamcback.domain.notification.entity.PushMessage; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class PushReceiptClaimServiceTest { - - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-04T00:00:00Z"), - ZoneId.of("Asia/Seoul") - ); - - @Mock - private PushMessageRepository pushMessageRepository; - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchRepository pushDispatchRepository; - - @Test - void claimDueReceiptsMarksDueReceiptPendingMessagesAsSending() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); - - when(pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked(any(LocalDateTime.class), anyInt())) - .thenReturn(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.SENDING, 1L))); - - List items = service().claimDueReceipts(); - - assertThat(items).containsExactly(new PushReceiptItem(20L, 10L, "ticket-1")); - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.SENDING); - } - - @Test - void receiptOkMarksDeliveredByTicketIdMap() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage first = receiptPendingMessage(dispatch, installation, "ticket-1"); - PushMessage second = receiptPendingMessage(dispatch, installation, "ticket-2"); - ReflectionTestUtils.setField(second, "pushMessageId", 21L); - - stubMessages(List.of(first, second)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.DELIVERED, 2L))); - - service().recordReceipts( - List.of( - new PushReceiptItem(20L, 10L, "ticket-1"), - new PushReceiptItem(21L, 10L, "ticket-2") - ), - Map.of( - "ticket-2", new ExpoPushReceipt("ok", null, null), - "ticket-1", new ExpoPushReceipt("error", "failed", new ExpoPushErrorDetails("MessageTooBig")) - ) - ); - - assertThat(first.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(first.getReceiptError()).isEqualTo("MessageTooBig"); - assertThat(second.getStatus()).isEqualTo(PushMessageStatus.DELIVERED); - assertThat(second.getReceiptError()).isNull(); - } - - @Test - void deviceNotRegisteredReceiptFailsMessageAndDeactivatesInstallation() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); - - stubMessages(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); - when(pushInstallationRepository.findById(100L)) - .thenReturn(Optional.of(installation)); - - service().recordReceipts( - List.of(new PushReceiptItem(20L, 10L, "ticket-1")), - Map.of("ticket-1", new ExpoPushReceipt( - "error", - null, - new ExpoPushErrorDetails("DeviceNotRegistered") - )) - ); - - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(installation.isActive()).isFalse(); - } - - @Test - void missingReceiptSchedulesLimitedRetry() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); - - stubMessages(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.RECEIPT_PENDING, 1L))); - - service().recordReceipts( - List.of(new PushReceiptItem(20L, 10L, "ticket-1")), - Map.of() - ); - - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); - assertThat(message.getReceiptAttempts()).isEqualTo(1); - assertThat(message.getReceiptAvailableAt()).isEqualTo(LocalDateTime.now(CLOCK).plusMinutes(1)); - } - - @Test - void maxReceiptAttemptsMarksFailed() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); - ReflectionTestUtils.setField(message, "receiptAttempts", 2); - - stubMessages(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); - - service().recordReceipts( - List.of(new PushReceiptItem(20L, 10L, "ticket-1")), - Map.of() - ); - - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(message.getReceiptAvailableAt()).isNull(); - } - - @Test - void sentMoreThanTwentyFourHoursAgoExpiresWithoutReceiptRequestItem() { - PushInstallation installation = installation(); - PushDispatch dispatch = dispatch(); - PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); - ReflectionTestUtils.setField(message, "sentAt", LocalDateTime.now(CLOCK).minusHours(25)); - - when(pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked(any(LocalDateTime.class), anyInt())) - .thenReturn(List.of(message)); - stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); - - List items = service().claimDueReceipts(); - - assertThat(items).isEmpty(); - assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); - assertThat(message.getReceiptError()).isEqualTo("receipt_expired"); - } - - @Test - void dispatchSummaryUsesOnlyFinalStatusesForCompletion() { - PushDispatch allDelivered = dispatch(); - allDelivered.updateRecipientCount(2); - allDelivered.updateStatusFromMessageSummary(0, 0, 2, 0, LocalDateTime.now(CLOCK)); - assertThat(allDelivered.getStatus()).isEqualTo(PushDispatchStatus.COMPLETED); - - PushDispatch partial = dispatch(); - partial.updateRecipientCount(2); - partial.updateStatusFromMessageSummary(0, 0, 1, 1, LocalDateTime.now(CLOCK)); - assertThat(partial.getStatus()).isEqualTo(PushDispatchStatus.PARTIAL_FAILED); - - PushDispatch failed = dispatch(); - failed.updateRecipientCount(2); - failed.updateStatusFromMessageSummary(0, 0, 0, 2, LocalDateTime.now(CLOCK)); - assertThat(failed.getStatus()).isEqualTo(PushDispatchStatus.FAILED); - - PushDispatch processing = dispatch(); - processing.updateRecipientCount(2); - processing.updateStatusFromMessageSummary(1, 0, 1, 0, LocalDateTime.now(CLOCK)); - assertThat(processing.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); - } - - private PushReceiptClaimService service() { - return new PushReceiptClaimService( - pushMessageRepository, - pushInstallationRepository, - pushDispatchRepository, - new PushReceiptWorkerProperties(true, 1000, 3), - CLOCK - ); - } - - private void stubMessages(List messages) { - when(pushMessageRepository.findAllByPushMessageIdIn(any(Collection.class))) - .thenReturn(messages); - } - - private void stubStatusCounts( - PushDispatch dispatch, - List counts - ) { - when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) - .thenReturn(counts); - when(pushDispatchRepository.findAllById(any(Iterable.class))) - .thenReturn(List.of(dispatch)); - } - - private PushInstallation installation() { - PushInstallation installation = new PushInstallation( - 1L, - "install-1", - "ExponentPushToken[token]", - AppVariant.DEV - ); - ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); - return installation; - } - - private PushDispatch dispatch() { - PushDispatch dispatch = new PushDispatch( - NotificationType.GENERAL, - PushMode.TEST, - AppVariant.DEV, - PushTargetType.INSTALLATION, - "install-1", - "title", - "body", - PushActionType.TEST, - "{}", - "7b347ad7-6138-4cb7-af7d-f5201703a596", - 1L, - LocalDateTime.now(CLOCK) - ); - ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); - dispatch.updateRecipientCount(1); - return dispatch; - } - - private PushMessage receiptPendingMessage( - PushDispatch dispatch, - PushInstallation installation, - String ticketId - ) { - PushMessage message = new PushMessage( - dispatch, - installation, - LocalDateTime.now(CLOCK).minusMinutes(20) - ); - ReflectionTestUtils.setField(message, "pushMessageId", 20L); - message.recordTicket("ok", ticketId, null, LocalDateTime.now(CLOCK).minusMinutes(20)); - return message; - } - - private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { - - private final Long dispatchId; - private final PushMessageStatus status; - private final long count; - - private StatusCount( - Long dispatchId, - PushMessageStatus status, - long count - ) { - this.dispatchId = dispatchId; - this.status = status; - this.count = count; - } - - @Override - public Long getDispatchId() { - return dispatchId; - } - - @Override - public PushMessageStatus getStatus() { - return status; - } - - @Override - public long getCount() { - return count; - } - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java deleted file mode 100644 index 494b3000..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java +++ /dev/null @@ -1,322 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; -import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; -import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; -import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; -import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; -import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; -import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -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.anyCollection; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class SurveyPushScheduleServiceTest { - - private static final String SURVEY_KEY = "fall-2026"; - private static final Clock FIXED_CLOCK = Clock.fixed( - Instant.parse("2026-08-06T01:00:00Z"), - ZoneId.of("Asia/Seoul") - ); - - @Mock - private SurveyPushScheduleRepository repository; - - private SurveyPushScheduleService service; - - @BeforeEach - void setUp() { - service = new SurveyPushScheduleService(repository, FIXED_CLOCK); - } - - @Test - void upsertAdminSchedulesCreatesThreeWholeAudienceSchedules() { - List saved = new ArrayList<>(); - when(repository.findByIdempotencyKey(any())).thenReturn(Optional.empty()); - when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> { - SurveyPushSchedule schedule = invocation.getArgument(0); - saved.add(schedule); - return schedule; - }); - when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) - .thenAnswer(ignored -> saved); - - service.upsertAdminSchedules( - SURVEY_KEY, - new AdminSurveyPushScheduleReq( - LocalDateTime.parse("2026-08-10T10:00:00"), - LocalDateTime.parse("2026-08-20T10:00:00"), - 100 - ) - ); - - assertThat(saved).hasSize(3); - assertThat(saved) - .extracting(SurveyPushSchedule::getNotificationStage) - .containsExactly( - SurveyNotificationStage.STARTED, - SurveyNotificationStage.D_MINUS_3, - SurveyNotificationStage.DEADLINE - ); - assertThat(saved.get(1).getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); - assertThat(saved).allSatisfy(schedule -> { - assertThat(schedule.getTargetUserId()).isNull(); - assertThat(schedule.getRewardPoint()).isEqualTo(100); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); - }); - } - - @Test - void upsertAdminSchedulesUpdatesOnlyPendingExistingSchedule() { - SurveyPushSchedule pending = schedule(SurveyNotificationStage.STARTED, null, "2026-08-10T10:00:00", 100); - SurveyPushSchedule completed = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100); - completed.complete(LocalDateTime.parse("2026-08-17T10:01:00")); - - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":STARTED")) - .thenReturn(Optional.of(pending)); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":D_MINUS_3")) - .thenReturn(Optional.of(completed)); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":DEADLINE")) - .thenReturn(Optional.empty()); - when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) - .thenReturn(List.of(pending, completed)); - - service.upsertAdminSchedules( - SURVEY_KEY, - new AdminSurveyPushScheduleReq( - LocalDateTime.parse("2026-08-11T10:00:00"), - LocalDateTime.parse("2026-08-21T10:00:00"), - 200 - ) - ); - - assertThat(pending.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-11T10:00:00")); - assertThat(pending.getRewardPoint()).isEqualTo(200); - assertThat(completed.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); - assertThat(completed.getRewardPoint()).isEqualTo(100); - assertThat(completed.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); - } - - @Test - void upsertAdminSchedulesValidatesTimesAndRewardPoint() { - assertThatThrownBy(() -> service.upsertAdminSchedules( - SURVEY_KEY, - new AdminSurveyPushScheduleReq( - LocalDateTime.parse("2026-08-18T10:00:00"), - LocalDateTime.parse("2026-08-20T10:00:00"), - 100 - ) - )) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.INVALID_INPUT); - - assertThatThrownBy(() -> service.upsertAdminSchedules( - SURVEY_KEY, - new AdminSurveyPushScheduleReq( - LocalDateTime.parse("2026-08-10T10:00:00"), - LocalDateTime.parse("2026-08-20T10:00:00"), - -1 - ) - )) - .isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ResultCode.INVALID_INPUT); - } - - @Test - void remindAfterLaterCreatesOrUpdatesOnePendingPersonalSchedule() { - SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); - SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 50); - - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(deadline)); - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.empty()); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.of(existing)); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isTrue(); - assertThat(response.scheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-07T10:00:00")); - assertThat(existing.getRewardPoint()).isEqualTo(100); - } - - @Test - void remindAfterLaterSuppressesByPriorityAndExpiry() { - assertReminderSuppressed( - "2026-08-07T10:00:00", - "2026-08-07T09:00:00", - SurveyReminderSuppressedBy.DEADLINE - ); - assertReminderSuppressed( - "2026-08-20T10:00:00", - "2026-08-07T09:00:00", - SurveyReminderSuppressedBy.D3 - ); - - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.empty()); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - assertThat(response.scheduled()).isFalse(); - assertThat(response.scheduledAt()).isNull(); - assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); - } - - @Test - void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByD3() { - SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); - - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.of(existing)); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isFalse(); - assertThat(response.scheduledAt()).isNull(); - assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); - assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); - assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); - verify(repository, never()).save(any(SurveyPushSchedule.class)); - } - - @Test - void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByDeadline() { - SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); - - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T11:00:00", 100))); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.of(existing)); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isFalse(); - assertThat(response.scheduledAt()).isNull(); - assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.DEADLINE); - assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); - assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); - verify(repository, never()).save(any(SurveyPushSchedule.class)); - } - - @Test - void remindAfterLaterCancelsExistingPendingReminderWhenExpired() { - SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); - - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.of(existing)); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isFalse(); - assertThat(response.scheduledAt()).isNull(); - assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); - assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); - assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); - verify(repository, never()).save(any(SurveyPushSchedule.class)); - } - - @Test - void remindAfterLaterDoesNotCreateCancelledRowWhenSuppressedWithoutExistingReminder() { - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.empty()); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isFalse(); - assertThat(response.scheduledAt()).isNull(); - assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); - verify(repository, never()).save(any(SurveyPushSchedule.class)); - } - - @Test - void remindAfterLaterDoesNotRecreateProcessedReminder() { - SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); - SurveyPushSchedule completed = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); - completed.complete(LocalDateTime.parse("2026-08-07T09:01:00")); - - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(deadline)); - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.empty()); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.of(completed)); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isFalse(); - assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.ALREADY_PROCESSED); - } - - private void assertReminderSuppressed( - String deadlineAt, - String d3At, - SurveyReminderSuppressedBy suppressedBy - ) { - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, deadlineAt, 100))); - when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, d3At, 100))); - when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) - .thenReturn(Optional.empty()); - - SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); - - assertThat(response.scheduled()).isFalse(); - assertThat(response.scheduledAt()).isNull(); - assertThat(response.suppressedBy()).isEqualTo(suppressedBy); - } - - private SurveyPushSchedule schedule( - SurveyNotificationStage stage, - Long targetUserId, - String scheduledAt, - int rewardPoint - ) { - return new SurveyPushSchedule( - SURVEY_KEY, - stage, - targetUserId, - LocalDateTime.parse(scheduledAt), - rewardPoint, - SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), - LocalDateTime.parse("2026-08-06T10:00:00") - ); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java deleted file mode 100644 index b914c971..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java +++ /dev/null @@ -1,276 +0,0 @@ -package devkor.com.teamcback.domain.notification.service; - -import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; -import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.NotificationType; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushEventType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; -import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; -import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; -import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; -import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.response.ResultCode; -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class SurveyPushScheduleWorkerTest { - - private static final String SURVEY_KEY = "fall-2026"; - private static final Clock FIXED_CLOCK = Clock.fixed( - Instant.parse("2026-08-17T01:00:00Z"), - ZoneId.of("Asia/Seoul") - ); - - @Mock - private SurveyPushScheduleRepository surveyPushScheduleRepository; - - @Mock - private PushInstallationRepository pushInstallationRepository; - - @Mock - private PushDispatchService pushDispatchService; - - @Mock - private PushEventFlagService pushEventFlagService; - - private SurveyPushScheduleWorker worker; - - @BeforeEach - void setUp() { - worker = new SurveyPushScheduleWorker( - surveyPushScheduleRepository, - pushInstallationRepository, - pushDispatchService, - pushEventFlagService, - FIXED_CLOCK - ); - } - - @Test - void surveyFlagFalseDoesNotClaimOrEnqueue() { - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(false); - - int processed = worker.processDueSchedulesOnce(); - - assertThat(processed).isZero(); - verify(surveyPushScheduleRepository, never()).findDuePendingForUpdateSkipLocked(any(), any(Integer.class)); - verify(pushDispatchService, never()).enqueue(any()); - } - - @Test - void dueStartedScheduleEnqueuesAllProductionActualGeneralAndCompletes() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); - - worker.processDueSchedulesOnce(); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - PushDispatchCommand command = captor.getValue(); - assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); - assertThat(command.mode()).isEqualTo(PushMode.ACTUAL); - assertThat(command.appVariant()).isEqualTo(AppVariant.PRODUCTION); - assertThat(command.targetType()).isEqualTo(PushTargetType.ALL); - assertThat(command.targetValue()).isEqualTo("ALL"); - assertThat(command.actionType()).isEqualTo(PushActionType.HOME); - assertThat(command.actionParams()).isEmpty(); - assertThat(command.idempotencyKey()).isEqualTo("survey:" + SURVEY_KEY + ":STARTED:production"); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); - assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); - } - - @Test - void dueReminderScheduleEnqueuesUserTarget() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); - when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-18T10:00:00", 100))); - when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)).thenReturn(true); - - worker.processDueSchedulesOnce(); - - ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); - verify(pushDispatchService).enqueue(captor.capture()); - assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.USER); - assertThat(captor.getValue().targetValue()).isEqualTo("7"); - assertThat(captor.getValue().title()).isEqualTo("잠깐, 설문을 잊지 않으셨나요?"); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); - } - - @Test - void noActiveTargetMarksSkippedWithProcessedAt() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(false); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService, never()).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.SKIPPED); - assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); - } - - @Test - void pushDispatchInvalidInputNotCausedByNoTargetKeepsPending() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); - when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.INVALID_INPUT)); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); - assertThat(schedule.getProcessedAt()).isNull(); - } - - @Test - void pushDispatchGlobalExceptionKeepsPending() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); - when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.UNSUPPORTED_REQUEST)); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); - assertThat(schedule.getProcessedAt()).isNull(); - } - - @Test - void pushDispatchRuntimeExceptionKeepsPending() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); - when(pushDispatchService.enqueue(any())).thenThrow(new RuntimeException("boom")); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); - assertThat(schedule.getProcessedAt()).isNull(); - } - - @Test - void reminderIsCancelledWhenLatestD3DateHasPriority() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); - when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100))); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService, never()).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); - } - - @Test - void reminderIsCancelledWhenLatestDeadlineDateHasPriority() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T10:00:00", 100))); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService, never()).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); - assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); - } - - @Test - void reminderIsCancelledWhenNowIsAfterDeadline() { - SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-16T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(schedule)); - when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) - .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:59:59", 100))); - - worker.processDueSchedulesOnce(); - - verify(pushDispatchService, never()).enqueue(any()); - assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); - assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); - } - - @Test - void deadlineAndD3SchedulesDoNotRunReminderPriorityCancellation() { - SurveyPushSchedule started = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); - SurveyPushSchedule d3 = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T09:00:00", 100); - SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); - when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); - when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) - .thenReturn(List.of(started, d3, deadline)); - when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); - - worker.processDueSchedulesOnce(); - - verify(surveyPushScheduleRepository, never()).findBySurveyKeyAndNotificationStage(any(), any()); - verify(pushDispatchService, times(3)).enqueue(any()); - assertThat(started.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); - assertThat(d3.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); - assertThat(deadline.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); - } - - private SurveyPushSchedule schedule( - SurveyNotificationStage stage, - Long targetUserId, - String scheduledAt, - int rewardPoint - ) { - return new SurveyPushSchedule( - SURVEY_KEY, - stage, - targetUserId, - LocalDateTime.parse(scheduledAt), - rewardPoint, - SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), - LocalDateTime.parse("2026-08-06T10:00:00") - ); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java deleted file mode 100644 index 4e78095c..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package devkor.com.teamcback.domain.notification.template; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class DomainPushContentFactoryTest { - - @Test - void placeBecameVacantJoinsBuildingAndPlaceWithSingleSpace() { - PushContent content = DomainPushContentFactory.placeBecameVacant( - " 신공학관 ", - " 라운지 " - ); - - assertThat(content.title()).isEqualTo("기다리던 자리가 생겼어요!"); - assertThat(content.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); - } - - @Test - void placeBecameVacantDoesNotIncludeNullWhenOneValueExists() { - PushContent buildingOnly = DomainPushContentFactory.placeBecameVacant("신공학관", null); - PushContent placeOnly = DomainPushContentFactory.placeBecameVacant(null, "라운지"); - - assertThat(buildingOnly.body()).isEqualTo("신공학관이 한산해요. 방문하기 전 현황을 확인해보세요."); - assertThat(placeOnly.body()).isEqualTo("라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); - assertThat(buildingOnly.body()).doesNotContain("null"); - assertThat(placeOnly.body()).doesNotContain("null"); - } - - @Test - void placeBecameVacantUsesFallbackWhenBothValuesAreBlank() { - PushContent content = DomainPushContentFactory.placeBecameVacant(" ", null); - - assertThat(content.body()).isEqualTo("즐겨찾기한 공간이 한산해요. 방문하기 전 현황을 확인해보세요."); - } - - @Test - void characterUnlockedUsesFallbackWhenNameIsNullOrBlank() { - PushContent nullName = DomainPushContentFactory.characterUnlocked(null); - PushContent blankName = DomainPushContentFactory.characterUnlocked(" "); - - assertThat(nullName.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); - assertThat(nullName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); - assertThat(blankName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); - } - - @Test - void reportResolvedCreatesConfiguredTitleAndBody() { - PushContent content = DomainPushContentFactory.reportResolved(); - - assertThat(content.title()).isEqualTo("신고 처리 결과를 확인해주세요."); - assertThat(content.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); - } - @Test - void surveyContentsAreConfiguredExactly() { - assertThat(DomainPushContentFactory.surveyStarted()) - .isEqualTo(new PushContent( - "고대로를 함께 만들어주세요!", - "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." - )); - assertThat(DomainPushContentFactory.surveyDMinus3()) - .isEqualTo(new PushContent( - "단 3초! 고대로의 개선을 위해 도와주세요", - "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." - )); - assertThat(DomainPushContentFactory.surveyDeadline(100)) - .isEqualTo(new PushContent( - "설문이 오늘 마감돼요!", - "설문에 참여하면 100 포인트를 받을 수 있어요.(5초 소요)" - )); - assertThat(DomainPushContentFactory.surveyRemindAfterLater(100)) - .isEqualTo(new PushContent( - "잠깐, 설문을 잊지 않으셨나요?", - "지금 투표에 참여하고 100포인트를 받아보세요.(5초 소요)" - )); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java deleted file mode 100644 index b58033b0..00000000 --- a/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package devkor.com.teamcback.domain.notification.validation; - -import devkor.com.teamcback.domain.notification.entity.type.AppVariant; -import devkor.com.teamcback.domain.notification.entity.type.PushActionType; -import devkor.com.teamcback.domain.notification.entity.type.PushMode; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class PushActionValidatorTest { - - private final PushActionValidator validator = new PushActionValidator(); - - @Test - void characterStoreAllowsNoParams() { - Map params = validator.validateAndNormalize( - PushActionType.CHARACTER_STORE, - PushMode.ACTUAL, - AppVariant.PRODUCTION, - Map.of() - ); - - assertThat(params).isEmpty(); - } - - @Test - void characterStoreRejectsParams() { - assertThatThrownBy(() -> validator.validateAndNormalize( - PushActionType.CHARACTER_STORE, - PushMode.ACTUAL, - AppVariant.PRODUCTION, - Map.of("characterId", 1L) - )).isInstanceOf(GlobalException.class); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java deleted file mode 100644 index 32020469..00000000 --- a/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package devkor.com.teamcback.domain.report.service; - -import devkor.com.teamcback.domain.report.dto.request.UpdateReportStatusReq; -import devkor.com.teamcback.domain.report.entity.ReasonCategory; -import devkor.com.teamcback.domain.report.entity.Report; -import devkor.com.teamcback.domain.report.entity.ReportStatus; -import devkor.com.teamcback.domain.report.entity.TargetType; -import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; -import devkor.com.teamcback.domain.report.repository.ReportRepository; -import devkor.com.teamcback.domain.review.repository.ReviewRepository; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class ReportServiceTest { - - @Mock - private ReportRepository reportRepository; - - @Mock - private ReviewRepository reviewRepository; - - @Mock - private UserRepository userRepository; - - @Mock - private ApplicationEventPublisher eventPublisher; - - private ReportService reportService; - private User reporter; - - @BeforeEach - void setUp() { - reportService = new ReportService( - reportRepository, - reviewRepository, - userRepository, - eventPublisher - ); - - reporter = new User("reporter", "reporter@test.com", Role.USER, Provider.KAKAO); - ReflectionTestUtils.setField(reporter, "userId", 7L); - } - - @Test - void publishesEventWhenPendingReportBecomesFinalStatus() { - Report report = report(ReportStatus.PENDING, reporter); - when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); - - reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); - - ArgumentCaptor captor = ArgumentCaptor.forClass(ReportResolvedEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); - assertThat(captor.getValue().reportId()).isEqualTo(1L); - assertThat(captor.getValue().reporterUserId()).isEqualTo(7L); - assertThat(captor.getValue().finalStatus()).isEqualTo(ReportStatus.REJECTED); - } - - @Test - void doesNotPublishWhenFinalReportIsReprocessed() { - Report report = report(ReportStatus.RESOLVED, reporter); - when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); - - reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); - - verify(eventPublisher, never()).publishEvent(any()); - } - - @Test - void doesNotPublishWhenStatusDoesNotChangeToFinal() { - Report report = report(ReportStatus.PENDING, reporter); - when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); - - reportService.updateReportStatus(1L, req(ReportStatus.PENDING)); - - verify(eventPublisher, never()).publishEvent(any()); - } - - private UpdateReportStatusReq req(ReportStatus status) { - UpdateReportStatusReq req = new UpdateReportStatusReq(); - req.setStatus(status); - return req; - } - - private Report report( - ReportStatus status, - User reporter - ) { - Report report = new Report( - TargetType.REVIEW, - 20L, - ReasonCategory.SPAM_OR_ADVERTISING, - "sensitive report content", - status, - reporter, - null - ); - ReflectionTestUtils.setField(report, "id", 1L); - return report; - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java b/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java deleted file mode 100644 index 9d53c9e7..00000000 --- a/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package devkor.com.teamcback.domain.usagesurvey.service; - -import static devkor.com.teamcback.global.response.ResultCode.ALREADY_ANSWERED_USAGE_SURVEY; -import static devkor.com.teamcback.global.response.ResultCode.INVALID_USAGE_SURVEY_OPTION; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; -import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; -import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; -import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyDismissalRepository; -import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyResponseRepository; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.global.annotation.UpdateScore; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class UsageSurveyServiceTest { - @Mock - private UsageSurveyResponseRepository responseRepository; - @Mock - private UsageSurveyDismissalRepository dismissalRepository; - @Mock - private UserRepository userRepository; - - private UsageSurveyService service; - private User user; - - @BeforeEach - void setUp() { - service = new UsageSurveyService( - responseRepository, - dismissalRepository, - userRepository - ); - user = new User("survey-user", "survey@test.com", Role.USER, Provider.KAKAO); - lenient().when(userRepository.findById(7L)).thenReturn(Optional.of(user)); - } - - @Test - void statusReturnsAnsweredQuestionsAndReward() { - when(responseRepository.findAllByUserUserId(7L)).thenReturn(List.of( - new UsageSurveyResponse( - user, - UsageSurveyQuestion.INSTALL_REASON, - "SCHOOL_INFO" - ) - )); - - var result = service.getStatus(7L); - - assertThat(result.answeredQuestionKeys()) - .containsExactly(UsageSurveyQuestion.INSTALL_REASON); - assertThat(result.rewardPoint()).isEqualTo(10); - } - - @Test - void validResponseIsStoredAndMethodCarriesTenPointReward() throws Exception { - var req = new SubmitUsageSurveyResponseReq( - UsageSurveyQuestion.DESIRED_FEATURE, - "STUDY_ROOM_LINK" - ); - - var result = service.submitResponse(7L, req); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(UsageSurveyResponse.class); - verify(responseRepository).save(captor.capture()); - assertThat(captor.getValue().getQuestionKey()) - .isEqualTo(UsageSurveyQuestion.DESIRED_FEATURE); - assertThat(captor.getValue().getOptionKey()).isEqualTo("STUDY_ROOM_LINK"); - assertThat(result.getRewardPoint()).isEqualTo(10); - assertThat(UsageSurveyService.class - .getMethod("submitResponse", Long.class, SubmitUsageSurveyResponseReq.class) - .getAnnotation(UpdateScore.class) - .addScore()).isEqualTo(10); - } - - @Test - void duplicateResponseIsRejectedBeforeReward() { - when(responseRepository.existsByUserUserIdAndQuestionKey( - 7L, - UsageSurveyQuestion.INSTALL_REASON - )).thenReturn(true); - - assertThatThrownBy(() -> service.submitResponse( - 7L, - new SubmitUsageSurveyResponseReq( - UsageSurveyQuestion.INSTALL_REASON, - "FACILITIES" - ) - )).isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(ALREADY_ANSWERED_USAGE_SURVEY); - } - - @Test - void mismatchedOptionIsRejected() { - assertThatThrownBy(() -> service.submitResponse( - 7L, - new SubmitUsageSurveyResponseReq( - UsageSurveyQuestion.INSTALL_REASON, - "STUDY_ROOM_LINK" - ) - )).isInstanceOf(GlobalException.class) - .extracting("resultCode") - .isEqualTo(INVALID_USAGE_SURVEY_OPTION); - } - - @Test - void laterDismissalIsStoredForExitMetrics() { - service.recordDismissal(7L, new RecordUsageSurveyDismissalReq( - UsageSurveyQuestion.DISAPPOINTMENT, - UsageSurveyDismissReason.LATER - )); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(UsageSurveyDismissal.class); - verify(dismissalRepository).save(captor.capture()); - assertThat(captor.getValue().getQuestionKey()) - .isEqualTo(UsageSurveyQuestion.DISAPPOINTMENT); - assertThat(captor.getValue().getDismissReason()) - .isEqualTo(UsageSurveyDismissReason.LATER); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java deleted file mode 100644 index e164bfa9..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package devkor.com.teamcback.domain.user.entity; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -class LevelTest { - - @DisplayName("점수 구간별 레벨 계산 (0/5/20/40/60)") - @ParameterizedTest - @CsvSource({ - "0, LEVEL1", "4, LEVEL1", - "5, LEVEL2", "19, LEVEL2", - "20, LEVEL3", "39, LEVEL3", - "40, LEVEL4", "59, LEVEL4", - "60, LEVEL5", "999, LEVEL5", - }) - void fromScore(long score, Level expected) { - assertEquals(expected, Level.fromScore(score)); - } - - @DisplayName("다음 레벨 체인") - @Test - void getNextLevel() { - assertEquals(Level.LEVEL2, Level.LEVEL1.getNextLevel()); - assertEquals(Level.LEVEL3, Level.LEVEL2.getNextLevel()); - assertEquals(Level.LEVEL4, Level.LEVEL3.getNextLevel()); - assertEquals(Level.LEVEL5, Level.LEVEL4.getNextLevel()); - assertNull(Level.LEVEL5.getNextLevel()); - } - - @DisplayName("updateScore 시 score와 level이 함께 갱신") - @Test - void updateScoreSyncsLevel() { - User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - assertEquals(Level.LEVEL1, user.getLevel()); - - user.updateScore(10L, true); - assertEquals(Level.LEVEL2, user.getLevel()); - assertEquals(10L, user.getScore()); - - // 리뷰 삭제 등으로 점수가 내려가면 레벨도 함께 하락 - user.updateScore(3L, true); - assertEquals(Level.LEVEL1, user.getLevel()); - } - - @DisplayName("포인트 적립/차감은 0 미만으로 내려가지 않음") - @Test - void addPointFloorsAtZero() { - User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - assertEquals(0L, user.getPoint()); - - user.addPoint(10); - assertEquals(10L, user.getPoint()); - - user.addPoint(-15); // 리뷰 삭제 등으로 적립분보다 큰 차감이 와도 0에서 멈춤 - assertEquals(0L, user.getPoint()); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java deleted file mode 100644 index f2a5feb7..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package devkor.com.teamcback.domain.user.repository; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import devkor.com.teamcback.domain.user.entity.Level; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import jakarta.persistence.EntityManager; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import devkor.com.teamcback.global.config.QueryDslConfig; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; - -@DataJpaTest -@ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 -class UserRepositoryBackfillTest { - @Autowired - UserRepository userRepository; - - @Autowired - EntityManager em; - - @DisplayName("score와 불일치하는 level만 백필하고, 재실행 시 0건 (멱등)") - @Test - void backfillLevels() { - User staleUser = userRepository.save(new User("stale", "stale@test.com", Role.USER, Provider.KAKAO)); - staleUser.updateScore(60L, false); // level = LEVEL5 - User freshUser = userRepository.save(new User("fresh", "fresh@test.com", Role.USER, Provider.KAKAO)); - freshUser.updateScore(10L, false); // level = LEVEL2 - em.flush(); - - // 컬럼 도입 이전의 레거시 상태(레벨 불일치)를 native SQL로 재현 - em.createNativeQuery("UPDATE tb_user SET level = 'LEVEL1' WHERE username = 'stale'").executeUpdate(); - em.clear(); - - int updated = userRepository.backfillLevels(); - em.clear(); - - assertEquals(1, updated); - assertEquals(Level.LEVEL5, userRepository.findByUserId(staleUser.getUserId()).getLevel()); - assertEquals(Level.LEVEL2, userRepository.findByUserId(freshUser.getUserId()).getLevel()); - - // 두 번째 실행은 아무것도 갱신하지 않음 - assertEquals(0, userRepository.backfillLevels()); - } - - @DisplayName("point가 NULL인 레거시 행만 score 값으로 백필 (사용 후 0이 된 잔액은 재적립 안 됨)") - @Test - void backfillPoints() { - User legacyUser = userRepository.save(new User("legacy", "legacy@test.com", Role.USER, Provider.KAKAO)); - legacyUser.updateScore(30L, false); - User spentUser = userRepository.save(new User("spent", "spent@test.com", Role.USER, Provider.KAKAO)); - spentUser.updateScore(30L, false); // point는 자바 초기값 0 (이미 초기화된 사용자로 간주) - em.flush(); - - // 컬럼 도입 이전의 레거시 상태(point NULL)를 native SQL로 재현 - em.createNativeQuery("UPDATE tb_user SET point = NULL WHERE username = 'legacy'").executeUpdate(); - em.clear(); - - assertEquals(1, userRepository.backfillPoints()); - em.clear(); - - assertEquals(30L, userRepository.findByUserId(legacyUser.getUserId()).getPoint()); - assertEquals(0L, userRepository.findByUserId(spentUser.getUserId()).getPoint()); // NULL 아니면 미변경 - - // 두 번째 실행은 아무것도 갱신하지 않음 (멱등) - assertEquals(0, userRepository.backfillPoints()); - } - - @DisplayName("포인트 차감: 잔액이 충분할 때만 원자적으로 차감") - @Test - void deductPoint() { - User user = userRepository.save(new User("buyer", "buyer@test.com", Role.USER, Provider.KAKAO)); - user.addPoint(10); - em.flush(); - - assertEquals(1, userRepository.deductPoint(user.getUserId(), 7)); - assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); - - // 잔액(3)보다 큰 금액은 차감 실패 - assertEquals(0, userRepository.deductPoint(user.getUserId(), 4)); - assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java deleted file mode 100644 index f61f5538..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package devkor.com.teamcback.domain.user.service; - -import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; -import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; -import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; -import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.notification.service.PushInstallationService; -import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; -import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; -import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.validator.AppleValidator; -import devkor.com.teamcback.domain.user.validator.GoogleValidator; -import devkor.com.teamcback.domain.user.validator.KakaoValidator; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.jwt.JwtUtil; -import org.junit.jupiter.api.BeforeEach; -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.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class UserServiceAdminLoginTest { - @InjectMocks - UserService userService; - - @Mock UserRepository userRepository; - @Mock CategoryRepository categoryRepository; - @Mock BookmarkRepository bookmarkRepository; - @Mock UserBookmarkLogRepository userBookmarkLogRepository; - @Mock SuggestionRepository suggestionRepository; - @Mock UserCharacterRepository userCharacterRepository; - @Mock JwtUtil jwtUtil; - @Mock KakaoValidator kakaoValidator; - @Mock GoogleValidator googleValidator; - @Mock AppleValidator appleValidator; - @Mock PasswordEncoder passwordEncoder; - @Mock PushInstallationService pushInstallationService; - - AdminLoginReq request; - - @BeforeEach - void setUp() { - request = new AdminLoginReq(); - ReflectionTestUtils.setField(request, "provider", Provider.GOOGLE); - ReflectionTestUtils.setField(request, "token", "google-id-token"); - } - - @Test - void issuesTokensOnlyForExistingAdmin() { - User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.GOOGLE); - ReflectionTestUtils.setField(admin, "userId", 7L); - when(googleValidator.validateToken("google-id-token")).thenReturn("admin@example.com"); - when(userRepository.findByEmailAndProvider("admin@example.com", Provider.GOOGLE)).thenReturn(admin); - when(jwtUtil.createAccessToken("7", "ROLE_ADMIN")).thenReturn("access"); - when(jwtUtil.createRefreshToken("7", "ROLE_ADMIN")).thenReturn("refresh"); - - AdminLoginRes result = userService.adminLogin(request); - - assertEquals("access", result.getAccessToken()); - assertEquals("refresh", result.getRefreshToken()); - assertEquals(7L, result.getUserId()); - assertEquals("admin@example.com", result.getEmail()); - } - - @Test - void usesDedicatedKakaoAdminAudience() { - ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); - ReflectionTestUtils.setField(request, "token", "kakao-admin-id-token"); - User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.KAKAO); - ReflectionTestUtils.setField(admin, "userId", 8L); - when(kakaoValidator.validateAdminToken("kakao-admin-id-token")).thenReturn("admin@example.com"); - when(userRepository.findByEmailAndProvider("admin@example.com", Provider.KAKAO)).thenReturn(admin); - - userService.adminLogin(request); - - verify(kakaoValidator).validateAdminToken("kakao-admin-id-token"); - verify(kakaoValidator, never()).validateToken("kakao-admin-id-token"); - } - - @Test - void rejectsNonAdminWithoutCreatingUser() { - User user = new User("user", "user@example.com", Role.USER, Provider.GOOGLE); - when(googleValidator.validateToken("google-id-token")).thenReturn("user@example.com"); - when(userRepository.findByEmailAndProvider("user@example.com", Provider.GOOGLE)).thenReturn(user); - - GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); - - assertEquals(FORBIDDEN, exception.getResultCode()); - verify(userRepository, never()).save(any(User.class)); - } - - @Test - void rejectsUnsupportedProviderBeforeTokenValidation() { - ReflectionTestUtils.setField(request, "provider", Provider.APPLE); - - GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); - - assertEquals(INVALID_INPUT, exception.getResultCode()); - verify(appleValidator, never()).validateToken("google-id-token"); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java deleted file mode 100644 index 2062522b..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package devkor.com.teamcback.domain.user.service; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; -import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; -import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.notification.service.PushInstallationService; -import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.validator.AppleValidator; -import devkor.com.teamcback.domain.user.validator.GoogleValidator; -import devkor.com.teamcback.domain.user.validator.KakaoValidator; -import devkor.com.teamcback.global.jwt.JwtUtil; -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; -import org.mockito.InjectMocks; -import org.mockito.InOrder; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class UserServiceDeleteUserTest { - @InjectMocks - UserService userService; - - @Mock - UserRepository userRepository; - @Mock - CategoryRepository categoryRepository; - @Mock - BookmarkRepository bookmarkRepository; - @Mock - UserBookmarkLogRepository userBookmarkLogRepository; - @Mock - SuggestionRepository suggestionRepository; - @Mock - UserCharacterRepository userCharacterRepository; - @Mock - PushInstallationService pushInstallationService; - @Mock - JwtUtil jwtUtil; - @Mock - KakaoValidator kakaoValidator; - @Mock - GoogleValidator googleValidator; - @Mock - AppleValidator appleValidator; - @Mock - PasswordEncoder passwordEncoder; - - @DisplayName("회원 탈퇴 시 캐릭터 보유 이력을 사용자 삭제 전에 정리 (FK 제약)") - @Test - void deleteUserCleansUpOwnedCharacters() { - User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - ReflectionTestUtils.setField(user, "userId", 1L); - - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(categoryRepository.findByUser(user)).thenReturn(List.of()); - when(suggestionRepository.findByUser(user)).thenReturn(List.of()); - when(userBookmarkLogRepository.findByUser(user)).thenReturn(List.of()); - - userService.deleteUser(1L); - - InOrder inOrder = Mockito.inOrder(userCharacterRepository, userRepository); - inOrder.verify(userCharacterRepository).deleteAllByUser(user); // 소유 이력 먼저 - inOrder.verify(userRepository).delete(user); // 그 다음 사용자 삭제 - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java deleted file mode 100644 index 053eb5ae..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package devkor.com.teamcback.domain.user.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; -import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; -import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; -import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.validator.AppleValidator; -import devkor.com.teamcback.domain.user.validator.GoogleValidator; -import devkor.com.teamcback.domain.user.validator.KakaoValidator; -import devkor.com.teamcback.global.jwt.JwtUtil; -import java.util.Optional; -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.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class UserServiceGetUserInfoTest { - @InjectMocks - UserService userService; - - @Mock - UserRepository userRepository; - @Mock - CategoryRepository categoryRepository; - @Mock - BookmarkRepository bookmarkRepository; - @Mock - UserBookmarkLogRepository userBookmarkLogRepository; - @Mock - SuggestionRepository suggestionRepository; - @Mock - UserCharacterRepository userCharacterRepository; - @Mock - JwtUtil jwtUtil; - @Mock - KakaoValidator kakaoValidator; - @Mock - GoogleValidator googleValidator; - @Mock - AppleValidator appleValidator; - @Mock - PasswordEncoder passwordEncoder; - - @DisplayName("마이페이지: 영속화된 level 컬럼 기반 응답 + isUpgraded 리셋") - @Test - void getUserInfo() { - User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - ReflectionTestUtils.setField(user, "userId", 1L); - user.updateScore(10L, true); // LEVEL2, 레벨업 직후 상태 - - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(categoryRepository.countAllByUser(user)).thenReturn(1L); - - GetUserInfoRes res = userService.getUserInfo(1L); - - assertEquals(2, res.getLevel()); - assertEquals(10L, res.getScore()); - assertEquals(0L, res.getPoint()); // updateScore는 포인트를 건드리지 않음 (적립은 aspect에서) - assertEquals(10L, res.getRemainScoreToNextLevel()); // LEVEL3 시작(20) - 10 - assertEquals(33, res.getPercent()); // 100 * (10-5) / (20-5) - assertTrue(res.isUpgraded()); - assertFalse(user.isUpgraded()); // 조회 후 리셋 - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java deleted file mode 100644 index cd3c2b71..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package devkor.com.teamcback.domain.user.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; -import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; -import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.notification.service.PushInstallationService; -import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; -import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; -import devkor.com.teamcback.domain.user.dto.response.LoginUserRes; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.validator.AppleValidator; -import devkor.com.teamcback.domain.user.validator.GoogleValidator; -import devkor.com.teamcback.domain.user.validator.KakaoValidator; -import devkor.com.teamcback.global.jwt.JwtUtil; -import org.junit.jupiter.api.BeforeEach; -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.security.crypto.password.PasswordEncoder; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -class UserServiceReleaseLoginTest { - @InjectMocks - UserService userService; - - @Mock UserRepository userRepository; - @Mock CategoryRepository categoryRepository; - @Mock BookmarkRepository bookmarkRepository; - @Mock UserBookmarkLogRepository userBookmarkLogRepository; - @Mock SuggestionRepository suggestionRepository; - @Mock UserCharacterRepository userCharacterRepository; - @Mock JwtUtil jwtUtil; - @Mock KakaoValidator kakaoValidator; - @Mock GoogleValidator googleValidator; - @Mock AppleValidator appleValidator; - @Mock PasswordEncoder passwordEncoder; - @Mock PushInstallationService pushInstallationService; - - LoginUserReq request; - - @BeforeEach - void setUp() { - ReflectionTestUtils.setField(userService, "adminToken", "admin-token"); - request = new LoginUserReq(); - ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); - ReflectionTestUtils.setField(request, "email", "untrusted-client@example.com"); - ReflectionTestUtils.setField(request, "token", "kakao-id-token"); - } - - @Test - void returnsLoginKeyExtractedFromVerifiedToken() { - User user = new User("member", "verified@example.com", Role.USER, Provider.KAKAO); - ReflectionTestUtils.setField(user, "userId", 3L); - when(kakaoValidator.validateToken("kakao-id-token")).thenReturn("verified@example.com"); - when(userRepository.findByEmailAndProvider("verified@example.com", Provider.KAKAO)).thenReturn(user); - when(passwordEncoder.encode(anyString())).thenReturn("encoded-code"); - when(jwtUtil.createAccessToken("3", "ROLE_USER")).thenReturn("access"); - when(jwtUtil.createRefreshToken("3", "ROLE_USER")).thenReturn("refresh"); - - LoginUserRes result = userService.releaseLogin(request); - - assertEquals("verified@example.com", result.getLoginKey()); - assertEquals("access", result.getAccessToken()); - assertEquals("refresh", result.getRefreshToken()); - verify(userRepository).findByEmailAndProvider("verified@example.com", Provider.KAKAO); - } -} diff --git a/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java deleted file mode 100644 index 7cb264a7..00000000 --- a/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package devkor.com.teamcback.domain.user.validator; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import devkor.com.teamcback.domain.user.validator.client.KakaoClient; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.global.jwt.OIDC.OIDCUtil; -import devkor.com.teamcback.global.jwt.OIDC.dto.OIDCDecodePayload; -import devkor.com.teamcback.global.redis.RedisUtil; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -class KakaoValidatorTest { - KakaoValidator kakaoValidator; - - @BeforeEach - void setUp() { - kakaoValidator = new KakaoValidator( - Mockito.mock(OIDCUtil.class), - Mockito.mock(KakaoClient.class), - Mockito.mock(RedisUtil.class) - ); - } - - @Test - void keepsEmailFromVerifiedToken() { - OIDCDecodePayload payload = new OIDCDecodePayload( - "https://kauth.kakao.com", - "client-id", - "123456789", - "member@example.com" - ); - - assertEquals("member@example.com", kakaoValidator.resolveEmail(payload)); - } - - @Test - void createsStableInternalEmailWhenKakaoEmailIsMissing() { - OIDCDecodePayload payload = new OIDCDecodePayload( - "https://kauth.kakao.com", - "client-id", - "123456789", - null - ); - - assertEquals( - "kakao_123456789@noemail.kodaero.invalid", - kakaoValidator.resolveEmail(payload) - ); - } - - @Test - void rejectsTokenWithoutEmailAndSubject() { - OIDCDecodePayload payload = new OIDCDecodePayload( - "https://kauth.kakao.com", - "client-id", - null, - null - ); - - assertThrows(GlobalException.class, () -> kakaoValidator.resolveEmail(payload)); - } -} diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java new file mode 100644 index 00000000..fee85b3c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java @@ -0,0 +1,142 @@ +package devkor.com.teamcback.global.aop; + +import devkor.com.teamcback.domain.bookmark.dto.request.CreateBookmarkReq; +import devkor.com.teamcback.domain.bookmark.service.BookmarkService; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.suggestion.dto.request.CreateSuggestionReq; +import devkor.com.teamcback.domain.suggestion.entity.SuggestionType; +import devkor.com.teamcback.domain.suggestion.service.SuggestionService; +import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.service.UserService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.ArrayList; +import java.util.List; + +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; + +@Slf4j +@Disabled +@ExtendWith(SpringExtension.class) +@SpringBootTest +public class UpdateScoreAspectTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private SuggestionService suggestionService; + + @Autowired + private BookmarkService bookmarkService; + + @Test + public void suggestionScoreTest() { + Long userId = 36L; // 임시로 제 계정 사용했습니다. + User user = findUser(userId); + CreateSuggestionReq req = new CreateSuggestionReq("Test제목", SuggestionType.INCONVENIENCE, "내용", "이메일"); + + // 건의함 작성 시 포인트 확인 + long beforeScore = user.getScore(); + log.info("건의함 작성 전 Score : " + beforeScore); + suggestionService.createSuggestion(userId, req, null); + + user = findUser(userId); + long afterScore = user.getScore(); + log.info("건의함 작성 후 Score : " + afterScore); + Assertions.assertThat(beforeScore+3).isEqualTo(afterScore); + + // 점수가 기준치를 넘었는지 확인 + if(user.isUpgraded()) { + log.info("레벨이 올랐습니다."); + + // 업그레이드 후 마이페이지 첫 조회 + GetUserInfoRes info = userService.getUserInfo(userId); + log.info("마이페이지 isUgraded : " + info.isUpgraded()); + Assertions.assertThat(info.isUpgraded()).isTrue(); // True + + user = findUser(userId); // 조회 후 User 정보 + log.info("조회 후 isUpgraded : " + user.isUpgraded()); + Assertions.assertThat(user.isUpgraded()).isFalse(); // false + } else { + log.info("레벨이 오르지 않았습니다."); + } + } + + @Test + public void bookmarkScoreTest() { + Long userId = 36L; // 임시로 제 계정 사용했습니다. + List list = new ArrayList<>(); + + list.add(96L); // 카테고리 ID : 새 로그 검사용 + LocationType type = LocationType.PLACE; + long placeId = 66L; + String memo = "memo"; + + User user = findUser(userId); + CreateBookmarkReq req = new CreateBookmarkReq(list, type, placeId, memo); + + // 1. 건의함 작성 시 포인트 확인 + long beforeScore = user.getScore(); + log.info("북마크 생성 전 Score : " + beforeScore); + bookmarkService.createBookmark(userId, req); + + user = findUser(userId); + long afterScore = user.getScore(); + log.info("북마크 생성 후 Score : " + afterScore); + Assertions.assertThat(beforeScore+1).isEqualTo(afterScore); + + // 2. 점수가 기준치를 넘었는지 확인 + if(user.isUpgraded()) { + log.info("레벨이 올랐습니다."); + + // 업그레이드 후 마이페이지 첫 조회 + GetUserInfoRes info = userService.getUserInfo(userId); + log.info("마이페이지 isUgraded : " + info.isUpgraded()); + Assertions.assertThat(info.isUpgraded()).isTrue(); // True + + user = findUser(userId); // 조회 후 User 정보 + log.info("조회 후 isUpgraded : " + user.isUpgraded()); + Assertions.assertThat(user.isUpgraded()).isFalse(); // false + } else { + log.info("레벨이 오르지 않았습니다."); + } + + // 3. 다른 카테고리에 동일 장소 북마크 추가 : 점수 증가X + log.info("북마크 재생성 전 Score : " + afterScore); + list.clear(); + list.add(99L); + req = new CreateBookmarkReq(list, type, placeId, memo); + bookmarkService.createBookmark(userId, req); + + user = findUser(userId); + long againScore = user.getScore(); + log.info("북마크 재생성 후 Score : " + againScore); + Assertions.assertThat(againScore).isEqualTo(afterScore); + + // 4. 동일 북마크 다시 넣었을 때 점수 증가 X + bookmarkService.createBookmark(userId, req); + + user = findUser(userId); + long dupScore = user.getScore(); + log.info("중복 북마크 생성 후 Score (저장X) : " + dupScore); + Assertions.assertThat(dupScore).isEqualTo(againScore); + } + + private User findUser(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } +} diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java deleted file mode 100644 index 710fe33f..00000000 --- a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package devkor.com.teamcback.global.aop; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import devkor.com.teamcback.domain.common.repository.FileRepository; -import devkor.com.teamcback.domain.place.repository.PlaceRepository; -import devkor.com.teamcback.domain.review.repository.ReviewRepository; -import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; -import devkor.com.teamcback.domain.user.entity.Level; -import devkor.com.teamcback.domain.user.entity.Provider; -import devkor.com.teamcback.domain.user.entity.Role; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.vote.repository.VoteRecordRepository; -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; - -@ExtendWith(MockitoExtension.class) -class UpdateScoreLevelTest { - @InjectMocks - UpdateScoreAspect updateScoreAspect; - - @Mock - UserRepository userRepository; - @Mock - SuggestionRepository suggestionRepository; - @Mock - VoteRecordRepository voteRecordRepository; - @Mock - ReviewRepository reviewRepository; - @Mock - PlaceRepository placeRepository; - @Mock - FileRepository fileRepository; - - @DisplayName("점수 적립 시 레벨업 판정과 level 컬럼 갱신 + 포인트 동일 적립") - @Test - void increaseScoreUpdatesLevel() { - User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); - - // 건의 작성 +10점 → LEVEL1(0) → LEVEL2(5~) 레벨업, 포인트도 +10 - updateScoreAspect.increaseScore(user, 10); - assertEquals(10L, user.getScore()); - assertEquals(10L, user.getPoint()); - assertEquals(Level.LEVEL2, user.getLevel()); - assertTrue(user.isUpgraded()); - - // 같은 레벨 내 적립은 레벨업 아님 - updateScoreAspect.increaseScore(user, 5); - assertEquals(15L, user.getScore()); - assertEquals(15L, user.getPoint()); - assertEquals(Level.LEVEL2, user.getLevel()); - assertFalse(user.isUpgraded()); - - // 리뷰 최대 점수(+13)로 임계값을 건너뛰어도 레벨과 컬럼이 일치 - updateScoreAspect.increaseScore(user, 13); - assertEquals(28L, user.getScore()); - assertEquals(28L, user.getPoint()); - assertEquals(Level.LEVEL3, user.getLevel()); - assertTrue(user.isUpgraded()); - } -} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml deleted file mode 100644 index ed69394e..00000000 --- a/src/test/resources/application-test.yml +++ /dev/null @@ -1,101 +0,0 @@ -spring: - cache: - type: none - datasource: - driver-class-name: org.h2.Driver - url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1 - username: sa - password: - - jpa: - open-in-view: false - hibernate: - ddl-auto: create-drop - naming: - physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl - show-sql: true - properties: - hibernate: - format_sql: true - defer-datasource-initialization: false - sql: - init: - mode: never - - data: - redis: - host: localhost - port: 6379 - password: - - mail: - host: localhost - port: 25 - username: test - password: test - -jwt: - secret: - key: test-jwt-secret-key-for-testing-purposes-only-must-be-at-least-256-bits - social: - kakao: - iss: test - aud: test - google: - iss: test - aud: test - apple: - iss: test - aud: test - dev-aud: test - admin: - token: test-admin-token - -cloud: - aws: - s3: - bucket: test-bucket - credentials: - access-key: test - secret-key: test - region: - static: ap-northeast-2 - -profile: - image: - lv1-url: http://localhost/profile/lv1.jpg - lv2-url: http://localhost/profile/lv2.jpg - lv3-url: http://localhost/profile/lv3.jpg - lv4-url: http://localhost/profile/lv4.jpg - lv5-url: http://localhost/profile/lv5.jpg - -place: - default-image: - cafe: http://localhost/place/CAFE.jpg - cafeteria: http://localhost/place/CAFETERIA.jpg - convenience-store: http://localhost/place/CONVENIENCE_STORE.jpg - gym: http://localhost/place/GYM.jpg - lounge: http://localhost/place/LOUNGE.jpg - reading-room: http://localhost/place/READING_ROOM.jpg - shower-room: http://localhost/place/SHOWER_ROOM.jpg - sleeping-room: http://localhost/place/SLEEPING_ROOM.jpg - study-room: http://localhost/place/STUDY_ROOM.jpg - -date: - api: - holiday: - end-point: http://localhost/api/holiday - encoded-key: test - decoded-key: test - -metrics: - environment: test - -management: - endpoints: - web: - exposure: - include: health - -staff: - emails: test@test.com diff --git a/src/test/resources/logback-spring.xml b/src/test/resources/logback-spring.xml deleted file mode 100644 index 8e82bcbe..00000000 --- a/src/test/resources/logback-spring.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml deleted file mode 100644 index 1f46bb17..00000000 --- a/src/test/resources/logback-test.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - From 41711cc56e8a861c30e7517b85765df09b3d8f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 02:11:22 +0900 Subject: [PATCH 2/4] feat: add admin social login endpoint --- .../user/controller/UserController.java | 11 ++ .../user/dto/request/AdminLoginReq.java | 15 +++ .../user/dto/response/AdminLoginRes.java | 24 ++++ .../domain/user/service/UserService.java | 26 +++++ .../service/UserServiceAdminLoginTest.java | 103 ++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java index 30af7515..0814ac83 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java +++ b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java @@ -1,7 +1,9 @@ package devkor.com.teamcback.domain.user.controller; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -59,6 +61,15 @@ public CommonResponse releaseLogin( return CommonResponse.success(userService.releaseLogin(loginUserReq)); } + /** + * 관리자 대시보드 소셜 로그인 + */ + @Operation(summary = "관리자 소셜 로그인", description = "Google 또는 Kakao ID Token을 검증하고 기존 ADMIN 계정에만 토큰을 발급") + @PostMapping("/login/admin") + public CommonResponse adminLogin(@RequestBody AdminLoginReq adminLoginReq) { + return CommonResponse.success(userService.adminLogin(adminLoginReq)); + } + /** * 자동 로그인 */ diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java new file mode 100644 index 00000000..dd8c20e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.user.dto.request; + +import devkor.com.teamcback.domain.user.entity.Provider; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "관리자 소셜 로그인 요청") +public class AdminLoginReq { + @Schema(description = "관리자 소셜 로그인 제공자", allowableValues = {"GOOGLE", "KAKAO"}) + private Provider provider; + + @Schema(description = "소셜 로그인에서 발급받은 OIDC ID Token") + private String token; +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java new file mode 100644 index 00000000..16ba6507 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.user.dto.response; + +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.User; +import lombok.Getter; + +@Getter +public class AdminLoginRes { + private final String accessToken; + private final String refreshToken; + private final Long userId; + private final String username; + private final String email; + private final Provider provider; + + public AdminLoginRes(String accessToken, String refreshToken, User user) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.userId = user.getUserId(); + this.username = user.getUsername(); + this.email = user.getEmail(); + this.provider = user.getProvider(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 4b5831a5..6f3d0eec 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -10,7 +10,9 @@ import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -121,6 +123,30 @@ private String validateToken(Provider provider, String token) { }; } + /** + * 관리자 대시보드용 소셜 로그인. + * 일반 로그인과 달리 신규 사용자를 만들지 않으며 기존 ADMIN 계정만 토큰을 발급한다. + */ + @Transactional(readOnly = true) + public AdminLoginRes adminLogin(AdminLoginReq adminLoginReq) { + Provider provider = adminLoginReq.getProvider(); + if(provider != Provider.GOOGLE && provider != Provider.KAKAO) { + throw new GlobalException(INVALID_INPUT); + } + + String email = validateToken(provider, adminLoginReq.getToken()); + User user = userRepository.findByEmailAndProvider(email, provider); + if(user == null || user.getRole() != Role.ADMIN) { + throw new GlobalException(FORBIDDEN); + } + + return new AdminLoginRes( + jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), + jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), + user + ); + } + /** * 자동 로그인 */ diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java new file mode 100644 index 00000000..d36467c2 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java @@ -0,0 +1,103 @@ +package devkor.com.teamcback.domain.user.service; + +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.jwt.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +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.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceAdminLoginTest { + @InjectMocks + UserService userService; + + @Mock UserRepository userRepository; + @Mock CategoryRepository categoryRepository; + @Mock BookmarkRepository bookmarkRepository; + @Mock UserBookmarkLogRepository userBookmarkLogRepository; + @Mock SuggestionRepository suggestionRepository; + @Mock UserCharacterRepository userCharacterRepository; + @Mock JwtUtil jwtUtil; + @Mock KakaoValidator kakaoValidator; + @Mock GoogleValidator googleValidator; + @Mock AppleValidator appleValidator; + @Mock PasswordEncoder passwordEncoder; + @Mock PushInstallationService pushInstallationService; + + AdminLoginReq request; + + @BeforeEach + void setUp() { + request = new AdminLoginReq(); + ReflectionTestUtils.setField(request, "provider", Provider.GOOGLE); + ReflectionTestUtils.setField(request, "token", "google-id-token"); + } + + @Test + void issuesTokensOnlyForExistingAdmin() { + User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.GOOGLE); + ReflectionTestUtils.setField(admin, "userId", 7L); + when(googleValidator.validateToken("google-id-token")).thenReturn("admin@example.com"); + when(userRepository.findByEmailAndProvider("admin@example.com", Provider.GOOGLE)).thenReturn(admin); + when(jwtUtil.createAccessToken("7", "ROLE_ADMIN")).thenReturn("access"); + when(jwtUtil.createRefreshToken("7", "ROLE_ADMIN")).thenReturn("refresh"); + + AdminLoginRes result = userService.adminLogin(request); + + assertEquals("access", result.getAccessToken()); + assertEquals("refresh", result.getRefreshToken()); + assertEquals(7L, result.getUserId()); + assertEquals("admin@example.com", result.getEmail()); + } + + @Test + void rejectsNonAdminWithoutCreatingUser() { + User user = new User("user", "user@example.com", Role.USER, Provider.GOOGLE); + when(googleValidator.validateToken("google-id-token")).thenReturn("user@example.com"); + when(userRepository.findByEmailAndProvider("user@example.com", Provider.GOOGLE)).thenReturn(user); + + GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); + + assertEquals(FORBIDDEN, exception.getResultCode()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void rejectsUnsupportedProviderBeforeTokenValidation() { + ReflectionTestUtils.setField(request, "provider", Provider.APPLE); + + GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); + + assertEquals(INVALID_INPUT, exception.getResultCode()); + verify(appleValidator, never()).validateToken("google-id-token"); + } +} From f23e3f812894bc0b9334043ecc9cb78c67ec41c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 04:52:31 +0900 Subject: [PATCH 3/4] feat: support Kakao web admin audience --- .../domain/user/service/UserService.java | 4 +++- .../domain/user/validator/KakaoValidator.java | 12 +++++++++++- src/main/resources/application.yml | 1 + .../user/service/UserServiceAdminLoginTest.java | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 6f3d0eec..2efe9f49 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -134,7 +134,9 @@ public AdminLoginRes adminLogin(AdminLoginReq adminLoginReq) { throw new GlobalException(INVALID_INPUT); } - String email = validateToken(provider, adminLoginReq.getToken()); + String email = provider == Provider.KAKAO + ? kakaoValidator.validateAdminToken(adminLoginReq.getToken()) + : googleValidator.validateToken(adminLoginReq.getToken()); User user = userRepository.findByEmailAndProvider(email, provider); if(user == null || user.getRole() != Role.ADMIN) { throw new GlobalException(FORBIDDEN); diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java index 9415ebb0..5de8f149 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java @@ -25,15 +25,25 @@ public class KakaoValidator{ private String ISS; @Value("${jwt.social.kakao.aud}") private String AUD; + @Value("${jwt.social.kakao.admin-aud:}") + private String ADMIN_AUD; public OIDCPublicKeysResponse getCachedData() { return kakaoClient.getPublicKeys(); } public String validateToken(String token) { + return validateToken(token, AUD); + } + + public String validateAdminToken(String token) { + return validateToken(token, ADMIN_AUD.isBlank() ? AUD : ADMIN_AUD); + } + + private String validateToken(String token, String audience) { try { // 카카오 id_token 정보 - String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, AUD, ISS); + String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, audience, ISS); // 공개키 가져오기 OIDCPublicKeysResponse publicKeysResponse = getCachedData(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64b76d02..ec4569ce 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,7 @@ jwt: kakao: iss: ${KAKAO_ISS} aud: ${KAKAO_AUD} + admin-aud: ${KAKAO_ADMIN_AUD:} google: iss: ${GOOGLE_ISS} aud: ${GOOGLE_AUD} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java index d36467c2..f61f5538 100644 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java @@ -79,6 +79,21 @@ void issuesTokensOnlyForExistingAdmin() { assertEquals("admin@example.com", result.getEmail()); } + @Test + void usesDedicatedKakaoAdminAudience() { + ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); + ReflectionTestUtils.setField(request, "token", "kakao-admin-id-token"); + User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.KAKAO); + ReflectionTestUtils.setField(admin, "userId", 8L); + when(kakaoValidator.validateAdminToken("kakao-admin-id-token")).thenReturn("admin@example.com"); + when(userRepository.findByEmailAndProvider("admin@example.com", Provider.KAKAO)).thenReturn(admin); + + userService.adminLogin(request); + + verify(kakaoValidator).validateAdminToken("kakao-admin-id-token"); + verify(kakaoValidator, never()).validateToken("kakao-admin-id-token"); + } + @Test void rejectsNonAdminWithoutCreatingUser() { User user = new User("user", "user@example.com", Role.USER, Provider.GOOGLE); From 13e0f53c2cb4435f6bbac7f849c3cf8e09228a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=84=EC=83=81=EC=9C=A4?= <818jsy72@gmail.com> Date: Fri, 7 Aug 2026 12:48:02 +0900 Subject: [PATCH 4/4] fix: keep production admin login hotfix self-contained --- src/main/resources/application.yml | 2 +- .../domain/user/service/UserServiceAdminLoginTest.java | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ec4569ce..608185e4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,7 +75,7 @@ jwt: kakao: iss: ${KAKAO_ISS} aud: ${KAKAO_AUD} - admin-aud: ${KAKAO_ADMIN_AUD:} + admin-aud: ${KAKAO_ADMIN_AUD:1a14d374f8b973d583be9e48d98b0954} google: iss: ${GOOGLE_ISS} aud: ${GOOGLE_AUD} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java index f61f5538..acb5ac1d 100644 --- a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java @@ -12,8 +12,6 @@ import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; -import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; -import devkor.com.teamcback.domain.notification.service.PushInstallationService; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; @@ -45,13 +43,11 @@ class UserServiceAdminLoginTest { @Mock BookmarkRepository bookmarkRepository; @Mock UserBookmarkLogRepository userBookmarkLogRepository; @Mock SuggestionRepository suggestionRepository; - @Mock UserCharacterRepository userCharacterRepository; @Mock JwtUtil jwtUtil; @Mock KakaoValidator kakaoValidator; @Mock GoogleValidator googleValidator; @Mock AppleValidator appleValidator; @Mock PasswordEncoder passwordEncoder; - @Mock PushInstallationService pushInstallationService; AdminLoginReq request;