diff --git a/.gitignore b/.gitignore index be192a0..8ef7f54 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ moodoodle-batch/src/main/resources/*.yml moodoodle-common/src/main/resources/*.yml moodoodle-domain/src/main/resources/*.yml moodoodle-infrastructure/src/main/resources/*.yml +moodoodle-notification/src/main/resources/*.yml \ No newline at end of file diff --git a/moodoodle-api/build.gradle b/moodoodle-api/build.gradle index ae71689..f02fc34 100644 --- a/moodoodle-api/build.gradle +++ b/moodoodle-api/build.gradle @@ -6,6 +6,7 @@ dependencies { implementation project(':moodoodle-common') implementation project(':moodoodle-domain') implementation project(':moodoodle-infrastructure') + implementation project(':moodoodle-notification') // spring boot implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/moodoodle-api/src/main/java/zzangdol/MoodoodleApiApplication.java b/moodoodle-api/src/main/java/zzangdol/MoodoodleApiApplication.java index aa0f041..c86a975 100644 --- a/moodoodle-api/src/main/java/zzangdol/MoodoodleApiApplication.java +++ b/moodoodle-api/src/main/java/zzangdol/MoodoodleApiApplication.java @@ -9,7 +9,8 @@ public class MoodoodleApiApplication { public static void main(String[] args) { - System.setProperty("spring.config.name", "application-api, application-domain, application-infrastructure"); + System.setProperty("spring.config.name", + "application-api, application-domain, application-infrastructure, application-notification"); SpringApplication.run(MoodoodleApiApplication.class, args); } diff --git a/moodoodle-api/src/main/java/zzangdol/user/business/UserFacade.java b/moodoodle-api/src/main/java/zzangdol/user/business/UserFacade.java index 0244452..ccfc687 100644 --- a/moodoodle-api/src/main/java/zzangdol/user/business/UserFacade.java +++ b/moodoodle-api/src/main/java/zzangdol/user/business/UserFacade.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import zzangdol.user.implement.UserCommandService; +import zzangdol.user.presentation.dto.request.PushNotificationRequest; import zzangdol.user.presentation.dto.request.UserInfoUpdateRequest; import zzangdol.user.presentation.dto.response.UserInfoResponse; import zzangdol.user.domain.User; @@ -25,4 +26,8 @@ public boolean withDrawUser(User user) { userCommandService.withDrawUser(user); return true; } + + public void handlePushNotifications(User user, PushNotificationRequest request) { + userCommandService.handlePushNotifications(user, request); + } } diff --git a/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandService.java b/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandService.java index 4bd166c..8d00625 100644 --- a/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandService.java +++ b/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandService.java @@ -1,12 +1,15 @@ package zzangdol.user.implement; import zzangdol.user.domain.User; +import zzangdol.user.presentation.dto.request.PushNotificationRequest; import zzangdol.user.presentation.dto.request.UserInfoUpdateRequest; public interface UserCommandService { User updateUserInfo(User user, UserInfoUpdateRequest request); + User handlePushNotifications(User user, PushNotificationRequest request); + void withDrawUser(User user); } diff --git a/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandServiceImpl.java b/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandServiceImpl.java index 5c8bbde..4afe087 100644 --- a/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandServiceImpl.java +++ b/moodoodle-api/src/main/java/zzangdol/user/implement/UserCommandServiceImpl.java @@ -1,13 +1,17 @@ package zzangdol.user.implement; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import zzangdol.diary.dao.DiaryRepository; +import zzangdol.notification.dao.FcmTokenRepository; +import zzangdol.notification.domain.FcmToken; import zzangdol.report.dao.ReportRepository; import zzangdol.scrap.dao.ScrapRepository; import zzangdol.user.dao.UserRepository; import zzangdol.user.domain.User; +import zzangdol.user.presentation.dto.request.PushNotificationRequest; import zzangdol.user.presentation.dto.request.UserInfoUpdateRequest; @RequiredArgsConstructor @@ -19,6 +23,7 @@ public class UserCommandServiceImpl implements UserCommandService { private final ReportRepository reportRepository; private final DiaryRepository diaryRepository; private final UserRepository userRepository; + private final FcmTokenRepository fcmTokenRepository; @Override public User updateUserInfo(User user, UserInfoUpdateRequest request) { @@ -27,6 +32,40 @@ public User updateUserInfo(User user, UserInfoUpdateRequest request) { return user; } + @Override + public User handlePushNotifications(User user, PushNotificationRequest request) { + user.updatePushNotificationsEnabled(request.getPushNotificationsEnabled()); + + if (Boolean.TRUE.equals(request.getPushNotificationsEnabled())) { + storeFcmToken(request.getFcmToken(), user); + } else { + abortFcmToken(request.getFcmToken(), user); + } + + return userRepository.save(user); + } + + public void storeFcmToken(String token, User user) { + Optional optionalFcmToken = fcmTokenRepository.findByUserAndToken(user, token); + + optionalFcmToken.ifPresentOrElse( + fcmToken -> {}, + () -> fcmTokenRepository.save(FcmToken.builder() + .token(token) + .user(user) + .build()) + ); + } + + public void abortFcmToken(String token, User user) { + Optional optionalFcmToken = fcmTokenRepository.findByUserAndToken(user, token); + + optionalFcmToken.ifPresentOrElse( + fcmTokenRepository::delete, + () -> {} + ); + } + @Override public void withDrawUser(User user) { scrapRepository.deleteByUser(user); diff --git a/moodoodle-api/src/main/java/zzangdol/user/presentation/UserController.java b/moodoodle-api/src/main/java/zzangdol/user/presentation/UserController.java index 7a9da98..bbcdcbd 100644 --- a/moodoodle-api/src/main/java/zzangdol/user/presentation/UserController.java +++ b/moodoodle-api/src/main/java/zzangdol/user/presentation/UserController.java @@ -7,15 +7,18 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import zzangdol.FCMService; import zzangdol.global.annotation.AuthUser; +import zzangdol.response.ResponseDto; import zzangdol.user.business.UserFacade; +import zzangdol.user.domain.User; +import zzangdol.user.presentation.dto.request.PushNotificationRequest; import zzangdol.user.presentation.dto.request.UserInfoUpdateRequest; import zzangdol.user.presentation.dto.response.UserInfoResponse; -import zzangdol.response.ResponseDto; -import zzangdol.user.domain.User; @RequiredArgsConstructor @ApiResponse(responseCode = "2000", description = "성공") @@ -25,6 +28,7 @@ public class UserController { private final UserFacade userFacade; + private final FCMService fcmService; @Operation( summary = "사용자 정보 조회 🔑", @@ -44,6 +48,30 @@ public ResponseDto updateUserInfo(@AuthUser User user, @Reques return ResponseDto.onSuccess(userFacade.updateUserInfo(user, request)); } + @Operation( + summary = "Push 알림 허용 / 거부 🔑", + description = "사용자가 Push 알림을 허용하거나 거부합니다." + ) + @PatchMapping("/push-notifications") + public ResponseDto handlePushNotifications(@AuthUser User user, @RequestBody PushNotificationRequest request) { + userFacade.handlePushNotifications(user, request); + return ResponseDto.onSuccess(); + } + + @Operation( + summary = "테스트 푸시 알림 전송 🔑", + description = "특정 사용자의 FCM 토큰으로 푸시 알림을 즉시 전송합니다." + ) + @PostMapping("/send-test-notification") + public ResponseDto sendTestNotification(@RequestBody PushNotificationRequest request) { + try { + fcmService.sendNotification(request.getFcmToken(), "테스트 title", "테스트 body"); + } catch (Exception e) { + e.printStackTrace(); + } + return ResponseDto.onSuccess(); + } + @Operation( summary = "회원 탈퇴 🔑", description = "현재 로그인된 사용자의 계정을 탈퇴하고, 모든 관련 데이터를 삭제합니다.") diff --git a/moodoodle-api/src/main/java/zzangdol/user/presentation/dto/request/PushNotificationRequest.java b/moodoodle-api/src/main/java/zzangdol/user/presentation/dto/request/PushNotificationRequest.java new file mode 100644 index 0000000..ce3b893 --- /dev/null +++ b/moodoodle-api/src/main/java/zzangdol/user/presentation/dto/request/PushNotificationRequest.java @@ -0,0 +1,13 @@ +package zzangdol.user.presentation.dto.request; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PushNotificationRequest { + + private Boolean pushNotificationsEnabled; + private String fcmToken; + +} diff --git a/moodoodle-domain/src/main/java/zzangdol/notification/dao/FcmTokenRepository.java b/moodoodle-domain/src/main/java/zzangdol/notification/dao/FcmTokenRepository.java new file mode 100644 index 0000000..2f1e7e8 --- /dev/null +++ b/moodoodle-domain/src/main/java/zzangdol/notification/dao/FcmTokenRepository.java @@ -0,0 +1,12 @@ +package zzangdol.notification.dao; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import zzangdol.notification.domain.FcmToken; +import zzangdol.user.domain.User; + +public interface FcmTokenRepository extends JpaRepository { + + Optional findByUserAndToken(User user, String token); + +} diff --git a/moodoodle-domain/src/main/java/zzangdol/notification/domain/FcmToken.java b/moodoodle-domain/src/main/java/zzangdol/notification/domain/FcmToken.java new file mode 100644 index 0000000..4a0e80e --- /dev/null +++ b/moodoodle-domain/src/main/java/zzangdol/notification/domain/FcmToken.java @@ -0,0 +1,36 @@ +package zzangdol.notification.domain; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import zzangdol.user.domain.User; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class FcmToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Builder + public FcmToken(String token, User user) { + this.token = token; + this.user = user; + } +} diff --git a/moodoodle-domain/src/main/java/zzangdol/user/domain/User.java b/moodoodle-domain/src/main/java/zzangdol/user/domain/User.java index 206ef54..82e0834 100644 --- a/moodoodle-domain/src/main/java/zzangdol/user/domain/User.java +++ b/moodoodle-domain/src/main/java/zzangdol/user/domain/User.java @@ -31,6 +31,7 @@ public class User extends BaseTimeEntity implements UserDetails { private String email; private String password; private String nickname; + private boolean pushNotificationsEnabled; private LocalTime notificationTime; private Boolean isRead; @@ -98,4 +99,10 @@ public void updateNotificationTime(LocalTime notificationTime) { public void setRead(Boolean read) { isRead = read; } + + public void updatePushNotificationsEnabled(Boolean pushNotificationsEnabled) { + if (pushNotificationsEnabled != null) { + this.pushNotificationsEnabled = pushNotificationsEnabled; + } + } } diff --git a/moodoodle-notification/build.gradle b/moodoodle-notification/build.gradle new file mode 100644 index 0000000..341a9d6 --- /dev/null +++ b/moodoodle-notification/build.gradle @@ -0,0 +1,21 @@ +dependencies { + implementation project(':moodoodle-domain') + + // spring boot + implementation 'org.springframework.boot:spring-boot-starter' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + implementation 'org.springframework.boot:spring-boot-starter-web' + + // fcm + implementation 'com.google.firebase:firebase-admin:8.0.0' + implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2' + +} + +task copyYML(type: Copy) { + copy { + from '../moodoodle-submodule/notification' + include "*.yml" + into 'src/main/resources' + } +} \ No newline at end of file diff --git a/moodoodle-notification/gradle/wrapper/gradle-wrapper.jar b/moodoodle-notification/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/moodoodle-notification/gradle/wrapper/gradle-wrapper.jar differ diff --git a/moodoodle-notification/gradle/wrapper/gradle-wrapper.properties b/moodoodle-notification/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/moodoodle-notification/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/moodoodle-notification/src/main/java/zzangdol/FCMService.java b/moodoodle-notification/src/main/java/zzangdol/FCMService.java new file mode 100644 index 0000000..c1fc936 --- /dev/null +++ b/moodoodle-notification/src/main/java/zzangdol/FCMService.java @@ -0,0 +1,37 @@ +package zzangdol; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import zzangdol.notification.dao.FcmTokenRepository; +import zzangdol.notification.domain.FcmToken; + +@Slf4j +@RequiredArgsConstructor +@Service +public class FCMService { + + private final FcmTokenRepository fcmTokenRepository; + + public void sendNotification(String token, String title, String body) throws Exception { + Message message = Message.builder() + .putData("title", title) + .putData("body", body) + .setToken(token) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + System.out.println("Successfully sent message: " + response); + } + + public void sendNotificationToAllUsers(String title, String body) throws Exception { + List tokens = fcmTokenRepository.findAll(); + for (FcmToken token : tokens) { + sendNotification(token.getToken(), title, body); + } + } + +} \ No newline at end of file diff --git a/moodoodle-notification/src/main/java/zzangdol/FirebaseConfig.java b/moodoodle-notification/src/main/java/zzangdol/FirebaseConfig.java new file mode 100644 index 0000000..6d26908 --- /dev/null +++ b/moodoodle-notification/src/main/java/zzangdol/FirebaseConfig.java @@ -0,0 +1,34 @@ +package zzangdol; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Base64; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FirebaseConfig { + + @Value("${fcm.key}") + private String fcmJson; + + @PostConstruct + public void initFirebase() { + String base64String = fcmJson; + byte[] decodedBytes = Base64.getDecoder().decode(base64String); + InputStream credentialStream = new ByteArrayInputStream(decodedBytes); + + try { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(credentialStream)) + .build(); + FirebaseApp.initializeApp(options); + } catch (Exception e) { + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/moodoodle-notification/src/main/java/zzangdol/NotificationScheduler.java b/moodoodle-notification/src/main/java/zzangdol/NotificationScheduler.java new file mode 100644 index 0000000..3e95a8e --- /dev/null +++ b/moodoodle-notification/src/main/java/zzangdol/NotificationScheduler.java @@ -0,0 +1,23 @@ +package zzangdol; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class NotificationScheduler { + + private final FCMService fcmService; + + @Scheduled(cron = "0 0 21 * * ?") + public void scheduleDailyNotification() { + try { + String title = "Daily Reminder"; + String body = "This is your daily reminder!"; + fcmService.sendNotificationToAllUsers(title, body); + } catch (Exception e) { + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/moodoodle-notification/src/test/java/zzangdol/moodoodlenotification/MoodoodleNotificationApplicationTests.java b/moodoodle-notification/src/test/java/zzangdol/moodoodlenotification/MoodoodleNotificationApplicationTests.java new file mode 100644 index 0000000..47a305e --- /dev/null +++ b/moodoodle-notification/src/test/java/zzangdol/moodoodlenotification/MoodoodleNotificationApplicationTests.java @@ -0,0 +1,13 @@ +package zzangdol.moodoodlenotification; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class MoodoodleNotificationApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/moodoodle-submodule b/moodoodle-submodule index a0f337d..d8f7373 160000 --- a/moodoodle-submodule +++ b/moodoodle-submodule @@ -1 +1 @@ -Subproject commit a0f337da25451b828a572933d27ffddbd4aafd87 +Subproject commit d8f73730b9bc1e9cc8b7726259cfeb494235f6bf diff --git a/settings.gradle b/settings.gradle index 63fc74a..0235af2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,3 +5,4 @@ include 'moodoodle-common' include 'moodoodle-domain' include 'moodoodle-infrastructure' include 'moodoodle-code-coverage-report' +include 'moodoodle-notification'