diff --git a/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java b/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java index eb804673..86da0670 100644 --- a/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java +++ b/src/main/java/com/server/capple/config/apns/dto/ApnsClientRequest.java @@ -1,19 +1,25 @@ package com.server.capple.config.apns.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import com.server.capple.domain.board.entity.Board; +import com.server.capple.domain.boardComment.entity.BoardComment; +import com.server.capple.domain.notifiaction.entity.NotificationType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; public class ApnsClientRequest { - @Getter @NoArgsConstructor @ToString public static class SimplePushBody { private Aps aps; - - public SimplePushBody(String title, String subTitle, String body, Integer badge, String threadId, String targetContentId) { - this.aps = new Aps(new Aps.Alert(title, subTitle, body), badge, threadId, targetContentId); + private String boardId; + private String boardCommentId; + @Builder + public SimplePushBody(String title, String subTitle, String body, Integer badge, String sound, String threadId, String targetContentId, String boardId, String boardCommentId) { + this.aps = new Aps(new Aps.Alert(title, subTitle, body), badge, sound, threadId, targetContentId); + this.boardId = boardId; + this.boardCommentId = boardCommentId; } @ToString @@ -23,11 +29,12 @@ public SimplePushBody(String title, String subTitle, String body, Integer badge, public static class Aps { private Alert alert; private Integer badge; + @Schema(defaultValue = "default") + private String sound; // Library/Sounds 폴더 내의 파일 이름 @JsonProperty("thread-id") private String threadId; @JsonProperty("target-content-id") private String targetContentId; // 프론트 측 작업 필요함 - @ToString @Getter @AllArgsConstructor @@ -41,53 +48,90 @@ public static class Alert { } @Getter - @AllArgsConstructor @NoArgsConstructor - @Builder @ToString - public static class FullAlertBody { + public static class BoardNotificationBody { private Aps aps; + private String boardId; + @Builder + public BoardNotificationBody(NotificationType type, Board board) { + this.aps = Aps.builder() + .threadId("board-" + board.getId()) + .alert(Aps.Alert.builder() + .title(type.getTitle()) + .body(board.getContent()) + .build()) + .build(); + this.boardId = board.getId().toString(); + } + @ToString @Getter + @Builder @AllArgsConstructor @NoArgsConstructor + public static class Aps { + private Alert alert; + @Builder.Default + private String sound = "default"; // Library/Sounds 폴더 내의 파일 이름 + @JsonProperty("thread-id") + private String threadId; + + @ToString + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Alert { + private String title; + private String body; + } + } + } + + @Getter + @NoArgsConstructor + @ToString + public static class BoardCommentNotificationBody { + private Aps aps; + private String boardId; + private String boardCommentId; @Builder + public BoardCommentNotificationBody(NotificationType type, Board board, BoardComment boardComment) { + this.aps = Aps.builder().threadId("board-" + board.getId()) + .alert(Aps.Alert.builder() + .title(type.getTitle()) + .subtitle(boardComment.getContent()) + .body(board.getContent()) + .build()) + .build(); + this.boardId = board.getId().toString(); + this.boardCommentId = boardComment.getId().toString(); + } + @ToString + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor public static class Aps { - private Alert alert; // alert 정보 - private Integer badge; // 앱 아이콘에 표시할 뱃지 숫자 + private Alert alert; + private Integer badge; @Schema(defaultValue = "default") private String sound; // Library/Sounds 폴더 내의 파일 이름 - @Schema(defaultValue = "thread-id") - private String threadId; // 알림 그룹화를 위한 thread id (UNNotificationContent 객체의 threadIdentifier와 일치해야 함) - private String category; // 알림 그룹화를 위한 category, (UNNotificationCategory 식별자와 일치해야 함) - @Schema(defaultValue = "0") - @JsonProperty("content-available") - private Integer contentAvailable; // 백그라운드 알림 여부, 1이면 백그라운드 알림, 0이면 포그라운드 알림 (백그라운드일 경우 alert, badge, sound는 넣으면 안됨) - @Schema(defaultValue = "0") - @JsonProperty("mutable-content") - private Integer mutableContent; // 알림 서비스 확장 플래그 - @Schema(defaultValue = "") - @JsonProperty("target-content-id") - private String targetContentId; // 알림이 클릭되었을 때 가져올 창의 식별자, UNNotificationContent 객체에 채워짐 + @JsonProperty("thread-id") + private String threadId; + @ToString @Getter + @Builder @AllArgsConstructor @NoArgsConstructor - @Builder - @ToString public static class Alert { - @Schema(defaultValue = "title") private String title; - @Schema(defaultValue = "subTitle") private String subtitle; - @Schema(defaultValue = "body") private String body; - @Schema(defaultValue = "") - @JsonProperty("launch-image") - private String launchImage; // 실행시 보여줄 이미지 파일, 기본 실행 이미지 대신 입력한 이미지 또는 스토리보드가 켜짐 } } } - } diff --git a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java index 81acd50b..592c102a 100644 --- a/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java +++ b/src/main/java/com/server/capple/config/apns/service/ApnsServiceImpl.java @@ -58,6 +58,7 @@ public Boolean sendApns(T request, List deviceToken) { deviceToken.parallelStream() .forEach(token -> { + if (token.isBlank()) return; tmpWebClient .method(HttpMethod.POST) .uri(token) diff --git a/src/main/java/com/server/capple/domain/board/service/BoardServiceImpl.java b/src/main/java/com/server/capple/domain/board/service/BoardServiceImpl.java index 5b658be3..b52edb10 100644 --- a/src/main/java/com/server/capple/domain/board/service/BoardServiceImpl.java +++ b/src/main/java/com/server/capple/domain/board/service/BoardServiceImpl.java @@ -10,7 +10,9 @@ import com.server.capple.domain.board.repository.BoardHeartRedisRepository; import com.server.capple.domain.board.repository.BoardHeartRepository; import com.server.capple.domain.board.repository.BoardRepository; +import com.server.capple.domain.boardSubscribeMember.service.BoardSubscribeMemberService; import com.server.capple.domain.member.entity.Member; +import com.server.capple.domain.notifiaction.service.NotificationService; import com.server.capple.global.exception.RestApiException; import com.server.capple.global.exception.errorCode.BoardErrorCode; import lombok.RequiredArgsConstructor; @@ -29,6 +31,8 @@ public class BoardServiceImpl implements BoardService { private final BoardMapper boardMapper; private final BoardHeartRepository boardHeartRepository; private final BoardHeartMapper boardHeartMapper; + private final NotificationService notificationService; + private final BoardSubscribeMemberService boardSubscribeMemberService; @Override public BoardResponse.BoardCreate createBoard(Member member, BoardType boardType, String content) { @@ -38,6 +42,7 @@ public BoardResponse.BoardCreate createBoard(Member member, BoardType boardType, } else { throw new RestApiException(BoardErrorCode.BOARD_BAD_REQUEST); } + boardSubscribeMemberService.createBoardSubscribeMember(member, board); return boardMapper.toBoardCreate(board); } @@ -98,6 +103,7 @@ public BoardResponse.BoardDelete deleteBoard(Member member, Long boardId) { } board.delete(); + boardSubscribeMemberService.deleteBoardSubscribeMemberByBoardId(boardId); return boardMapper.toBoardDelete(board); } @@ -122,12 +128,13 @@ public ToggleBoardHeart toggleBoardHeart(Member member, Long boardId) { }); boolean isLiked = boardHeart.toggleHeart(); board.setHeartCount(boardHeart.isLiked()); + if (isLiked) notificationService.sendBoardHeartNotification(member.getId(), board); return new ToggleBoardHeart(boardId, isLiked); } @Override public Board findBoard(Long boardId) { return boardRepository.findById(boardId) - .orElseThrow(() -> new RestApiException(BoardErrorCode.BOARD_NOT_FOUND)); + .orElseThrow(() -> new RestApiException(BoardErrorCode.BOARD_NOT_FOUND)); } } diff --git a/src/main/java/com/server/capple/domain/boardComment/service/BoardCommentServiceImpl.java b/src/main/java/com/server/capple/domain/boardComment/service/BoardCommentServiceImpl.java index 17a55311..335fb8b2 100644 --- a/src/main/java/com/server/capple/domain/boardComment/service/BoardCommentServiceImpl.java +++ b/src/main/java/com/server/capple/domain/boardComment/service/BoardCommentServiceImpl.java @@ -14,8 +14,10 @@ import com.server.capple.domain.boardComment.repository.BoardCommentHeartRedisRepository; import com.server.capple.domain.boardComment.repository.BoardCommentHeartRepository; import com.server.capple.domain.boardComment.repository.BoardCommentRepository; +import com.server.capple.domain.boardSubscribeMember.service.BoardSubscribeMemberService; import com.server.capple.domain.member.entity.Member; import com.server.capple.domain.member.service.MemberService; +import com.server.capple.domain.notifiaction.service.NotificationService; import com.server.capple.global.exception.RestApiException; import com.server.capple.global.exception.errorCode.CommentErrorCode; import lombok.RequiredArgsConstructor; @@ -35,6 +37,8 @@ public class BoardCommentServiceImpl implements BoardCommentService { private final BoardCommentHeartRepository boardCommentHeartRepository; private final BoardCommentMapper boardCommentMapper; private final BoardCommentHeartMapper boardCommentHeartMapper; + private final NotificationService notificationService; + private final BoardSubscribeMemberService boardSubscribeMemberService; @Override @Transactional @@ -44,6 +48,8 @@ public BoardCommentId createBoardComment(Member member, Long boardId, BoardComme BoardComment boardComment = boardCommentRepository.save( boardCommentMapper.toBoardComment(loginMember, board, request.getComment())); + notificationService.sendBoardCommentNotification(loginMember.getId(), board, boardComment); // 게시글 댓글 알림 + boardSubscribeMemberService.createBoardSubscribeMember(loginMember, board); // 알림 리스트 추가 board.increaseCommentCount(); return new BoardCommentId(boardComment.getId()); @@ -85,6 +91,9 @@ public ToggleBoardCommentHeart toggleBoardCommentHeart(Member member, Long board }); boolean isLiked = boardCommentHeart.toggleHeart(); boardComment.setHeartCount(boardCommentHeart.isLiked()); + if(isLiked && !boardComment.getMember().getId().equals(member.getId())) { + notificationService.sendBoardCommentHeartNotification(member.getId(), boardComment.getBoard(), boardComment); + } return new ToggleBoardCommentHeart(boardCommentId, isLiked); } diff --git a/src/main/java/com/server/capple/domain/boardSubscribeMember/entity/BoardSubscribeMember.java b/src/main/java/com/server/capple/domain/boardSubscribeMember/entity/BoardSubscribeMember.java new file mode 100644 index 00000000..41ae5622 --- /dev/null +++ b/src/main/java/com/server/capple/domain/boardSubscribeMember/entity/BoardSubscribeMember.java @@ -0,0 +1,24 @@ +package com.server.capple.domain.boardSubscribeMember.entity; + +import com.server.capple.domain.board.entity.Board; +import com.server.capple.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Entity +@AllArgsConstructor +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BoardSubscribeMember { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne + @JoinColumn(name = "board_id", nullable = false) + private Board board; +} diff --git a/src/main/java/com/server/capple/domain/boardSubscribeMember/reposiotry/BoardSubscribeMemberRepository.java b/src/main/java/com/server/capple/domain/boardSubscribeMember/reposiotry/BoardSubscribeMemberRepository.java new file mode 100644 index 00000000..8267aa3f --- /dev/null +++ b/src/main/java/com/server/capple/domain/boardSubscribeMember/reposiotry/BoardSubscribeMemberRepository.java @@ -0,0 +1,13 @@ +package com.server.capple.domain.boardSubscribeMember.reposiotry; + +import com.server.capple.domain.boardSubscribeMember.entity.BoardSubscribeMember; +import com.server.capple.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BoardSubscribeMemberRepository extends JpaRepository { + Boolean existsByMemberIdAndBoardId(Long memberId, Long boardId); + List findBoardSubscribeMembersByBoardId(Long boardId); + void deleteBoardSubscribeMemberByBoardId(Long boardId); +} diff --git a/src/main/java/com/server/capple/domain/boardSubscribeMember/service/BoardSubscribeMemberService.java b/src/main/java/com/server/capple/domain/boardSubscribeMember/service/BoardSubscribeMemberService.java new file mode 100644 index 00000000..b1d37e67 --- /dev/null +++ b/src/main/java/com/server/capple/domain/boardSubscribeMember/service/BoardSubscribeMemberService.java @@ -0,0 +1,12 @@ +package com.server.capple.domain.boardSubscribeMember.service; + +import com.server.capple.domain.board.entity.Board; +import com.server.capple.domain.member.entity.Member; + +import java.util.List; + +public interface BoardSubscribeMemberService { + void createBoardSubscribeMember(Member member, Board board); + List findBoardSubscribeMembers(Long boardId); + void deleteBoardSubscribeMemberByBoardId(Long boardId); +} diff --git a/src/main/java/com/server/capple/domain/boardSubscribeMember/service/BoardSubscribeMemberServiceImpl.java b/src/main/java/com/server/capple/domain/boardSubscribeMember/service/BoardSubscribeMemberServiceImpl.java new file mode 100644 index 00000000..a9c5fb24 --- /dev/null +++ b/src/main/java/com/server/capple/domain/boardSubscribeMember/service/BoardSubscribeMemberServiceImpl.java @@ -0,0 +1,33 @@ +package com.server.capple.domain.boardSubscribeMember.service; + +import com.server.capple.domain.board.entity.Board; +import com.server.capple.domain.boardSubscribeMember.entity.BoardSubscribeMember; +import com.server.capple.domain.boardSubscribeMember.reposiotry.BoardSubscribeMemberRepository; +import com.server.capple.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BoardSubscribeMemberServiceImpl implements BoardSubscribeMemberService { + private final BoardSubscribeMemberRepository boardSubscribeMemberRepository; + @Override + public void createBoardSubscribeMember(Member member, Board board) { + if(boardSubscribeMemberRepository.existsByMemberIdAndBoardId(member.getId(), board.getId())) { + return; + } + boardSubscribeMemberRepository.save(BoardSubscribeMember.builder().member(member).board(board).build()); + } + + @Override + public List findBoardSubscribeMembers(Long boardId) { + return boardSubscribeMemberRepository.findBoardSubscribeMembersByBoardId(boardId).stream().map(BoardSubscribeMember::getMember).toList(); + } + + @Override + public void deleteBoardSubscribeMemberByBoardId(Long boardId) { + boardSubscribeMemberRepository.deleteBoardSubscribeMemberByBoardId(boardId); + } +} diff --git a/src/main/java/com/server/capple/domain/notifiaction/entity/NotificationType.java b/src/main/java/com/server/capple/domain/notifiaction/entity/NotificationType.java new file mode 100644 index 00000000..0b7735c2 --- /dev/null +++ b/src/main/java/com/server/capple/domain/notifiaction/entity/NotificationType.java @@ -0,0 +1,18 @@ +package com.server.capple.domain.notifiaction.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationType { + BOARD_HEART("누군가 내 게시글을 좋아했어요", null), + BOARD_COMMENT("누군가 내 게시글에 댓글을 달았어요", null), + BAORD_COMMENT_DUPLCATE("누군가 나와 같은 게시글에 댓글을 달았아요", null), + BOARD_COMMENT_HEART("누군가 내 댓글을 좋아했어요", null), + TODAY_QUESTION_PUBLISHED("오늘의 질문", "오늘의 질문이 준비 되었어요.\n지금 바로 답변해보세요."), + TODAY_QUESTION_CLOSED("오늘의 질문", "오늘의 질문 답변 시간이 마감되었어요.\n다른 러너들은 어떻게 답 했는지 확인해보세요."), + ; + private final String title; + private final String content; +} diff --git a/src/main/java/com/server/capple/domain/notifiaction/service/NotificationService.java b/src/main/java/com/server/capple/domain/notifiaction/service/NotificationService.java new file mode 100644 index 00000000..6de71bb4 --- /dev/null +++ b/src/main/java/com/server/capple/domain/notifiaction/service/NotificationService.java @@ -0,0 +1,10 @@ +package com.server.capple.domain.notifiaction.service; + +import com.server.capple.domain.board.entity.Board; +import com.server.capple.domain.boardComment.entity.BoardComment; + +public interface NotificationService { + void sendBoardHeartNotification(Long actorId, Board board); + void sendBoardCommentNotification(Long actorId, Board board, BoardComment boardComment); + void sendBoardCommentHeartNotification(Long actorId, Board board, BoardComment boardComment); +} diff --git a/src/main/java/com/server/capple/domain/notifiaction/service/NotificationServiceImpl.java b/src/main/java/com/server/capple/domain/notifiaction/service/NotificationServiceImpl.java new file mode 100644 index 00000000..a01bbe1b --- /dev/null +++ b/src/main/java/com/server/capple/domain/notifiaction/service/NotificationServiceImpl.java @@ -0,0 +1,67 @@ +package com.server.capple.domain.notifiaction.service; + +import com.server.capple.config.apns.dto.ApnsClientRequest; +import com.server.capple.config.apns.service.ApnsService; +import com.server.capple.domain.board.entity.Board; +import com.server.capple.domain.boardComment.entity.BoardComment; +import com.server.capple.domain.boardSubscribeMember.service.BoardSubscribeMemberService; +import com.server.capple.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.server.capple.domain.notifiaction.entity.NotificationType.*; + +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService { + private final ApnsService apnsService; + private final BoardSubscribeMemberService boardSubscribeMemberService; + + @Override + public void sendBoardHeartNotification(Long actorId, Board board) { + if (actorId.equals(board.getWriter().getId())) return; + apnsService.sendApnsToMembers(ApnsClientRequest.BoardNotificationBody.builder() + .type(BOARD_HEART) + .board(board) + .build(), board.getWriter().getId()); + // TODO 알림 데이터베이스 저장 + } + + @Override + public void sendBoardCommentNotification(Long actorId, Board board, BoardComment boardComment) { + List subscribers = boardSubscribeMemberService.findBoardSubscribeMembers(board.getId()); + List subscriberIds = subscribers.stream() + .map(Member::getId) + .filter(id -> !id.equals(actorId)) +// 게시판 구독자에게 알림 전송 + .peek(subscriberId -> { + if (subscriberId.equals(board.getWriter().getId())) { + apnsService.sendApnsToMembers(ApnsClientRequest.BoardCommentNotificationBody.builder() + .type(BOARD_COMMENT) + .board(board) + .boardComment(boardComment) + .build(), subscriberId); + } + }) + .filter(id -> !id.equals(board.getWriter().getId())) + .toList(); + apnsService.sendApnsToMembers(ApnsClientRequest.BoardCommentNotificationBody.builder() + .type(BAORD_COMMENT_DUPLCATE) + .board(board) + .boardComment(boardComment) + .build(), subscriberIds); + // TODO 알림 데이터베이스 저장 + } + + @Override + public void sendBoardCommentHeartNotification(Long actorId, Board board, BoardComment boardComment) { + apnsService.sendApnsToMembers(ApnsClientRequest.BoardCommentNotificationBody.builder() + .type(BOARD_COMMENT_HEART) + .board(board) + .boardComment(boardComment) + .build(), boardComment.getMember().getId()); + // TODO 알림 데이터베이스 저장 + } +} diff --git a/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java b/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java index 58f1d90b..e4e9305a 100644 --- a/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java +++ b/src/test/java/com/server/capple/config/apns/service/ApnsServiceImplTest.java @@ -1,6 +1,6 @@ package com.server.capple.config.apns.service; -import com.server.capple.config.apns.dto.ApnsClientRequest.SimplePushBody; +import com.server.capple.config.apns.dto.ApnsClientRequest; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -34,7 +34,7 @@ void sendApns() { String targetContentId = "targetContentId"; //when - Boolean result = apnsService.sendApns(new SimplePushBody(title, subTitle, body, null, threadId, targetContentId), List.of(simulatorDeviceToken)); + Boolean result = apnsService.sendApns(ApnsClientRequest.SimplePushBody.builder().title(title).subTitle(subTitle).body(body).sound("default").threadId(threadId).targetContentId(targetContentId).build(), List.of(simulatorDeviceToken)); //then assertTrue(result); @@ -55,7 +55,7 @@ void sendApnsMessages() { for (int i = 0; i < 100; i++) deviceTokens.add(simulatorDeviceToken); //when - Boolean result = apnsService.sendApns(new SimplePushBody(title, subTitle, body, null, threadId, targetContentId), deviceTokens); + Boolean result = apnsService.sendApns(ApnsClientRequest.SimplePushBody.builder().title(title).subTitle(subTitle).body(body).sound("default").threadId(threadId).targetContentId(targetContentId).build(), deviceTokens); //then assertTrue(result);