diff --git a/.gitignore b/.gitignore index 63de11d..2f82d7f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,11 +36,17 @@ out/ ### VS Code ### .vscode/ +### SECRETS ### application.yml application-local.yml application-dev.yml application-prod.yml application.properties +/src/main/resources/firebase-private-key.json + +### not used ### +spy.log + ### just memo memo.md diff --git a/build.gradle b/build.gradle index d4b6bfb..6377ec0 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,10 @@ dependencies { //p6spy implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.1' + // FCM + implementation("com.google.firebase:firebase-admin:6.8.1") + implementation("com.squareup.okhttp3:okhttp:4.9.1") + runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/shy_polarbear/server/domain/comment/service/CommentService.java b/src/main/java/com/shy_polarbear/server/domain/comment/service/CommentService.java index d11e38f..3331425 100644 --- a/src/main/java/com/shy_polarbear/server/domain/comment/service/CommentService.java +++ b/src/main/java/com/shy_polarbear/server/domain/comment/service/CommentService.java @@ -10,6 +10,8 @@ import com.shy_polarbear.server.domain.comment.repository.CommentRepository; import com.shy_polarbear.server.domain.feed.model.Feed; import com.shy_polarbear.server.domain.feed.service.FeedService; +import com.shy_polarbear.server.domain.notification.service.NotificationService; +import com.shy_polarbear.server.domain.notification.vo.NotificationParams; import com.shy_polarbear.server.domain.user.model.User; import com.shy_polarbear.server.domain.user.service.UserService; import com.shy_polarbear.server.global.common.dto.NoCountPageResponse; @@ -31,6 +33,7 @@ public class CommentService { private final UserService userService; private final FeedService feedService; + private final NotificationService notificationService; private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; @@ -53,10 +56,16 @@ public CommentCreateResponse createComment(Long currentUserId, Long feedId, Comm Long parentId = request.getParentId(); if (Objects.isNull(parentId)) { // 부모 댓글 Comment comment = commentRepository.save(Comment.createComment(user, request.getContent(), feed)); + if (checkIsFeedAuthorNotificationReceiver(feed.getAuthor(), user)) { // FCM 알림 + notificationService.pushMessage(NotificationParams.ofNewFeedComment(feed.getAuthor(), feed, comment)); + } return CommentCreateResponse.ofParent(comment); } else { // 자식 댓글 Comment parent = commentRepository.findById(parentId).orElseThrow(() -> new CommentException(ExceptionStatus.NOT_FOUND_COMMENT)); Comment comment = commentRepository.save(Comment.createChildComment(user, request.getContent(), feed, parent)); + if (checkIsCommentAuthorNotificationReceiver(parent.getAuthor(), user)) { + notificationService.pushMessage(NotificationParams.ofNewChildComment(feed.getAuthor(), feed, comment)); + } return CommentCreateResponse.ofChild(comment); } } @@ -116,4 +125,12 @@ private static void checkIsAuthor(User user, Comment comment) { if (!comment.isAuthor(user.getId())) throw new CommentException(ExceptionStatus.NOT_MY_COMMENT); } + + private static boolean checkIsFeedAuthorNotificationReceiver(User feedAuthor, User commentAuthor) { + return !feedAuthor.getId().equals(commentAuthor.getId()); + } + + private static boolean checkIsCommentAuthorNotificationReceiver(User parentCommentAuthor, User childCommentAuthor) { + return !parentCommentAuthor.getId().equals(childCommentAuthor.getId()); + } } diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/controller/NotificationController.java b/src/main/java/com/shy_polarbear/server/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..0679bea --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/controller/NotificationController.java @@ -0,0 +1,37 @@ +package com.shy_polarbear.server.domain.notification.controller; + +import com.shy_polarbear.server.domain.notification.dto.NotificationReadResponse; +import com.shy_polarbear.server.domain.notification.dto.NotificationResponse; +import com.shy_polarbear.server.domain.notification.service.NotificationService; +import com.shy_polarbear.server.global.auth.security.PrincipalDetails; +import com.shy_polarbear.server.global.common.dto.ApiResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + private final NotificationService notificationService; + + @GetMapping("/users/me") + public ApiResponse> getMyNotifications( + @AuthenticationPrincipal PrincipalDetails principalDetails) { + return ApiResponse.success(notificationService.getMyNotifications(principalDetails.getUser().getId())); + } + + @PutMapping("/{notificationId}/read") + public ApiResponse readNotification( + @PathVariable Long notificationId, + @AuthenticationPrincipal PrincipalDetails principalDetails) { + return ApiResponse.success( + notificationService.readNotification(notificationId, principalDetails.getUser().getId()) + ); + } +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/dto/NotificationReadResponse.java b/src/main/java/com/shy_polarbear/server/domain/notification/dto/NotificationReadResponse.java new file mode 100644 index 0000000..7268864 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/dto/NotificationReadResponse.java @@ -0,0 +1,15 @@ +package com.shy_polarbear.server.domain.notification.dto; + +import com.shy_polarbear.server.domain.notification.model.Notification; +import lombok.Builder; + +@Builder +public record NotificationReadResponse ( + Long notificationId +){ + public static NotificationReadResponse of(Notification notification) { + return NotificationReadResponse.builder() + .notificationId(notification.getId()) + .build(); + } +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/dto/NotificationResponse.java b/src/main/java/com/shy_polarbear/server/domain/notification/dto/NotificationResponse.java new file mode 100644 index 0000000..38f0833 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/dto/NotificationResponse.java @@ -0,0 +1,33 @@ +package com.shy_polarbear.server.domain.notification.dto; + +import com.shy_polarbear.server.domain.notification.model.Notification; +import com.shy_polarbear.server.domain.notification.model.NotificationType; +import lombok.Builder; + +@Builder +public record NotificationResponse ( + Long notificationId, + NotificationType notificationType, + String redirectTarget, + Long redirectTargetId, + String title, + String content, + String createdDate, + boolean isRead +){ + public static NotificationResponse of(Notification notification) { + NotificationType notificationType = notification.getNotificationType(); + String target = notificationType.getRedirectTargetClass().getSimpleName(); + + return NotificationResponse.builder() + .notificationId(notification.getId()) + .notificationType(notificationType) + .redirectTarget(target) + .redirectTargetId(notification.getRedirectTargetId()) + .title(notification.getTitle()) + .content(notification.getContent()) + .createdDate(notification.getCreatedDate()) + .isRead(notification.isRead()) + .build(); + } +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/exception/NotificationException.java b/src/main/java/com/shy_polarbear/server/domain/notification/exception/NotificationException.java new file mode 100644 index 0000000..ccffdc3 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/exception/NotificationException.java @@ -0,0 +1,11 @@ +package com.shy_polarbear.server.domain.notification.exception; + +import com.shy_polarbear.server.global.exception.CustomException; +import com.shy_polarbear.server.global.exception.ExceptionStatus; + +public class NotificationException extends CustomException { + public NotificationException(ExceptionStatus exceptionStatus) { + super(exceptionStatus); + } + +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/model/Notification.java b/src/main/java/com/shy_polarbear/server/domain/notification/model/Notification.java index 3379681..33a5fe5 100644 --- a/src/main/java/com/shy_polarbear/server/domain/notification/model/Notification.java +++ b/src/main/java/com/shy_polarbear/server/domain/notification/model/Notification.java @@ -1,6 +1,5 @@ package com.shy_polarbear.server.domain.notification.model; -import com.shy_polarbear.server.domain.comment.model.Comment; import com.shy_polarbear.server.domain.user.model.User; import com.shy_polarbear.server.global.common.model.BaseEntity; import lombok.AccessLevel; @@ -17,47 +16,48 @@ public class Notification extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "notofocation_id") + @Column(name = "notification_id") private Long id; + + @Column(nullable = false, updatable = false) private String title; + @Column(nullable = false, updatable = false) private String content; + @Column(nullable = false) private boolean isRead; + @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "comment_id") - private Comment comment; + @JoinColumn(name = "user_id", nullable = false) + private User receiver; + @Enumerated(EnumType.STRING) + @Column(nullable = false, updatable = false) private NotificationType notificationType; + @Column(nullable = false, updatable = false) + private Long redirectTargetId; + + @Builder - private Notification(String title, String content, User user, NotificationType notificationType, Comment comment) { + private Notification(String title, String content, User receiver, NotificationType notificationType, Long redirectTargetId) { this.title = title; this.content = content; - this.user = user; - this.comment = comment; + this.receiver = receiver; this.notificationType = notificationType; + this.redirectTargetId = redirectTargetId; } - //알림타입이 댓글/대댓글일 경우 - public static Notification createCommentNotification(String title, String content, User user, NotificationType notificationType, Comment comment) { + public static Notification createNotification(User receiver, String title, String content, NotificationType notificationType, Long redirectTargetId) { return Notification.builder() + .receiver(receiver) .title(title) .content(content) - .user(user) .notificationType(notificationType) - .comment(comment) + .redirectTargetId(redirectTargetId) .build(); } - //알림 타입이 제한일 경우 - public static Notification createLimitNotification(String title, String content, User user) { - return Notification.builder() - .title(title) - .content(content) - .user(user) - .notificationType(NotificationType.LIMIT) - .build(); + public void read() { + this.isRead = true; } } diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationReceiverType.java b/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationReceiverType.java new file mode 100644 index 0000000..9a47ac5 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationReceiverType.java @@ -0,0 +1,5 @@ +package com.shy_polarbear.server.domain.notification.model; + +enum NotificationReceiverType { + COMMON, AUTHOR +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationType.java b/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationType.java index c1926ba..e9444ef 100644 --- a/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationType.java +++ b/src/main/java/com/shy_polarbear/server/domain/notification/model/NotificationType.java @@ -1,5 +1,18 @@ package com.shy_polarbear.server.domain.notification.model; +import com.shy_polarbear.server.domain.comment.model.Comment; +import com.shy_polarbear.server.domain.feed.model.Feed; +import com.shy_polarbear.server.global.common.model.BaseEntity; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter public enum NotificationType { - COMMENT, CHILD_COMMENT, LIMIT + NEW_FEED_COMMENT(NotificationReceiverType.AUTHOR, Feed.class), + NEW_COMMENT_CHILD_COMMENT(NotificationReceiverType.AUTHOR, Feed.class); +// LIMIT(NotificationReceiverType.COMMON, User.class); + + private final NotificationReceiverType notificationReceiverType; + private final Class redirectTargetClass; } diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepository.java b/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..78634a9 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,8 @@ +package com.shy_polarbear.server.domain.notification.repository; + +import com.shy_polarbear.server.domain.notification.model.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom { + +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepositoryCustom.java b/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepositoryCustom.java new file mode 100644 index 0000000..2116ec0 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.shy_polarbear.server.domain.notification.repository; + +import com.shy_polarbear.server.domain.notification.model.Notification; +import java.util.List; +import java.util.Optional; + +public interface NotificationRepositoryCustom { + // 특정 알림 조회 + Optional findByIdAndReceiverId(Long notificationId, Long receiverId); + + // 내 알림 조회 + List findAllByReceiverId(Long userId); + +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepositoryImpl.java b/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepositoryImpl.java new file mode 100644 index 0000000..a32c7fb --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/repository/NotificationRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.shy_polarbear.server.domain.notification.repository; + +import static com.shy_polarbear.server.domain.notification.model.QNotification.notification; + +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.shy_polarbear.server.domain.notification.model.Notification; +import com.shy_polarbear.server.global.common.constants.BusinessLogicConstants; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByReceiverId(Long userId) { + JPAQuery query = queryFactory.selectFrom(notification) + .where(notification.receiver.id.eq(userId)) + .orderBy(notification.id.desc()) + .limit(BusinessLogicConstants.RECENT_NOTIFICATION_LIMIT); + + return query.fetch(); + } + + @Override + public Optional findByIdAndReceiverId(Long notificationId, Long receiverId) { + JPAQuery query = queryFactory.selectFrom(notification) + .where(notification.id.eq(notificationId) + .and(notification.receiver.id.eq(receiverId))); + return Optional.ofNullable(query.fetchOne()); + } + +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/service/FirebaseCloudMessagingService.java b/src/main/java/com/shy_polarbear/server/domain/notification/service/FirebaseCloudMessagingService.java new file mode 100644 index 0000000..a81c287 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/service/FirebaseCloudMessagingService.java @@ -0,0 +1,101 @@ +package com.shy_polarbear.server.domain.notification.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.shy_polarbear.server.domain.notification.exception.NotificationException; +import com.shy_polarbear.server.domain.notification.vo.FcmMessage; +import com.shy_polarbear.server.domain.notification.vo.Message; +import com.shy_polarbear.server.domain.notification.vo.MessageData; +import com.shy_polarbear.server.domain.notification.vo.NotificationParams; +import com.shy_polarbear.server.global.exception.ExceptionStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletableFuture; + + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class FirebaseCloudMessagingService { + private final ObjectMapper objectMapper; + private final JSONParser jsonParser; + + private static final String FCM_PRIVATE_KEY_PATH = "firebase-private-key.json"; + private static final String FIREBASE_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; + // TODO: 요청할 URL 전달 받기 + private static final String PROJECT_ID_URL = "https://fcm.googleapis.com/v1/projects/shypolarbear-199e5\n/messages:send"; + + private String getAccessToken() { // FCM 토큰 발급 받기 + try { + GoogleCredentials credentials = GoogleCredentials + .fromStream(new ClassPathResource(FCM_PRIVATE_KEY_PATH).getInputStream()) + .createScoped(List.of(FIREBASE_SCOPE)); + credentials.refreshIfExpired(); + return credentials.getAccessToken().getTokenValue(); + } catch (IOException e) { + throw new NotificationException(ExceptionStatus.GET_FCM_ACCESS_TOKEN_ERROR); + } + } + + private String buildMessage(String targetToken, NotificationParams params) { + try { + FcmMessage fcmMessage = FcmMessage.builder() + .validate_only(false) + .message(Message.builder() + .token(targetToken) + .data(MessageData.builder() + .title(params.title()) + .body(params.content()) + .redirectTargetId(String.valueOf(params.redirectTargetId())) + .type(params.notificationType().toString()) + .build()) + .build()) + .build(); + + return objectMapper.writeValueAsString(fcmMessage); + } catch (JsonProcessingException e) { + throw new NotificationException(ExceptionStatus.FCM_MESSAGE_JSON_PARSING_ERROR); + } + } + + // TODO: Async + public CompletableFuture sendPushMessage(String fcmToken, NotificationParams notificationParams) { + String message = buildMessage(fcmToken, notificationParams); + String accessToken = getAccessToken(); + + + OkHttpClient okHttpClient = new OkHttpClient(); + Request request = new Request.Builder() + .url(PROJECT_ID_URL) + .addHeader("Authorization", "Bearer " + accessToken) + .addHeader("Content-Type", "application/json; UTF-8") + .post(RequestBody.create(message, MediaType.parse("application/json; charset=urf-8"))) + .build(); + + try (Response response = okHttpClient.newCall(request).execute()) { + + if (!response.isSuccessful() && response.body() != null) { // 비정상적인 응답일 경우, false 반환 + JSONObject responseBody = (JSONObject) jsonParser.parse(response.body().string()); + String errorMessage = ((JSONObject) responseBody.get("error")).get("message").toString(); + log.warn("FCM [sendPushMessage] okHttp response is not OK : {}", errorMessage); + return CompletableFuture.completedFuture(false); + } + + return CompletableFuture.completedFuture(true); + } catch (Exception e) { + log.warn("FCM [sendPushMessage] I/O Exception : {}", e.getMessage()); + throw new NotificationException(ExceptionStatus.SEND_FCM_PUSH_ERROR); + } + } +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/service/NotificationService.java b/src/main/java/com/shy_polarbear/server/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..ab2d28b --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/service/NotificationService.java @@ -0,0 +1,62 @@ +package com.shy_polarbear.server.domain.notification.service; + +import com.shy_polarbear.server.domain.notification.dto.NotificationReadResponse; +import com.shy_polarbear.server.domain.notification.dto.NotificationResponse; +import com.shy_polarbear.server.domain.notification.exception.NotificationException; +import com.shy_polarbear.server.domain.notification.model.Notification; +import com.shy_polarbear.server.domain.notification.repository.NotificationRepository; +import com.shy_polarbear.server.domain.notification.vo.NotificationParams; +import com.shy_polarbear.server.domain.user.model.User; +import com.shy_polarbear.server.domain.user.service.UserService; +import com.shy_polarbear.server.global.exception.ExceptionStatus; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NotificationService { + private final FirebaseCloudMessagingService fcmService; + private final NotificationRepository notificationRepository; + private final UserService userService; + + @Transactional + public void pushMessage(NotificationParams params) { + User receiver = userService.getUser(params.receiver().getId()); + + fcmService.sendPushMessage(receiver.getFcmToken(), params); + Notification notification = Notification.createNotification( + receiver, + params.title(), + params.content(), + params.notificationType(), + params.redirectTargetId()); + + notificationRepository.save(notification); + receiver.addNotification(notification); + } + + // 내 알림 리스트 조회 + public List getMyNotifications(Long userId) { + List notificationList = notificationRepository.findAllByReceiverId(1L); + return notificationList.stream() + .map(NotificationResponse::of) + .toList(); + } + + // 알림 읽음 처리 + @Transactional + public NotificationReadResponse readNotification(Long notificationId, Long userId) { + Notification notification = notificationRepository.findByIdAndReceiverId(notificationId, userId) + .orElseThrow(() -> new NotificationException(ExceptionStatus.NOT_FOUND_NOTIFICATION)); + notification.read(); + + return NotificationReadResponse.of(notification); + } + +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/vo/FcmMessage.java b/src/main/java/com/shy_polarbear/server/domain/notification/vo/FcmMessage.java new file mode 100644 index 0000000..9d5b725 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/vo/FcmMessage.java @@ -0,0 +1,10 @@ +package com.shy_polarbear.server.domain.notification.vo; + +import lombok.Builder; + +@Builder +public record FcmMessage ( // 필드명은 FCM 메세지 스펙 따라야 합니다 + Boolean validate_only, + Message message +){ +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/vo/Message.java b/src/main/java/com/shy_polarbear/server/domain/notification/vo/Message.java new file mode 100644 index 0000000..a293818 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/vo/Message.java @@ -0,0 +1,10 @@ +package com.shy_polarbear.server.domain.notification.vo; + +import lombok.Builder; + +@Builder +public record Message( + String token, + MessageData data +) { +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/vo/MessageData.java b/src/main/java/com/shy_polarbear/server/domain/notification/vo/MessageData.java new file mode 100644 index 0000000..a864b8f --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/vo/MessageData.java @@ -0,0 +1,12 @@ +package com.shy_polarbear.server.domain.notification.vo; + +import lombok.Builder; + +@Builder +public record MessageData( + String title, + String body, + String redirectTargetId, + String type +) { +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/vo/NotificationParams.java b/src/main/java/com/shy_polarbear/server/domain/notification/vo/NotificationParams.java new file mode 100644 index 0000000..5f2f0ec --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/vo/NotificationParams.java @@ -0,0 +1,52 @@ +package com.shy_polarbear.server.domain.notification.vo; + +import com.shy_polarbear.server.domain.comment.model.Comment; +import com.shy_polarbear.server.domain.feed.model.Feed; +import com.shy_polarbear.server.domain.notification.model.NotificationType; +import com.shy_polarbear.server.domain.user.model.User; +import lombok.Builder; + +public record NotificationParams( + User receiver, + NotificationType notificationType, + Long redirectTargetId, + String title, + String content +){ + @Builder + public NotificationParams {} + + public static NotificationParams ofNewFeedComment( + User feedAuthor, + Feed feed, + Comment comment + ) { + final String content = """ + %s 글에 댓글이 달렸어요! + %s""".formatted(feed.getTitle(), comment.getContent()); + return NotificationParams.builder() + .receiver(feedAuthor) + .notificationType(NotificationType.NEW_FEED_COMMENT) + .redirectTargetId(feed.getId()) + .title(feed.getTitle()) + .content(content) + .build(); + } + + public static NotificationParams ofNewChildComment( + User parentCommentAuthor, + Feed feed, + Comment childComment + ) { + final String content = """ + %s 글에 대댓글이 달렸어요! + %s""".formatted(feed.getTitle(), childComment.getContent()); + return NotificationParams.builder() + .receiver(parentCommentAuthor) + .notificationType(NotificationType.NEW_COMMENT_CHILD_COMMENT) + .redirectTargetId(feed.getId()) + .title(feed.getTitle()) + .content(content) + .build(); + } +} diff --git a/src/main/java/com/shy_polarbear/server/domain/notification/vo/PushMessage.java b/src/main/java/com/shy_polarbear/server/domain/notification/vo/PushMessage.java new file mode 100644 index 0000000..bf4b400 --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/domain/notification/vo/PushMessage.java @@ -0,0 +1,8 @@ +package com.shy_polarbear.server.domain.notification.vo; + +public record PushMessage( + Long receiverId, + String title, + String body +) { +} diff --git a/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/JoinRequest.java b/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/JoinRequest.java index 95c4371..3e2e3a8 100644 --- a/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/JoinRequest.java +++ b/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/JoinRequest.java @@ -19,4 +19,7 @@ public class JoinRequest { @NotBlank private String email; private String profileImage; + + @NotBlank + private String fcmToken; } diff --git a/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/SocialLoginRequest.java b/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/SocialLoginRequest.java index 396a66c..8f4f264 100644 --- a/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/SocialLoginRequest.java +++ b/src/main/java/com/shy_polarbear/server/domain/user/dto/auth/request/SocialLoginRequest.java @@ -14,9 +14,11 @@ public class SocialLoginRequest { private String socialType; @NotBlank private String socialAccessToken; + @NotBlank + private String fcmToken; - public static SocialLoginRequest from(String socialType, String socialAccessToken) { - return new SocialLoginRequest(socialType, socialAccessToken); + public static SocialLoginRequest from(String socialType, String socialAccessToken, String fcmToken) { + return new SocialLoginRequest(socialType, socialAccessToken, fcmToken); } } diff --git a/src/main/java/com/shy_polarbear/server/domain/user/model/User.java b/src/main/java/com/shy_polarbear/server/domain/user/model/User.java index 9c3a12f..5641707 100644 --- a/src/main/java/com/shy_polarbear/server/domain/user/model/User.java +++ b/src/main/java/com/shy_polarbear/server/domain/user/model/User.java @@ -1,6 +1,7 @@ package com.shy_polarbear.server.domain.user.model; +import com.shy_polarbear.server.domain.notification.model.Notification; import com.shy_polarbear.server.domain.quiz.model.UserQuiz; import com.shy_polarbear.server.domain.point.model.Point; import com.shy_polarbear.server.global.common.model.BaseEntity; @@ -55,6 +56,10 @@ public class User extends BaseEntity { private ProviderType provider; @Column(nullable = false, unique = true) private String password; + @Column(unique = true) + private String fcmToken; + @OneToMany(mappedBy = "receiver") + private List notificationList = new ArrayList<>(); public void addUserQuiz(UserQuiz userQuiz) { this.userQuiz.add(userQuiz); @@ -118,6 +123,18 @@ public boolean isSameNickName(String nickName) { return Objects.equals(this.nickName, nickName); } + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + + public void removeFcmToken() { + this.fcmToken = null; + } + + public void addNotification(Notification notification) { + this.notificationList.add(notification); + } + // test public void setIdForTest(Long mockId) { this.id = mockId; diff --git a/src/main/java/com/shy_polarbear/server/global/common/config/WebConfig.java b/src/main/java/com/shy_polarbear/server/global/common/config/WebConfig.java new file mode 100644 index 0000000..bc1ae4e --- /dev/null +++ b/src/main/java/com/shy_polarbear/server/global/common/config/WebConfig.java @@ -0,0 +1,13 @@ +package com.shy_polarbear.server.global.common.config; + +import org.json.simple.parser.JSONParser; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class WebConfig { + @Bean + public JSONParser jsonParser() { + return new JSONParser(); + } +} diff --git a/src/main/java/com/shy_polarbear/server/global/common/constants/BusinessLogicConstants.java b/src/main/java/com/shy_polarbear/server/global/common/constants/BusinessLogicConstants.java index b1168cb..478bae7 100644 --- a/src/main/java/com/shy_polarbear/server/global/common/constants/BusinessLogicConstants.java +++ b/src/main/java/com/shy_polarbear/server/global/common/constants/BusinessLogicConstants.java @@ -24,6 +24,11 @@ public abstract class BusinessLogicConstants { public static final int COMMENT_CONTENT_MAX_LENGTH = 300; public static final String COMMENT_LIMIT_PARAM_DEFAULT_VALUE = "10"; + /** + * FCM 푸시 알림 + **/ + public static final int RECENT_NOTIFICATION_LIMIT = 30; + /** * 유저 */ diff --git a/src/main/java/com/shy_polarbear/server/global/exception/ExceptionStatus.java b/src/main/java/com/shy_polarbear/server/global/exception/ExceptionStatus.java index 3e64eac..110361f 100644 --- a/src/main/java/com/shy_polarbear/server/global/exception/ExceptionStatus.java +++ b/src/main/java/com/shy_polarbear/server/global/exception/ExceptionStatus.java @@ -46,8 +46,14 @@ public enum ExceptionStatus { QUIZ_SUBMISSION_NULL_CLIENT_ERROR(400, 3003, "클라이언트 오류: 제출된 선택지가 NULL 입니다."), //랭킹 - NOT_FOUND_RANKING(400, 8001, "존재하지 않는 랭킹입니다."),; - + NOT_FOUND_RANKING(400, 8001, "존재하지 않는 랭킹입니다."), + + // 푸시 알림 + GET_FCM_ACCESS_TOKEN_ERROR(400, 4000, "FCM 토큰을 받는데 실패했습니다."), + FCM_MESSAGE_JSON_PARSING_ERROR(400,4001,"FCM 토큰 JSON 파싱 과정에서 오류가 발생했습니다."), + SEND_FCM_PUSH_ERROR(400,4002,"FCM 푸시 알림을 전송하는데 실패했습니다."), + NOT_FOUND_NOTIFICATION(404, 4004, "존재하지 않는 알림입니다."); + ; private final int httpCode;