diff --git a/.gitignore b/.gitignore index 3ffb54479..4360b70da 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ src/main/generated/ ## Apple Login Key File ## *.p8 + +/src/main/resources/websoso-fcm.json diff --git a/build.gradle b/build.gradle index 5ef33187d..37daf1463 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,9 @@ dependencies { //Apple Login implementation 'com.nimbusds:nimbus-jose-jwt:3.10' + + // FCM + implementation 'com.google.firebase:firebase-admin:8.1.0' } tasks.named('test') { diff --git a/src/main/java/org/websoso/WSSServer/controller/UserController.java b/src/main/java/org/websoso/WSSServer/controller/UserController.java index 80923d6d6..6575bb0d3 100644 --- a/src/main/java/org/websoso/WSSServer/controller/UserController.java +++ b/src/main/java/org/websoso/WSSServer/controller/UserController.java @@ -22,6 +22,7 @@ import org.websoso.WSSServer.dto.feed.UserFeedsGetResponse; import org.websoso.WSSServer.dto.user.EditMyInfoRequest; import org.websoso.WSSServer.dto.user.EditProfileStatusRequest; +import org.websoso.WSSServer.dto.user.FCMTokenRequest; import org.websoso.WSSServer.dto.user.LoginResponse; import org.websoso.WSSServer.dto.user.MyProfileResponse; import org.websoso.WSSServer.dto.user.NicknameValidation; @@ -209,4 +210,14 @@ public ResponseEntity getUserIdAndNicknameAndGender(P .status(OK) .body(userService.getUserIdAndNicknameAndGender(user)); } + + @PostMapping("/fcm-token") + public ResponseEntity registerFCMToken(Principal principal, + @Valid @RequestBody FCMTokenRequest fcmTokenRequest) { + User user = userService.getUserOrException(Long.valueOf(principal.getName())); + userService.registerFCMToken(user, fcmTokenRequest.fcmToken()); + return ResponseEntity + .status(OK) + .build(); + } } diff --git a/src/main/java/org/websoso/WSSServer/domain/User.java b/src/main/java/org/websoso/WSSServer/domain/User.java index 3fccfe5e2..eb4b28ddc 100644 --- a/src/main/java/org/websoso/WSSServer/domain/User.java +++ b/src/main/java/org/websoso/WSSServer/domain/User.java @@ -49,6 +49,9 @@ public class User { @Column(nullable = false) private String socialId; + @Column + private String fcmToken; + @Column(columnDefinition = "varchar(10)", nullable = false) private String nickname; //TODO 일부 특수문자 제외, 앞뒤 공백 불가능 @@ -136,4 +139,8 @@ public void editMyInfo(EditMyInfoRequest editMyInfoRequest) { this.gender = Gender.valueOf(editMyInfoRequest.gender()); this.birth = Year.of(editMyInfoRequest.birth()); } + + public void updateFCMToken(String fcmToken) { + this.fcmToken = fcmToken; + } } diff --git a/src/main/java/org/websoso/WSSServer/dto/user/FCMTokenRequest.java b/src/main/java/org/websoso/WSSServer/dto/user/FCMTokenRequest.java new file mode 100644 index 000000000..5c38f8345 --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/dto/user/FCMTokenRequest.java @@ -0,0 +1,9 @@ +package org.websoso.WSSServer.dto.user; + +import jakarta.validation.constraints.NotBlank; + +public record FCMTokenRequest( + @NotBlank(message = "FCM Token 값은 null 이거나, 공백일 수 없습니다.") + String fcmToken +) { +} diff --git a/src/main/java/org/websoso/WSSServer/notification/FCMConfig.java b/src/main/java/org/websoso/WSSServer/notification/FCMConfig.java new file mode 100644 index 000000000..5754cbbb7 --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/notification/FCMConfig.java @@ -0,0 +1,41 @@ +package org.websoso.WSSServer.notification; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +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; + +@Slf4j +@Configuration +public class FCMConfig { + + @Value("${fcm.key-path}") + private String firebaseConfigPath; + + @Bean + public FirebaseMessaging firebaseMessaging() { + try { + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()); + + FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(googleCredentials) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(firebaseOptions); + } + + return FirebaseMessaging.getInstance(); + } catch (IOException e) { + log.error("[FirebaseMessaging] Failed to initialize FirebaseMessaging", e); + throw new IllegalStateException("Failed to initialize FirebaseMessaging due to firebase key file", e); + } + } +} diff --git a/src/main/java/org/websoso/WSSServer/notification/FCMService.java b/src/main/java/org/websoso/WSSServer/notification/FCMService.java new file mode 100644 index 000000000..edd24b764 --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/notification/FCMService.java @@ -0,0 +1,83 @@ +package org.websoso.WSSServer.notification; + +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.ApsAlert; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.websoso.WSSServer.notification.dto.FCMMessageRequest; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FCMService { + + private final FirebaseMessaging firebaseMessaging; + + public void sendPushMessage(String targetFCMToken, FCMMessageRequest fcmMessageRequest) { + Message message = createMessage(targetFCMToken, fcmMessageRequest); + + try { + firebaseMessaging.send(message); + } catch (FirebaseMessagingException e) { + log.error("[FirebaseMessagingException] exception ", e); + // TODO: discord로 알림 추가 혹은 후속 작업 논의 후 추가 + } + } + + private Message createMessage(String targetFCMToken, FCMMessageRequest fcmMessageRequest) { + ApnsConfig apnsConfig = ApnsConfig.builder() + .setAps(Aps.builder() + .setAlert(ApsAlert.builder() + .setTitle(fcmMessageRequest.title()) + .setBody(fcmMessageRequest.body()) + .build()) + .build()) + .build(); + + return Message.builder() + .setToken(targetFCMToken) + .putData("title", fcmMessageRequest.title()) + .putData("body", fcmMessageRequest.body()) + .putData("feedId", fcmMessageRequest.feedId()) + .putData("view", fcmMessageRequest.view()) + .setApnsConfig(apnsConfig) + .build(); + } + + public void sendMulticastPushMessage(List targetFCMTokens, FCMMessageRequest fcmMessageRequest) { + MulticastMessage multicastMessage = createMulticastMessage(targetFCMTokens, fcmMessageRequest); + try { + firebaseMessaging.sendMulticast(multicastMessage); + } catch (Exception e) { + log.error("[FirebaseMessagingException] exception ", e); + // TODO: discord로 알림 추가 혹은 후속 작업 논의 후 추가 + } + } + + private MulticastMessage createMulticastMessage(List targetFCMTokens, FCMMessageRequest fcmMessageRequest) { + ApnsConfig apnsConfig = ApnsConfig.builder() + .setAps(Aps.builder() + .setAlert(ApsAlert.builder() + .setTitle(fcmMessageRequest.title()) + .setBody(fcmMessageRequest.body()) + .build()) + .build()) + .build(); + + return MulticastMessage.builder() + .addAllTokens(targetFCMTokens) + .putData("title", fcmMessageRequest.title()) + .putData("body", fcmMessageRequest.body()) + .putData("feedId", fcmMessageRequest.feedId()) + .putData("view", fcmMessageRequest.view()) + .setApnsConfig(apnsConfig) + .build(); + } +} diff --git a/src/main/java/org/websoso/WSSServer/notification/dto/FCMMessageRequest.java b/src/main/java/org/websoso/WSSServer/notification/dto/FCMMessageRequest.java new file mode 100644 index 000000000..1752f57ad --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/notification/dto/FCMMessageRequest.java @@ -0,0 +1,13 @@ +package org.websoso.WSSServer.notification.dto; + +public record FCMMessageRequest( + String title, + String body, + String feedId, + String view +) { + + public static FCMMessageRequest of(String title, String body, String feedId, String view) { + return new FCMMessageRequest(title, body, feedId, view); + } +} diff --git a/src/main/java/org/websoso/WSSServer/service/CommentService.java b/src/main/java/org/websoso/WSSServer/service/CommentService.java index 3fc89c7e0..285f33e03 100644 --- a/src/main/java/org/websoso/WSSServer/service/CommentService.java +++ b/src/main/java/org/websoso/WSSServer/service/CommentService.java @@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import org.websoso.WSSServer.domain.Comment; import org.websoso.WSSServer.domain.Feed; +import org.websoso.WSSServer.domain.Novel; import org.websoso.WSSServer.domain.User; import org.websoso.WSSServer.domain.common.DiscordWebhookMessage; import org.websoso.WSSServer.domain.common.ReportedType; @@ -22,6 +23,8 @@ import org.websoso.WSSServer.dto.comment.CommentsGetResponse; import org.websoso.WSSServer.dto.user.UserBasicInfo; import org.websoso.WSSServer.exception.exception.CustomCommentException; +import org.websoso.WSSServer.notification.FCMService; +import org.websoso.WSSServer.notification.dto.FCMMessageRequest; import org.websoso.WSSServer.repository.CommentRepository; @Service @@ -35,9 +38,63 @@ public class CommentService { private final BlockService blockService; private final ReportedCommentService reportedCommentService; private final MessageService messageService; + private final FCMService fcmService; + private final NovelService novelService; - public void createComment(Long userId, Feed feed, String commentContent) { - commentRepository.save(Comment.create(userId, feed, commentContent)); + public void createComment(User user, Feed feed, String commentContent) { + commentRepository.save(Comment.create(user.getUserId(), feed, commentContent)); + sendCommentPushMessageToFeedOwner(user, feed); + sendCommentPushMessageToCommenters(user, feed); + } + + private void sendCommentPushMessageToFeedOwner(User user, Feed feed) { + if (isUserCommentOwner(user, feed.getUser())) { + return; + } + + FCMMessageRequest fcmMessageRequest = FCMMessageRequest.of( + createNotificationTitle(feed), + String.format("%s님이 내 수다글에 댓글을 남겼어요", user.getNickname()), + String.valueOf(feed.getFeedId()), + "feedDetail" + ); + fcmService.sendPushMessage( + feed.getUser().getFcmToken(), + fcmMessageRequest + ); + } + + private String createNotificationTitle(Feed feed) { + if (feed.getNovelId() == null) { + String feedContent = feed.getFeedContent(); + return feedContent.length() <= 12 + ? feedContent + : "'" + feedContent.substring(0, 12) + "...'"; + } + Novel novel = novelService.getNovelOrException(feed.getNovelId()); + return novel.getTitle(); + } + + private void sendCommentPushMessageToCommenters(User user, Feed feed) { + List commentersUserId = feed.getComments() + .stream() + .map(Comment::getUserId) + .filter(userId -> !userId.equals(user.getUserId())) + .distinct() + .map(userService::getUserOrException) + .map(User::getFcmToken) + .toList(); + + FCMMessageRequest fcmMessageRequest = FCMMessageRequest.of( + createNotificationTitle(feed), + "내가 댓글 단 수다글에 또 다른 댓글이 달렸어요.", + String.valueOf(feed.getFeedId()), + "feedDetail" + ); + fcmService.sendMulticastPushMessage( + commentersUserId, + fcmMessageRequest + ); } public void updateComment(Long userId, Feed feed, Long commentId, String commentContent) { diff --git a/src/main/java/org/websoso/WSSServer/service/FeedService.java b/src/main/java/org/websoso/WSSServer/service/FeedService.java index 7aed0d1fa..66fc8853f 100644 --- a/src/main/java/org/websoso/WSSServer/service/FeedService.java +++ b/src/main/java/org/websoso/WSSServer/service/FeedService.java @@ -44,6 +44,8 @@ import org.websoso.WSSServer.dto.user.UserBasicInfo; import org.websoso.WSSServer.exception.exception.CustomFeedException; import org.websoso.WSSServer.exception.exception.CustomUserException; +import org.websoso.WSSServer.notification.FCMService; +import org.websoso.WSSServer.notification.dto.FCMMessageRequest; import org.websoso.WSSServer.repository.AvatarRepository; import org.websoso.WSSServer.repository.FeedRepository; import org.websoso.WSSServer.repository.NovelRepository; @@ -70,6 +72,7 @@ public class FeedService { private final MessageService messageService; private final UserService userService; private final NovelRepository novelRepository; + private final FCMService fcmService; public void createFeed(User user, FeedCreateRequest request) { if (request.novelId() != null) { @@ -115,6 +118,36 @@ public void likeFeed(User user, Long feedId) { if (isPopularFeed) { popularFeedService.createPopularFeed(feed); } + + sendLikePushMessage(user, feed); + } + + private void sendLikePushMessage(User liker, Feed feed) { + if (liker.equals(feed.getUser())) { + return; + } + + FCMMessageRequest fcmMessageRequest = FCMMessageRequest.of( + createNotificationTitle(feed), + String.format("%s님이 내 수다글을 좋아해요.", liker.getNickname()), + String.valueOf(feed.getFeedId()), + "feedDetail" + ); + fcmService.sendPushMessage( + feed.getUser().getFcmToken(), + fcmMessageRequest + ); + } + + private String createNotificationTitle(Feed feed) { + if (feed.getNovelId() == null) { + String feedContent = feed.getFeedContent(); + return feedContent.length() <= 12 + ? feedContent + : "'" + feedContent.substring(0, 12) + "...'"; + } + Novel novel = novelService.getNovelOrException(feed.getNovelId()); + return novel.getTitle(); } public void unLikeFeed(User user, Long feedId) { @@ -154,7 +187,7 @@ public FeedsGetResponse getFeeds(User user, String category, Long lastFeedId, in public void createComment(User user, Long feedId, CommentCreateRequest request) { Feed feed = getFeedOrException(feedId); validateFeedAccess(feed, user); - commentService.createComment(user.getUserId(), feed, request.commentContent()); + commentService.createComment(user, feed, request.commentContent()); } public void updateComment(User user, Long feedId, Long commentId, CommentUpdateRequest request) { diff --git a/src/main/java/org/websoso/WSSServer/service/PopularFeedService.java b/src/main/java/org/websoso/WSSServer/service/PopularFeedService.java index 1498eb647..2ed5da2ec 100644 --- a/src/main/java/org/websoso/WSSServer/service/PopularFeedService.java +++ b/src/main/java/org/websoso/WSSServer/service/PopularFeedService.java @@ -5,10 +5,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.websoso.WSSServer.domain.Feed; +import org.websoso.WSSServer.domain.Novel; import org.websoso.WSSServer.domain.PopularFeed; import org.websoso.WSSServer.domain.User; import org.websoso.WSSServer.dto.popularFeed.PopularFeedGetResponse; import org.websoso.WSSServer.dto.popularFeed.PopularFeedsGetResponse; +import org.websoso.WSSServer.notification.FCMService; +import org.websoso.WSSServer.notification.dto.FCMMessageRequest; import org.websoso.WSSServer.repository.PopularFeedRepository; @Service @@ -17,11 +20,43 @@ public class PopularFeedService { private final PopularFeedRepository popularFeedRepository; + private final NovelService novelService; + private final FCMService fcmService; public void createPopularFeed(Feed feed) { if (!popularFeedRepository.existsByFeed(feed)) { popularFeedRepository.save(PopularFeed.create(feed)); + + sendPopularFeedPushMessage(feed); + } + } + + private void sendPopularFeedPushMessage(Feed feed) { + FCMMessageRequest fcmMessageRequest = FCMMessageRequest.of( + "지금 뜨는 수다글 등극\uD83D\uDE4C", + createNotificationBody(feed), + String.valueOf(feed.getFeedId()), + "feedDetail" + ); + fcmService.sendPushMessage( + feed.getUser().getFcmToken(), + fcmMessageRequest + ); + } + + private String createNotificationBody(Feed feed) { + return String.format("내가 남긴 %s 글이 관심 받고 있어요!", generateNotificationBodyFragment(feed)); + } + + private String generateNotificationBodyFragment(Feed feed) { + if (feed.getNovelId() == null) { + String feedContent = feed.getFeedContent(); + return feedContent.length() <= 12 + ? feedContent + : "'" + feedContent.substring(0, 12) + "...'"; } + Novel novel = novelService.getNovelOrException(feed.getNovelId()); + return String.format("<%s>", novel.getTitle()); } @Transactional(readOnly = true) diff --git a/src/main/java/org/websoso/WSSServer/service/UserService.java b/src/main/java/org/websoso/WSSServer/service/UserService.java index 9fc6b1c2e..931a4557e 100644 --- a/src/main/java/org/websoso/WSSServer/service/UserService.java +++ b/src/main/java/org/websoso/WSSServer/service/UserService.java @@ -241,4 +241,9 @@ public void editMyInfo(User user, EditMyInfoRequest editMyInfoRequest) { public UserIdAndNicknameResponse getUserIdAndNicknameAndGender(User user) { return UserIdAndNicknameResponse.of(user); } + + @Transactional + public void registerFCMToken(User user, String fcmToken) { + user.updateFCMToken(fcmToken); + } }