diff --git a/linkmind/build.gradle b/linkmind/build.gradle index 5b9a44b..88f97f0 100644 --- a/linkmind/build.gradle +++ b/linkmind/build.gradle @@ -71,8 +71,12 @@ dependencies { implementation 'io.sentry:sentry-spring-boot-starter:5.7.0' + //sqs + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.1") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-sqs' } + tasks.named('test') { useJUnitPlatform() } diff --git a/linkmind/src/main/java/com/app/toaster/config/fcm/FCMConfig.java b/linkmind/src/main/java/com/app/toaster/config/fcm/FCMConfig.java deleted file mode 100644 index eb37e02..0000000 --- a/linkmind/src/main/java/com/app/toaster/config/fcm/FCMConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.app.toaster.config.fcm; - -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import com.google.firebase.messaging.*; -import jakarta.annotation.PostConstruct; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.ClassPathResource; - -import java.io.IOException; -import java.io.InputStream; - -@Slf4j -@Configuration -public class FCMConfig { - - @Value("${fcm.key.path}") - private String SERViCE_ACCOUNT_JSON; - - @PostConstruct - public void init() { - try { - ClassPathResource resource = new ClassPathResource(SERViCE_ACCOUNT_JSON); - InputStream serviceAccount = resource.getInputStream(); - - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(serviceAccount)) - .build(); - - FirebaseApp.initializeApp(options); - log.info("파이어베이스 서버와의 연결에 성공했습니다."); - } catch (IOException e) { - log.error("파이어베이스 서버와의 연결에 실패했습니다."); - } - } - -} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/controller/AuthController.java b/linkmind/src/main/java/com/app/toaster/controller/AuthController.java index 5f27834..b2ec680 100644 --- a/linkmind/src/main/java/com/app/toaster/controller/AuthController.java +++ b/linkmind/src/main/java/com/app/toaster/controller/AuthController.java @@ -52,8 +52,8 @@ public ApiResponse signOut(@UserId Long userId) { @DeleteMapping("/withdraw") @ResponseStatus(HttpStatus.OK) - public ApiResponse withdraw(@UserId Long userId) { - authService.withdraw(userId); + public ApiResponse withdraw(@UserId Long userId, @RequestHeader("Authorization") String socialAccessToken) { + authService.withdraw(userId,socialAccessToken); return ApiResponse.success(Success.DELETE_USER_SUCCESS); } } diff --git a/linkmind/src/main/java/com/app/toaster/controller/FCMController.java b/linkmind/src/main/java/com/app/toaster/controller/FCMController.java deleted file mode 100644 index 25c8167..0000000 --- a/linkmind/src/main/java/com/app/toaster/controller/FCMController.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.app.toaster.controller; - -import com.app.toaster.controller.request.fcm.FCMPushRequestDto; -import com.app.toaster.service.fcm.FCMService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.io.IOException; - -@RestController -@RequestMapping("/alarm") -@RequiredArgsConstructor -public class FCMController { - - private final FCMService fcmService; - - /** - * 헤더와 바디를 직접 만들어 알림을 전송하는 테스트용 API (상대 답변 알람 전송에 사용) - */ - @PostMapping - @ResponseStatus(HttpStatus.OK) - public ResponseEntity sendNotificationByToken(@RequestBody FCMPushRequestDto request) throws IOException { - - fcmService.pushAlarm(request); - return ResponseEntity.ok().body("푸시알림 전송에 성공했습니다!"); - } -} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/controller/request/fcm/PushMessage.java b/linkmind/src/main/java/com/app/toaster/controller/request/fcm/PushMessage.java deleted file mode 100644 index 0e9098f..0000000 --- a/linkmind/src/main/java/com/app/toaster/controller/request/fcm/PushMessage.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.app.toaster.controller.request.fcm; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public enum PushMessage { - - // 알림 - TODAY_QNA("토스터", - "링크가 구워지는 중"); - - private String title; - private String body; - - } \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/controller/response/timer/GetTimerResponseDto.java b/linkmind/src/main/java/com/app/toaster/controller/response/timer/GetTimerResponseDto.java index 1b1b5cf..9311b07 100644 --- a/linkmind/src/main/java/com/app/toaster/controller/response/timer/GetTimerResponseDto.java +++ b/linkmind/src/main/java/com/app/toaster/controller/response/timer/GetTimerResponseDto.java @@ -12,6 +12,8 @@ public record GetTimerResponseDto (String categoryName, String remindTime, ArrayList remindDates) { public static GetTimerResponseDto of(Reminder reminder){ + if(reminder.getCategory() == null) + return new GetTimerResponseDto("전체", reminder.getRemindTime().toString(), reminder.getRemindDates()); return new GetTimerResponseDto(reminder.getCategory().getTitle(), reminder.getRemindTime().toString(), reminder.getRemindDates()); } } diff --git a/linkmind/src/main/java/com/app/toaster/domain/User.java b/linkmind/src/main/java/com/app/toaster/domain/User.java index 51b4e60..6702146 100644 --- a/linkmind/src/main/java/com/app/toaster/domain/User.java +++ b/linkmind/src/main/java/com/app/toaster/domain/User.java @@ -74,4 +74,5 @@ public String getFcmToken() { public void updateProfile(String profile){ this.profile = profile; } + } diff --git a/linkmind/src/main/java/com/app/toaster/exception/Error.java b/linkmind/src/main/java/com/app/toaster/exception/Error.java index a22bb7e..63193ee 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Error.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Error.java @@ -56,6 +56,7 @@ public enum Error { UNPROCESSABLE_CREATE_TIMER_EXCEPTION(HttpStatus.UNPROCESSABLE_ENTITY, "이미 타이머가 존재하는 클립입니다."), UNPROCESSABLE_ENTITY_CREATE_CLIP_EXCEPTION(HttpStatus.UNPROCESSABLE_ENTITY, "클립은 최대 50개까지만 등록가능합니다."), UNPROCESSABLE_PRESIGNEDURL_EXCEPTION(HttpStatus.UNPROCESSABLE_ENTITY, "presignedUrl 발급 중 에러가 발생했습니다."), + UNPROCESSABLE_KAKAO_SERVER_EXCEPTION(HttpStatus.UNPROCESSABLE_ENTITY, "카카오서버와 통신 중 오류가 발생했습니다."), /** @@ -65,7 +66,7 @@ public enum Error { INVALID_ENCRYPT_COMMUNICATION(HttpStatus.INTERNAL_SERVER_ERROR, "ios 통신 증명 과정 중 문제가 발생했습니다."), CREATE_PUBLIC_KEY_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "publickey 생성 과정 중 문제가 발생했습니다."), FAIL_TO_SEND_PUSH_ALARM(HttpStatus.INTERNAL_SERVER_ERROR, "다수기기 푸시메시지 전송 실패"), - + FAIL_TO_SEND_SQS(HttpStatus.INTERNAL_SERVER_ERROR, "sqs 전송 실패"), CREATE_TOAST_PROCCESS_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "토스트 저장 중 문제가 발생했습니다. 카테고리 또는 s3 관련 문제로 예상됩니다.") ; diff --git a/linkmind/src/main/java/com/app/toaster/exception/Success.java b/linkmind/src/main/java/com/app/toaster/exception/Success.java index 562e626..8c1676d 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Success.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Success.java @@ -47,6 +47,8 @@ public enum Success { UPDATE_TIMER_DATETIME_SUCCESS(HttpStatus.OK, "타이머 시간/날짜 수정 완료"), UPDATE_TIMER_COMMENT_SUCCESS(HttpStatus.OK, "타이머 코멘트 수정 완료"), CHANGE_TIMER_ALARM_SUCCESS(HttpStatus.OK, "타이머 알람여부 수정 완료"), + PUSH_ALARM_PERIODIC_SUCCESS(HttpStatus.OK, "푸시알림 활성에 성공했습니다."), + PUSH_ALARM_SUCCESS(HttpStatus.OK, "푸시알림 전송에 성공했습니다."), /** diff --git a/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMConfig.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMConfig.java new file mode 100644 index 0000000..0deebeb --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMConfig.java @@ -0,0 +1,100 @@ +package com.app.toaster.external.client.fcm; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.*; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +@Slf4j +@Configuration +public class FCMConfig { + + @Value("${fcm.key.path}") + private String SERVICE_ACCOUNT_JSON; + + @PostConstruct + public void init() { + try { + ClassPathResource resource = new ClassPathResource(SERVICE_ACCOUNT_JSON); + InputStream serviceAccount = resource.getInputStream(); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + log.info("파이어베이스 서버와의 연결에 성공했습니다."); + } catch (IOException e) { + log.error("파이어베이스 서버와의 연결에 실패했습니다."); + } + } + + // 여러 개의 파이어베이스 앱을 사용하는 경우 + @Bean + FirebaseMessaging firebaseMessaging() throws IOException { + + ClassPathResource resource = new ClassPathResource(SERVICE_ACCOUNT_JSON); + InputStream refreshToken = resource.getInputStream(); + + FirebaseApp firebaseApp = null; + List firebaseAppList = FirebaseApp.getApps(); + + if (!firebaseAppList.isEmpty() && firebaseAppList != null) { + for (FirebaseApp app : firebaseAppList) { + if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { + firebaseApp = app; + } + } + } else { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(refreshToken)) + .build(); + + firebaseApp = FirebaseApp.initializeApp(options); + } + + return FirebaseMessaging.getInstance(firebaseApp); + + + } + + // TODO 플랫폼마다 별도의 설정이 필요한 경우 사용 + + // Android + public AndroidConfig TokenAndroidConfig(FCMPushRequestDto request) { + return AndroidConfig.builder() +// .setCollapseKey(request.getCollapseKey()) + .setNotification(AndroidNotification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .build()) + .build(); + } + + // Apple + public ApnsConfig TokenApnsConfig(FCMPushRequestDto request) { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setAlert( + ApsAlert.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) +// .setLaunchImage(request.getImgUrl()) + .build() + ) +// .setCategory(request.getCollapseKey()) + .setSound("default") + .build()) + .build(); + } +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMController.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMController.java new file mode 100644 index 0000000..5b511d9 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMController.java @@ -0,0 +1,45 @@ +package com.app.toaster.external.client.fcm; + +import com.app.toaster.common.dto.ApiResponse; +import com.app.toaster.external.client.sqs.SqsProducer; +import com.app.toaster.external.client.fcm.FCMPushRequestDto; +import com.app.toaster.exception.Success; +import com.app.toaster.external.client.fcm.FCMService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +@RestController +@RequestMapping("/alarm") +@RequiredArgsConstructor +public class FCMController { + + private final FCMService fcmService; +// private final FCMScheduler fcmScheduler; + private final SqsProducer sqsProducer; + + /** + * 헤더와 바디를 직접 만들어 알림을 전송하는 테스트용 API + */ + @PostMapping + @ResponseStatus(HttpStatus.OK) + public ResponseEntity sendNotificationByToken( + @RequestBody FCMPushRequestDto request) throws IOException { + + fcmService.pushAlarm(request); + return ResponseEntity.ok().body("푸시알림 전송에 성공했습니다!"); + } + + /** + * 새로운 질문이 도착했음을 알리는 푸시 알림 활성화 API + */ + @PostMapping("/qna") + @ResponseStatus(HttpStatus.OK) + public ApiResponse sendTopicScheduledTest(@RequestBody FCMPushRequestDto request) { + sqsProducer.sendMessage(request); + return ApiResponse.success(Success.PUSH_ALARM_PERIODIC_SUCCESS); + } +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/domain/FCMMessage.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMMessage.java similarity index 94% rename from linkmind/src/main/java/com/app/toaster/domain/FCMMessage.java rename to linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMMessage.java index cda67e4..ffd1657 100644 --- a/linkmind/src/main/java/com/app/toaster/domain/FCMMessage.java +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMMessage.java @@ -1,4 +1,4 @@ -package com.app.toaster.domain; +package com.app.toaster.external.client.fcm; import lombok.AccessLevel; import lombok.AllArgsConstructor; diff --git a/linkmind/src/main/java/com/app/toaster/controller/request/fcm/FCMPushRequestDto.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMPushRequestDto.java similarity index 68% rename from linkmind/src/main/java/com/app/toaster/controller/request/fcm/FCMPushRequestDto.java rename to linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMPushRequestDto.java index 8cee8a6..d973aa0 100644 --- a/linkmind/src/main/java/com/app/toaster/controller/request/fcm/FCMPushRequestDto.java +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMPushRequestDto.java @@ -1,10 +1,14 @@ -package com.app.toaster.controller.request.fcm; +package com.app.toaster.external.client.fcm; +import com.app.toaster.domain.Reminder; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.*; import lombok.extern.slf4j.Slf4j; -import java.awt.*; +import java.time.LocalDateTime; +import java.time.format.TextStyle; +import java.util.Locale; +import java.util.Random; @Slf4j @Getter @@ -13,6 +17,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class FCMPushRequestDto { + private String targetToken; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -23,12 +28,14 @@ public class FCMPushRequestDto { private String image; - public static FCMPushRequestDto sendTestPush(String targetToken) { + public static FCMPushRequestDto sendTestPush(String targetToken, String comment) { return FCMPushRequestDto.builder() .targetToken(targetToken) - .title("🍞" + PushMessage.TODAY_QNA.getTitle()) - .body(PushMessage.TODAY_QNA.getBody()) + .title("🍞 토스트 ") + .body(comment) .build(); + } + } \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMScheduler.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMScheduler.java new file mode 100644 index 0000000..36079e2 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMScheduler.java @@ -0,0 +1,43 @@ +package com.app.toaster.external.client.fcm; + +import com.app.toaster.domain.Reminder; +import com.app.toaster.infrastructure.TimerRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FCMScheduler { + private final TimerRepository timerRepository; + private final FCMService fcmService; + + private final ObjectMapper objectMapper; // FCM의 body 형태에 따라 생성한 값을 문자열로 저장하기 위한 Mapper 클래스 + + @Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul") + public String pushTodayTimer() { + + log.info("리마인드 알람"); + + // 오늘 요일 + int today = LocalDateTime.now().getDayOfWeek().getValue(); + + timerRepository.findAll().stream().filter(reminder -> reminder.getRemindDates().contains(today)).forEach(timer -> { + System.out.println("timerId : " + timer.getId()); + String cronExpression = String.format("0 %s %s * * ?", timer.getRemindTime().getMinute(),timer.getRemindTime().getHour()); + + fcmService.schedulePushAlarm(cronExpression, timer.getId()); + + }); + + return "오늘의 토스터를 구워 전달했어요!!!"; + } + +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMService.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMService.java new file mode 100644 index 0000000..f3ab511 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/FCMService.java @@ -0,0 +1,237 @@ +package com.app.toaster.external.client.fcm; + +import com.app.toaster.domain.Category; +import com.app.toaster.external.client.sqs.SqsProducer; +import com.app.toaster.domain.Reminder; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.NotFoundException; +import com.app.toaster.infrastructure.CategoryRepository; +import com.app.toaster.infrastructure.TimerRepository; +import com.app.toaster.infrastructure.ToastRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PessimisticLockException; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.scheduling.TaskScheduler; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.concurrent.ScheduledFuture; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class FCMService { + private final CategoryRepository categoryRepository; + private final ToastRepository toastRepository; + private final TimerRepository timerRepository; + + private final ObjectMapper objectMapper; // FCM의 body 형태에 따라 생성한 값을 문자열로 저장하기 위한 Mapper 클래스 + + @Value("${fcm.key.path}") + private String SERVICE_ACCOUNT_JSON; + @Value("${fcm.api.url}") + private String FCM_API_URL; + @Value("${fcm.topic}") + private String topic; + + + private static ScheduledFuture scheduledFuture; + private final TaskScheduler taskScheduler; + private final PlatformTransactionManager transactionManager; + private final SqsProducer sqsProducer; + + @PersistenceContext + private EntityManager em; + + + private final int PUSH_MESSAGE_NUMBER = 5; + + /** + * 단일 기기 + * - Firebase에 메시지를 수신하는 함수 (헤더와 바디 직접 만들기) + */ + @Transactional + public String pushAlarm(FCMPushRequestDto request) throws IOException { + + String message = makeSingleMessage(request); + sendPushMessage(message); + return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken(); + } + + // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [단일 기기] + private String makeSingleMessage(FCMPushRequestDto request) throws JsonProcessingException { + + FCMMessage fcmMessage = FCMMessage.builder() + .message(FCMMessage.Message.builder() + .token(request.getTargetToken()) // 1:1 전송 시 반드시 필요한 대상 토큰 설정 + .notification(FCMMessage.Notification.builder() + .title(request.getTitle()) + .body(request.getBody()) + .image(request.getImage()) + .build()) + .build() + ).validateOnly(false) + .build(); + + return objectMapper.writeValueAsString(fcmMessage); + } + + + // 실제 파이어베이스 서버로 푸시 메시지를 전송하는 메서드 + private void sendPushMessage(String message) throws IOException { + + OkHttpClient client = new OkHttpClient(); + RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); + Request httpRequest = new Request.Builder() + .url(FCM_API_URL) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build(); + + Response response = client.newCall(httpRequest).execute(); + + log.info("단일 기기 알림 전송 성공 ! successCount: 1 messages were sent successfully"); + log.info("알림 전송: {}", response.body().string()); + } + + // Firebase에서 Access Token 가져오기 + private String getAccessToken() throws IOException { + + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(SERVICE_ACCOUNT_JSON).getInputStream()) + .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); + googleCredentials.refreshIfExpired(); + log.info("getAccessToken() - googleCredentials: {} ", googleCredentials.getAccessToken().getTokenValue()); + + return googleCredentials.getAccessToken().getTokenValue(); + } + + // 푸시알림 스케줄러 + public void schedulePushAlarm(String cronExpression,Long timerId) { + + scheduledFuture = taskScheduler.schedule(() -> { + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + TransactionDefinition transactionDefinition = new DefaultTransactionDefinition(); + TransactionStatus transactionStatus = transactionManager.getTransaction(transactionDefinition); + + try { + + Reminder timer = timerRepository.findById(timerId).orElseThrow( + ()-> new NotFoundException(Error.NOT_FOUND_TIMER, Error.NOT_FOUND_TIMER.getMessage()) + ); + + String cron = String.format("0 %s %s * * ?", timer.getRemindTime().getMinute(),timer.getRemindTime().getHour()); + + // 현재 알람이 커져있고 설정값이 동일하면 알람 전송 + if(timer.getIsAlarm() && timer.getUser().getFcmIsAllowed() && cronExpression.equals(cron)) { + System.out.println("================= 전송시간 ================="); + //sqs 푸시 + FCMPushRequestDto request = getPushMessage(timer,toastRepository.getUnReadToastNumber(timer.getUser().getUserId()) ); + + sqsProducer.sendMessage(request); + + System.out.println("========="+request.getTitle() + request.getBody()+"========="); + + } + + } catch (PessimisticLockingFailureException | PessimisticLockException e) { + transactionManager.rollback(transactionStatus); + } finally { + em.close(); + } + + + }, new CronTrigger(cronExpression)); + + } + + // 스케줄러에서 예약된 작업을 제거하는 메서드 + public static void clearScheduledTasks() { + if (scheduledFuture != null) { + log.info("이전 스케줄링 예약 취소!"); + scheduledFuture.cancel(false); + } + log.info("ScheduledFuture: {}", scheduledFuture); + } + + private FCMPushRequestDto getPushMessage(Reminder reminder, int unReadToastNumber){ + Random random = new Random(); + int randomNumber = random.nextInt(PUSH_MESSAGE_NUMBER); + String categoryTitle = "전체"; + + Category category = timerRepository.findCategoryByReminderId(reminder.getId()); + if(category != null){ + categoryTitle = category.getTitle(); + } + + + String title=""; + String body=""; + + switch (randomNumber) { + case 0 -> { + title = reminder.getUser().getNickname()+PushMessage.ALARM_MESSAGE_0.getTitle(); + body = categoryTitle+PushMessage.ALARM_MESSAGE_0.getBody(); + } + case 1 -> { + title = "띵동! " + categoryTitle+PushMessage.ALARM_MESSAGE_1.getTitle(); + body = PushMessage.ALARM_MESSAGE_1.getBody(); + } + case 2 -> { + title = reminder.getUser().getNickname()+"님, "+categoryTitle+PushMessage.ALARM_MESSAGE_2.getTitle(); + body = PushMessage.ALARM_MESSAGE_2.getBody(); + } + case 3 -> { + LocalDateTime now = LocalDateTime.now(); + + title = now.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.KOREA)+"요일 "+now.getHour()+"시에는 " + +categoryTitle+PushMessage.ALARM_MESSAGE_3.getTitle(); + body = PushMessage.ALARM_MESSAGE_3.getBody(); + } + case 4 -> { + title = reminder.getUser().getNickname()+"님, " +categoryTitle+PushMessage.ALARM_MESSAGE_4.getTitle(); + body = PushMessage.ALARM_MESSAGE_4.getBody()+unReadToastNumber+"개 남아있어요"; + } + }; + + return FCMPushRequestDto.builder() + .targetToken(reminder.getUser().getFcmToken()) + .title(title) + .body(body) + .build(); + } +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/external/client/fcm/PushMessage.java b/linkmind/src/main/java/com/app/toaster/external/client/fcm/PushMessage.java new file mode 100644 index 0000000..05126d7 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/fcm/PushMessage.java @@ -0,0 +1,31 @@ +package com.app.toaster.external.client.fcm; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum PushMessage { + + ALARM_MESSAGE_0("님, 타이머가 완료되었어요!", + " 클립 읽기 딱 좋은 시간이에요."), + + ALARM_MESSAGE_1(" 클립이 다 구워졌어요.", + "링크가 타기 전에 읽어보세요🔗"), + + ALARM_MESSAGE_2(" 클립을 읽을 시간이에요!", + "타이머에서 확인해보세요⏱️"), + + ALARM_MESSAGE_3(" 클립을 읽어보세요!", + " 토스터 읽기 좋은 시간이에요🍞"), + + ALARM_MESSAGE_4(" 클립 읽을 시간 리마인드 드려요!", + " 아직 못 읽은 링크가 "); + + + private String title; + private String body; + + +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/sqs/ScheduleConfig.java b/linkmind/src/main/java/com/app/toaster/external/client/sqs/ScheduleConfig.java new file mode 100644 index 0000000..9ffbb37 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/sqs/ScheduleConfig.java @@ -0,0 +1,52 @@ +package com.app.toaster.external.client.sqs; + +import com.app.toaster.external.client.fcm.FCMService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 특정 시간대에 알림을 보내주기 위해 Spring이 제공하는 TaskScheduler를 빈으로 등록 + */ +@Configuration +@EnableScheduling +public class ScheduleConfig { + + private static final int POOL_SIZE = 10; + private static ThreadPoolTaskScheduler scheduler; + + + @Bean + public TaskScheduler scheduler() { + scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(POOL_SIZE); + // 스레드 이름 접두사 설정 + scheduler.setThreadNamePrefix("현재 쓰레드 풀-"); + // 거부된 실행 핸들러 설정 + scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + scheduler.initialize(); + return scheduler; + } + + // 스케줄러 중지 후 재시작 (초기화) + public static void resetScheduler() { + scheduler.shutdown(); + FCMService.clearScheduledTasks(); + scheduler.setPoolSize(POOL_SIZE); + scheduler.setThreadNamePrefix("현재 쓰레드 풀-"); + scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + scheduler.initialize(); + } + + + + // 단일 스레드로 예약된 작업을 처리하고자 할 때 사용 + /*@Bean + public TaskScheduler scheduler() { + return new ConcurrentTaskScheduler(); + }*/ +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsConfig.java b/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsConfig.java new file mode 100644 index 0000000..3920d5e --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsConfig.java @@ -0,0 +1,63 @@ +package com.app.toaster.external.client.sqs; + +import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; +import io.awspring.cloud.sqs.config.SqsMessageListenerContainerFactory; +import io.awspring.cloud.sqs.operations.SqsTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; + +import java.time.Duration; + +@Import(SqsBootstrapConfiguration.class) +@Configuration +public class SqsConfig { + + @Value("${cloud.aws.credentials.access-key}") + private String AWS_ACCESS_KEY; + + @Value("${cloud.aws.credentials.secret-key}") + private String AWS_SECRET_KEY; + + @Value("${cloud.aws.region.static}") + private String AWS_REGION; + + // 클라이언트 설정: region과 자격증명 + @Bean + public SqsAsyncClient sqsAsyncClient() { + return SqsAsyncClient.builder() + .credentialsProvider(() -> new AwsCredentials() { + @Override + public String accessKeyId() { + return AWS_ACCESS_KEY; + } + + @Override + public String secretAccessKey() { + return AWS_SECRET_KEY; + } + }) + .region(Region.of(AWS_REGION)) + .build(); + } + + // Listener Factory 설정 (Listener 쪽) + @Bean + public SqsMessageListenerContainerFactory defaultSqsListenerContainerFactory() { + return SqsMessageListenerContainerFactory.builder() + .sqsAsyncClient(sqsAsyncClient()) + .build(); + } + + + + // 메시지 발송을 위한 SQS 템플릿 설정 (Sender 쪽) + @Bean + public SqsTemplate sqsTemplate() { + return SqsTemplate.newTemplate(sqsAsyncClient()); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsConsumer.java b/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsConsumer.java new file mode 100644 index 0000000..3bca224 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsConsumer.java @@ -0,0 +1,68 @@ +package com.app.toaster.external.client.sqs; + +import com.app.toaster.external.client.fcm.FCMPushRequestDto; +import com.app.toaster.external.client.fcm.FCMService; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.annotation.SqsListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest; + +import java.util.Map; + +/** + * 큐 대기열에 있는 메시지 목록을 조회하여 받아오는(pull) 역할 + * + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SqsConsumer { + + @Value("${cloud.aws.sqs.notification.url}") + private String QUEUE_URL; + + private final ObjectMapper objectMapper; + + private final FCMService fcmService; + private final SqsAsyncClient sqsAsyncClient; + private static final String SQS_CONSUME_LOG_MESSAGE = + "====> [SQS Queue Response]\n" + "info: %s\n" + "header: %s\n"; + + +// SQS로부터 메시지를 받는 Listener | 메시지를 받은 이후의 삭제 정책을 NEVER로 지정 +// -> 절대 삭제 요청을 보내지 않고, ack 메서드를 호출할 때 삭제 요청을 보냄 + @SqsListener(value = "${cloud.aws.sqs.notification.name}") + public void consume(@Payload String payload, @Headers Map headers) { + System.out.println("======== 수신 받음 =============="); + +// log.info(headers.toString()); + + try { + FCMPushRequestDto request = objectMapper.readValue(payload, FCMPushRequestDto.class); + fcmService.pushAlarm(request); + +// log.info(SQS_CONSUME_LOG_MESSAGE + payload); + + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + + private void deleteMessage(String receiptHandle) { + System.out.println("========== deleteMessage ========="); + DeleteMessageRequest deleteMessageRequest = DeleteMessageRequest.builder() + .queueUrl(QUEUE_URL) + .receiptHandle(receiptHandle) + .build(); + sqsAsyncClient.deleteMessage(deleteMessageRequest); + } + + +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsProducer.java b/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsProducer.java new file mode 100644 index 0000000..63b3f9f --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/sqs/SqsProducer.java @@ -0,0 +1,38 @@ +package com.app.toaster.external.client.sqs; + +import com.app.toaster.external.client.fcm.FCMPushRequestDto; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.awspring.cloud.sqs.operations.SqsTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SqsProducer { + + @Value("${cloud.aws.sqs.notification.name}") + private String QUEUE_NAME; + + private static final String GROUP_ID = "sqs"; + private final ObjectMapper objectMapper; + private final SqsTemplate template; + private static final String SQS_QUEUE_REQUEST_LOG_MESSAGE = "====> [SQS Queue Request] : %s "; + + + public void sendMessage(FCMPushRequestDto requestDto) { + System.out.println("Sender: " + requestDto.getBody()); + template.send(to -> { + try { + to.queue(QUEUE_NAME) + .messageGroupId(GROUP_ID) + .payload(objectMapper.writeValueAsString(requestDto)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + } +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/infrastructure/TimerRepository.java b/linkmind/src/main/java/com/app/toaster/infrastructure/TimerRepository.java index df7e49f..53183b9 100644 --- a/linkmind/src/main/java/com/app/toaster/infrastructure/TimerRepository.java +++ b/linkmind/src/main/java/com/app/toaster/infrastructure/TimerRepository.java @@ -5,15 +5,26 @@ import com.app.toaster.domain.User; import org.checkerframework.checker.units.qual.C; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; import java.util.ArrayList; - +import java.util.List; +@Repository public interface TimerRepository extends JpaRepository { ArrayList findAllByUser(User user); + void deleteAllByUser(User user); ArrayList findAllByCategoryAndUser(Category category, User user); Reminder findByCategory_CategoryId(Long categoryId); -} + + @Query("select r.category from Reminder r where r.id = :id") + Category findCategoryByReminderId(@Param("id") Long reminderId); + + +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/infrastructure/ToastRepository.java b/linkmind/src/main/java/com/app/toaster/infrastructure/ToastRepository.java index f82bb37..4457be8 100644 --- a/linkmind/src/main/java/com/app/toaster/infrastructure/ToastRepository.java +++ b/linkmind/src/main/java/com/app/toaster/infrastructure/ToastRepository.java @@ -34,10 +34,12 @@ public interface ToastRepository extends JpaRepository { ) List searchToastsByQuery(Long userId, String query); - Long countAllByUser(User user); + Long countAllByUser(User user); - Long countALLByUserAndIsReadTrue(User user); + Long countALLByUserAndIsReadTrue(User user); - Long countAllByUserAndIsReadFalse(User user); + Long countAllByUserAndIsReadFalse(User user); + @Query("SELECT COUNT(t) FROM Toast t WHERE t.user.userId = :userId AND t.isRead = false") + Integer getUnReadToastNumber(Long userId); } diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java b/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java index 4bddf69..369f429 100644 --- a/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java +++ b/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; import com.app.toaster.config.jwt.JwtService; import com.app.toaster.controller.request.auth.SignInRequestDto; @@ -60,11 +61,12 @@ public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto reque LoginResult loginResult = login(socialType, socialAccessToken); String socialId = loginResult.id(); String profileImage = loginResult.profile(); + String nickname = loginResult.nickname(); Boolean isRegistered = userRepository.existsBySocialIdAndSocialType(socialId, socialType); if (!isRegistered) { User newUser = User.builder() - .nickname("토스터"+socialId) + .nickname(nickname==null?"토스터"+socialId.substring(5):nickname) .socialId(socialId) .socialType(socialType).build(); newUser.updateFcmIsAllowed(true); //신규 유저면 true박고 @@ -84,8 +86,9 @@ public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto reque user.updateRefreshToken(refreshToken); user.updateFcmToken(fcmToken); user.updateProfile(profileImage == null ? BASIC_ROOT+BASIC_THUMBNAIL : profileImage); - - + if (nickname!=null){ //탈퇴 안했던 유저들도 수정될 수 있도록 변경 + user.updateNickname(nickname); + } return SignInResponseDto.of(user.getUserId(), accessToken, refreshToken, fcmToken, isRegistered,user.getFcmIsAllowed(), user.getProfile()); } @@ -123,14 +126,17 @@ else if (socialType.toString() == "KAKAO") { return kakaoSignInService.getKaKaoId(socialAccessToken); } else{ - return LoginResult.of("test", "뭔가 로직에 문제가 있음."); + return LoginResult.of("test", "뭔가 로직에 문제가 있음.","닉네임에 문제가 있음."); } } @Transactional - public void withdraw(Long userId) { + public void withdraw(Long userId, String accessToken) { User user = userRepository.findByUserId(userId).orElseThrow( ()->new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage())); + if (user.getSocialType() == SocialType.KAKAO){ + kakaoSignInService.withdrawKakao(accessToken); + } try { toastService.deleteAllToast(user); }catch (IOException e){ @@ -146,4 +152,5 @@ public void withdraw(Long userId) { throw new UnprocessableEntityException(Error.UNPROCESSABLE_ENTITY_DELETE_EXCEPTION, Error.UNPROCESSABLE_ENTITY_DELETE_EXCEPTION.getMessage()); } } + } diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java index c3501ee..7c7b833 100644 --- a/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java @@ -35,6 +35,6 @@ public LoginResult getAppleId(String identityToken) { .toEntity(ApplePublicKeys.class); PublicKey publicKey = publicKeyGenerator.generatePublicKey(headers, result.getBody()); Claims claims = appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey); - return LoginResult.of(claims.getSubject(),null); + return LoginResult.of(claims.getSubject(),null,null); } } diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java index 01fbd6e..d24ae56 100644 --- a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java +++ b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java @@ -10,6 +10,9 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.ForbiddenException; +import com.app.toaster.exception.model.UnprocessableEntityException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.JsonArray; @@ -21,15 +24,40 @@ public class KakaoSignInService { @Value("${jwt.KAKAO_URL}") private String KAKAO_URL; + @Value("${jwt.KAKAO_WITHDRAW}") + private String KAKAO_WITHDRAW; + public LoginResult getKaKaoId(String accessToken) { + ResponseEntity responseData = requestKakaoServer(accessToken, Strategy.LOGIN); + ObjectMapper objectMapper = new ObjectMapper(); + HashMap profileResponse = (HashMap)objectMapper.convertValue( responseData.getBody(),Map.class).get("properties"); + return LoginResult.of(objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString(), profileResponse==null?null:profileResponse.get("profile_image").toString(), + profileResponse==null?null:profileResponse.get("nickname").toString()); //프로필 이미지 허용 x시 null로 넘기기. + } + + public String withdrawKakao(String accessToken){ + ResponseEntity responseData = requestKakaoServer(accessToken, Strategy.WITHDRAWAL); + ObjectMapper objectMapper = new ObjectMapper(); + HashMap profileResponse = (HashMap)objectMapper.convertValue( responseData.getBody(),Map.class); + return profileResponse.get("id").toString(); + } + + private ResponseEntity requestKakaoServer(String accessToken, Strategy strategy){ RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.add("Authorization","Bearer "+ accessToken); + HttpEntity httpEntity = new HttpEntity<>(headers); ResponseEntity responseData; - responseData = restTemplate.postForEntity(KAKAO_URL,httpEntity,Object.class); - ObjectMapper objectMapper = new ObjectMapper(); - HashMap profileResponse = (HashMap)objectMapper.convertValue( responseData.getBody(),Map.class).get("properties"); - return LoginResult.of(objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString(), profileResponse==null?null:profileResponse.get("profile_image").toString()); //프로필 이미지 허용 x시 null로 넘기기. + switch (strategy){ + case WITHDRAWAL -> { + return restTemplate.postForEntity(KAKAO_WITHDRAW,httpEntity,Object.class); + } + case LOGIN -> { + return restTemplate.postForEntity(KAKAO_URL, httpEntity, Object.class); + } + } + throw new UnprocessableEntityException(Error.UNPROCESSABLE_KAKAO_SERVER_EXCEPTION, Error.UNPROCESSABLE_KAKAO_SERVER_EXCEPTION.getMessage()); } + } diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/LoginResult.java b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/LoginResult.java index a872aba..556e48c 100644 --- a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/LoginResult.java +++ b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/LoginResult.java @@ -1,7 +1,7 @@ package com.app.toaster.service.auth.kakao; -public record LoginResult(String id, String profile) { - public static LoginResult of(String id, String profile){ - return new LoginResult(id,profile); +public record LoginResult(String id, String profile, String nickname) { + public static LoginResult of(String id, String profile,String nickname){ + return new LoginResult(id,profile,nickname); } } diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/Strategy.java b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/Strategy.java new file mode 100644 index 0000000..bfcdf39 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/Strategy.java @@ -0,0 +1,6 @@ +package com.app.toaster.service.auth.kakao; + +public enum Strategy { + WITHDRAWAL, + LOGIN +} diff --git a/linkmind/src/main/java/com/app/toaster/service/category/CategoryService.java b/linkmind/src/main/java/com/app/toaster/service/category/CategoryService.java index bf433d3..c702b24 100644 --- a/linkmind/src/main/java/com/app/toaster/service/category/CategoryService.java +++ b/linkmind/src/main/java/com/app/toaster/service/category/CategoryService.java @@ -36,7 +36,7 @@ public class CategoryService { private final CategoryRepository categoryRepository; private final ToastRepository toastRepository; - private final static int MAX_CATERGORY_NUMBER = 50; + private final static int MAX_CATERGORY_NUMBER = 15; private final TimerRepository timerRepository; @Transactional @@ -48,7 +48,7 @@ public void createCategory(final Long userId, final CreateCategoryDto createCate val categoryNum= categoryRepository.findAllByUser(presentUser).size(); - if(categoryNum >= MAX_CATERGORY_NUMBER){ + if(categoryNum > MAX_CATERGORY_NUMBER){ throw new CustomException(Error.UNPROCESSABLE_ENTITY_CREATE_CLIP_EXCEPTION, Error.UNPROCESSABLE_ENTITY_CREATE_CLIP_EXCEPTION.getMessage()); } diff --git a/linkmind/src/main/java/com/app/toaster/service/fcm/FCMService.java b/linkmind/src/main/java/com/app/toaster/service/fcm/FCMService.java deleted file mode 100644 index a489e49..0000000 --- a/linkmind/src/main/java/com/app/toaster/service/fcm/FCMService.java +++ /dev/null @@ -1,213 +0,0 @@ -package com.app.toaster.service.fcm; - -import com.app.toaster.controller.request.fcm.FCMPushRequestDto; -import com.app.toaster.domain.FCMMessage; -import com.app.toaster.domain.User; -import com.app.toaster.exception.Error; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.messaging.BatchResponse; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.MulticastMessage; -import com.google.firebase.messaging.Notification; -import com.google.firebase.messaging.TopicManagementResponse; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - - -@Slf4j -@Service -@RequiredArgsConstructor -public class FCMService { - - private final ObjectMapper objectMapper; // FCM의 body 형태에 따라 생성한 값을 문자열로 저장하기 위한 Mapper 클래스 - - @Value("${fcm.key.path}") - private String SERVICE_ACCOUNT_JSON; - @Value("${fcm.api.url}") - private String FCM_API_URL; - @Value("${fcm.topic}") - private String topic; - - /** - * 단일 기기 - * - Firebase에 메시지를 수신하는 함수 (헤더와 바디 직접 만들기) - */ - @Transactional - public String pushAlarm(FCMPushRequestDto request) throws IOException { - - String message = makeSingleMessage(request); - sendPushMessage(message); - return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken(); - } - - /** - * 다수 기기 - * - Firebase에 메시지를 수신하는 함수 (동일한 메시지를 2명 이상의 유저에게 발송) - */ - public String multipleSendByToken(FCMPushRequestDto request, List userList) { - - // User 리스트에서 FCM 토큰만 꺼내와서 리스트로 저장 - List tokenList = userList.stream() - .map(User::getFcmToken).toList(); - - // 2명만 있다고 가정 - log.info("tokenList: {}🌈, {}🌈",tokenList.get(0), tokenList.get(1)); - - MulticastMessage message = makeMultipleMessage(request, tokenList); - - try { - BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message); - log.info("다수 기기 알림 전송 성공 ! successCount: " + response.getSuccessCount() + " messages were sent successfully"); - log.info("알림 전송: {}", response.getResponses().toString()); - - return "알림을 성공적으로 전송했습니다. \ntargetUserId = 1." + tokenList.get(0) + ", \n\n2." + tokenList.get(1); - } catch (FirebaseMessagingException e) { - log.error("다수기기 푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage()); - throw new IllegalArgumentException(Error.FAIL_TO_SEND_PUSH_ALARM.getMessage()); - } - } - - /** - * 주제 구독 등록 및 취소 - * - 특정 타깃 토큰 없이 해당 주제를 구독한 모든 유저에 푸시 전송 - */ - @Transactional - public String pushTopicAlarm(FCMPushRequestDto request) throws IOException { - - String message = makeTopicMessage(request); - sendPushMessage(message); - return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken(); - } - - // Topic 구독 설정 - application.yml에서 topic명 관리 - // 단일 요청으로 최대 1000개의 기기를 Topic에 구독 등록 및 취소할 수 있다. - - public void subscribe() throws FirebaseMessagingException { - // These registration tokens come from the client FCM SDKs. - List registrationTokens = Arrays.asList( - "YOUR_REGISTRATION_TOKEN_1", - // ... - "YOUR_REGISTRATION_TOKEN_n" - ); - - // Subscribe the devices corresponding to the registration tokens to the topic. - TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopic( - registrationTokens, topic); - - log.info(response.getSuccessCount() + " tokens were subscribed successfully"); - } - - // Topic 구독 취소 - public void unsubscribe() throws FirebaseMessagingException { - // These registration tokens come from the client FCM SDKs. - List registrationTokens = Arrays.asList( - "YOUR_REGISTRATION_TOKEN_1", - // ... - "YOUR_REGISTRATION_TOKEN_n" - ); - - // Unsubscribe the devices corresponding to the registration tokens from the topic. - TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopic( - registrationTokens, topic); - - log.info(response.getSuccessCount() + " tokens were unsubscribed successfully"); - } - - // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [단일 기기] - - private String makeSingleMessage(FCMPushRequestDto request) throws JsonProcessingException { - - FCMMessage fcmMessage = FCMMessage.builder() - .message(FCMMessage.Message.builder() - .token(request.getTargetToken()) // 1:1 전송 시 반드시 필요한 대상 토큰 설정 - .notification(FCMMessage.Notification.builder() - .title(request.getTitle()) - .body(request.getBody()) - .image(request.getImage()) - .build()) - .build() - ).validateOnly(false) - .build(); - - return objectMapper.writeValueAsString(fcmMessage); - } - - // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [주제 구독] - private String makeTopicMessage(FCMPushRequestDto request) throws JsonProcessingException { - - FCMMessage fcmMessage = FCMMessage.builder() - .message(FCMMessage.Message.builder() - .topic(topic) // 토픽 구독에서 반드시 필요한 설정 (token 지정 x) - .notification(FCMMessage.Notification.builder() - .title(request.getTitle()) - .body(request.getBody()) - .image(request.getImage()) - .build()) - .build() - ).validateOnly(false) - .build(); - - return objectMapper.writeValueAsString(fcmMessage); - } - - // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [다수 기기] - private static MulticastMessage makeMultipleMessage(FCMPushRequestDto request, List tokenList) { - MulticastMessage message = MulticastMessage.builder() - .setNotification(Notification.builder() - .setTitle(request.getTitle()) - .setBody(request.getBody()) - .setImage(request.getImage()) - .build()) - .addAllTokens(tokenList) - .build(); - - log.info("message: {}", request.getTitle() +" "+ request.getBody()); - return message; - } - - // 실제 파이어베이스 서버로 푸시 메시지를 전송하는 메서드 - private void sendPushMessage(String message) throws IOException { - - OkHttpClient client = new OkHttpClient(); - RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); - Request httpRequest = new Request.Builder() - .url(FCM_API_URL) - .post(requestBody) - .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) - .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") - .build(); - - Response response = client.newCall(httpRequest).execute(); - - log.info("단일 기기 알림 전송 성공 ! successCount: 1 messages were sent successfully"); - log.info("알림 전송: {}", response.body().string()); - } - - // Firebase에서 Access Token 가져오기 - private String getAccessToken() throws IOException { - - GoogleCredentials googleCredentials = GoogleCredentials - .fromStream(new ClassPathResource(SERVICE_ACCOUNT_JSON).getInputStream()) - .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); - googleCredentials.refreshIfExpired(); - log.info("getAccessToken() - googleCredentials: {} ", googleCredentials.getAccessToken().getTokenValue()); - - return googleCredentials.getAccessToken().getTokenValue(); - } -} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/service/timer/TimerService.java b/linkmind/src/main/java/com/app/toaster/service/timer/TimerService.java index 8d63eb1..5f30cb4 100644 --- a/linkmind/src/main/java/com/app/toaster/service/timer/TimerService.java +++ b/linkmind/src/main/java/com/app/toaster/service/timer/TimerService.java @@ -1,6 +1,5 @@ package com.app.toaster.service.timer; -import com.app.toaster.controller.request.fcm.FCMPushRequestDto; import com.app.toaster.controller.request.timer.CreateTimerRequestDto; import com.app.toaster.controller.request.timer.UpdateTimerCommentDto; import com.app.toaster.controller.request.timer.UpdateTimerDateTimeDto; @@ -16,15 +15,15 @@ import com.app.toaster.exception.model.ForbiddenException; import com.app.toaster.exception.model.NotFoundException; import com.app.toaster.exception.model.UnauthorizedException; +import com.app.toaster.external.client.fcm.FCMPushRequestDto; +import com.app.toaster.external.client.fcm.FCMService; import com.app.toaster.infrastructure.CategoryRepository; import com.app.toaster.infrastructure.TimerRepository; import com.app.toaster.infrastructure.UserRepository; -import com.app.toaster.service.fcm.FCMService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; import java.time.DayOfWeek; import java.time.Duration; import java.time.LocalDateTime; @@ -40,6 +39,8 @@ public class TimerService { private final CategoryRepository categoryRepository; private final TimerRepository timerRepository; + private final FCMService fcmService; + private final Locale locale = Locale.KOREA; @@ -76,6 +77,14 @@ public void createTimer(Long userId, CreateTimerRequestDto createTimerRequestDto .comment(comment) .build(); + if (reminder.getRemindDates().contains(LocalDateTime.now().getDayOfWeek().getValue())) + if(reminder.getRemindTime().isAfter(LocalTime.now())){ + String cronExpression = String.format("0 %s %s * * ?", reminder.getRemindTime().getMinute(),reminder.getRemindTime().getHour()); + + fcmService.schedulePushAlarm(cronExpression, reminder.getId()); + System.out.println("test 성공"); + } + timerRepository.save(reminder); } @@ -95,11 +104,22 @@ public void updateTimerDatetime(Long userId, Long timerId, UpdateTimerDateTimeDt .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_TIMER, Error.NOT_FOUND_TIMER.getMessage())); if (!presentUser.equals(reminder.getUser())){ - throw new ForbiddenException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); + throw new CustomException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); } reminder.updateRemindDates(updateTimerDateTimeDto.remindDates()); reminder.updateRemindTime(updateTimerDateTimeDto.remindTime()); + LocalDateTime now = LocalDateTime.now(); + + // 바뀐 타이머가 오늘 이후 설정되어있으면 새로운 schedule 추가 + if (reminder.getRemindDates().contains(now.getDayOfWeek().getValue())) + if(reminder.getRemindTime().isAfter(LocalTime.now())){ + String cronExpression = String.format("0 %s %s * * ?", reminder.getRemindTime().getMinute(),reminder.getRemindTime().getHour()); + + fcmService.schedulePushAlarm(cronExpression, reminder.getId()); + } + + } @Transactional @@ -110,16 +130,15 @@ public void updateTimerComment(Long userId, Long timerId, UpdateTimerCommentDto .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_TIMER, Error.NOT_FOUND_TIMER.getMessage())); if (!presentUser.equals(reminder.getUser())){ - throw new ForbiddenException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); + throw new UnauthorizedException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); } reminder.updateComment(updateTimerCommentDto.newComment()); } - @Transactional - public void deleteTimer(Long userId, Long timerId){ + public void changeAlarm(Long userId, Long timerId){ User presentUser = findUser(userId); Reminder reminder = timerRepository.findById(timerId) @@ -129,24 +148,25 @@ public void deleteTimer(Long userId, Long timerId){ throw new ForbiddenException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); } - timerRepository.delete(reminder); + reminder.changeAlarm(); } + @Transactional - public void changeAlarm(Long userId, Long timerId){ + public void deleteTimer(Long userId, Long timerId){ User presentUser = findUser(userId); Reminder reminder = timerRepository.findById(timerId) .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_TIMER, Error.NOT_FOUND_TIMER.getMessage())); if (!presentUser.equals(reminder.getUser())){ - throw new ForbiddenException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); + throw new UnauthorizedException(Error.INVALID_USER_ACCESS, Error.INVALID_USER_ACCESS.getMessage()); } - reminder.changeAlarm(); + timerRepository.delete(reminder); } - public GetTimerPageResponseDto getTimerPage(Long userId){ + public GetTimerPageResponseDto getTimerPage(Long userId) { User presentUser = findUser(userId); ArrayList reminders = timerRepository.findAllByUser(presentUser); @@ -184,44 +204,31 @@ private User findUser(Long userId){ private boolean isCompletedTimer(Reminder reminder){ // 현재 시간 LocalDateTime now = LocalDateTime.now(); - List resultDateTimeList = new ArrayList<>(); - - // 주어진 요일 인덱스에 대해 LocalDateTime 생성 및 리스트에 추가 - for (Integer dayOfWeekIndex : reminder.getRemindDates()) { - DayOfWeek currentDayOfWeek = now.getDayOfWeek(); + LocalTime futureDateTime = LocalTime.from(now.plusHours(1)); + LocalTime pastDateTime = LocalTime.from(now.minusHours(1)); - LocalDateTime newDateTime = now.plusDays(dayOfWeekIndex - currentDayOfWeek.getValue()); - newDateTime = LocalDateTime.of(newDateTime.toLocalDate(), reminder.getRemindTime()); - - resultDateTimeList.add(newDateTime); - - if (currentDayOfWeek.getValue() == 1 || currentDayOfWeek.getValue() == 7) { - resultDateTimeList.add(currentDayOfWeek.getValue() == 1 ? newDateTime.minusWeeks(1) : newDateTime.plusWeeks(1)); - } + if (reminder.getRemindDates().contains(now.getDayOfWeek().getValue())) { + LocalTime reminderTime = reminder.getRemindTime(); + return !reminderTime.isBefore(pastDateTime) && !reminderTime.isAfter(futureDateTime) && reminder.getIsAlarm(); } - for(LocalDateTime remind : resultDateTimeList){ - Duration duration = Duration.between(now, remind); - if (Math.abs(duration.toMinutes()) <= TimeIntervalInHours) { - return true; - } - } return false; } // 완료된 타이머 날짜,시간 포맷 private CompletedTimerDto createCompletedTimerDto(Reminder reminder) { - String time = reminder.getRemindTime().format(DateTimeFormatter.ofPattern("a hh:mm",locale)); - String date = getRemindDate(reminder); + String time = reminder.getRemindTime().format(DateTimeFormatter.ofPattern("a hh:mm")); + String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("E요일")); return CompletedTimerDto.of(reminder, time, date); } // 대기중인 타이머 날짜,시간 포맷 private WaitingTimerDto createWaitingTimerDto(Reminder reminder) { + LocalDateTime now = LocalDateTime.now(); String time = (reminder.getRemindTime().getMinute() == 0) - ? reminder.getRemindTime().format(DateTimeFormatter.ofPattern("a h시",locale)) - : reminder.getRemindTime().format(DateTimeFormatter.ofPattern("a h시 mm분",locale)); + ? reminder.getRemindTime().format(DateTimeFormatter.ofPattern("a h시")) + : reminder.getRemindTime().format(DateTimeFormatter.ofPattern("a h시 mm분")); String dates = reminder.getRemindDates().stream() .map(this::mapIndexToDayString) @@ -233,34 +240,9 @@ private WaitingTimerDto createWaitingTimerDto(Reminder reminder) { // 인덱스로 요일 알아내기 private String mapIndexToDayString(int index) { DayOfWeek dayOfWeek = DayOfWeek.of(index); - String dayName = dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL,locale); + String dayName = dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, Locale.getDefault()); return dayName.substring(0, 1); } - private String getRemindDate(Reminder reminder){ - LocalDateTime remindDate = LocalDateTime.now(); - LocalTime now = LocalTime.now(); - - // 비교할 시간 범위를 설정합니다. - LocalTime startTime = LocalTime.of(23, 0); // 11시 - LocalTime endTime = LocalTime.of(1, 0); // 1시 - - // 현재 시간이 11시 이후 또는 1시 이전인지 확인합니다. - if (reminder.getRemindTime().isAfter(startTime) && now.isBefore(startTime)) { - if(remindDate.getDayOfWeek().getValue() == 1 ) - remindDate = remindDate.plusDays(6); - else - remindDate = remindDate.minusDays(1); - } - if (reminder.getRemindTime().isBefore(endTime) && now.isAfter(endTime)) { - if(remindDate.getDayOfWeek().getValue() == 7 ) - remindDate = remindDate.minusDays(6); - else - remindDate = remindDate.plusDays(1); - } - - return remindDate.format(DateTimeFormatter.ofPattern("E요일",locale)); - } - }